package bots import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "strconv" "strings" "time" "unicode/utf8" "go.uber.org/zap" verificationapp "telesrv/internal/app/verification" "telesrv/internal/branding" "telesrv/internal/domain" ) // Built-in @verifybot: the applicant front door for official platform // verification. // // The dialog is button driven (inline keyboards) for every closed choice -- which // peer to file, which category, confirm/skip/cancel -- and text driven for the // free-form fields. It owns no verification rule whatsoever: ownership, public // username, restrictions, already-verified, cooldown, rate limits and the status // machine all live in app/verification, and this file only renders the answers // that service gives. Every field is checked by the domain // (ValidateVerificationURL, ValidateDraft, ValidateForSubmission) before it is // offered to the service, so the applicant gets a specific reason instead of a // generic refusal. // // Conversation state is domain.BotChatState (Command="verify"), read and written // under the per-user serviceBotReplyLock, so the Get->modify->Upsert round trip // is atomic and replies stay ordered. The application record itself is the // durable truth: every completed step is pushed into it with SaveDraft, and the // chat state is only a resumable cursor over it plus the button token table. // Both ends of the contract are asserted here, so a signature change in // app/verification breaks the build instead of silently disabling the bot or the // decision notifications. var ( _ verificationApplications = (*verificationapp.Service)(nil) _ verificationapp.ApplicantNotifier = (*Service)(nil) ) const ( // verifyBotCommand is the single BotChatState.Command of this dialog. There is // one flow, so the step is what distinguishes the phases. verifyBotCommand = "verify" verifyBotCmdNew = "new" verifyBotCmdStatus = "status" verifyStepIntro = "intro" verifyStepTarget = "target" verifyStepCategory = "category" verifyStepDescription = "description" verifyStepWebsite = "website" verifyStepSocial = "social" verifyStepPress = "press" verifyStepNote = "note" verifyStepConfirm = "confirm" // verifyStepDone is a terminal state that is deliberately *kept*: it is what // makes a second press of the Submit button replay the same confirmation // instead of filing a second application or reporting an expired button. verifyStepDone = "done" verifyDraftAppID = "app" verifyDraftVersion = "ver" verifyDraftCategory = "cat" verifyDraftDescription = "desc" verifyDraftWebsite = "site" verifyDraftSocial = "social" verifyDraftPress = "press" verifyDraftNote = "note" verifyDraftTargetTitle = "tgt_title" verifyDraftTargetUsername = "tgt_username" verifyDraftGeneration = "gen" // verifyDraftOptionPrefix namespaces the button token table inside the draft // map, so a token can never collide with a payload field. verifyDraftOptionPrefix = "opt:" // verifyCallbackDataPrefix tags the bot's own callback data. It carries no // information beyond "this is a @verifybot button". verifyCallbackDataPrefix = "vb:" // verifyOptionTokenBytes is the entropy behind one button token. verifyOptionTokenBytes = 6 // verifyOptionTokenMaxLen bounds the token part of callback data before the // table is even consulted. verifyOptionTokenMaxLen = 32 // verifyTokenGenerations is how many keyboard renders' worth of button tokens // stay resolvable: the current render plus the two before it. Enough that // pressing the same button twice (or a button on the message just above) still // resolves, while the table stays a bounded handful of entries. verifyTokenGenerations = 3 // verifyMaxTargetButtons bounds one target picker. An operator account can // administer far more peers than fit in an inline keyboard, and the token table // is per-user durable state, so the picker is truncated rather than unbounded. verifyMaxTargetButtons = 12 // verifyStatusListLimit bounds the /status listing. verifyStatusListLimit = 10 // Opaque button choices as stored in the per-user token table. These strings // never travel over the wire: only the random token that maps to them does. verifyChoiceApply = "apply" verifyChoiceSkip = "skip" verifyChoiceSubmit = "submit" verifyChoiceAbort = "abort" verifyChoiceTargetPrefix = "tgt:" verifyChoiceCategoryPrefix = "cat:" verifyChoiceBlockedPrefix = "no:" ) func verifyBotStartText() string { return `I collect applications for official ` + branding.ProductName() + ` verification: the badge shown next to the name of a channel, supergroup or bot whose identity has been confirmed. Before you apply, check that the subject of the application: - is a channel, supergroup or bot with a public @username; - is one you created or administer; - stands for a real organisation, brand, media outlet, institution or public figure; - has been written about by press you do not control. This badge is never sold and never granted automatically. A person reads every application, and I message you here with the decision. Tap the button below, or send /new, to start. Send /help for the full list of commands.` } func verifyBotHelpText() string { return `I collect official ` + branding.ProductName() + ` verification applications. /new - file a verification application /status - list your applications and their status /cancel - withdraw the application you are working on /help - show this message One application asks for: the subject, a category, a description, the official website, optional social links, links to independent press coverage, and an optional comment for the reviewers. You can send /cancel at any point, and /status any time after filing.` } const verifyBotIdleText = `I only collect official verification applications. Send /new to file one, /status to check the ones you filed, or /help to see what I understand.` const ( verifyPromptTargetText = `Which one do you want verified? Pick it below.` verifyPromptCategoryText = `What kind of project is it? Pick the closest category.` verifyPromptDescriptionTmpl = `Now describe the subject in one message: what it is, who is behind it and what it is publicly known for. Between %d and %d characters.` verifyPromptWebsiteText = `Send the official website of the subject, for example https://example.com It has to be a plain public http(s) address. I never open the links you send: they are stored and shown to the reviewers as links, nothing more.` verifyPromptSocialTmpl = `Send links to the official social media accounts of the subject, one per line, up to %d of them. Tap Skip if there are none.` verifyPromptPressTmpl = `Send links to independent press coverage, one per line: at least %d of them, up to %d. This is the part a reviewer actually checks, so it has to be coverage you do not control -- articles about the subject on news sites, not your own pages or social posts.` verifyPromptNoteText = `Anything else the reviewers should know? Send it in one message, or tap Skip.` verifyURLRejectText = `That is not a link I can accept. It has to be a plain http:// or https:// address of a public site, with no login credentials and no unusual port -- for example https://example.com/news/story.` verifyExpiredButtonText = `That button is no longer active. Send /new to start an application, or /status to see the ones you filed.` verifyUnavailableText = `Official verification is not available on this server right now.` verifyNoTargetsText = `I could not find anything of yours to verify. Official verification covers a channel, supergroup or bot with a public @username that you created or administer, so give the subject a public username first and come back with /new.` verifyNoEligibleText = `None of the ones you administer can be filed right now. Tap any of them to see why.` verifyPickButtonsText = `Please use the buttons in my message above.` verifyNothingToCancelText = `There is nothing to cancel. Send /new to file an application.` verifyNoApplicationsText = `You have not filed any verification applications yet. Send /new to file one.` verifyIncompleteText = `The application is not complete yet, so let's fill the missing part first.` verifySubmitButtonText = `Submit application` verifySkipButtonText = `Skip` verifyCancelButtonText = `Cancel application` verifyApplyButtonText = `Apply for verification` ) // verifyBotGlobalCommands are the commands honoured in every step, so an // applicant is never trapped mid-form. Same contract as botFatherGlobalCommands. var verifyBotGlobalCommands = map[string]bool{ "start": true, "help": true, "cancel": true, verifyBotCmdNew: true, verifyBotCmdStatus: true, } // verifyCategoryLabels renders domain.VerificationCategories for humans. A // missing entry falls back to the raw value, so adding a category cannot make the // picker disappear. var verifyCategoryLabels = map[string]string{ "media": "Media outlet", "government": "Government", "company": "Company", "brand": "Brand", "sport": "Sport", "culture": "Culture", "education": "Education", "nonprofit": "Non-profit", "public_figure": "Public figure", "service": "Service", "other": "Other", } // verifyOption is one inline button before its token is minted. type verifyOption struct { text string choice string style domain.MarkupButtonStyle } // --------------------------------------------------------------------------- // Message entry point // --------------------------------------------------------------------------- // respondAsVerify generates and writes one @verifybot reply. It is called from // OnPrivateMessage's goroutine, serialised per user by serviceBotReplyLock, on a // context detached from the user's already-answered RPC. func (s *Service) respondAsVerify(userID int64, msg domain.Message) { mu := s.serviceBotReplyLock(domain.VerifyBotUserID, userID) mu.Lock() defer mu.Unlock() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if allowed, retryAfter := s.allowVerifyDialog(ctx, userID); !allowed { s.sendServiceBotReply(ctx, domain.VerifyBotUserID, userID, botReply{ Text: verifyFloodText(retryAfter), }) return } reply := s.handleVerify(ctx, userID, msg.Body) s.sendServiceBotReply(ctx, domain.VerifyBotUserID, userID, reply) } // allowVerifyDialog bounds dialog traffic per applicant. It is deliberately // separate from the application-creation limit inside the verification service: // that one bounds how many applications exist, this one bounds how hard the state // machine itself can be driven, so a script cannot burn writes without ever // submitting. A missing limiter means no bound, which is the pre-Redis behaviour. func (s *Service) allowVerifyDialog(ctx context.Context, userID int64) (bool, int) { if s == nil || s.dialogLimiter == nil || s.dialogRateLimit <= 0 || s.dialogRateWindow <= 0 { return true, 0 } key := fmt.Sprintf("bot-dialog:%d:%d", domain.VerifyBotUserID, userID) allowed, retryAfter, err := s.dialogLimiter.Allow(ctx, key, s.dialogRateLimit, s.dialogRateWindow) if err != nil { // A limiter outage must not silence the bot: fail open, log once. s.log.Warn("verifybot: dialog rate limit check failed", zap.Int64("user_id", userID), zap.Error(err)) return true, 0 } return allowed, retryAfter } func verifyFloodText(retryAfter int) string { if retryAfter > 0 { return fmt.Sprintf("Too many requests. Please wait %d seconds and try again.", retryAfter) } return "Too many requests. Please wait a moment and try again." } func (s *Service) handleVerify(ctx context.Context, userID int64, body string) botReply { text := strings.TrimSpace(body) state, found, err := s.bots.GetBotChatState(ctx, domain.VerifyBotUserID, userID) if err != nil { s.log.Error("verifybot: get chat state", zap.Int64("user_id", userID), zap.Error(err)) return internalReply() } if found && state.Command != verifyBotCommand { // Unreachable dirty state (an older dialog shape): drop it rather than trap // the applicant in a step no handler owns. s.deleteVerifyState(ctx, userID) state, found = domain.BotChatState{}, false } if cmd, ok := parseBotCommand(text); ok { if verifyBotGlobalCommands[cmd] { return s.handleVerifyCommand(ctx, userID, cmd, state, found) } return botReply{Text: "I do not know that command. Send /help for the list."} } if !found { if text == "" { // Stickers and captionless media with no active dialog: stay silent // instead of answering every non-text message. return botReply{} } return botReply{Text: verifyBotIdleText} } if text == "" { return s.verifyStepReminder(state) } switch state.Step { case verifyStepDescription: return s.handleVerifyDescription(ctx, state, text) case verifyStepWebsite: return s.handleVerifyWebsite(ctx, state, text) case verifyStepSocial: return s.handleVerifySocial(ctx, state, text) case verifyStepPress: return s.handleVerifyPress(ctx, state, text) case verifyStepNote: return s.handleVerifyNote(ctx, state, text) case verifyStepIntro, verifyStepDone: return botReply{Text: verifyBotIdleText} case verifyStepTarget, verifyStepCategory, verifyStepConfirm: // Button-only steps. Answered deliberately without re-rendering the // keyboard: a fresh render mints a new token generation and would eventually // expire the very buttons the applicant is looking at. return botReply{Text: verifyPickButtonsText} default: s.deleteVerifyState(ctx, userID) return botReply{Text: "Something went wrong, I forgot what we were doing. Send /new to start again."} } } func (s *Service) handleVerifyCommand(ctx context.Context, userID int64, cmd string, state domain.BotChatState, found bool) botReply { if cmd == "help" { return botReply{Text: verifyBotHelpText()} } if s.verification == nil { return botReply{Text: verifyUnavailableText} } switch cmd { case "start": return s.verifyIntro(ctx, userID, state, found) case verifyBotCmdNew: if !found { state = verifyNewState(userID) } return s.startVerifyApplication(ctx, state) case verifyBotCmdStatus: return s.verifyStatusReply(ctx, userID) case "cancel": return s.cancelVerifyApplication(ctx, userID, state, found) default: return botReply{Text: verifyBotHelpText()} } } // --------------------------------------------------------------------------- // Callback entry point // --------------------------------------------------------------------------- // OnCallbackQuery answers an inline-button click on a message sent by one of the // built-in bots; it is the bots side of rpc.ServiceBotCallbacks. ok=false means // "not one of my bots", which the edge reports as DATA_INVALID. // // The RPC edge calls this synchronously instead of pushing // updateBotCallbackQuery, because an internal bot has no MTProto session and no // Bot API queue: the ordinary path could only ever end in BOT_RESPONSE_TIMEOUT. func (s *Service) OnCallbackQuery(ctx context.Context, query domain.BotCallbackQuery) (domain.BotCallbackAnswer, bool, error) { if s == nil || s.bots == nil || !s.HandlesBot(query.BotUserID) { return domain.BotCallbackAnswer{}, false, nil } if query.UserID <= 0 || query.UserID == query.BotUserID { return domain.BotCallbackAnswer{}, false, nil } if query.BotUserID == domain.VerifierBotUserID { // The built-in third-party verifier owns its own token table and dialog // (verifierbot.go). return s.onVerifierCallback(ctx, query) } if query.BotUserID == domain.BotFatherUserID { // @BotFather's button-driven /mybots menu (mybots.go). return s.onBotFatherCallback(ctx, query) } if query.BotUserID != domain.VerifyBotUserID { // The other built-in bots never attach an inline keyboard, so there is // nothing to route. An empty answer still beats hanging the click for the // whole callback timeout. return domain.BotCallbackAnswer{}, true, nil } return s.onVerifyCallback(ctx, query) } func (s *Service) onVerifyCallback(ctx context.Context, query domain.BotCallbackQuery) (domain.BotCallbackAnswer, bool, error) { userID := query.UserID mu := s.serviceBotReplyLock(domain.VerifyBotUserID, userID) mu.Lock() defer mu.Unlock() if allowed, retryAfter := s.allowVerifyDialog(ctx, userID); !allowed { // Answering with an alert keeps the click from hanging for the whole callback // timeout, and the dialog state is left exactly as it was. return domain.BotCallbackAnswer{Message: verifyFloodText(retryAfter), Alert: true}, true, nil } state, found, err := s.bots.GetBotChatState(ctx, domain.VerifyBotUserID, userID) if err != nil { s.log.Error("verifybot: get chat state for callback", zap.Int64("user_id", userID), zap.Error(err)) return domain.BotCallbackAnswer{}, true, err } if !found || state.Command != verifyBotCommand { return verifyAlert(verifyExpiredButtonText), true, nil } choice, ok := verifyResolveOption(state, query.Data) if !ok { // The only way to reach this is callback data that is not in *this* user's // own token table: a button from an expired generation, or data that was // replayed or fabricated. All of them are refused identically and change // nothing at all. return verifyAlert(verifyExpiredButtonText), true, nil } if s.verification == nil { return verifyAlert(verifyUnavailableText), true, nil } // The follow-up message is written on a context detached from the caller's // RPC: the answer unblocks the click, and the message must survive the client // hanging up immediately afterwards. sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) defer cancel() reply, answer := s.applyVerifyChoice(sendCtx, state, choice) s.sendServiceBotReply(sendCtx, domain.VerifyBotUserID, userID, reply) return answer, true, nil } // applyVerifyChoice executes one resolved button choice. func (s *Service) applyVerifyChoice(ctx context.Context, state domain.BotChatState, choice string) (botReply, domain.BotCallbackAnswer) { switch { case choice == verifyChoiceApply: return s.startVerifyApplication(ctx, state), domain.BotCallbackAnswer{} case choice == verifyChoiceAbort: return s.cancelVerifyApplication(ctx, state.UserID, state, true), domain.BotCallbackAnswer{} case choice == verifyChoiceSubmit: return s.submitVerifyApplication(ctx, state), domain.BotCallbackAnswer{} case choice == verifyChoiceSkip: return s.skipVerifyStep(ctx, state), domain.BotCallbackAnswer{} case strings.HasPrefix(choice, verifyChoiceTargetPrefix): return s.chooseVerifyTarget(ctx, state, strings.TrimPrefix(choice, verifyChoiceTargetPrefix)), domain.BotCallbackAnswer{} case strings.HasPrefix(choice, verifyChoiceCategoryPrefix): return s.chooseVerifyCategory(ctx, state, strings.TrimPrefix(choice, verifyChoiceCategoryPrefix)), domain.BotCallbackAnswer{} case strings.HasPrefix(choice, verifyChoiceBlockedPrefix): // An ineligible candidate is offered as a button only so the reason can be // told on tap. It changes nothing. return botReply{}, verifyAlert(verifyIneligibleText(strings.TrimPrefix(choice, verifyChoiceBlockedPrefix))) default: s.log.Warn("verifybot: unknown button choice", zap.Int64("user_id", state.UserID), zap.String("choice", choice)) return botReply{}, verifyAlert(verifyExpiredButtonText) } } // verifyAlert is a refusal shown as a modal on the clicking client. A refusal is // deliberately an alert rather than a toast: it is the only feedback the applicant // gets, because a refused click writes no message. func verifyAlert(text string) domain.BotCallbackAnswer { return domain.BotCallbackAnswer{Alert: true, Message: verifyClampAnswer(text)} } func verifyClampAnswer(text string) string { return verifyTruncate(text, domain.MaxBotCallbackAnswerLen) } // --------------------------------------------------------------------------- // Flow // --------------------------------------------------------------------------- // verifyIntro answers /start. It does not throw away an application in progress: // the button it offers resumes the existing draft. func (s *Service) verifyIntro(ctx context.Context, userID int64, state domain.BotChatState, found bool) botReply { if !found { state = verifyNewState(userID) state.Step = verifyStepIntro } markup := s.verifyOptionKeyboard(&state, [][]verifyOption{{ {text: verifyApplyButtonText, choice: verifyChoiceApply, style: domain.MarkupButtonStylePrimary}, }}) if !s.saveVerifyState(ctx, state) { return internalReply() } return botReply{Text: verifyBotStartText(), ReplyMarkup: markup} } // startVerifyApplication is /new and the Apply button. An applicant has at most // one draft, so an existing one is resumed rather than replaced -- which is also // what StartDraft does, so the dialog follows the record instead of guessing. func (s *Service) startVerifyApplication(ctx context.Context, state domain.BotChatState) botReply { if s.verification == nil { return botReply{Text: verifyUnavailableText} } verifyEnsureDraft(&state) app, err := s.verification.Draft(ctx, state.UserID) switch { case err == nil && app.ID > 0: verifyHydrateState(&state, app) lead := fmt.Sprintf("You already have application #%d in progress for %s, so let's carry on. Send /cancel to drop it and start over.", app.ID, verifyTargetLabel(app.TargetTitle, app.TargetUsername)) return s.verifyAdvance(ctx, state, verifyMissingStep(verifyDraftInputOf(app)), lead) case err != nil && !errors.Is(err, domain.ErrVerificationApplicationNotFound): return s.verifyErrorReply(state.UserID, "read draft", err) } return s.verifyAdvance(ctx, state, verifyStepTarget, "") } // chooseVerifyTarget opens the draft for the picked subject. // // The type and id come from the token table, i.e. from state this server wrote // when it rendered the keyboard -- never from the callback data. StartDraft // re-checks ownership and eligibility anyway, and it is idempotent: a second // press returns the same draft with created=false, so the same reply is produced // and no second application exists. func (s *Service) chooseVerifyTarget(ctx context.Context, state domain.BotChatState, payload string) botReply { kind, rawID, split := strings.Cut(payload, ":") targetID, err := strconv.ParseInt(rawID, 10, 64) targetType := domain.VerificationTargetType(kind) if !split || err != nil || targetID <= 0 || !targetType.Valid() { s.log.Warn("verifybot: malformed target choice", zap.Int64("user_id", state.UserID), zap.String("payload", payload)) return botReply{Text: verifyExpiredButtonText} } app, created, err := s.verification.StartDraft(ctx, domain.SubmitVerificationApplicationRequest{ ApplicantUserID: state.UserID, TargetType: targetType, TargetID: targetID, }) if err != nil { return s.verifyErrorReply(state.UserID, "start draft", err) } if !created && (app.TargetID != targetID || app.TargetType != targetType) { // One draft per applicant is the store's contract, so say so instead of // silently switching the subject under the applicant. return botReply{Text: fmt.Sprintf("You already have application #%d in progress for %s. Send /cancel to drop it, then /new to pick another subject.", app.ID, verifyTargetLabel(app.TargetTitle, app.TargetUsername))} } verifyHydrateState(&state, app) lead := fmt.Sprintf("Subject: %s. This is application #%d.", verifyTargetLabel(app.TargetTitle, app.TargetUsername), app.ID) return s.verifyAdvance(ctx, state, verifyMissingStep(verifyDraftInputOf(app)), lead) } func (s *Service) chooseVerifyCategory(ctx context.Context, state domain.BotChatState, payload string) botReply { if !domain.ValidVerificationCategory(payload) { s.log.Warn("verifybot: unknown category choice", zap.Int64("user_id", state.UserID), zap.String("payload", payload)) return botReply{Text: verifyExpiredButtonText} } verifyEnsureDraft(&state) state.Draft[verifyDraftCategory] = payload if reply, ok := s.saveVerifyDraft(ctx, &state); !ok { return reply } return s.verifyAdvance(ctx, state, verifyStepDescription, "Category: "+verifyCategoryLabel(payload)+".") } func (s *Service) skipVerifyStep(ctx context.Context, state domain.BotChatState) botReply { verifyEnsureDraft(&state) switch state.Step { case verifyStepSocial: state.Draft[verifyDraftSocial] = "" if reply, ok := s.saveVerifyDraft(ctx, &state); !ok { return reply } return s.verifyAdvance(ctx, state, verifyStepPress, "No social links.") case verifyStepNote: state.Draft[verifyDraftNote] = "" if reply, ok := s.saveVerifyDraft(ctx, &state); !ok { return reply } return s.verifyAdvance(ctx, state, verifyStepConfirm, "No extra comment.") default: return s.verifyStepReminder(state) } } func (s *Service) handleVerifyDescription(ctx context.Context, state domain.BotChatState, text string) botReply { length := utf8.RuneCountInString(text) if length < domain.MinVerificationDescriptionLength { return botReply{Text: fmt.Sprintf("That is %d characters and I need at least %d. Say what the subject is, who is behind it and what it is publicly known for.", length, domain.MinVerificationDescriptionLength)} } if length > domain.MaxVerificationDescriptionLength { return botReply{Text: fmt.Sprintf("That is %d characters and the limit is %d. Please shorten it.", length, domain.MaxVerificationDescriptionLength)} } verifyEnsureDraft(&state) state.Draft[verifyDraftDescription] = text if reply, ok := s.saveVerifyDraft(ctx, &state); !ok { return reply } return s.verifyAdvance(ctx, state, verifyStepWebsite, "Description saved.") } func (s *Service) handleVerifyWebsite(ctx context.Context, state domain.BotChatState, text string) botReply { if err := domain.ValidateVerificationURL(text); err != nil { return botReply{Text: verifyURLRejectText} } verifyEnsureDraft(&state) state.Draft[verifyDraftWebsite] = text if reply, ok := s.saveVerifyDraft(ctx, &state); !ok { return reply } return s.verifyAdvance(ctx, state, verifyStepSocial, "Website saved.") } func (s *Service) handleVerifySocial(ctx context.Context, state domain.BotChatState, text string) botReply { if verifyIsSkipText(text) { return s.skipVerifyStep(ctx, state) } links := verifySplitLinks(text) if len(links) > domain.MaxVerificationSocialLinks { return botReply{Text: fmt.Sprintf("That is %d links and I can keep at most %d. Please send the most important ones.", len(links), domain.MaxVerificationSocialLinks)} } if reply, ok := verifyCheckLinks(links); !ok { return reply } verifyEnsureDraft(&state) state.Draft[verifyDraftSocial] = strings.Join(links, "\n") if reply, ok := s.saveVerifyDraft(ctx, &state); !ok { return reply } return s.verifyAdvance(ctx, state, verifyStepPress, fmt.Sprintf("Saved %d social link(s).", len(links))) } func (s *Service) handleVerifyPress(ctx context.Context, state domain.BotChatState, text string) botReply { links := verifySplitLinks(text) if len(links) < domain.MinVerificationPressLinks { return botReply{Text: fmt.Sprintf("I counted %d link(s) and the official bar is at least %d, from outlets you do not control. Send them in one message, one per line.", len(links), domain.MinVerificationPressLinks)} } if len(links) > domain.MaxVerificationPressLinks { return botReply{Text: fmt.Sprintf("That is %d links and I can keep at most %d. Please send the strongest ones.", len(links), domain.MaxVerificationPressLinks)} } if reply, ok := verifyCheckLinks(links); !ok { return reply } verifyEnsureDraft(&state) state.Draft[verifyDraftPress] = strings.Join(links, "\n") if reply, ok := s.saveVerifyDraft(ctx, &state); !ok { return reply } return s.verifyAdvance(ctx, state, verifyStepNote, fmt.Sprintf("Saved %d press link(s).", len(links))) } func (s *Service) handleVerifyNote(ctx context.Context, state domain.BotChatState, text string) botReply { if verifyIsSkipText(text) { return s.skipVerifyStep(ctx, state) } if utf8.RuneCountInString(text) > domain.MaxVerificationCommentLength { return botReply{Text: fmt.Sprintf("That comment is %d characters and the limit is %d. Please shorten it.", utf8.RuneCountInString(text), domain.MaxVerificationCommentLength)} } verifyEnsureDraft(&state) state.Draft[verifyDraftNote] = text if reply, ok := s.saveVerifyDraft(ctx, &state); !ok { return reply } return s.verifyAdvance(ctx, state, verifyStepConfirm, "Comment saved.") } // submitVerifyApplication files the draft. // // Pressing Submit twice is idempotent by construction: the first press moves the // dialog to verifyStepDone, and a repeat press is answered from the stored // application instead of calling Submit again. func (s *Service) submitVerifyApplication(ctx context.Context, state domain.BotChatState) botReply { applicationID := verifyDraftInt(state, verifyDraftAppID) if applicationID <= 0 { return botReply{Text: verifyBotIdleText} } if state.Step == verifyStepDone { app, err := s.verification.Application(ctx, applicationID) if err != nil { return s.verifyErrorReply(state.UserID, "read application", err) } if app.Status == domain.VerificationStatusSubmitted || app.Status == domain.VerificationStatusInReview { return botReply{Text: verifySubmittedText(app)} } // It has moved on since it was filed (decided, or withdrawn from elsewhere): // report where it stands instead of repeating a stale confirmation. return botReply{Text: fmt.Sprintf("Application #%d for %s: %s. Send /status for the full list.", app.ID, verifyTargetLabel(app.TargetTitle, app.TargetUsername), verifyStatusLabel(app.Status))} } input := verifyDraftInput(state) if err := input.ValidateForSubmission(); err != nil { return s.verifyAdvance(ctx, state, verifyMissingStep(input), verifyIncompleteText) } app, err := s.verification.Submit(ctx, state.UserID, applicationID, verifyDraftInt(state, verifyDraftVersion)) if errors.Is(err, domain.ErrVerificationVersionConflict) { if current, readErr := s.verification.Application(ctx, applicationID); readErr == nil { app, err = s.verification.Submit(ctx, state.UserID, applicationID, current.Version) } } if errors.Is(err, domain.ErrVerificationApplicationInvalid) { // The service enforces the full official bar at submission and the record is // the truth, not this dialog's cursor. Re-read it and put the applicant back // on the step that is actually missing instead of narrating a validation // error they cannot act on. if current, readErr := s.verification.Application(ctx, applicationID); readErr == nil && current.Editable() { verifyHydrateState(&state, current) return s.verifyAdvance(ctx, state, verifyMissingStep(verifyDraftInputOf(current)), verifyIncompleteText) } } if err != nil { return s.verifyErrorReply(state.UserID, "submit application", err) } state.Step = verifyStepDone verifyEnsureDraft(&state) state.Draft[verifyDraftVersion] = strconv.FormatInt(app.Version, 10) if !s.saveVerifyState(ctx, state) { return internalReply() } return botReply{Text: verifySubmittedText(app)} } // cancelVerifyApplication withdraws the applicant's open application, whether it // is still a draft in this dialog or already in the review queue. func (s *Service) cancelVerifyApplication(ctx context.Context, userID int64, state domain.BotChatState, haveState bool) botReply { if s.verification == nil { return botReply{Text: verifyUnavailableText} } app, found := s.verifyActiveApplication(ctx, userID, state, haveState) if haveState { s.deleteVerifyState(ctx, userID) } if !found { return botReply{Text: verifyNothingToCancelText} } cancelled, err := s.verification.Cancel(ctx, userID, app.ID, app.Version, "withdrawn by the applicant") if errors.Is(err, domain.ErrVerificationVersionConflict) { if current, readErr := s.verification.Application(ctx, app.ID); readErr == nil { cancelled, err = s.verification.Cancel(ctx, userID, app.ID, current.Version, "withdrawn by the applicant") } } if err != nil { return s.verifyErrorReply(userID, "cancel application", err) } return botReply{Text: fmt.Sprintf("Application #%d for %s is withdrawn. Send /new when you want to file another one.", cancelled.ID, verifyTargetLabel(cancelled.TargetTitle, cancelled.TargetUsername))} } // verifyActiveApplication finds the application /cancel should act on: the one // this dialog is holding, else the open draft, else the newest still-active // record. func (s *Service) verifyActiveApplication(ctx context.Context, userID int64, state domain.BotChatState, haveState bool) (domain.VerificationApplication, bool) { if haveState { if id := verifyDraftInt(state, verifyDraftAppID); id > 0 { if app, err := s.verification.Application(ctx, id); err == nil && app.ApplicantUserID == userID && app.Status.Active() { return app, true } } } if app, err := s.verification.Draft(ctx, userID); err == nil && app.ID > 0 { return app, true } apps, err := s.verification.ApplicantApplications(ctx, userID, verifyStatusListLimit) if err != nil { s.log.Warn("verifybot: list applications for cancel", zap.Int64("user_id", userID), zap.Error(err)) return domain.VerificationApplication{}, false } for _, app := range apps { if app.Status.Active() { return app, true } } return domain.VerificationApplication{}, false } func (s *Service) verifyStatusReply(ctx context.Context, userID int64) botReply { apps, err := s.verification.ApplicantApplications(ctx, userID, verifyStatusListLimit) if err != nil { return s.verifyErrorReply(userID, "list applications", err) } if len(apps) == 0 { return botReply{Text: verifyNoApplicationsText} } var b strings.Builder b.WriteString("Your verification applications:") for _, app := range apps { b.WriteString("\n\n#") b.WriteString(strconv.FormatInt(app.ID, 10)) b.WriteString(" - ") b.WriteString(verifyTargetLabel(app.TargetTitle, app.TargetUsername)) b.WriteString("\nStatus: ") b.WriteString(verifyStatusLabel(app.Status)) if date := verifyDateLabel(app); date != "" { b.WriteString(" (") b.WriteString(date) b.WriteString(")") } // Only DecisionReason is ever shown: it is the reason written to be read by // the applicant. app.InternalNote is the reviewers' private note and must // never appear in a bot message. if app.Status == domain.VerificationStatusRejected { if reason := strings.TrimSpace(app.DecisionReason); reason != "" { b.WriteString("\nReason: ") b.WriteString(reason) } } } b.WriteString("\n\nSend /new to file another application.") return botReply{Text: b.String()} } // verifyAdvance moves the dialog to step and renders that step's prompt. lead is // an optional acknowledgement of what was just accepted. func (s *Service) verifyAdvance(ctx context.Context, state domain.BotChatState, step, lead string) botReply { state.Step = step verifyEnsureDraft(&state) var reply botReply switch step { case verifyStepTarget: return s.verifyTargetPrompt(ctx, state, lead) case verifyStepCategory: markup := s.verifyOptionKeyboard(&state, verifyCategoryRows()) reply = botReply{Text: verifyJoin(lead, verifyPromptCategoryText), ReplyMarkup: markup} case verifyStepDescription: reply = botReply{Text: verifyJoin(lead, fmt.Sprintf(verifyPromptDescriptionTmpl, domain.MinVerificationDescriptionLength, domain.MaxVerificationDescriptionLength))} case verifyStepWebsite: reply = botReply{Text: verifyJoin(lead, verifyPromptWebsiteText)} case verifyStepSocial: markup := s.verifyOptionKeyboard(&state, verifySkipRows()) reply = botReply{ Text: verifyJoin(lead, fmt.Sprintf(verifyPromptSocialTmpl, domain.MaxVerificationSocialLinks)), ReplyMarkup: markup, } case verifyStepPress: reply = botReply{Text: verifyJoin(lead, fmt.Sprintf(verifyPromptPressTmpl, domain.MinVerificationPressLinks, domain.MaxVerificationPressLinks))} case verifyStepNote: markup := s.verifyOptionKeyboard(&state, verifySkipRows()) reply = botReply{Text: verifyJoin(lead, verifyPromptNoteText), ReplyMarkup: markup} case verifyStepConfirm: markup := s.verifyOptionKeyboard(&state, [][]verifyOption{ {{text: verifySubmitButtonText, choice: verifyChoiceSubmit, style: domain.MarkupButtonStyleSuccess}}, {{text: verifyCancelButtonText, choice: verifyChoiceAbort, style: domain.MarkupButtonStyleDanger}}, }) reply = botReply{Text: verifyJoin(lead, verifySummaryText(state)), ReplyMarkup: markup} default: s.log.Error("verifybot: advance to unknown step", zap.Int64("user_id", state.UserID), zap.String("step", step)) return internalReply() } if !s.saveVerifyState(ctx, state) { return internalReply() } return reply } // verifyTargetPrompt renders the subject picker. Ineligible candidates are shown // too, as buttons that only explain themselves, so "why is my channel not // listed" never needs asking. func (s *Service) verifyTargetPrompt(ctx context.Context, state domain.BotChatState, lead string) botReply { targets, err := s.verification.EligibleTargets(ctx, state.UserID) if err != nil { return s.verifyErrorReply(state.UserID, "eligible targets", err) } if len(targets) == 0 { return botReply{Text: verifyJoin(lead, verifyNoTargetsText)} } rows := make([][]verifyOption, 0, verifyMaxTargetButtons+1) eligible := 0 for _, wantEligible := range []bool{true, false} { for _, target := range targets { if target.Eligible != wantEligible || len(rows) >= verifyMaxTargetButtons { continue } label := verifyTargetButtonText(target) if wantEligible { eligible++ rows = append(rows, []verifyOption{{ text: label, choice: verifyChoiceTargetPrefix + string(target.Type) + ":" + strconv.FormatInt(target.ID, 10), }}) continue } rows = append(rows, []verifyOption{{ text: label + " (unavailable)", choice: verifyChoiceBlockedPrefix + target.Reason, }}) } } rows = append(rows, []verifyOption{{ text: verifyCancelButtonText, choice: verifyChoiceAbort, style: domain.MarkupButtonStyleDanger, }}) prompt := verifyPromptTargetText if eligible == 0 { prompt = verifyNoEligibleText } markup := s.verifyOptionKeyboard(&state, rows) if !s.saveVerifyState(ctx, state) { return internalReply() } return botReply{Text: verifyJoin(lead, prompt), ReplyMarkup: markup} } func (s *Service) verifyStepReminder(state domain.BotChatState) botReply { switch state.Step { case verifyStepDescription: return botReply{Text: fmt.Sprintf(verifyPromptDescriptionTmpl, domain.MinVerificationDescriptionLength, domain.MaxVerificationDescriptionLength)} case verifyStepWebsite: return botReply{Text: verifyPromptWebsiteText} case verifyStepSocial: return botReply{Text: fmt.Sprintf(verifyPromptSocialTmpl, domain.MaxVerificationSocialLinks)} case verifyStepPress: return botReply{Text: fmt.Sprintf(verifyPromptPressTmpl, domain.MinVerificationPressLinks, domain.MaxVerificationPressLinks)} case verifyStepNote: return botReply{Text: verifyPromptNoteText} case verifyStepTarget, verifyStepCategory, verifyStepConfirm: return botReply{Text: verifyPickButtonsText} default: return botReply{Text: verifyBotIdleText} } } // --------------------------------------------------------------------------- // Applicant notifications (verification.ApplicantNotifier) // --------------------------------------------------------------------------- // SendVerificationNotice delivers one queued outbox row as an ordinary // @verifybot message, so the decision lands in the applicant's history and // dialog list like any other message. Returning an error keeps the outbox row // pending for the next cycle. func (s *Service) SendVerificationNotice(ctx context.Context, recipientUserID int64, app domain.VerificationApplication, kind string) error { if s == nil || s.messages == nil { return fmt.Errorf("verification notice: bots service is not configured") } if recipientUserID <= 0 { return fmt.Errorf("verification notice: recipient is empty") } text, ok := verifyNoticeText(app, kind) if !ok { return fmt.Errorf("verification notice: unsupported kind %q", kind) } mu := s.serviceBotReplyLock(domain.VerifyBotUserID, recipientUserID) mu.Lock() defer mu.Unlock() if _, sent := s.sendServiceBotReplyResult(ctx, domain.VerifyBotUserID, recipientUserID, botReply{Text: text}); !sent { return fmt.Errorf("verification notice: deliver %q for application %d", kind, app.ID) } return nil } // verifyNoticeText renders one outbox row for the applicant. // // app.InternalNote is NEVER rendered here, under any kind: it is the reviewers' // private note, kept for the audit trail and the admin panel only. The single // field that reaches the applicant is app.DecisionReason, which exists precisely // because it was written to be read by them. func verifyNoticeText(app domain.VerificationApplication, kind string) (string, bool) { target := verifyTargetLabel(app.TargetTitle, app.TargetUsername) switch kind { case verificationapp.NoticeKindSubmitted: return fmt.Sprintf("Application #%d for %s is in the review queue. I will message you here as soon as it is decided.", app.ID, target), true case verificationapp.NoticeKindApproved: return fmt.Sprintf("Application #%d is approved: %s is now officially verified and carries the badge. Thank you for the paperwork.", app.ID, target), true case verificationapp.NoticeKindRejected: text := fmt.Sprintf("Application #%d for %s was not approved.", app.ID, target) if reason := strings.TrimSpace(app.DecisionReason); reason != "" { text += "\n\nReason: " + reason } return text + "\n\nYou can file again later with /new once the reason no longer applies.", true case verificationapp.NoticeKindCancelled: return fmt.Sprintf("Application #%d for %s is withdrawn. Send /new whenever you want to file it again.", app.ID, target), true case verificationapp.NoticeKindRevoked: text := fmt.Sprintf("The official verification of %s has been revoked, and the badge is no longer shown.", target) if reason := strings.TrimSpace(app.DecisionReason); reason != "" { text += "\n\nReason: " + reason } return text, true default: return "", false } } // --------------------------------------------------------------------------- // Button tokens // --------------------------------------------------------------------------- // verifyOptionKeyboard renders one inline keyboard and records its buttons in the // per-user token table. // // SECURITY: callback data never carries a target id, a peer type, an application // id or anything else the server would have to trust. Every button's data is // verifyCallbackDataPrefix plus a freshly generated random token, and that token // is meaningful only as a key into the token table of *this* applicant's own // domain.BotChatState. A press is therefore incapable of naming a peer at all: // the server resolves the choice from state it wrote itself when it rendered the // keyboard. A client that replays or fabricates callback data can at best hit a // token absent from its own table, which is refused -- so forging a choice for // somebody else's channel is impossible by construction, not by validation. // // Tokens are minted afresh on every render and kept for verifyTokenGenerations // renders. That window is what makes a second press of the same button resolve to // the same choice (the handlers are idempotent, so the reply repeats) while the // table stays bounded. func (s *Service) verifyOptionKeyboard(state *domain.BotChatState, rows [][]verifyOption) *domain.MessageReplyMarkup { if state == nil || len(rows) == 0 { return nil } verifyEnsureDraft(state) generation := verifyDraftInt(*state, verifyDraftGeneration) + 1 state.Draft[verifyDraftGeneration] = strconv.FormatInt(generation, 10) verifyPruneOptions(state, generation) markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline} for _, row := range rows { buttons := make([]domain.MarkupButton, 0, len(row)) for _, option := range row { if option.text == "" || option.choice == "" { continue } token := s.verifyOptionToken() state.Draft[verifyDraftOptionPrefix+token] = strconv.FormatInt(generation, 10) + "|" + option.choice buttons = append(buttons, domain.MarkupButton{ Type: domain.MarkupButtonCallback, Text: option.text, Style: option.style, Data: []byte(verifyCallbackDataPrefix + token), }) } if len(buttons) > 0 { markup.Inline = append(markup.Inline, buttons) } } if len(markup.Inline) == 0 { return nil } return markup } // verifyOptionToken mints one opaque button token. func (s *Service) verifyOptionToken() string { var buf [verifyOptionTokenBytes]byte if _, err := rand.Read(buf[:]); err == nil { return hex.EncodeToString(buf[:]) } // A crypto/rand failure must not brick the dialog. Unpredictability is defence // in depth here rather than the load-bearing property -- a token is only ever // resolved against the caller's own chat state, and the RPC edge additionally // requires the data to appear in a keyboard of a message in the caller's own // box -- so a monotonic fallback is safe. return strconv.FormatInt(s.now().UnixNano()+s.replySeq.Add(1), 36) } // verifyResolveOption maps callback data back onto the choice recorded for it. // Anything that is not in this user's own table is refused. func verifyResolveOption(state domain.BotChatState, data []byte) (string, bool) { raw := string(data) if !strings.HasPrefix(raw, verifyCallbackDataPrefix) { return "", false } token := raw[len(verifyCallbackDataPrefix):] if token == "" || len(token) > verifyOptionTokenMaxLen { return "", false } value, found := state.Draft[verifyDraftOptionPrefix+token] if !found { return "", false } _, choice, split := strings.Cut(value, "|") if !split || choice == "" { return "", false } return choice, true } // verifyPruneOptions drops tokens older than the retained window. func verifyPruneOptions(state *domain.BotChatState, generation int64) { oldest := generation - verifyTokenGenerations + 1 for key, value := range state.Draft { if !strings.HasPrefix(key, verifyDraftOptionPrefix) { continue } rawGen, _, _ := strings.Cut(value, "|") gen, err := strconv.ParseInt(rawGen, 10, 64) if err != nil || gen < oldest { delete(state.Draft, key) } } } func verifyCategoryRows() [][]verifyOption { rows := make([][]verifyOption, 0, len(domain.VerificationCategories)/2+2) var row []verifyOption for _, category := range domain.VerificationCategories { row = append(row, verifyOption{ text: verifyCategoryLabel(category), choice: verifyChoiceCategoryPrefix + category, }) if len(row) == 2 { rows = append(rows, row) row = nil } } if len(row) > 0 { rows = append(rows, row) } return append(rows, []verifyOption{{ text: verifyCancelButtonText, choice: verifyChoiceAbort, style: domain.MarkupButtonStyleDanger, }}) } func verifySkipRows() [][]verifyOption { return [][]verifyOption{ {{text: verifySkipButtonText, choice: verifyChoiceSkip}}, {{text: verifyCancelButtonText, choice: verifyChoiceAbort, style: domain.MarkupButtonStyleDanger}}, } } // --------------------------------------------------------------------------- // State helpers // --------------------------------------------------------------------------- func verifyNewState(userID int64) domain.BotChatState { return domain.BotChatState{ BotUserID: domain.VerifyBotUserID, UserID: userID, Command: verifyBotCommand, Step: verifyStepIntro, Draft: map[string]string{}, } } func verifyEnsureDraft(state *domain.BotChatState) { if state.Draft == nil { state.Draft = map[string]string{} } if state.BotUserID == 0 { state.BotUserID = domain.VerifyBotUserID } if state.Command == "" { state.Command = verifyBotCommand } } func (s *Service) saveVerifyState(ctx context.Context, state domain.BotChatState) bool { verifyEnsureDraft(&state) state.BotUserID = domain.VerifyBotUserID clone := domain.BotChatState{ BotUserID: state.BotUserID, UserID: state.UserID, Command: state.Command, Step: state.Step, Draft: make(map[string]string, len(state.Draft)), } for key, value := range state.Draft { clone.Draft[key] = value } if err := s.bots.UpsertBotChatState(ctx, clone); err != nil { s.log.Error("verifybot: save chat state", zap.Int64("user_id", state.UserID), zap.Error(err)) return false } return true } func (s *Service) deleteVerifyState(ctx context.Context, userID int64) { if err := s.bots.DeleteBotChatState(ctx, domain.VerifyBotUserID, userID); err != nil { s.log.Error("verifybot: delete chat state", zap.Int64("user_id", userID), zap.Error(err)) } } // saveVerifyDraft pushes the accumulated payload into the application record and // refreshes the stored optimistic-locking version. func (s *Service) saveVerifyDraft(ctx context.Context, state *domain.BotChatState) (botReply, bool) { verifyEnsureDraft(state) applicationID := verifyDraftInt(*state, verifyDraftAppID) if applicationID <= 0 { return botReply{Text: verifyBotIdleText}, false } input := verifyDraftInput(*state) app, err := s.verification.SaveDraft(ctx, state.UserID, applicationID, verifyDraftInt(*state, verifyDraftVersion), input) if errors.Is(err, domain.ErrVerificationVersionConflict) { // The record moved under us (a second device in the same dialog). Re-read the // authoritative version once and retry; a second conflict is reported. if current, readErr := s.verification.Application(ctx, applicationID); readErr == nil { app, err = s.verification.SaveDraft(ctx, state.UserID, applicationID, current.Version, input) } } if err != nil { return s.verifyErrorReply(state.UserID, "save draft", err), false } state.Draft[verifyDraftVersion] = strconv.FormatInt(app.Version, 10) return botReply{}, true } func verifyHydrateState(state *domain.BotChatState, app domain.VerificationApplication) { verifyEnsureDraft(state) state.Draft[verifyDraftAppID] = strconv.FormatInt(app.ID, 10) state.Draft[verifyDraftVersion] = strconv.FormatInt(app.Version, 10) state.Draft[verifyDraftCategory] = app.Category state.Draft[verifyDraftDescription] = app.Description state.Draft[verifyDraftWebsite] = app.OfficialWebsite state.Draft[verifyDraftSocial] = strings.Join(app.SocialLinks, "\n") state.Draft[verifyDraftPress] = strings.Join(app.PressLinks, "\n") state.Draft[verifyDraftNote] = app.AdditionalNote state.Draft[verifyDraftTargetTitle] = app.TargetTitle state.Draft[verifyDraftTargetUsername] = app.TargetUsername } func verifyDraftInput(state domain.BotChatState) domain.VerificationDraftInput { return domain.VerificationDraftInput{ Category: state.Draft[verifyDraftCategory], Description: state.Draft[verifyDraftDescription], OfficialWebsite: state.Draft[verifyDraftWebsite], SocialLinks: verifySplitLinks(state.Draft[verifyDraftSocial]), PressLinks: verifySplitLinks(state.Draft[verifyDraftPress]), AdditionalNote: state.Draft[verifyDraftNote], }.Normalize() } func verifyDraftInputOf(app domain.VerificationApplication) domain.VerificationDraftInput { return domain.VerificationDraftInput{ Category: app.Category, Description: app.Description, OfficialWebsite: app.OfficialWebsite, SocialLinks: app.SocialLinks, PressLinks: app.PressLinks, AdditionalNote: app.AdditionalNote, }.Normalize() } // verifyMissingStep is the first step whose field the official bar still needs. // It is what /new resumes into and what an incomplete Submit is bounced to. // The optional steps (social links, comment) are never resumed into, because a // skipped optional field is indistinguishable from an unasked one and an // applicant must not be forced back through it. func verifyMissingStep(input domain.VerificationDraftInput) string { input = input.Normalize() switch { case !domain.ValidVerificationCategory(input.Category): return verifyStepCategory case utf8.RuneCountInString(input.Description) < domain.MinVerificationDescriptionLength: return verifyStepDescription case input.OfficialWebsite == "": return verifyStepWebsite case len(input.PressLinks) < domain.MinVerificationPressLinks: return verifyStepPress default: return verifyStepConfirm } } func verifyDraftInt(state domain.BotChatState, key string) int64 { value, err := strconv.ParseInt(strings.TrimSpace(state.Draft[key]), 10, 64) if err != nil { return 0 } return value } // --------------------------------------------------------------------------- // Rendering helpers // --------------------------------------------------------------------------- func verifySummaryText(state domain.BotChatState) string { input := verifyDraftInput(state) var b strings.Builder b.WriteString("Here is your application. Nothing reaches the reviewers until you tap ") b.WriteString(verifySubmitButtonText) b.WriteString(".\n\nSubject: ") b.WriteString(verifyTargetLabel(state.Draft[verifyDraftTargetTitle], state.Draft[verifyDraftTargetUsername])) b.WriteString("\nCategory: ") b.WriteString(verifyCategoryLabel(input.Category)) b.WriteString("\nWebsite: ") b.WriteString(input.OfficialWebsite) b.WriteString("\n\nDescription:\n") b.WriteString(input.Description) b.WriteString("\n\nPress coverage:") for _, link := range input.PressLinks { b.WriteString("\n- ") b.WriteString(link) } if len(input.SocialLinks) > 0 { b.WriteString("\n\nSocial links:") for _, link := range input.SocialLinks { b.WriteString("\n- ") b.WriteString(link) } } if input.AdditionalNote != "" { b.WriteString("\n\nComment:\n") b.WriteString(input.AdditionalNote) } return b.String() } func verifySubmittedText(app domain.VerificationApplication) string { return fmt.Sprintf("Application #%d is filed for %s.\n\nA reviewer reads it by hand, and I message you here with the decision. Send /status any time to see where it stands, or /cancel to withdraw it while it is still open.", app.ID, verifyTargetLabel(app.TargetTitle, app.TargetUsername)) } // verifyTargetLabel renders a subject for humans. The username is the identity // that matters, the title is the context. func verifyTargetLabel(title, username string) string { title = strings.TrimSpace(title) username = strings.TrimSpace(username) switch { case username != "" && title != "": return title + " (@" + username + ")" case username != "": return "@" + username case title != "": return title default: return "the selected subject" } } func verifyTargetButtonText(target domain.VerificationTarget) string { label := strings.TrimSpace(target.Username) if label != "" { label = "@" + label } else { label = strings.TrimSpace(target.Title) } if label == "" { label = "id " + strconv.FormatInt(target.ID, 10) } return verifyTargetKindLabel(target.Type) + ": " + verifyTruncate(label, 64) } func verifyTargetKindLabel(kind domain.VerificationTargetType) string { switch kind { case domain.VerificationTargetBot: return "Bot" case domain.VerificationTargetChannel: return "Channel" case domain.VerificationTargetSupergroup: return "Group" case domain.VerificationTargetUser: return "Account" default: return "Subject" } } func verifyCategoryLabel(category string) string { category = strings.TrimSpace(category) if label, ok := verifyCategoryLabels[category]; ok { return label } if category == "" { return "not chosen yet" } return category } func verifyStatusLabel(status domain.VerificationStatus) string { switch status { case domain.VerificationStatusDraft: return "draft, not filed yet" case domain.VerificationStatusSubmitted: return "waiting for a reviewer" case domain.VerificationStatusInReview: return "being reviewed" case domain.VerificationStatusApproved: return "approved, the badge is live" case domain.VerificationStatusRejected: return "not approved" case domain.VerificationStatusCancelled: return "withdrawn" default: return string(status) } } func verifyDateLabel(app domain.VerificationApplication) string { switch { case !app.ReviewedAt.IsZero(): return "decided " + app.ReviewedAt.UTC().Format("2006-01-02") case !app.SubmittedAt.IsZero(): return "filed " + app.SubmittedAt.UTC().Format("2006-01-02") case !app.CreatedAt.IsZero(): return "started " + app.CreatedAt.UTC().Format("2006-01-02") default: return "" } } func verifyJoin(lead, body string) string { lead = strings.TrimSpace(lead) if lead == "" { return body } return lead + "\n\n" + body } // verifySplitLinks accepts links separated by newlines or spaces. A URL cannot // contain whitespace, so this is lossless and forgiving of how the applicant's // client wraps the message. func verifySplitLinks(text string) []string { return strings.Fields(text) } // verifyCheckLinks validates every link with the domain rule and names the first // one that fails, so the applicant knows which line to fix. func verifyCheckLinks(links []string) (botReply, bool) { for _, link := range links { if err := domain.ValidateVerificationURL(link); err != nil { return botReply{Text: verifyURLRejectText + "\n\nI could not accept: " + verifyTruncate(link, 120)}, false } } return botReply{}, true } func verifyIsSkipText(text string) bool { switch strings.ToLower(strings.TrimSpace(text)) { case "skip", "-", "none", "no": return true default: return false } } func verifyTruncate(value string, max int) string { if max <= 0 || utf8.RuneCountInString(value) <= max { return value } runes := []rune(value) return string(runes[:max]) } // --------------------------------------------------------------------------- // Error mapping // --------------------------------------------------------------------------- func (s *Service) verifyErrorReply(userID int64, operation string, err error) botReply { if text, ok := verifyPolicyText(err); ok { return botReply{Text: text} } s.log.Error("verifybot: "+operation, zap.Int64("user_id", userID), zap.Error(err)) return internalReply() } // verifyPolicyText turns a policy refusal into a sentence the applicant can act // on. Anything that is not a policy error is an internal fault and is logged // instead of being narrated. func verifyPolicyText(err error) (string, bool) { switch { case err == nil: return "", false case errors.Is(err, verificationapp.ErrDisabled): return verifyUnavailableText, true case errors.Is(err, domain.ErrVerificationTargetAlreadyVerified): return "That one is already verified, so there is nothing to apply for.", true case errors.Is(err, domain.ErrVerificationTargetNotPublic): return "That one has no public @username. Official verification only covers a public presence, so set a username first.", true case errors.Is(err, domain.ErrVerificationTargetRestricted): return "That one carries a restriction, so it cannot be verified while the restriction stands.", true case errors.Is(err, domain.ErrVerificationTargetSystem): return "That is a built-in service account and cannot be filed.", true case errors.Is(err, domain.ErrVerificationNotOwner): return "You are not the creator or an administrator of that one, so you cannot file it.", true case errors.Is(err, domain.ErrVerificationApplicationExists): return "There is already an active application for that one. Send /status to see it.", true case errors.Is(err, domain.ErrVerificationCooldown): return "That one was reviewed recently. A rejected application can only be filed again after the cooldown has passed.", true case errors.Is(err, domain.ErrVerificationRateLimited): return "You have reached the limit on open applications. Finish or cancel one before filing another.", true case errors.Is(err, domain.ErrVerificationUserTargetsDisabled): return "Plain user accounts are not accepted for verification on this server.", true case errors.Is(err, domain.ErrVerificationApplicationNotFound): return "I cannot find that application any more. Send /new to start a fresh one.", true case errors.Is(err, domain.ErrVerificationStatusInvalid): return "That application has already been decided, so it cannot be changed. Send /status to see it.", true case errors.Is(err, domain.ErrVerificationVersionConflict): return "That application just changed somewhere else. Send /status to see where it stands now.", true case errors.Is(err, domain.ErrVerificationURLInvalid): return verifyURLRejectText, true case errors.Is(err, domain.ErrVerificationApplicationInvalid), errors.Is(err, domain.ErrVerificationTargetInvalid): return "I could not accept that. Send /help to see what an application needs.", true default: return "", false } } // verifyIneligibleReasons are the domain refusals that can arrive in // domain.VerificationTarget.Reason. Mapping through the sentinel keeps the // applicant-facing wording in one place (verifyPolicyText). var verifyIneligibleReasons = []error{ domain.ErrVerificationTargetAlreadyVerified, domain.ErrVerificationTargetNotPublic, domain.ErrVerificationTargetRestricted, domain.ErrVerificationTargetSystem, domain.ErrVerificationNotOwner, domain.ErrVerificationApplicationExists, domain.ErrVerificationCooldown, domain.ErrVerificationRateLimited, domain.ErrVerificationUserTargetsDisabled, domain.ErrVerificationTargetInvalid, domain.ErrVerificationApplicationInvalid, } func verifyIneligibleText(reason string) string { reason = strings.TrimSpace(reason) for _, sentinel := range verifyIneligibleReasons { if sentinel.Error() != reason { continue } if text, ok := verifyPolicyText(sentinel); ok { return text } } return "That one cannot be filed right now." }