package domain import ( "errors" "fmt" "hash/fnv" "strconv" "strings" ) const ( // MaxStoryID keeps story ids inside TL int / PostgreSQL int4 bounds. MaxStoryID = MaxMessageBoxID // MaxStoryIDs limits exact-id story RPCs and view increments. MaxStoryIDs = 200 // MaxStoryListLimit bounds one active/archive/pinned story page. MaxStoryListLimit = 100 // MaxStoryInteractionListLimit bounds one story viewer/reaction page. MaxStoryInteractionListLimit = 100 // MaxStoryInteractionOffsetLength bounds server-generated owner interaction // cursors before they reach store-side parsing or query predicates. MaxStoryInteractionOffsetLength = 64 // MaxStoryAlbumOffset bounds stories.getAlbumStories count-offset paging // before a future album store can turn malformed requests into deep OFFSET // work. DrKLO sends loadedObjects.size(), so normal UI paging stays small. MaxStoryAlbumOffset = 10000 // MaxStoryPinnedToTop bounds stories.togglePinnedToTop. It matches the // client default stories_pinned_to_top_count_max used by TDesktop/DrKLO. MaxStoryPinnedToTop = 3 // MaxStoryPrivacyFanoutTargets bounds one privacy-change fanout candidate set. MaxStoryPrivacyFanoutTargets = 5000 // MaxStorySendAsChannels bounds stories.getChatsToSend channel candidates. MaxStorySendAsChannels = 200 // MaxStoryMediaAreas bounds one story's overlay/click target vector. MaxStoryMediaAreas = 16 // MaxStoryMediaAreaURLLength bounds mediaAreaUrl click targets stored in // one story snapshot. MaxStoryMediaAreaURLLength = 2048 // MaxStoryStarGiftSlugLength bounds collectible gift slugs stored in one // story snapshot. TDesktop/DrKLO treat the slug as a deep-link path token. MaxStoryStarGiftSlugLength = 255 // MaxStoryGeoAddressPartLength bounds optional mediaAreaGeoPoint address // labels stored in one story snapshot. MaxStoryGeoAddressPartLength = 256 // MaxStoryWeatherEmojiLength bounds mediaAreaWeather emoji labels stored in // one story snapshot. MaxStoryWeatherEmojiLength = 32 // DefaultStoryPeriod is the Layer 225 sendStory period when the optional // period flag is absent. DefaultStoryPeriod = 86400 // MaxStoryViewQueryLength bounds q in stories.getStoryViewsList before it // reaches store-side LIKE/search predicates. MaxStoryViewQueryLength = 128 // DefaultStoryCanSendRemaining is the development-stage story quota returned // by canSendStory until production limits are modeled. DefaultStoryCanSendRemaining = 100 ) var ( ErrStoryIDInvalid = errors.New("story id invalid") ErrStoryNotFound = errors.New("story not found") ErrStoryPeerInvalid = errors.New("story peer invalid") ErrStoryNotModified = errors.New("story not modified") ErrStoryOffsetInvalid = errors.New("story offset invalid") ErrStoryPeriodInvalid = errors.New("story period invalid") ) // ValidateStoryInteractionOffset validates stories.getStoryViewsList and // stories.getStoryReactionsList keyset cursors without depending on TL types. func ValidateStoryInteractionOffset(offset string, reactionsOnly bool) error { return validateStoryInteractionOffset(offset, reactionsOnly, false) } // ValidateStoryReactionInteractionOffset validates stories.getStoryReactionsList // cursors. When forwardsFirst is set, reaction-list pages may legitimately use // group 1 for ordinary reaction rows after public repost rows. func ValidateStoryReactionInteractionOffset(offset string, forwardsFirst bool) error { return validateStoryInteractionOffset(offset, true, forwardsFirst) } func validateStoryInteractionOffset(offset string, reactionsOnly, forwardsFirst bool) error { if offset == "" { return nil } if len(offset) > MaxStoryInteractionOffsetLength { return ErrStoryOffsetInvalid } parts := strings.Split(offset, ":") if len(parts) != 3 && len(parts) != 4 { return ErrStoryOffsetInvalid } group, err1 := strconv.Atoi(parts[0]) date, err2 := strconv.Atoi(parts[1]) viewerID, err3 := strconv.ParseInt(parts[2], 10, 64) var messageID int var err4 error if len(parts) == 4 { messageID, err4 = strconv.Atoi(parts[3]) } reactionGroupInvalid := reactionsOnly && group != 0 && !(forwardsFirst && group == 1) if err1 != nil || err2 != nil || err3 != nil || err4 != nil || group < 0 || group > 1 || reactionGroupInvalid || date <= 0 || viewerID == 0 || messageID < 0 || messageID > MaxMessageBoxID { return ErrStoryOffsetInvalid } return nil } // Story is a protocol-neutral Telegram story snapshot owned by one peer. type Story struct { Owner Peer ID int RandomID int64 Date int ExpireDate int Deleted bool Pinned bool PinnedToTopOrder int Public bool CloseFriends bool Contacts bool SelectedContacts bool NoForwards bool Edited bool Out bool PrivacyRules []PrivacyRule AllowUserIDs []int64 DisallowUserIDs []int64 Caption string Entities []MessageEntity Media *MessageMedia MediaAreas []StoryMediaArea Forward *StoryForward Views StoryViews SentReaction *MessageReaction } // Active reports whether the story belongs in the active strip at now. func (s Story) Active(now int) bool { return !s.Deleted && s.ExpireDate > now } // Interactable reports whether a viewer may create new view/reaction state. // Expired profile-pinned stories remain visible from profiles, but ordinary // expired stories must not accept stale interaction writes. func (s Story) Interactable(now int) bool { return !s.Deleted && (s.ExpireDate > now || s.Pinned) } // VisibleTo reports story visibility with no relationship facts. func (s Story) VisibleTo(viewerUserID int64) bool { return s.VisibleToWithFacts(viewerUserID, false, false) } // VisibleToWithFacts reports story visibility using already-loaded viewer facts. func (s Story) VisibleToWithFacts(viewerUserID int64, viewerIsContact, viewerCloseFriend bool) bool { return s.VisibleToWithStoryFacts(viewerUserID, viewerIsContact, viewerCloseFriend, false) } // VisibleToWithStoryFacts reports story visibility using already-loaded viewer // facts, including owner-side story blocklist membership. func (s Story) VisibleToWithStoryFacts(viewerUserID int64, viewerIsContact, viewerCloseFriend, viewerStoryBlocked bool) bool { if viewerUserID == 0 { return false } if s.Owner.Type == PeerTypeUser && s.Owner.ID == viewerUserID { return true } if viewerStoryBlocked { return false } if int64InSet(s.DisallowUserIDs, viewerUserID) { return false } if int64InSet(s.AllowUserIDs, viewerUserID) { return true } if s.Public { return true } if s.Contacts && viewerIsContact { return true } if s.CloseFriends && viewerCloseFriend { return true } return false } func int64InSet(ids []int64, needle int64) bool { for _, id := range ids { if id == needle { return true } } return false } // StoryForward contains the protocol-neutral source of a reposted story. type StoryForward struct { // Source is the durable server-side source story owner used for counters // and owner interaction lists. From is the client-visible clickable peer // and may be empty when forwards privacy requires a from_name-only header. Source Peer From Peer FromName string StoryID int Modified bool } // StoryViews is the owner-visible aggregate of story views and reactions. type StoryViews struct { ViewsCount int ForwardsCount int ReactionsCount int Reactions []ChannelMessageReactionCount RecentViewers []int64 HasViewers bool } // StoryView is one viewer's durable view/reaction row. type StoryView struct { Owner Peer StoryID int ViewerID int64 Date int Reaction *MessageReaction // Repost is set for public repost interactions. It is protocol-neutral and // converted by rpc to storyViewPublicRepost. Repost *Story PublicForward *StoryPublicForward Blocked bool BlockedMyStoriesFrom bool } // StoryPublicForward is a public channel/supergroup message that shared a story // via messageMediaStory. It is converted by rpc to storyViewPublicForward or // storyReactionPublicForward. type StoryPublicForward struct { Message ChannelMessage } // StoryViewListRequest pages owner-visible story viewers. type StoryViewListRequest struct { ViewerUserID int64 Owner Peer StoryID int Offset string Limit int Query string JustContacts bool ReactionsFirst bool ForwardsFirst bool } // StoryViewList is a bounded page for stories.getStoryViewsList. type StoryViewList struct { Count int ViewsCount int ForwardsCount int ReactionsCount int Views []StoryView NextOffset string } // StoryReactionListRequest pages peers that reacted to one story. type StoryReactionListRequest struct { ViewerUserID int64 Owner Peer StoryID int Reaction *MessageReaction Offset string Limit int ForwardsFirst bool CanViewOwnerInteractions bool } // StoryReactionList is a bounded page for stories.getStoryReactionsList. type StoryReactionList struct { Count int Reactions []StoryView NextOffset string } // StoryMessageForwardListRequest pages public channel/supergroup messages that // shared one source story as messageMediaStory. type StoryMessageForwardListRequest struct { ViewerUserID int64 Owner Peer StoryID int Offset string Limit int ReactionsFirst bool ForwardsFirst bool } // StoryMessageForwardList is a bounded page of public message forwards. type StoryMessageForwardList struct { Count int Forwards []StoryView NextOffset string } // StoryPublicForwardListRequest pages public repost stories for one source story. type StoryPublicForwardListRequest struct { ViewerUserID int64 Owner Peer StoryID int Offset string Limit int } // StoryPublicForwardList is a bounded page of public repost story forwards. type StoryPublicForwardList struct { Count int Forwards []StoryView NextOffset string } // PeerStories is the story list for one owner peer from one viewer's perspective. type PeerStories struct { Peer Peer MaxReadID int Stories []Story Users []User Channels []Channel } // StoryList is a bounded story page. type StoryList struct { Count int State string HasMore bool // Hidden reports that this list is the viewer's hidden story source list. Hidden bool Stories []Story // PinnedToTop contains the owner-visible top-pinned story IDs in display // order for stories.stories.pinned_to_top. PinnedToTop []int Peers []PeerStories Users []User Channels []Channel } // StoryListCursor is the opaque getAllStories seek position after one peer. type StoryListCursor struct { Set bool Date int Peer Peer } // StoryListDigest summarizes a complete viewer-scoped active story source list. type StoryListDigest struct { Count int Hash uint64 } // DigestStoryPeerList returns a deterministic digest for an already ordered // complete active/hidden story peer list. func DigestStoryPeerList(peers []PeerStories) StoryListDigest { h := fnv.New64a() fmt.Fprintf(h, "story-list|count=%d|", len(peers)) for _, peer := range peers { fmt.Fprintf(h, "p=%s:%d:%d:%d|", peer.Peer.Type, peer.Peer.ID, peer.MaxReadID, len(peer.Stories)) for _, story := range peer.Stories { fmt.Fprintf( h, "s=%d:%d:%d:%t:%t:%d:%t:%t:%t:%t:%t:%t:%d:%d:%#v:%#v:%#v:%#v:%#v:%#v:%#v:%#v|", story.ID, story.Date, story.ExpireDate, story.Deleted, story.Pinned, story.PinnedToTopOrder, story.Public, story.CloseFriends, story.Contacts, story.SelectedContacts, story.Edited, story.Out, story.Views.ViewsCount, story.Views.ReactionsCount, story.SentReaction, story.Caption, story.Entities, story.Media, story.MediaAreas, story.Forward, story.PrivacyRules, story.AllowUserIDs, ) fmt.Fprintf(h, "disallow=%#v|noforwards=%t|", story.DisallowUserIDs, story.NoForwards) } } return StoryListDigest{Count: len(peers), Hash: h.Sum64()} } // StoryReadState is the per viewer+owner read boundary. type StoryReadState struct { ViewerID int64 Peer Peer MaxReadID int Date int } // RecentStory is the domain counterpart of TL recentStory. type RecentStory struct { Peer Peer MaxID int Live bool } // PeerStoryProjection is the per-viewer story summary embedded into peer objects. type PeerStoryProjection struct { Peer Peer Recent RecentStory Hidden bool } // StoryReadResult describes a readStories mutation. type StoryReadResult struct { ViewerID int64 Peer Peer MaxReadID int Advanced bool Date int } // StoryReactionResult describes one story reaction mutation. type StoryReactionResult struct { ViewerID int64 Peer Peer StoryID int Reaction *MessageReaction Story Story Changed bool Date int } // StoryMediaAreaKind identifies a protocol-neutral story overlay area. type StoryMediaAreaKind string const ( StoryMediaAreaSuggestedReaction StoryMediaAreaKind = "suggested_reaction" StoryMediaAreaURL StoryMediaAreaKind = "url" StoryMediaAreaGeoPoint StoryMediaAreaKind = "geo_point" StoryMediaAreaVenue StoryMediaAreaKind = "venue" StoryMediaAreaWeather StoryMediaAreaKind = "weather" StoryMediaAreaChannelPost StoryMediaAreaKind = "channel_post" StoryMediaAreaStarGift StoryMediaAreaKind = "star_gift" ) // StoryMediaAreaCoordinates is a percentage-based story media overlay box. type StoryMediaAreaCoordinates struct { X float64 Y float64 W float64 H float64 Rotation float64 Radius float64 HasRadius bool } // StoryGeoPointAddress is the protocol-neutral counterpart of geoPointAddress. type StoryGeoPointAddress struct { CountryISO2 string State string City string Street string } // StoryMediaArea is a protocol-neutral media area stored with a story snapshot. type StoryMediaArea struct { Kind StoryMediaAreaKind Coordinates StoryMediaAreaCoordinates Dark bool Flipped bool Reaction *MessageReaction URL string Geo *MessageGeoPoint GeoAddress *StoryGeoPointAddress Venue *MessageVenue WeatherEmoji string TemperatureC float64 Color int ChannelID int64 MsgID int StarGiftSlug string } // StoryCreateRequest creates one owner story with a store-assigned monotonic ID. type StoryCreateRequest struct { Owner Peer RandomID int64 Date int Period int Pinned bool Public bool CloseFriends bool Contacts bool SelectedContacts bool NoForwards bool PrivacyRules []PrivacyRule AllowUserIDs []int64 DisallowUserIDs []int64 Caption string Entities []MessageEntity Media *MessageMedia MediaAreas []StoryMediaArea Forward *StoryForward } // StoryCreateResult describes a sendStory mutation. type StoryCreateResult struct { Story Story Duplicate bool } // StoryEditRequest applies a partial story edit. type StoryEditRequest struct { Owner Peer ID int Media *MessageMedia UpdateMedia bool Caption string Entities []MessageEntity UpdateCaption bool Public bool CloseFriends bool Contacts bool SelectedContacts bool PrivacyRules []PrivacyRule AllowUserIDs []int64 DisallowUserIDs []int64 UpdatePrivacy bool MediaAreas []StoryMediaArea UpdateMediaAreas bool } // StoryEditResult describes a story edit and its previous visible facts. type StoryEditResult struct { Story Story Previous Story } // StoryMutationResult describes story mutations that may emit updateStory. type StoryMutationResult struct { Peer Peer IDs []int Stories []Story Previous []Story } // UpsertStoryRequest inserts or replaces a story snapshot. type UpsertStoryRequest struct { Story Story }