Ctrl K
ComunidadGuías…

internal/app/bots/verifierbot.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package bots

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"errors"
	"fmt"
	"strconv"
	"strings"
	"time"
	"unicode/utf8"

	"go.uber.org/zap"

	"telesrv/internal/branding"
	"telesrv/internal/domain"
)

// Built-in @verifierbot: the applicant front door for THIRD-PARTY verification
// (core.telegram.org/api/bots/verification).
//
// The difference from @verifybot is the whole reason this file exists, and it is
// the first thing every message here has to get across. The official checkmark is
// granted by the PLATFORM: an operator reviews the application @verifybot collected
// and flips the peer's verified flag. A third-party mark is granted by a VERIFIER
// BOT: the peer gets that bot's own icon before its name and that bot's line of
// description in its profile. Neither mechanism reads the other's state, and
// neither implies the other.
//
// This bot is the first verifier of a deployment -- the demo and the reference for
// the feature -- and its own verifier status (icon, company name, description
// permission) is granted by an operator in the admin panel, exactly like any other
// verifier's. That is the honesty constraint the dialog is built around: with no
// live, enabled BotVerifierSettings row this bot has no icon to put anywhere, so
// /start says so plainly and offers no apply button, instead of collecting
// paperwork nobody can act on.
//
// The dialog is button driven (inline keyboards) for every closed choice -- which
// peer, confirm/cancel, which mark to remove -- and text driven for the single
// free-form field (the justification). It owns no verification rule: verifier
// status, the per-verifier limit, the application status machine and the mark
// itself all live in app/botverification, and this file only renders the answers
// that service gives.
//
// Conversation state is domain.BotChatState (Command="verifier"), read and written
// under the per-user serviceBotReplyLock, so the Get->modify->Upsert round trip is
// atomic and replies stay ordered.

// customVerifications is the applicant-side surface of third-party verification
// used by this bot: app/botverification.Service satisfies it.
//
// It is declared as a narrow port rather than taken as a concrete service for the
// usual reason plus one specific to this feature: granting a mark is granting a
// badge, and the bot must not be able to reach past the service that owns the
// rules. Nothing here can write a mark, grant verifier status, decide an
// application or read another applicant's queue -- the operator's decision path is
// not on this interface at all.
//
// Once app/botverification lands, assert the contract in this package the way
// verifybot.go does for app/verification, so a signature change breaks the build
// instead of silently disabling the bot:
//
//	var _ customVerifications = (*botverificationapp.Service)(nil)
type customVerifications interface {
	// VerifierSettings reads this bot's own verifier status, or
	// domain.ErrVerifierNotFound when an operator has not granted it.
	VerifierSettings(ctx context.Context, botID int64) (domain.BotVerifierSettings, error)
	// CreateRequest files an application. A pending application on the same
	// (verifier, peer) pair reports domain.ErrCustomVerificationRequestExists.
	CreateRequest(ctx context.Context, req domain.CustomVerificationRequest) (domain.CustomVerificationRequest, error)
	// ApplicantRequests returns one applicant's own history, newest first.
	ApplicantRequests(ctx context.Context, applicantUserID int64, limit int) ([]domain.CustomVerificationRequest, error)
	// PendingRequest returns the live application for a (verifier, peer) pair, or
	// domain.ErrCustomVerificationRequestNotFound.
	PendingRequest(ctx context.Context, verifierBotID int64, peer domain.Peer) (domain.CustomVerificationRequest, error)
	// RevokeMark removes this verifier's mark from a peer. removed=false means there
	// was nothing to remove, which is what makes a repeated revoke a no-op.
	RevokeMark(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error)
	// Marks lists granted marks. The bot only ever queries its own
	// (VerifierBotID, peer) pairs, so it can tell an applicant whether a mark is
	// actually live.
	Marks(ctx context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error)
}

// verifierBotTargets enumerates the peers an applicant may put forward: the bots
// they own, the channels and supergroups they created or administer, and their own
// account. app/verification.Service satisfies it as-is (EligibleTargets), which is
// why verificationApplications is accepted as a fallback source.
//
// Only the identity fields (Type, ID, Title, Username) are used here.
// VerificationTarget.Eligible and .Reason are deliberately IGNORED: they answer
// "may this peer be filed for the platform checkmark", which is a different bar
// belonging to a different mechanism -- a peer that is already officially verified,
// or has no public username, is a perfectly good third-party target. What the
// enumeration is trusted for is authorisation: it only ever contains peers the
// applicant owns or administers, which is why every button press re-resolves
// against it instead of trusting callback data.
type verifierBotTargets interface {
	EligibleTargets(ctx context.Context, applicantUserID int64) ([]domain.VerificationTarget, error)
}

// verifierBotApplicantNotifier mirrors app/botverification.ApplicantNotifier: the
// port that carries an operator's decision back to the applicant as an ordinary
// @verifierbot message. It is restated here so this package compiles the assertion
// below against its own copy of the contract; replace it with the real interface
// once that package exists.
type verifierBotApplicantNotifier interface {
	SendVerificationDecision(ctx context.Context, recipientUserID int64, req domain.CustomVerificationRequest) error
}

var _ verifierBotApplicantNotifier = (*Service)(nil)

const (
	// verifierBotCommand is the single BotChatState.Command of this dialog. There are
	// two flows (apply, revoke) and the step distinguishes them.
	verifierBotCommand = "verifier"

	verifierBotCmdVerify = "verify"
	verifierBotCmdStatus = "status"
	verifierBotCmdRevoke = "revoke"

	verifierStepIntro  = "intro"
	verifierStepTarget = "target"
	verifierStepReason = "reason"
	// verifierStepConfirm is the last button-only step of the apply flow.
	verifierStepConfirm = "confirm"
	// verifierStepDone is a terminal state that is deliberately *kept*: it is what
	// makes a second press of Confirm replay the same answer instead of filing a
	// second application or reporting an expired button.
	verifierStepDone          = "done"
	verifierStepRevokePick    = "revoke_pick"
	verifierStepRevokeConfirm = "revoke_confirm"

	verifierDraftTarget         = "tgt"
	verifierDraftTargetTitle    = "tgt_title"
	verifierDraftTargetUsername = "tgt_username"
	verifierDraftReason         = "reason"
	verifierDraftRequestID      = "req"
	// verifierDraftCorrelation is a per-attempt idempotency key handed to the
	// service. It is minted once when the summary is rendered and reused by every
	// repeat press of Confirm, so a double tap cannot become two applications even if
	// the dialog state is lost between them.
	verifierDraftCorrelation = "corr"
	// verifierDraftRevoked records the peer whose mark this dialog already removed
	// (and the label it was reported under), so a repeat press of the removal button
	// replays the same confirmation instead of reporting a second removal.
	verifierDraftRevoked      = "rev_done"
	verifierDraftRevokedLabel = "rev_done_label"
	verifierDraftGeneration   = "gen"
	// verifierDraftOptionPrefix namespaces the button token table inside the draft
	// map, so a token can never collide with a payload field.
	verifierDraftOptionPrefix = "opt:"

	// verifierCallbackDataPrefix tags this bot's own callback data. It carries no
	// information beyond "this is a @verifierbot button".
	verifierCallbackDataPrefix = "vfb:"
	// verifierOptionTokenBytes is the entropy behind one button token.
	verifierOptionTokenBytes = 6
	// verifierOptionTokenMaxLen bounds the token part of callback data before the
	// table is even consulted.
	verifierOptionTokenMaxLen = 32
	// verifierTokenGenerations is how many keyboard renders' worth of tokens stay
	// resolvable: the current render plus the two before it. Enough that pressing the
	// same button twice still resolves, while the table stays bounded.
	verifierTokenGenerations = 3

	// verifierMaxTargetButtons bounds one picker: an 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.
	verifierMaxTargetButtons = 12
	// verifierStatusListLimit bounds the /status listing and the /revoke picker.
	verifierStatusListLimit = 10
	// verifierMinReasonLength is the floor on the justification. An operator has to
	// act on it, and "please verify me" is not something anyone can check.
	verifierMinReasonLength = 20
)

// 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.
const (
	verifierChoiceApply       = "apply"
	verifierChoiceConfirm     = "confirm"
	verifierChoiceAbort       = "abort"
	verifierChoiceRevokeApply = "revoke"
	// The two peer-bearing choices carry the peer inside the token table entry, not
	// in the callback data. A confirmation button therefore names the peer it was
	// rendered for, so pressing a stale one cannot act on whatever peer the dialog
	// happens to be pointing at now.
	verifierChoiceTargetPrefix       = "tgt:"
	verifierChoiceRevokePrefix       = "rev:"
	verifierChoiceRevokeCommitPrefix = "revoke_commit:"
)

// verifierBotWhatText is the part of /start that is true whether or not an
// operator has activated this bot, so it is said first and unconditionally.
func verifierBotWhatText() string {
	return `I hand out THIRD-PARTY verification.

A third-party mark is a verifier's own icon, shown right before the name of a bot, a channel or an account, plus one line of description in its profile. It means "this verifier vouches for this peer" -- nothing more.

It is NOT the official ` + branding.ProductName() + ` checkmark. The platform badge is granted by the platform itself (@verifybot collects those applications); a third-party mark is granted by the company running a verifier bot. The two are stored, shown and taken away separately, and neither one implies the other.`
}

func verifierBotHelpText() string {
	return `I am a verifier bot. I grant third-party marks: my icon before the name of your bot, channel or account, plus a description in its profile. This is not the official ` + branding.ProductName() + ` checkmark.

/start - what a third-party mark is and who grants it
/verify - apply for the mark
/status - your applications and the marks you carry
/revoke - remove a mark from one of your peers
/cancel - drop the application I am collecting right now
/help - show this message

I do not decide anything: I collect the application, an operator grants or refuses the mark, and I message you here with the outcome.`
}

const (
	verifierBotIdleText = `I only hand out third-party verification marks. Send /verify to apply, /status to see where your applications stand, /revoke to remove a mark, or /help to see what I understand.`

	// verifierNotActivatedText is the honest answer when this bot has no live
	// verifier status. It never pretends an application is possible.
	verifierNotActivatedText = `I cannot accept applications yet.

I am the built-in verifier of this server, and verifier status is granted by hand: an operator has to give me an icon from the verification icon catalogue and a company name to vouch under (admin panel, bot verifiers), and keep that row enabled. Until it exists I have no icon to put on anything, so filing an application with me would only produce paperwork nobody can act on.

Send /start again once the operator tells you I am activated. /help lists the rest of my commands.`

	verifierUnavailableText = `Third-party verification is not available on this server right now.`

	verifierNoTargetSourceText = `I cannot look up your bots and channels right now, so I have nothing to offer you. Please try again in a moment.`

	verifierNoTargetsText = `I could not find anything of yours to mark. I can verify a bot you own, a channel or supergroup you created or administer, or your own account -- so get one of those first and come back with /verify.`

	verifierNoRequestsText = `You have not applied to me yet, and you carry none of my marks. Send /verify to apply.`

	verifierNothingToRevokeText = `You carry none of my marks, so there is nothing to remove. Send /status to see where your applications stand.`

	verifierNothingToCancelText = `There is nothing to cancel. Send /verify to apply for the mark.`

	verifierExpiredButtonText = `That button is no longer active. Send /verify to apply, or /status to see where your applications stand.`

	verifierPickButtonsText = `Please use the buttons in my message above.`

	verifierGoneTargetText = `That one is not yours to verify any more, so I dropped it. Send /verify to start again.`

	verifierApplyButtonText        = `Get verification`
	verifierConfirmButtonText      = `✅ Confirm`
	verifierCancelButtonText       = `❌ Cancel`
	verifierRevokeMenuButtonText   = `Remove a mark`
	verifierRevokeButtonText       = `✅ Remove the mark`
	verifierRevokeCancelButtonText = `❌ Keep the mark`
)

// verifierBotGlobalCommands are the commands honoured in every step, so an
// applicant is never trapped mid-dialog. Same contract as
// botFatherGlobalCommands.
var verifierBotGlobalCommands = map[string]bool{
	"start": true, "help": true, "cancel": true,
	verifierBotCmdVerify: true, verifierBotCmdStatus: true, verifierBotCmdRevoke: true,
}

// verifierOption is one inline button before its token is minted.
type verifierOption struct {
	text   string
	choice string
	style  domain.MarkupButtonStyle
}

// ---------------------------------------------------------------------------
// Message entry point
// ---------------------------------------------------------------------------

// respondAsVerifier generates and writes one @verifierbot 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) respondAsVerifier(userID int64, msg domain.Message) {
	mu := s.serviceBotReplyLock(domain.VerifierBotUserID, userID)
	mu.Lock()
	defer mu.Unlock()

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	if allowed, retryAfter := s.allowVerifierDialog(ctx, userID); !allowed {
		s.sendServiceBotReply(ctx, domain.VerifierBotUserID, userID, botReply{
			Text: verifierFloodText(retryAfter),
		})
		return
	}
	reply := s.handleVerifier(ctx, userID, msg.Body)
	s.sendServiceBotReply(ctx, domain.VerifierBotUserID, userID, reply)
}

// allowVerifierDialog bounds dialog traffic per applicant, keyed by this bot so it
// has its own budget rather than sharing @verifybot's. It is deliberately separate
// from any application limit inside the verification service: that one bounds how
// many applications exist, this one bounds how hard the state machine can be
// driven, so a script cannot burn writes without ever applying. A missing limiter
// means no bound, which is the pre-Redis behaviour.
func (s *Service) allowVerifierDialog(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.VerifierBotUserID, 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("verifierbot: dialog rate limit check failed",
			zap.Int64("user_id", userID), zap.Error(err))
		return true, 0
	}
	return allowed, retryAfter
}

func verifierFloodText(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) handleVerifier(ctx context.Context, userID int64, body string) botReply {
	text := strings.TrimSpace(body)
	state, found, err := s.bots.GetBotChatState(ctx, domain.VerifierBotUserID, userID)
	if err != nil {
		s.log.Error("verifierbot: get chat state", zap.Int64("user_id", userID), zap.Error(err))
		return internalReply()
	}
	if found && state.Command != verifierBotCommand {
		// Unreachable dirty state (an older dialog shape): drop it rather than trap the
		// applicant in a step no handler owns.
		s.deleteVerifierState(ctx, userID)
		state, found = domain.BotChatState{}, false
	}
	if cmd, ok := parseBotCommand(text); ok {
		if verifierBotGlobalCommands[cmd] {
			return s.handleVerifierCommand(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: verifierBotIdleText}
	}
	if text == "" {
		return s.verifierStepReminder(state)
	}
	switch state.Step {
	case verifierStepReason:
		return s.handleVerifierReason(ctx, state, text)
	case verifierStepIntro, verifierStepDone:
		return botReply{Text: verifierBotIdleText}
	case verifierStepTarget, verifierStepConfirm, verifierStepRevokePick, verifierStepRevokeConfirm:
		// 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: verifierPickButtonsText}
	default:
		s.deleteVerifierState(ctx, userID)
		return botReply{Text: "Something went wrong, I forgot what we were doing. Send /verify to start again."}
	}
}

func (s *Service) handleVerifierCommand(ctx context.Context, userID int64, cmd string, state domain.BotChatState, found bool) botReply {
	if cmd == "help" {
		return botReply{Text: verifierBotHelpText()}
	}
	if s.customVerification == nil {
		return botReply{Text: verifierUnavailableText}
	}
	switch cmd {
	case "start":
		return s.verifierIntro(ctx, userID, state, found)
	case verifierBotCmdVerify:
		if !found {
			state = verifierNewState(userID)
		}
		return s.startVerifierRequest(ctx, state)
	case verifierBotCmdStatus:
		return s.verifierStatusReply(ctx, userID)
	case verifierBotCmdRevoke:
		if !found {
			state = verifierNewState(userID)
		}
		return s.verifierRevokePrompt(ctx, state, "")
	case "cancel":
		return s.cancelVerifierDialog(ctx, userID, state, found)
	default:
		return botReply{Text: verifierBotHelpText()}
	}
}

// ---------------------------------------------------------------------------
// Callback entry point
// ---------------------------------------------------------------------------

// onVerifierCallback answers one inline-button click on a @verifierbot message. It
// is reached from OnCallbackQuery (verifybot.go), which is the bots side of
// rpc.ServiceBotCallbacks.
func (s *Service) onVerifierCallback(ctx context.Context, query domain.BotCallbackQuery) (domain.BotCallbackAnswer, bool, error) {
	userID := query.UserID
	mu := s.serviceBotReplyLock(domain.VerifierBotUserID, userID)
	mu.Lock()
	defer mu.Unlock()

	if allowed, retryAfter := s.allowVerifierDialog(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: verifierFloodText(retryAfter), Alert: true}, true, nil
	}

	state, found, err := s.bots.GetBotChatState(ctx, domain.VerifierBotUserID, userID)
	if err != nil {
		s.log.Error("verifierbot: get chat state for callback", zap.Int64("user_id", userID), zap.Error(err))
		return domain.BotCallbackAnswer{}, true, err
	}
	if !found || state.Command != verifierBotCommand {
		return verifierAlert(verifierExpiredButtonText), true, nil
	}
	choice, ok := verifierResolveOption(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 verifierAlert(verifierExpiredButtonText), true, nil
	}
	if s.customVerification == nil {
		return verifierAlert(verifierUnavailableText), 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.applyVerifierChoice(sendCtx, state, choice)
	s.sendServiceBotReply(sendCtx, domain.VerifierBotUserID, userID, reply)
	return answer, true, nil
}

// applyVerifierChoice executes one resolved button choice.
func (s *Service) applyVerifierChoice(ctx context.Context, state domain.BotChatState, choice string) (botReply, domain.BotCallbackAnswer) {
	switch {
	case choice == verifierChoiceApply:
		return s.startVerifierRequest(ctx, state), domain.BotCallbackAnswer{}
	case choice == verifierChoiceAbort:
		return s.cancelVerifierDialog(ctx, state.UserID, state, true), domain.BotCallbackAnswer{}
	case choice == verifierChoiceConfirm:
		return s.submitVerifierRequest(ctx, state), domain.BotCallbackAnswer{}
	case choice == verifierChoiceRevokeApply:
		return s.verifierRevokePrompt(ctx, state, ""), domain.BotCallbackAnswer{}
	case strings.HasPrefix(choice, verifierChoiceRevokeCommitPrefix):
		return s.commitVerifierRevoke(ctx, state, strings.TrimPrefix(choice, verifierChoiceRevokeCommitPrefix)), domain.BotCallbackAnswer{}
	case strings.HasPrefix(choice, verifierChoiceTargetPrefix):
		return s.chooseVerifierTarget(ctx, state, strings.TrimPrefix(choice, verifierChoiceTargetPrefix)), domain.BotCallbackAnswer{}
	case strings.HasPrefix(choice, verifierChoiceRevokePrefix):
		return s.chooseVerifierRevokeTarget(ctx, state, strings.TrimPrefix(choice, verifierChoiceRevokePrefix)), domain.BotCallbackAnswer{}
	default:
		s.log.Warn("verifierbot: unknown button choice",
			zap.Int64("user_id", state.UserID), zap.String("choice", choice))
		return botReply{}, verifierAlert(verifierExpiredButtonText)
	}
}

// verifierAlert 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 verifierAlert(text string) domain.BotCallbackAnswer {
	return domain.BotCallbackAnswer{Alert: true, Message: verifierTruncate(text, domain.MaxBotCallbackAnswerLen)}
}

// ---------------------------------------------------------------------------
// Verifier status
// ---------------------------------------------------------------------------

// verifierSettings resolves this bot's own verifier status. The bool is the whole
// point of the function: false means an operator has not activated the bot, and the
// returned reply says exactly that instead of hiding it behind a generic failure.
func (s *Service) verifierSettings(ctx context.Context, userID int64) (domain.BotVerifierSettings, botReply, bool) {
	if s.customVerification == nil {
		return domain.BotVerifierSettings{}, botReply{Text: verifierUnavailableText}, false
	}
	settings, err := s.customVerification.VerifierSettings(ctx, domain.VerifierBotUserID)
	switch {
	case errors.Is(err, domain.ErrVerifierNotFound), errors.Is(err, domain.ErrVerifierForbidden):
		return domain.BotVerifierSettings{}, botReply{Text: verifierNotActivatedText}, false
	case err != nil:
		return domain.BotVerifierSettings{}, s.verifierErrorReply(userID, "read verifier settings", err), false
	}
	if !settings.Enabled {
		// The operator's kill switch. Existing marks stay, but nothing new can be
		// granted, so promising an application would be a lie.
		return domain.BotVerifierSettings{}, botReply{Text: verifierNotActivatedText}, false
	}
	if err := settings.Validate(); err != nil {
		// A half-written verifier row (no icon, no company) cannot mark anything either.
		// Report it as not activated, and log it: this one is an operator mistake.
		s.log.Warn("verifierbot: verifier settings do not validate", zap.Error(err))
		return domain.BotVerifierSettings{}, botReply{Text: verifierNotActivatedText}, false
	}
	return settings, botReply{}, true
}

// verifierMarkDescription is the description a mark granted now would carry. It is
// resolved through the domain rule rather than read off the settings struct, so
// this dialog and the RPC edge can never disagree about it.
func verifierMarkDescription(settings domain.BotVerifierSettings) string {
	description, err := settings.DescriptionFor("")
	if err != nil {
		return ""
	}
	return strings.TrimSpace(description)
}

func verifierDescriptionLine(settings domain.BotVerifierSettings) string {
	if description := verifierMarkDescription(settings); description != "" {
		return description
	}
	return "(no description -- only the icon is shown)"
}

// ---------------------------------------------------------------------------
// Flow: /start
// ---------------------------------------------------------------------------

// verifierIntro answers /start. The explanation of what a third-party mark is
// comes first and unconditionally; the apply button only exists when there is a
// live verifier status behind it.
func (s *Service) verifierIntro(ctx context.Context, userID int64, state domain.BotChatState, found bool) botReply {
	if !found {
		state = verifierNewState(userID)
	}
	settings, refusal, ok := s.verifierSettings(ctx, userID)
	if !ok {
		// No keyboard, no state write: there is nothing for the applicant to press.
		return botReply{Text: verifierJoin(verifierBotWhatText(), refusal.Text)}
	}
	state.Step = verifierStepIntro
	markup := s.verifierOptionKeyboard(&state, [][]verifierOption{{
		{text: verifierApplyButtonText, choice: verifierChoiceApply, style: domain.MarkupButtonStylePrimary},
	}})
	if !s.saveVerifierState(ctx, state) {
		return internalReply()
	}
	live := fmt.Sprintf("Verifier: %s\n\nThe mark I would put on your peer:\n%s\n\nTap the button below, or send /verify, to apply. An operator reads every application and decides; I only collect it. Send /help for the rest of my commands.",
		verifierTruncate(strings.TrimSpace(settings.CompanyName), domain.MaxVerifierCompanyLength),
		verifierDescriptionLine(settings))
	return botReply{Text: verifierJoin(verifierBotWhatText(), live), ReplyMarkup: markup}
}

// ---------------------------------------------------------------------------
// Flow: apply
// ---------------------------------------------------------------------------

// startVerifierRequest is /verify and the apply button: it starts a fresh
// application, so the leftovers of any previous one are cleared first.
func (s *Service) startVerifierRequest(ctx context.Context, state domain.BotChatState) botReply {
	settings, refusal, ok := s.verifierSettings(ctx, state.UserID)
	if !ok {
		return refusal
	}
	verifierEnsureDraft(&state)
	for _, key := range []string{
		verifierDraftTarget, verifierDraftTargetTitle, verifierDraftTargetUsername,
		verifierDraftReason, verifierDraftRequestID, verifierDraftCorrelation,
		verifierDraftRevoked, verifierDraftRevokedLabel,
	} {
		delete(state.Draft, key)
	}
	return s.verifierTargetPrompt(ctx, state, settings, "")
}

// verifierTargetPrompt renders the peer picker.
func (s *Service) verifierTargetPrompt(ctx context.Context, state domain.BotChatState, settings domain.BotVerifierSettings, lead string) botReply {
	targets, reply, ok := s.verifierCandidates(ctx, state.UserID)
	if !ok {
		return botReply{Text: verifierJoin(lead, reply.Text)}
	}
	state.Step = verifierStepTarget
	rows := make([][]verifierOption, 0, len(targets)+1)
	for _, target := range targets {
		rows = append(rows, []verifierOption{{
			text:   verifierTargetButtonText(target),
			choice: verifierChoiceTargetPrefix + string(target.Type) + ":" + strconv.FormatInt(target.ID, 10),
		}})
	}
	rows = append(rows, []verifierOption{{
		text:   verifierCancelButtonText,
		choice: verifierChoiceAbort,
		style:  domain.MarkupButtonStyleDanger,
	}})
	markup := s.verifierOptionKeyboard(&state, rows)
	if !s.saveVerifierState(ctx, state) {
		return internalReply()
	}
	prompt := fmt.Sprintf("Which one should carry the %s mark? Pick it below.",
		verifierTruncate(strings.TrimSpace(settings.CompanyName), domain.MaxVerifierCompanyLength))
	return botReply{Text: verifierJoin(lead, prompt), ReplyMarkup: markup}
}

// verifierCandidates enumerates the applicant's own peers, bounded and filtered
// down to what a third-party mark can actually be attached to.
func (s *Service) verifierCandidates(ctx context.Context, userID int64) ([]domain.VerificationTarget, botReply, bool) {
	source := s.verifierTargetSource()
	if source == nil {
		return nil, botReply{Text: verifierNoTargetSourceText}, false
	}
	targets, err := source.EligibleTargets(ctx, userID)
	if err != nil {
		s.log.Warn("verifierbot: enumerate targets", zap.Int64("user_id", userID), zap.Error(err))
		return nil, botReply{Text: verifierNoTargetSourceText}, false
	}
	out := make([]domain.VerificationTarget, 0, len(targets))
	for _, target := range targets {
		if !verifierTargetVerifiable(target) {
			continue
		}
		out = append(out, target)
		if len(out) >= verifierMaxTargetButtons {
			break
		}
	}
	if len(out) == 0 {
		return nil, botReply{Text: verifierNoTargetsText}, false
	}
	return out, botReply{}, true
}

// verifierTargetVerifiable filters the enumeration down to peers a mark can exist
// on: the domain accepts user and channel peers only, and a built-in service
// account is never a third-party subject.
func verifierTargetVerifiable(target domain.VerificationTarget) bool {
	if target.ID <= 0 || !target.Type.Valid() {
		return false
	}
	peer := verifierTargetPeer(target)
	if peer.Type == domain.PeerTypeUser && domain.IsSystemUserID(peer.ID) {
		return false
	}
	return peer.Type == domain.PeerTypeUser || peer.Type == domain.PeerTypeChannel
}

func verifierTargetPeer(target domain.VerificationTarget) domain.Peer {
	return domain.Peer{Type: target.Type.PeerType(), ID: target.ID}
}

// chooseVerifierTarget opens the application for the picked peer.
//
// The kind and id come from the token table, i.e. from state this server wrote when
// it rendered the keyboard -- never from the callback data. They are then
// re-resolved against the applicant's own enumeration, which is what authorises the
// choice: a peer that is no longer theirs simply is not in the list any more.
func (s *Service) chooseVerifierTarget(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("verifierbot: malformed target choice",
			zap.Int64("user_id", state.UserID), zap.String("payload", payload))
		return botReply{Text: verifierExpiredButtonText}
	}
	settings, refusal, ok := s.verifierSettings(ctx, state.UserID)
	if !ok {
		return refusal
	}
	targets, reply, ok := s.verifierCandidates(ctx, state.UserID)
	if !ok {
		return reply
	}
	var target domain.VerificationTarget
	for _, candidate := range targets {
		if candidate.Type == targetType && candidate.ID == targetID {
			target = candidate
		}
	}
	if target.ID == 0 {
		return botReply{Text: verifierGoneTargetText}
	}
	peer := verifierTargetPeer(target)
	label := verifierPeerLabel(target.Title, target.Username, peer)

	// Already applied for, or already marked: say so instead of filing a duplicate the
	// service would refuse anyway.
	if pending, found := s.verifierPendingRequest(ctx, peer); found {
		return botReply{Text: fmt.Sprintf("Application #%d for %s is already with the operator, so there is nothing to file. Send /status to see where it stands.",
			pending.ID, label)}
	}
	if mark, found := s.verifierMark(ctx, peer); found {
		text := fmt.Sprintf("%s already carries my mark, so there is nothing to apply for.", label)
		if description := strings.TrimSpace(mark.Description); description != "" {
			text += "\n\nIt reads: " + verifierTruncate(description, domain.MaxCustomVerificationDescriptionLength)
		}
		// The picker stays where it is (the dialog is still at the subject step): this
		// only offers the way out, which is removing the mark.
		markup := s.verifierOptionKeyboard(&state, [][]verifierOption{
			{{text: verifierRevokeMenuButtonText, choice: verifierChoiceRevokeApply, style: domain.MarkupButtonStyleDanger}},
		})
		if !s.saveVerifierState(ctx, state) {
			return internalReply()
		}
		return botReply{Text: text + "\n\nSend /revoke, or tap below, if you want it removed.", ReplyMarkup: markup}
	}

	verifierEnsureDraft(&state)
	state.Draft[verifierDraftTarget] = string(targetType) + ":" + strconv.FormatInt(targetID, 10)
	state.Draft[verifierDraftTargetTitle] = target.Title
	state.Draft[verifierDraftTargetUsername] = target.Username
	delete(state.Draft, verifierDraftCorrelation)
	return s.verifierAdvance(ctx, state, settings, verifierStepReason, "Subject: "+label+".")
}

func (s *Service) handleVerifierReason(ctx context.Context, state domain.BotChatState, text string) botReply {
	length := utf8.RuneCountInString(text)
	if length < verifierMinReasonLength {
		return botReply{Text: fmt.Sprintf("That is %d characters and I need at least %d. An operator has to be able to check what you say, so name who is behind it and what it is known for.",
			length, verifierMinReasonLength)}
	}
	if length > domain.MaxCustomVerificationReasonLength {
		return botReply{Text: fmt.Sprintf("That is %d characters and the limit is %d. Please shorten it.",
			length, domain.MaxCustomVerificationReasonLength)}
	}
	settings, refusal, ok := s.verifierSettings(ctx, state.UserID)
	if !ok {
		return refusal
	}
	verifierEnsureDraft(&state)
	state.Draft[verifierDraftReason] = text
	return s.verifierAdvance(ctx, state, settings, verifierStepConfirm, "Saved.")
}

// submitVerifierRequest files the application.
//
// Pressing Confirm twice is idempotent by construction: the first press moves the
// dialog to verifierStepDone, and a repeat press is answered from the id stored in
// the dialog instead of calling CreateRequest again.
func (s *Service) submitVerifierRequest(ctx context.Context, state domain.BotChatState) botReply {
	if state.Step == verifierStepDone {
		if id := verifierDraftInt(state, verifierDraftRequestID); id > 0 {
			return botReply{Text: verifierFiledText(id, verifierDraftTargetLabel(state))}
		}
		return botReply{Text: verifierBotIdleText}
	}
	settings, refusal, ok := s.verifierSettings(ctx, state.UserID)
	if !ok {
		return refusal
	}
	target, targetOK := verifierDraftTargetOf(state)
	reason := strings.TrimSpace(state.Draft[verifierDraftReason])
	if !targetOK {
		return s.startVerifierRequest(ctx, state)
	}
	if utf8.RuneCountInString(reason) < verifierMinReasonLength {
		return s.verifierAdvance(ctx, state, settings, verifierStepReason, "I still need the reason.")
	}
	peer := verifierTargetPeer(target)
	label := verifierDraftTargetLabel(state)
	verifierEnsureDraft(&state)
	correlation := state.Draft[verifierDraftCorrelation]
	if correlation == "" {
		correlation = s.verifierCorrelationID(state.UserID)
		state.Draft[verifierDraftCorrelation] = correlation
	}
	created, err := s.customVerification.CreateRequest(ctx, domain.CustomVerificationRequest{
		VerifierBotID:   domain.VerifierBotUserID,
		ApplicantUserID: state.UserID,
		Peer:            peer,
		PeerTitle:       state.Draft[verifierDraftTargetTitle],
		PeerUsername:    state.Draft[verifierDraftTargetUsername],
		Reason:          reason,
		// RequestedDescription is deliberately empty: the mark carries the verifier's
		// own description, resolved by domain.BotVerifierSettings.DescriptionFor when the
		// operator grants it. Freezing today's text into the application would let the
		// granted mark drift from what the verifier actually says.
		Status:        domain.CustomVerificationPending,
		CorrelationID: correlation,
	})
	if errors.Is(err, domain.ErrCustomVerificationRequestExists) {
		// Someone (another device, or this dialog after a lost reply) already filed it.
		// Adopt that application instead of reporting a refusal for something the
		// applicant actually got.
		if pending, found := s.verifierPendingRequest(ctx, peer); found {
			state.Step = verifierStepDone
			state.Draft[verifierDraftRequestID] = strconv.FormatInt(pending.ID, 10)
			if !s.saveVerifierState(ctx, state) {
				return internalReply()
			}
			return botReply{Text: verifierFiledText(pending.ID, label)}
		}
	}
	if err != nil {
		return s.verifierErrorReply(state.UserID, "create request", err)
	}
	state.Step = verifierStepDone
	state.Draft[verifierDraftRequestID] = strconv.FormatInt(created.ID, 10)
	if !s.saveVerifierState(ctx, state) {
		return internalReply()
	}
	return botReply{Text: verifierFiledText(created.ID, label)}
}

// cancelVerifierDialog drops the dialog. It cannot withdraw an application that is
// already with the operator, and says so rather than implying otherwise.
func (s *Service) cancelVerifierDialog(ctx context.Context, userID int64, state domain.BotChatState, found bool) botReply {
	if !found {
		return botReply{Text: verifierNothingToCancelText}
	}
	filed := verifierDraftInt(state, verifierDraftRequestID)
	s.deleteVerifierState(ctx, userID)
	if filed > 0 && state.Step == verifierStepDone {
		return botReply{Text: fmt.Sprintf("Dropped. Application #%d is already with the operator, so it stays in the queue -- send /status to follow it.", filed)}
	}
	return botReply{Text: "Dropped, nothing was filed. Send /verify when you want to apply."}
}

// ---------------------------------------------------------------------------
// Flow: /status
// ---------------------------------------------------------------------------

func (s *Service) verifierStatusReply(ctx context.Context, userID int64) botReply {
	requests, err := s.customVerification.ApplicantRequests(ctx, userID, verifierStatusListLimit)
	if err != nil {
		return s.verifierErrorReply(userID, "list requests", err)
	}
	if len(requests) == 0 {
		return botReply{Text: verifierNoRequestsText}
	}
	var b strings.Builder
	b.WriteString("Your third-party verification applications:")
	live := 0
	for _, req := range requests {
		label := verifierPeerLabel(req.PeerTitle, req.PeerUsername, req.Peer)
		b.WriteString("\n\n#")
		b.WriteString(strconv.FormatInt(req.ID, 10))
		b.WriteString(" - ")
		b.WriteString(label)
		b.WriteString("\nStatus: ")
		b.WriteString(verifierStatusLabel(req.Status))
		if date := verifierDateLabel(req); date != "" {
			b.WriteString(" (")
			b.WriteString(date)
			b.WriteString(")")
		}
		// Only DecisionReason is ever shown: it is the text written to be read by the
		// applicant. req.InternalNote is the operator's private note and must never
		// appear in a bot message.
		if req.Status == domain.CustomVerificationRejected || req.Status == domain.CustomVerificationRevoked {
			if reason := strings.TrimSpace(req.DecisionReason); reason != "" {
				b.WriteString("\nReason: ")
				b.WriteString(reason)
			}
		}
		if req.Status == domain.CustomVerificationApproved {
			// The application says "approved"; whether the mark is still on the peer is a
			// different fact, so it is read rather than inferred.
			if mark, found := s.verifierMark(ctx, req.Peer); found {
				live++
				b.WriteString("\nMark: live")
				if description := strings.TrimSpace(mark.Description); description != "" {
					b.WriteString(" -- ")
					b.WriteString(verifierTruncate(description, 200))
				}
			} else {
				b.WriteString("\nMark: not on the peer any more")
			}
		}
	}
	if live > 0 {
		b.WriteString("\n\nSend /revoke to remove a mark.")
	} else {
		b.WriteString("\n\nSend /verify to apply for another peer.")
	}
	return botReply{Text: b.String()}
}

// ---------------------------------------------------------------------------
// Flow: /revoke
// ---------------------------------------------------------------------------

// verifierRevokePrompt renders the picker of marks this applicant can remove.
//
// The list is built from the applicant's OWN approved applications, intersected
// with the marks that are actually live. That is what authorises the removal: a
// peer nobody filed under this account can never appear here, so the picker cannot
// be used to strip a mark off somebody else's channel.
func (s *Service) verifierRevokePrompt(ctx context.Context, state domain.BotChatState, lead string) botReply {
	marked, reply, ok := s.verifierRevocable(ctx, state.UserID)
	if !ok {
		return botReply{Text: verifierJoin(lead, reply.Text)}
	}
	state.Step = verifierStepRevokePick
	verifierEnsureDraft(&state)
	rows := make([][]verifierOption, 0, len(marked)+1)
	for _, req := range marked {
		rows = append(rows, []verifierOption{{
			text:   verifierTruncate(verifierPeerLabel(req.PeerTitle, req.PeerUsername, req.Peer), 64),
			choice: verifierChoiceRevokePrefix + verifierPeerKey(req.Peer),
		}})
	}
	rows = append(rows, []verifierOption{{
		text:   verifierCancelButtonText,
		choice: verifierChoiceAbort,
		style:  domain.MarkupButtonStyleDanger,
	}})
	markup := s.verifierOptionKeyboard(&state, rows)
	if !s.saveVerifierState(ctx, state) {
		return internalReply()
	}
	return botReply{
		Text:        verifierJoin(lead, "Which mark should I remove? The icon and the description disappear from the profile, and getting them back means applying again."),
		ReplyMarkup: markup,
	}
}

// verifierRevocable returns the applicant's approved applications whose mark is
// still live, one per peer.
func (s *Service) verifierRevocable(ctx context.Context, userID int64) ([]domain.CustomVerificationRequest, botReply, bool) {
	requests, err := s.customVerification.ApplicantRequests(ctx, userID, verifierStatusListLimit)
	if err != nil {
		return nil, s.verifierErrorReply(userID, "list requests for revoke", err), false
	}
	seen := make(map[domain.Peer]struct{}, len(requests))
	out := make([]domain.CustomVerificationRequest, 0, len(requests))
	for _, req := range requests {
		if req.Status != domain.CustomVerificationApproved {
			continue
		}
		if _, duplicate := seen[req.Peer]; duplicate {
			continue
		}
		if _, found := s.verifierMark(ctx, req.Peer); !found {
			continue
		}
		seen[req.Peer] = struct{}{}
		out = append(out, req)
		if len(out) >= verifierMaxTargetButtons {
			break
		}
	}
	if len(out) == 0 {
		return nil, botReply{Text: verifierNothingToRevokeText}, false
	}
	return out, botReply{}, true
}

// chooseVerifierRevokeTarget asks for confirmation before anything is removed.
func (s *Service) chooseVerifierRevokeTarget(ctx context.Context, state domain.BotChatState, payload string) botReply {
	peer, ok := verifierParsePeer(payload)
	if !ok {
		s.log.Warn("verifierbot: malformed revoke choice",
			zap.Int64("user_id", state.UserID), zap.String("payload", payload))
		return botReply{Text: verifierExpiredButtonText}
	}
	marked, reply, ok := s.verifierRevocable(ctx, state.UserID)
	if !ok {
		return reply
	}
	var chosen domain.CustomVerificationRequest
	for _, req := range marked {
		if req.Peer == peer {
			chosen = req
		}
	}
	if chosen.ID == 0 {
		return botReply{Text: verifierNothingToRevokeText}
	}
	verifierEnsureDraft(&state)
	state.Step = verifierStepRevokeConfirm
	markup := s.verifierOptionKeyboard(&state, [][]verifierOption{
		{{
			text:   verifierRevokeButtonText,
			choice: verifierChoiceRevokeCommitPrefix + verifierPeerKey(peer),
			style:  domain.MarkupButtonStyleDanger,
		}},
		{{text: verifierRevokeCancelButtonText, choice: verifierChoiceAbort}},
	})
	if !s.saveVerifierState(ctx, state) {
		return internalReply()
	}
	return botReply{
		Text: fmt.Sprintf("Remove my mark from %s?\n\nThe icon before the name and the description in the profile go away immediately. Application #%d stays in your history as revoked, and a new mark would need a new application.",
			verifierPeerLabel(chosen.PeerTitle, chosen.PeerUsername, peer), chosen.ID),
		ReplyMarkup: markup,
	}
}

// commitVerifierRevoke removes the mark from the peer the pressed button was
// rendered for.
//
// A repeat press replays the same answer: the removed peer is recorded in the
// dialog together with the label it was reported under. A press whose peer is not
// the recorded one is not a replay and goes through the authorisation check again,
// so a stale confirmation can only ever be refused -- never applied to a peer the
// applicant did not confirm.
func (s *Service) commitVerifierRevoke(ctx context.Context, state domain.BotChatState, payload string) botReply {
	if state.Step == verifierStepDone && payload != "" && state.Draft[verifierDraftRevoked] == payload {
		return botReply{Text: verifierRevokedText(state.Draft[verifierDraftRevokedLabel])}
	}
	peer, ok := verifierParsePeer(payload)
	if !ok {
		s.log.Warn("verifierbot: malformed revoke commit",
			zap.Int64("user_id", state.UserID), zap.String("payload", payload))
		return botReply{Text: verifierExpiredButtonText}
	}
	// Re-authorise at the moment of the write: the picker's list is the only thing
	// that says this peer is the applicant's, and it was rendered some time ago.
	marked, reply, ok := s.verifierRevocable(ctx, state.UserID)
	if !ok {
		return reply
	}
	var chosen domain.CustomVerificationRequest
	for _, req := range marked {
		if req.Peer == peer {
			chosen = req
		}
	}
	if chosen.ID == 0 {
		return botReply{Text: verifierNothingToRevokeText}
	}
	label := verifierPeerLabel(chosen.PeerTitle, chosen.PeerUsername, peer)
	// The mark carries no revocation audit column, so the actor and the reason are
	// logged here rather than pretended into the store's signature.
	s.log.Info("verifierbot: applicant removed own mark",
		zap.Int64("user_id", state.UserID),
		zap.String("peer_type", string(peer.Type)),
		zap.Int64("peer_id", peer.ID))
	removed, err := s.customVerification.RevokeMark(ctx, domain.VerifierBotUserID, peer)
	if err != nil {
		return s.verifierErrorReply(state.UserID, "revoke mark", err)
	}
	if !removed {
		// The mark went away between the picker and this press. Nothing is recorded as
		// removed by this dialog, so a repeat press re-checks rather than replaying a
		// removal that never happened here.
		return botReply{Text: fmt.Sprintf("There was no mark left on %s, so nothing changed.", label)}
	}
	verifierEnsureDraft(&state)
	state.Step = verifierStepDone
	state.Draft[verifierDraftRevoked] = payload
	state.Draft[verifierDraftRevokedLabel] = label
	if !s.saveVerifierState(ctx, state) {
		return internalReply()
	}
	return botReply{Text: verifierRevokedText(label)}
}

func verifierRevokedText(label string) string {
	if strings.TrimSpace(label) == "" {
		label = "that peer"
	}
	return fmt.Sprintf("My mark is removed from %s: the icon and the description are gone from the profile. Send /verify if you ever want to apply again.", label)
}

// ---------------------------------------------------------------------------
// Step rendering
// ---------------------------------------------------------------------------

// verifierAdvance moves the dialog to step and renders that step's prompt. lead is
// an optional acknowledgement of what was just accepted.
func (s *Service) verifierAdvance(ctx context.Context, state domain.BotChatState, settings domain.BotVerifierSettings, step, lead string) botReply {
	state.Step = step
	verifierEnsureDraft(&state)
	var reply botReply
	switch step {
	case verifierStepTarget:
		return s.verifierTargetPrompt(ctx, state, settings, lead)
	case verifierStepReason:
		markup := s.verifierOptionKeyboard(&state, [][]verifierOption{{
			{text: verifierCancelButtonText, choice: verifierChoiceAbort, style: domain.MarkupButtonStyleDanger},
		}})
		reply = botReply{Text: verifierJoin(lead, verifierReasonPrompt(settings, verifierDraftTargetLabel(state))), ReplyMarkup: markup}
	case verifierStepConfirm:
		if state.Draft[verifierDraftCorrelation] == "" {
			state.Draft[verifierDraftCorrelation] = s.verifierCorrelationID(state.UserID)
		}
		markup := s.verifierOptionKeyboard(&state, [][]verifierOption{
			{{text: verifierConfirmButtonText, choice: verifierChoiceConfirm, style: domain.MarkupButtonStyleSuccess}},
			{{text: verifierCancelButtonText, choice: verifierChoiceAbort, style: domain.MarkupButtonStyleDanger}},
		})
		reply = botReply{Text: verifierJoin(lead, verifierSummaryText(state, settings)), ReplyMarkup: markup}
	default:
		s.log.Error("verifierbot: advance to unknown step",
			zap.Int64("user_id", state.UserID), zap.String("step", step))
		return internalReply()
	}
	if !s.saveVerifierState(ctx, state) {
		return internalReply()
	}
	return reply
}

func (s *Service) verifierStepReminder(state domain.BotChatState) botReply {
	switch state.Step {
	case verifierStepReason:
		return botReply{Text: fmt.Sprintf("I am waiting for the reason, in one message, between %d and %d characters.",
			verifierMinReasonLength, domain.MaxCustomVerificationReasonLength)}
	case verifierStepTarget, verifierStepConfirm, verifierStepRevokePick, verifierStepRevokeConfirm:
		return botReply{Text: verifierPickButtonsText}
	default:
		return botReply{Text: verifierBotIdleText}
	}
}

func verifierReasonPrompt(settings domain.BotVerifierSettings, label string) string {
	company := strings.TrimSpace(settings.CompanyName)
	if company == "" {
		company = "this verifier"
	}
	return fmt.Sprintf("Now tell me in one message why %s should vouch for %s: who is behind it, what it is publicly known for, and anything an operator can check. Between %d and %d characters.",
		company, label, verifierMinReasonLength, domain.MaxCustomVerificationReasonLength)
}

func verifierSummaryText(state domain.BotChatState, settings domain.BotVerifierSettings) string {
	var b strings.Builder
	b.WriteString("Here is what I will file. Nothing reaches the operator until you tap ")
	b.WriteString(verifierConfirmButtonText)
	b.WriteString(".\n\nVerifier: ")
	b.WriteString(strings.TrimSpace(settings.CompanyName))
	b.WriteString("\nSubject: ")
	b.WriteString(verifierDraftTargetLabel(state))
	b.WriteString("\nThe mark it would carry: ")
	b.WriteString(verifierDescriptionLine(settings))
	b.WriteString("\n\nWhy:\n")
	b.WriteString(state.Draft[verifierDraftReason])
	b.WriteString("\n\nThis is a third-party mark, not the official ")
	b.WriteString(branding.ProductName())
	b.WriteString(" checkmark, and I do not decide: an operator reads the application and either grants the mark or refuses it. I will message you here either way.")
	return b.String()
}

func verifierFiledText(requestID int64, label string) string {
	return fmt.Sprintf("Application #%d is filed for %s.\n\nAn operator reads it by hand -- I cannot grant my own mark. Send /status any time to see where it stands, and I will message you here as soon as it is decided.",
		requestID, label)
}

// ---------------------------------------------------------------------------
// Applicant notifications (botverification.ApplicantNotifier)
// ---------------------------------------------------------------------------

// SendVerificationDecision delivers one operator decision as an ordinary
// @verifierbot message, so it lands in the applicant's history and dialog list like
// any other message. Returning an error keeps the queued notification pending for
// the next cycle.
func (s *Service) SendVerificationDecision(ctx context.Context, recipientUserID int64, req domain.CustomVerificationRequest) error {
	if s == nil || s.messages == nil {
		return fmt.Errorf("custom verification decision: bots service is not configured")
	}
	if recipientUserID <= 0 {
		return fmt.Errorf("custom verification decision: recipient is empty")
	}
	text, ok := verifierDecisionText(req)
	if !ok {
		return fmt.Errorf("custom verification decision: nothing to report for request %d in status %q", req.ID, req.Status)
	}
	mu := s.serviceBotReplyLock(domain.VerifierBotUserID, recipientUserID)
	mu.Lock()
	defer mu.Unlock()
	if _, sent := s.sendServiceBotReplyResult(ctx, domain.VerifierBotUserID, recipientUserID, botReply{Text: text}); !sent {
		return fmt.Errorf("custom verification decision: deliver status %q for request %d", req.Status, req.ID)
	}
	return nil
}

// verifierDecisionText renders one decision for the applicant.
//
// req.InternalNote is NEVER rendered here, under any status: it is the operator's
// private note, kept for the audit trail and the admin panel only. The single field
// that reaches the applicant is req.DecisionReason, which exists precisely because
// it was written to be read by them.
func verifierDecisionText(req domain.CustomVerificationRequest) (string, bool) {
	label := verifierPeerLabel(req.PeerTitle, req.PeerUsername, req.Peer)
	switch req.Status {
	case domain.CustomVerificationApproved:
		return fmt.Sprintf("Application #%d is approved: %s now carries my mark -- my icon before the name and my description in the profile.\n\nThis is a third-party mark, not the official %s checkmark. Send /revoke if you ever want it removed.",
			req.ID, label, branding.ProductName()), true
	case domain.CustomVerificationRejected:
		text := fmt.Sprintf("Application #%d for %s was not approved, so no mark was granted.", req.ID, label)
		if reason := strings.TrimSpace(req.DecisionReason); reason != "" {
			text += "\n\nReason: " + reason
		}
		return text + "\n\nYou can apply again with /verify once the reason no longer applies.", true
	case domain.CustomVerificationRevoked:
		text := fmt.Sprintf("My mark has been taken off %s (application #%d): the icon and the description are no longer shown.", label, req.ID)
		if reason := strings.TrimSpace(req.DecisionReason); reason != "" {
			text += "\n\nReason: " + reason
		}
		return text, true
	default:
		// Pending is not a decision, and an unmodelled status is not one either: report
		// it instead of delivering a message that says nothing.
		return "", false
	}
}

// ---------------------------------------------------------------------------
// Button tokens
// ---------------------------------------------------------------------------

// verifierOptionKeyboard renders one inline keyboard and records its buttons in the
// per-user token table.
//
// SECURITY: callback data never carries a peer id, a peer type, an application id
// or anything else the server would have to trust. Every button's data is
// verifierCallbackDataPrefix 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: the server
// resolves the choice from state it wrote itself when it rendered the keyboard, and
// then re-resolves that choice against the applicant's own peers before acting. 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 verifierTokenGenerations
// 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) verifierOptionKeyboard(state *domain.BotChatState, rows [][]verifierOption) *domain.MessageReplyMarkup {
	if state == nil || len(rows) == 0 {
		return nil
	}
	verifierEnsureDraft(state)
	generation := verifierDraftInt(*state, verifierDraftGeneration) + 1
	state.Draft[verifierDraftGeneration] = strconv.FormatInt(generation, 10)
	verifierPruneOptions(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.verifierOptionToken()
			state.Draft[verifierDraftOptionPrefix+token] = strconv.FormatInt(generation, 10) + "|" + option.choice
			buttons = append(buttons, domain.MarkupButton{
				Type:  domain.MarkupButtonCallback,
				Text:  option.text,
				Style: option.style,
				Data:  []byte(verifierCallbackDataPrefix + token),
			})
		}
		if len(buttons) > 0 {
			markup.Inline = append(markup.Inline, buttons)
		}
	}
	if len(markup.Inline) == 0 {
		return nil
	}
	return markup
}

// verifierOptionToken mints one opaque button token.
func (s *Service) verifierOptionToken() string {
	var buf [verifierOptionTokenBytes]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)
}

// verifierCorrelationID mints the per-attempt idempotency key handed to the
// service with an application.
func (s *Service) verifierCorrelationID(userID int64) string {
	return "verifierbot:" + strconv.FormatInt(userID, 10) + ":" + s.verifierOptionToken()
}

// verifierResolveOption maps callback data back onto the choice recorded for it.
// Anything that is not in this user's own table is refused.
func verifierResolveOption(state domain.BotChatState, data []byte) (string, bool) {
	raw := string(data)
	if !strings.HasPrefix(raw, verifierCallbackDataPrefix) {
		return "", false
	}
	token := raw[len(verifierCallbackDataPrefix):]
	if token == "" || len(token) > verifierOptionTokenMaxLen {
		return "", false
	}
	value, found := state.Draft[verifierDraftOptionPrefix+token]
	if !found {
		return "", false
	}
	_, choice, split := strings.Cut(value, "|")
	if !split || choice == "" {
		return "", false
	}
	return choice, true
}

// verifierPruneOptions drops tokens older than the retained window.
func verifierPruneOptions(state *domain.BotChatState, generation int64) {
	oldest := generation - verifierTokenGenerations + 1
	for key, value := range state.Draft {
		if !strings.HasPrefix(key, verifierDraftOptionPrefix) {
			continue
		}
		rawGen, _, _ := strings.Cut(value, "|")
		gen, err := strconv.ParseInt(rawGen, 10, 64)
		if err != nil || gen < oldest {
			delete(state.Draft, key)
		}
	}
}

// ---------------------------------------------------------------------------
// Service lookups
// ---------------------------------------------------------------------------

// verifierTargetSource resolves the peer enumeration. An explicitly injected
// source wins; otherwise the official verification service is used purely as a
// directory of the applicant's own peers (see verifierBotTargets on why its
// eligibility verdicts are ignored).
func (s *Service) verifierTargetSource() verifierBotTargets {
	if s == nil {
		return nil
	}
	if s.verifierTargets != nil {
		return s.verifierTargets
	}
	if s.verification != nil {
		return s.verification
	}
	return nil
}

// verifierPendingRequest reports the live application on a peer, if any.
func (s *Service) verifierPendingRequest(ctx context.Context, peer domain.Peer) (domain.CustomVerificationRequest, bool) {
	req, err := s.customVerification.PendingRequest(ctx, domain.VerifierBotUserID, peer)
	switch {
	case errors.Is(err, domain.ErrCustomVerificationRequestNotFound):
		return domain.CustomVerificationRequest{}, false
	case err != nil:
		s.log.Warn("verifierbot: read pending request",
			zap.String("peer_type", string(peer.Type)), zap.Int64("peer_id", peer.ID), zap.Error(err))
		return domain.CustomVerificationRequest{}, false
	}
	if req.ID <= 0 || req.Peer != peer || req.Status != domain.CustomVerificationPending {
		return domain.CustomVerificationRequest{}, false
	}
	return req, true
}

// verifierMark reports whether this verifier's mark is live on a peer. The filter
// result is re-checked rather than trusted, so a store that ignores a filter field
// cannot make the bot claim a mark on the wrong peer.
func (s *Service) verifierMark(ctx context.Context, peer domain.Peer) (domain.CustomVerification, bool) {
	marks, err := s.customVerification.Marks(ctx, domain.CustomVerificationFilter{
		VerifierBotID: domain.VerifierBotUserID,
		PeerType:      peer.Type,
		PeerID:        peer.ID,
		Limit:         1,
	})
	if err != nil {
		s.log.Warn("verifierbot: read mark",
			zap.String("peer_type", string(peer.Type)), zap.Int64("peer_id", peer.ID), zap.Error(err))
		return domain.CustomVerification{}, false
	}
	for _, mark := range marks {
		if mark.VerifierBotID == domain.VerifierBotUserID && mark.Peer == peer {
			return mark, true
		}
	}
	return domain.CustomVerification{}, false
}

// ---------------------------------------------------------------------------
// State helpers
// ---------------------------------------------------------------------------

func verifierNewState(userID int64) domain.BotChatState {
	return domain.BotChatState{
		BotUserID: domain.VerifierBotUserID,
		UserID:    userID,
		Command:   verifierBotCommand,
		Step:      verifierStepIntro,
		Draft:     map[string]string{},
	}
}

func verifierEnsureDraft(state *domain.BotChatState) {
	if state.Draft == nil {
		state.Draft = map[string]string{}
	}
	if state.BotUserID == 0 {
		state.BotUserID = domain.VerifierBotUserID
	}
	if state.Command == "" {
		state.Command = verifierBotCommand
	}
}

func (s *Service) saveVerifierState(ctx context.Context, state domain.BotChatState) bool {
	verifierEnsureDraft(&state)
	state.BotUserID = domain.VerifierBotUserID
	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("verifierbot: save chat state", zap.Int64("user_id", state.UserID), zap.Error(err))
		return false
	}
	return true
}

func (s *Service) deleteVerifierState(ctx context.Context, userID int64) {
	if err := s.bots.DeleteBotChatState(ctx, domain.VerifierBotUserID, userID); err != nil {
		s.log.Error("verifierbot: delete chat state", zap.Int64("user_id", userID), zap.Error(err))
	}
}

func verifierDraftInt(state domain.BotChatState, key string) int64 {
	value, err := strconv.ParseInt(strings.TrimSpace(state.Draft[key]), 10, 64)
	if err != nil {
		return 0
	}
	return value
}

// verifierDraftTargetOf reads back the chosen subject. The stored form is the
// verification target kind plus its id, so the peer namespace is derived by the
// domain rather than remembered separately.
func verifierDraftTargetOf(state domain.BotChatState) (domain.VerificationTarget, bool) {
	kind, rawID, split := strings.Cut(state.Draft[verifierDraftTarget], ":")
	if !split {
		return domain.VerificationTarget{}, false
	}
	id, err := strconv.ParseInt(rawID, 10, 64)
	targetType := domain.VerificationTargetType(kind)
	if err != nil || id <= 0 || !targetType.Valid() {
		return domain.VerificationTarget{}, false
	}
	return domain.VerificationTarget{
		Type:     targetType,
		ID:       id,
		Title:    state.Draft[verifierDraftTargetTitle],
		Username: state.Draft[verifierDraftTargetUsername],
	}, true
}

func verifierDraftTargetLabel(state domain.BotChatState) string {
	target, ok := verifierDraftTargetOf(state)
	if !ok {
		return "the selected subject"
	}
	return verifierPeerLabel(target.Title, target.Username, verifierTargetPeer(target))
}

// verifierPeerKey renders a peer for the token table: the two peer namespaces a
// mark can live in, and an id. It never reaches a client -- only the random token
// standing for it does.
func verifierPeerKey(peer domain.Peer) string {
	return string(peer.Type) + ":" + strconv.FormatInt(peer.ID, 10)
}

// verifierParsePeer reads a verifierPeerKey back. It accepts only the two peer
// namespaces a mark can live in.
func verifierParsePeer(payload string) (domain.Peer, bool) {
	kind, rawID, split := strings.Cut(payload, ":")
	if !split {
		return domain.Peer{}, false
	}
	id, err := strconv.ParseInt(rawID, 10, 64)
	if err != nil || id <= 0 {
		return domain.Peer{}, false
	}
	peerType := domain.PeerType(kind)
	if peerType != domain.PeerTypeUser && peerType != domain.PeerTypeChannel {
		return domain.Peer{}, false
	}
	return domain.Peer{Type: peerType, ID: id}, true
}

// ---------------------------------------------------------------------------
// Rendering helpers
// ---------------------------------------------------------------------------

// verifierPeerLabel renders a subject for humans. The username is the identity
// that matters, the title is the context, and the peer is the last resort so a
// message never says "the selected peer" when it can name one.
func verifierPeerLabel(title, username string, peer domain.Peer) string {
	title = strings.TrimSpace(title)
	username = strings.TrimSpace(username)
	switch {
	case username != "" && title != "":
		return verifierTruncate(title, 64) + " (@" + username + ")"
	case username != "":
		return "@" + username
	case title != "":
		return verifierTruncate(title, 64)
	case peer.Type == domain.PeerTypeChannel && peer.ID > 0:
		return "channel " + strconv.FormatInt(peer.ID, 10)
	case peer.Type == domain.PeerTypeUser && peer.ID > 0:
		return "account " + strconv.FormatInt(peer.ID, 10)
	default:
		return "the selected peer"
	}
}

func verifierTargetButtonText(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 verifierTargetKindLabel(target.Type) + ": " + verifierTruncate(label, 64)
}

func verifierTargetKindLabel(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 verifierStatusLabel(status domain.CustomVerificationRequestStatus) string {
	switch status {
	case domain.CustomVerificationPending:
		return "waiting for an operator"
	case domain.CustomVerificationApproved:
		return "approved, the mark was granted"
	case domain.CustomVerificationRejected:
		return "not approved"
	case domain.CustomVerificationRevoked:
		return "the mark was taken away"
	default:
		return string(status)
	}
}

func verifierDateLabel(req domain.CustomVerificationRequest) string {
	switch {
	case !req.ApprovedAt.IsZero():
		return "granted " + req.ApprovedAt.UTC().Format("2006-01-02")
	case !req.RejectedAt.IsZero():
		return "decided " + req.RejectedAt.UTC().Format("2006-01-02")
	case !req.CreatedAt.IsZero():
		return "filed " + req.CreatedAt.UTC().Format("2006-01-02")
	default:
		return ""
	}
}

func verifierJoin(lead, body string) string {
	lead = strings.TrimSpace(lead)
	if lead == "" {
		return body
	}
	if strings.TrimSpace(body) == "" {
		return lead
	}
	return lead + "\n\n" + body
}

func verifierTruncate(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) verifierErrorReply(userID int64, operation string, err error) botReply {
	if text, ok := verifierPolicyText(err); ok {
		return botReply{Text: text}
	}
	s.log.Error("verifierbot: "+operation, zap.Int64("user_id", userID), zap.Error(err))
	return internalReply()
}

// verifierPolicyText 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 verifierPolicyText(err error) (string, bool) {
	switch {
	case err == nil:
		return "", false
	case errors.Is(err, domain.ErrVerifierNotFound), errors.Is(err, domain.ErrVerifierForbidden),
		errors.Is(err, domain.ErrVerifierSettingsInvalid):
		return verifierNotActivatedText, true
	case errors.Is(err, domain.ErrVerificationIconNotFound), errors.Is(err, domain.ErrVerificationIconInactive),
		errors.Is(err, domain.ErrVerificationIconInvalid):
		return "My icon is not usable right now, so I cannot mark anything. The operator has to fix that before I can take applications.", true
	case errors.Is(err, domain.ErrCustomVerificationLimit):
		return "I have marked as many peers as I am allowed to, so I cannot take another application until the operator raises the limit.", true
	case errors.Is(err, domain.ErrCustomVerificationRequestExists):
		return "There is already an application waiting for that one. Send /status to see it.", true
	case errors.Is(err, domain.ErrCustomVerificationTargetInvalid):
		return "That one cannot carry my mark. I can verify a bot, a channel or supergroup, or an account.", true
	case errors.Is(err, domain.ErrCustomVerificationNotFound):
		return "There is no mark of mine on that one, so there is nothing to remove.", true
	case errors.Is(err, domain.ErrCustomVerificationRequestNotFound):
		return "I cannot find that application any more. Send /verify to file a fresh one.", true
	case errors.Is(err, domain.ErrCustomVerificationRequestInvalid):
		return "I could not accept that. Send /help to see what an application needs.", true
	case errors.Is(err, domain.ErrCustomVerificationVersionConflict):
		return "That application just changed somewhere else. Send /status to see where it stands now.", true
	case errors.Is(err, domain.ErrVerifierDescriptionForbidden):
		return "I may only apply my own description, so I cannot take a custom one for your peer.", true
	default:
		return "", false
	}
}