package domain import ( "bytes" "errors" ) // This file defines the poll business objects and their shared semantics: // // - MessagePoll is the render-safe definition snapshot persisted alongside the message media JSONB — it carries no // server secret such as the quiz correct answers or solution; Closed is only a send-time snapshot, and the read path must overwrite it with the authoritative polls state. // - PollDefinition is a polls authoritative table row (containing the secrets and the mutable closed), which is the only thing vote validation and result gating trust. // - Vote validation (ValidatePollVote) and result gating (ResolvePollResults) are pure functions shared by the memory and // postgres stores, ruling out behavioral drift between the two implementations. // // Forwarding does not copy the poll: the forward snapshot carries the same poll id, and votes/closes aggregate globally (matching the official behavior). const ( // MaxPollQuestionLength matches the official poll question limit. MaxPollQuestionLength = 255 // MinPollAnswers / MaxPollAnswers are the answer count bounds (official 2..10). MinPollAnswers = 2 MaxPollAnswers = 10 // MaxPollAnswerTextLength is the single answer text limit (official 100). MaxPollAnswerTextLength = 100 // MaxPollSolutionLength is the quiz solution text limit (official 200). MaxPollSolutionLength = 200 // MinPollClosePeriod / MaxPollClosePeriod are the auto-close countdown bounds (official 5..600 seconds). MinPollClosePeriod = 5 MaxPollClosePeriod = 600 // MaxPollRecentVoters is the recent_voters truncation (same as reaction recent, official 3). MaxPollRecentVoters = 3 // MaxPollVotesPageLimit is the per-page limit of messages.getPollVotes. MaxPollVotesPageLimit = 50 ) // Poll-path business errors; the rpc layer maps them to the corresponding RPC error text. var ( ErrPollInvalid = errors.New("poll invalid") ErrPollNotFound = errors.New("poll not found") ErrPollClosed = errors.New("poll closed") ErrPollOptionInvalid = errors.New("poll option invalid") ErrPollRevoteNotAllowed = errors.New("poll revote not allowed") ErrPollNotCreator = errors.New("poll can only be closed by creator") ErrPollNotPublic = errors.New("poll voters are not public") ) // MessagePollAnswer is one candidate answer (text + option byte key + optional attached media). // Media only allows photo/document snapshots (the rpc layer restricts the input types). type MessagePollAnswer struct { Text string `json:"text"` Entities []MessageEntity `json:"entities,omitempty"` Option []byte `json:"option"` Media *MessageMedia `json:"media,omitempty"` } // MessagePoll is the poll definition snapshot on a message's media (render-safe, no secrets). type MessagePoll struct { ID int64 `json:"id"` Question string `json:"question"` QuestionEntities []MessageEntity `json:"question_entities,omitempty"` Answers []MessagePollAnswer `json:"answers"` // Closed is a send-time snapshot; the read path overwrites it with the authoritative polls state (forward copies/history consistency after closing rely on it). Closed bool `json:"closed,omitempty"` PublicVoters bool `json:"public_voters,omitempty"` MultipleChoice bool `json:"multiple_choice,omitempty"` Quiz bool `json:"quiz,omitempty"` RevotingDisabled bool `json:"revoting_disabled,omitempty"` ShuffleAnswers bool `json:"shuffle_answers,omitempty"` HideResultsUntilClose bool `json:"hide_results_until_close,omitempty"` ClosePeriod int `json:"close_period,omitempty"` CloseDate int `json:"close_date,omitempty"` // AttachedMedia is the poll question's attached media (messageMediaPoll.attached_media), a photo/document snapshot. AttachedMedia *MessageMedia `json:"attached_media,omitempty"` // Results is filled per viewer by the read path (with chosen/correct/solution gating) and is not persisted. Results *MessagePollResults `json:"-"` } // MessagePollAnswerVoters is the aggregate result of one answer (already gated per viewer). type MessagePollAnswerVoters struct { Option []byte Voters int Chosen bool Correct bool } // MessagePollResults is the aggregate result resolved per viewer. type MessagePollResults struct { TotalVoters int Voters []MessagePollAnswerVoters // same order as MessagePoll.Answers RecentVoters []int64 // filled only for public_voters, truncated to MaxPollRecentVoters ViewerVoted bool Solution string // sent only for a quiz and (viewer voted || closed) SolutionEntities []MessageEntity } // PollDefinition is a polls authoritative table row: mutable state (closed) + server secrets + the option set needed for validation. type PollDefinition struct { ID int64 CreatorUserID int64 Options [][]byte // valid option set (in answer order) PublicVoters bool MultipleChoice bool Quiz bool RevotingDisabled bool HideResultsUntilClose bool Closed bool ClosePeriod int CloseDate int CorrectOptions [][]byte Solution string SolutionEntities []MessageEntity } // ClosedAt returns whether the poll should be considered closed at time now (explicitly closed or close_date passed). func (d PollDefinition) ClosedAt(now int) bool { return d.Closed || (d.CloseDate > 0 && now >= d.CloseDate) } // HasOption reports whether option is in the valid option set. func (d PollDefinition) HasOption(option []byte) bool { for _, candidate := range d.Options { if bytes.Equal(candidate, option) { return true } } return false } // PollVote is one user's vote row. Options keeps the client-submitted order. type PollVote struct { PollID int64 UserID int64 Options [][]byte Date int } // PollAggregates is the raw aggregate produced by the store layer, handed to ResolvePollResults for viewer gating. // The Counts key is string(option). type PollAggregates struct { Counts map[string]int TotalVoters int RecentVoters []int64 // truncated to MaxPollRecentVoters by vote_date DESC; needed only for public_voters ViewerOptions [][]byte // the viewer's own vote (nil = not voted) } // ValidatePollVote validates one vote submission; existing is the viewer's current vote (nil=not voted) and an empty options means vote retraction. // The memory and postgres stores must share this function to keep the two implementations consistent. func ValidatePollVote(def PollDefinition, existing [][]byte, options [][]byte, now int) error { if def.ID == 0 { return ErrPollNotFound } if def.ClosedAt(now) { return ErrPollClosed } if def.Quiz || def.RevotingDisabled { // quiz / revoting_disabled: no retracting, no changing a vote. if len(existing) > 0 || len(options) == 0 { return ErrPollRevoteNotAllowed } } if def.Quiz { if len(options) != 1 { return ErrPollOptionInvalid } } else if len(options) > 1 && !def.MultipleChoice { return ErrPollOptionInvalid } if len(options) > len(def.Options) { return ErrPollOptionInvalid } seen := make(map[string]struct{}, len(options)) for _, option := range options { if !def.HasOption(option) { return ErrPollOptionInvalid } key := string(option) if _, dup := seen[key]; dup { return ErrPollOptionInvalid } seen[key] = struct{}{} } return nil } // ResolvePollResults resolves the raw aggregate into a viewer-perspective result: chosen marks the viewer's own options; // correct/solution are revealed only for a quiz and (viewer voted || closed); // hide_results_until_close hides counts from non-creators before closing (to stop a malicious client from bypassing the UI). func ResolvePollResults(def PollDefinition, agg PollAggregates, viewerUserID int64, now int) MessagePollResults { closed := def.ClosedAt(now) viewerVoted := len(agg.ViewerOptions) > 0 reveal := def.Quiz && (viewerVoted || closed) hideCounts := def.HideResultsUntilClose && !closed && viewerUserID != def.CreatorUserID chosen := make(map[string]struct{}, len(agg.ViewerOptions)) for _, option := range agg.ViewerOptions { chosen[string(option)] = struct{}{} } correct := make(map[string]struct{}, len(def.CorrectOptions)) if reveal { for _, option := range def.CorrectOptions { correct[string(option)] = struct{}{} } } out := MessagePollResults{ TotalVoters: agg.TotalVoters, Voters: make([]MessagePollAnswerVoters, 0, len(def.Options)), ViewerVoted: viewerVoted, } for _, option := range def.Options { key := string(option) _, isChosen := chosen[key] _, isCorrect := correct[key] voters := agg.Counts[key] if hideCounts { voters = 0 } out.Voters = append(out.Voters, MessagePollAnswerVoters{ Option: option, Voters: voters, Chosen: isChosen, Correct: isCorrect, }) } if def.PublicVoters && !hideCounts && len(agg.RecentVoters) > 0 { recent := agg.RecentVoters if len(recent) > MaxPollRecentVoters { recent = recent[:MaxPollRecentVoters] } out.RecentVoters = append([]int64(nil), recent...) } if reveal { out.Solution = def.Solution out.SolutionEntities = append([]MessageEntity(nil), def.SolutionEntities...) } return out } // ApplyPollState writes the authoritative state and the resolved results back to the definition snapshot on the media (the end of read-path enrichment). func ApplyPollState(poll *MessagePoll, def PollDefinition, results MessagePollResults, now int) { if poll == nil { return } poll.Closed = def.ClosedAt(now) poll.Results = &results } // VotePrivateMessagePollRequest is a private-message vote request (msg id is the viewer box id). type VotePrivateMessagePollRequest struct { UserID int64 Peer Peer MessageID int Options [][]byte // empty = retract the vote Date int } // PrivateMessagePollResult returns the message from both owners' perspectives (media already enriched per owner). type PrivateMessagePollResult struct { PollID int64 Messages []Message } // ClosePrivateMessagePollRequest closes a poll on a private message (poll creator only). type ClosePrivateMessagePollRequest struct { UserID int64 Peer Peer MessageID int Date int } // VoteChannelMessagePollRequest is a channel/supergroup message vote request. type VoteChannelMessagePollRequest struct { UserID int64 ChannelID int64 MessageID int Options [][]byte Date int } // ChannelMessagePollResult returns the voter-perspective message and the suggested push recipients. type ChannelMessagePollResult struct { PollID int64 Channel Channel Message ChannelMessage Recipients []int64 } // ChannelPollFanoutViews is the batched per-viewer enrich result of channel poll fan-out (eliminating the per-viewer GetMessages // N+1): viewer-invariant aggregates (counts/total/recent) are computed once + batched viewerOptions + batched member visibility, // and each viewer is composed with the same ResolvePollResults, byte-identical to the per-viewer path. // - Found: the poll message exists and is indeed a poll (otherwise treat as nothing to project). // - Message: the base message snapshot (not per-viewer enriched), for the app layer to layer bot history-visibility filtering on top. // - Polls: a present key = the viewer was evaluated; a nil value = the viewer cannot see it (non-member/pre-history hidden); // non-nil = the poll already enriched from that viewer's perspective. Bot history filtering is layered on top by the app layer (setting nil). type ChannelPollFanoutViews struct { Found bool Message ChannelMessage Polls map[int64]*MessagePoll } // CloseChannelMessagePollRequest closes a poll on a channel message (poll creator only). type CloseChannelMessagePollRequest struct { UserID int64 ChannelID int64 MessageID int Date int } // PollVotesListRequest is the paginated request of messages.getPollVotes. type PollVotesListRequest struct { PollID int64 Option []byte // optional: list only those who chose this option OffsetDate int // 0 = first page; paging uses the last row (date,user_id) of the previous page OffsetUserID int64 Limit int } // PollVotesList is a paginated voter result. type PollVotesList struct { Count int // total count satisfying the filter Votes []PollVote // When HasMore is true, the rpc layer encodes next_offset from the last row (Date,UserID). HasMore bool }