package rpc import ( "context" "strconv" "strings" "unicode/utf8" "github.com/gotd/td/tg" "telesrv/internal/domain" ) // 本文件集中 poll 的 tg↔domain 转换与 updateMessagePoll 组装。 // 定义快照(MessagePoll)不含机密;机密只进 PollDefinition(polls 权威表)。 // resolvePollAuxMedia 解析 poll 配图(答案图/题干图):只允许引用或上传 photo/document, // 其余 InputMedia(含嵌套 poll/todo——会先建孤儿权威行)显式拒绝。 func (r *Router) resolvePollAuxMedia(ctx context.Context, userID int64, input tg.InputMediaClass) (*domain.MessageMedia, error) { switch input.(type) { case *tg.InputMediaPhoto, *tg.InputMediaDocument, *tg.InputMediaUploadedPhoto, *tg.InputMediaUploadedDocument: default: return nil, mediaInvalidErr() } media, err := r.resolveInputMedia(ctx, userID, input) if err != nil { return nil, err } if media == nil || (media.Kind != domain.MessageMediaKindPhoto && media.Kind != domain.MessageMediaKindDocument) { return nil, mediaInvalidErr() } return media, nil } // domainPollFromInputMedia 校验 InputMediaPoll 并产出 (渲染快照, 权威定义)。 // pollID 由调用方分配;now 用于 close_period→close_date 折算。 func (r *Router) domainPollFromInputMedia(ctx context.Context, in *tg.InputMediaPoll, creatorUserID, pollID int64, now int) (*domain.MessagePoll, domain.PollDefinition, error) { var zero domain.PollDefinition poll := in.Poll if poll.SubscribersOnly || len(poll.CountriesISO2) > 0 { // subscribers-only / 国家限定是新版商业能力,当前无业务模型。 return nil, zero, mediaInvalidErr() } // open_answers(开放式答案,允许他人添加选项)在 TDesktop 创建面板默认勾选 //(kDefaultPollCreateFlags),不能拒;其依赖的 addPollAnswer/deletePollAnswer // 未接入,故静默剥离不回 echo——客户端不渲染"添加答案"入口,行为自洽(矩阵 todo)。 question := poll.Question.Text if strings.TrimSpace(question) == "" { return nil, zero, mediaEmptyErr() } if utf8.RuneCountInString(question) > domain.MaxPollQuestionLength { return nil, zero, mediaInvalidErr() } if len(poll.Question.Entities) > maxMessageEntityCount { return nil, zero, limitInvalidErr() } if len(poll.Answers) < domain.MinPollAnswers { return nil, zero, optionInvalidErr() } if len(poll.Answers) > domain.MaxPollAnswers { return nil, zero, optionsTooMuchErr() } snapshot := &domain.MessagePoll{ ID: pollID, Question: question, QuestionEntities: domainMessageEntities(poll.Question.Entities), PublicVoters: poll.PublicVoters, MultipleChoice: poll.MultipleChoice, Quiz: poll.Quiz, RevotingDisabled: poll.RevotingDisabled, ShuffleAnswers: poll.ShuffleAnswers, HideResultsUntilClose: poll.HideResultsUntilClose, } def := domain.PollDefinition{ ID: pollID, CreatorUserID: creatorUserID, PublicVoters: poll.PublicVoters, MultipleChoice: poll.MultipleChoice, Quiz: poll.Quiz, RevotingDisabled: poll.RevotingDisabled, HideResultsUntilClose: poll.HideResultsUntilClose, } // 答案两种形态:pollAnswer 自带 option 键(DrKLO Android 路径); // inputPollAnswer 无 option(TDesktop 创建路径)——option 键由服务端分配。 seen := make(map[string]struct{}, len(poll.Answers)) pendingOption := make([]bool, 0, len(poll.Answers)) for _, answerClass := range poll.Answers { var text tg.TextWithEntities var option []byte var answerMedia *domain.MessageMedia switch answer := answerClass.(type) { case *tg.PollAnswer: if answer.Media != nil { // pollAnswer.media 是 MessageMedia(服务端输出形态),不是合法输入。 return nil, zero, mediaInvalidErr() } if len(answer.Option) == 0 || len(answer.Option) > maxPollOptionBytes { return nil, zero, pollOptionInvalidErr() } text = answer.Text option = append([]byte(nil), answer.Option...) case *tg.InputPollAnswer: // 答案配图(TDesktop 创建面板可给每个答案附 photo/document)。 if input, ok := answer.GetMedia(); ok && input != nil { media, err := r.resolvePollAuxMedia(ctx, creatorUserID, input) if err != nil { return nil, zero, err } answerMedia = media } text = answer.Text default: return nil, zero, pollAnswerInvalidErr() } if strings.TrimSpace(text.Text) == "" || utf8.RuneCountInString(text.Text) > domain.MaxPollAnswerTextLength { return nil, zero, pollAnswerInvalidErr() } if len(text.Entities) > maxMessageEntityCount { return nil, zero, limitInvalidErr() } if option != nil { key := string(option) if _, dup := seen[key]; dup { return nil, zero, pollOptionInvalidErr() } seen[key] = struct{}{} } pendingOption = append(pendingOption, option == nil) snapshot.Answers = append(snapshot.Answers, domain.MessagePollAnswer{ Text: text.Text, Entities: domainMessageEntities(text.Entities), Option: option, Media: answerMedia, }) } // 为 inputPollAnswer 分配服务端 option 键:取未被显式键占用的最小单字节。 next := 0 for i := range snapshot.Answers { if !pendingOption[i] { continue } for next <= 0xFF { key := []byte{byte(next)} next++ if _, taken := seen[string(key)]; !taken { seen[string(key)] = struct{}{} snapshot.Answers[i].Option = key break } } if snapshot.Answers[i].Option == nil { return nil, zero, pollOptionInvalidErr() } } for _, answer := range snapshot.Answers { def.Options = append(def.Options, answer.Option) } // quiz 机密:correct_answers(Layer 224+ 为答案下标)必须恰好 1 个且指向合法选项; // solution 仅 quiz 可带。 correct, hasCorrect := in.GetCorrectAnswers() solution, hasSolution := in.GetSolution() if poll.Quiz { if poll.MultipleChoice { return nil, zero, mediaInvalidErr() } if !hasCorrect || len(correct) != 1 { return nil, zero, quizCorrectAnswersInvalidErr() } for _, index := range correct { if index < 0 || index >= len(def.Options) { return nil, zero, quizCorrectAnswersInvalidErr() } def.CorrectOptions = append(def.CorrectOptions, append([]byte(nil), def.Options[index]...)) } if hasSolution { if strings.TrimSpace(solution) == "" || utf8.RuneCountInString(solution) > domain.MaxPollSolutionLength { return nil, zero, mediaInvalidErr() } entities, _ := in.GetSolutionEntities() if len(entities) > maxMessageEntityCount { return nil, zero, limitInvalidErr() } def.Solution = solution def.SolutionEntities = domainMessageEntities(entities) } } else if hasCorrect || hasSolution { return nil, zero, mediaInvalidErr() } if _, ok := in.GetSolutionMedia(); ok { // quiz 解释配图依赖 polls 权威表扩列(机密侧),当前未接入(矩阵 todo)。 return nil, zero, mediaInvalidErr() } if input, ok := in.GetAttachedMedia(); ok && input != nil { // poll 题干配图(messageMediaPoll.attached_media),随快照落库。 media, err := r.resolvePollAuxMedia(ctx, creatorUserID, input) if err != nil { return nil, zero, err } snapshot.AttachedMedia = media } // close_period / close_date 互斥;period 折算出 close_date 供服务端到点判定。 closePeriod, hasPeriod := poll.GetClosePeriod() closeDate, hasDate := poll.GetCloseDate() if hasPeriod && hasDate { return nil, zero, mediaInvalidErr() } if hasPeriod { if closePeriod < domain.MinPollClosePeriod || closePeriod > domain.MaxPollClosePeriod { return nil, zero, mediaInvalidErr() } snapshot.ClosePeriod = closePeriod snapshot.CloseDate = now + closePeriod def.ClosePeriod = closePeriod def.CloseDate = now + closePeriod } else if hasDate { if closeDate <= now || closeDate > now+domain.MaxPollClosePeriod+60 { return nil, zero, mediaInvalidErr() } snapshot.CloseDate = closeDate def.CloseDate = closeDate } return snapshot, def, nil } // tgPoll 把定义快照转成 tg.Poll(Closed/Results 由读路径 enrichment 决定)。 func tgPoll(p domain.MessagePoll) tg.Poll { out := tg.Poll{ ID: p.ID, Closed: p.Closed, PublicVoters: p.PublicVoters, MultipleChoice: p.MultipleChoice, Quiz: p.Quiz, RevotingDisabled: p.RevotingDisabled, ShuffleAnswers: p.ShuffleAnswers, HideResultsUntilClose: p.HideResultsUntilClose, Question: tgTextWithEntities(p.Question, p.QuestionEntities), Answers: make([]tg.PollAnswerClass, 0, len(p.Answers)), } for _, answer := range p.Answers { item := &tg.PollAnswer{ Text: tgTextWithEntities(answer.Text, answer.Entities), Option: answer.Option, } if answer.Media != nil { item.SetMedia(tgMessageMedia(answer.Media)) } out.Answers = append(out.Answers, item) } if p.ClosePeriod > 0 { out.SetClosePeriod(p.ClosePeriod) } if p.CloseDate > 0 { out.SetCloseDate(p.CloseDate) } return out } // tgPollResults 输出 viewer 视角结果;未 enrich(Results==nil)时回退 min(客户端保留本地缓存)。 func tgPollResults(p domain.MessagePoll) tg.PollResults { out := tg.PollResults{} results := p.Results if results == nil { out.Min = true return out } voters := make([]tg.PollAnswerVoters, 0, len(results.Voters)) for _, item := range results.Voters { entry := tg.PollAnswerVoters{Chosen: item.Chosen, Correct: item.Correct, Option: item.Option} entry.SetVoters(item.Voters) voters = append(voters, entry) } out.SetResults(voters) out.SetTotalVoters(results.TotalVoters) if len(results.RecentVoters) > 0 { peers := make([]tg.PeerClass, 0, len(results.RecentVoters)) for _, userID := range results.RecentVoters { peers = append(peers, &tg.PeerUser{UserID: userID}) } out.SetRecentVoters(peers) } if results.Solution != "" { out.SetSolution(results.Solution) out.SetSolutionEntities(tgMessageEntities(results.SolutionEntities)) } return out } func tgTextWithEntities(text string, entities []domain.MessageEntity) tg.TextWithEntities { converted := tgMessageEntities(entities) if converted == nil { converted = []tg.MessageEntityClass{} } return tg.TextWithEntities{Text: text, Entities: converted} } // tgUpdateMessagePoll 组装一条 updateMessagePoll(poll 总是内联,避免客户端缓存缺失)。 func tgUpdateMessagePoll(peer domain.Peer, msgID int, poll *domain.MessagePoll) *tg.UpdateMessagePoll { if poll == nil { return nil } update := &tg.UpdateMessagePoll{ PollID: poll.ID, Results: tgPollResults(*poll), } update.SetPoll(tgPoll(*poll)) if tgp := tgPeer(peer); tgp != nil && msgID > 0 { update.SetPeer(tgp) update.SetMsgID(msgID) } return update } // pollVotesNextOffset 编解码 getPollVotes 翻页 token(",")。 func pollVotesNextOffset(votes []domain.PollVote) string { if len(votes) == 0 { return "" } last := votes[len(votes)-1] return strconv.Itoa(last.Date) + "," + strconv.FormatInt(last.UserID, 10) } func decodePollVotesOffset(offset string) (date int, userID int64, ok bool) { if offset == "" { return 0, 0, true } parts := strings.SplitN(offset, ",", 2) if len(parts) != 2 { return 0, 0, false } d, err1 := strconv.Atoi(parts[0]) u, err2 := strconv.ParseInt(parts[1], 10, 64) if err1 != nil || err2 != nil || d < 0 || u < 0 { return 0, 0, false } return d, u, true }