feat: add NFT usernames and bot verification (#22)

Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review.

The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation.

Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9
Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b

Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
This commit is contained in:
Egor Egorov 2026-07-27 20:18:00 +03:00 committed by GitHub
parent b0fd3976f1
commit fff8de783a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
169 changed files with 55769 additions and 282 deletions

View file

@ -74,15 +74,26 @@ You can control me by sending these commands:
/cancel - cancel the current operation
/help - show this message`
// botReply 是 BotFather 的一条回复。
// botReply 是内置 service bot 的一条回复。ReplyMarkup 为可选 inline keyboard
// 快照(@verifybot 的按钮式对话使用);落库前经 domain.ValidateReplyMarkup 校验。
type botReply struct {
Text string
Entities []domain.MessageEntity
Text string
Entities []domain.MessageEntity
ReplyMarkup *domain.MessageReplyMarkup
}
// HandlesBot 报告该收件人是否为内置应答 botmessages.BotResponder 实现)。
func (s *Service) HandlesBot(botUserID int64) bool {
return s != nil && (botUserID == domain.BotFatherUserID || botUserID == domain.StickersBotUserID || botUserID == domain.ChatBotUserID)
if s == nil {
return false
}
switch botUserID {
case domain.BotFatherUserID, domain.StickersBotUserID, domain.ChatBotUserID,
domain.VerifyBotUserID, domain.VerifierBotUserID:
return true
default:
return false
}
}
// OnPrivateMessage 处理投递给内置 bot 的私聊消息messages.BotResponder 实现)。
@ -103,6 +114,10 @@ func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg dom
go s.respondAsStickers(userID, msg)
case domain.ChatBotUserID:
go s.respondAsChatBot(userID, msg)
case domain.VerifyBotUserID:
go s.respondAsVerify(userID, msg)
case domain.VerifierBotUserID:
go s.respondAsVerifier(userID, msg)
}
}
@ -145,12 +160,24 @@ func (s *Service) sendServiceBotReplyResult(ctx context.Context, botUserID, user
if s == nil || s.messages == nil || reply.Text == "" {
return domain.SendPrivateTextResult{}, false
}
markup := reply.ReplyMarkup
if err := domain.ValidateReplyMarkup(markup); err != nil {
// 键盘校验必须先于落库I9结构非法的 markup 绝不写库,但正文仍然发出
// ——用户至少收到提示文本,不会因为一颗坏按钮而完全失联。
s.log.Error("service bot: invalid reply markup",
zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
markup = nil
}
if markup.IsZero() {
markup = nil
}
res, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: botUserID,
RecipientUserID: userID,
RandomID: s.botReplyRandomID(),
Message: reply.Text,
Entities: serviceBotReplyEntities(reply.Text, reply.Entities),
ReplyMarkup: markup,
Date: int(s.now().Unix()),
RecipientBlocked: s.serviceBotRecipientBlocked(ctx, botUserID, userID),
})

View file

@ -48,6 +48,30 @@ type aiChatGenerator interface {
GenerateTextStream(ctx context.Context, req domain.AITextGenerationRequest, emit func(domain.AIComposeText) error) (domain.AIComposeText, error)
}
// verificationApplications is the applicant-side surface of official platform
// verification used by the built-in @verifybot (app/verification.Service
// satisfies it as-is).
//
// It is declared as a narrow port rather than taken as a concrete service for the
// usual reason plus one specific to this feature: every verification rule --
// ownership, public username, restrictions, already-verified, cooldown, rate
// limit, the status machine -- belongs to that service, and the bot must not be
// able to reach past it. Nothing here can write a peer's verified flag.
type verificationApplications interface {
EligibleTargets(ctx context.Context, applicantUserID int64) ([]domain.VerificationTarget, error)
StartDraft(ctx context.Context, req domain.SubmitVerificationApplicationRequest) (domain.VerificationApplication, bool, error)
SaveDraft(ctx context.Context, applicantUserID, applicationID, version int64, draft domain.VerificationDraftInput) (domain.VerificationApplication, error)
Submit(ctx context.Context, applicantUserID, applicationID, version int64) (domain.VerificationApplication, error)
Cancel(ctx context.Context, applicantUserID, applicationID, version int64, reason string) (domain.VerificationApplication, error)
Draft(ctx context.Context, applicantUserID int64) (domain.VerificationApplication, error)
ApplicantApplications(ctx context.Context, applicantUserID int64, limit int) ([]domain.VerificationApplication, error)
Application(ctx context.Context, applicationID int64) (domain.VerificationApplication, error)
}
// The third-party verification ports live in verifierbot.go
// (customVerifications, verifierBotTargets): they are the built-in @verifierbot's
// only way to reach the feature, and are kept next to the dialog that uses them.
// RouterHooks 是 rpc 层回调router 创建后经 SetRouterHooks 延迟注入,打破
// router↔bots 的构造循环;这些能力都依赖 TL/连接层边界,不能在 app 层实现):
// - RevokeBotSessionstoken revoke 后撤销 bot 的全部已登录 session
@ -83,6 +107,9 @@ type Service struct {
stickers stickerSetCreator
installer userStickerSetInstaller
aiChat aiChatGenerator
verification verificationApplications
customVerification customVerifications
verifierTargets verifierBotTargets
telegramLogin *telegramloginapp.Service
hooks RouterHooks
textDrafts TextDraftPusher
@ -92,6 +119,13 @@ type Service struct {
now func() time.Time
chatBotStreamThrottle time.Duration
publicBaseURL string
// dialogLimiter bounds how often one applicant can drive a service-bot dialog.
// The verification service already rate-limits application creation; this is the
// separate bound on dialog traffic itself, so a script cannot spin the state
// machine (and its writes) even without ever submitting anything.
dialogLimiter store.RateLimiter
dialogRateLimit int
dialogRateWindow time.Duration
// replySeq 是回复 randomID 在 crypto/rand 失败时的兜底单调序列。
replySeq atomic.Int64
replyLocks [replyLockStripes]sync.Mutex
@ -177,6 +211,56 @@ func WithAIChatGenerator(g aiChatGenerator) Option {
}
}
// WithVerification injects the official verification service used by the
// built-in @verifybot. Without it the bot still answers, but every command
// reports that verification is unavailable rather than half-running the dialog.
func WithVerification(v verificationApplications) Option {
return func(s *Service) {
if v != nil {
s.verification = v
}
}
}
// WithCustomVerification injects the third-party verification service used by the
// built-in @verifierbot. Without it the bot still answers, but every command
// reports that third-party verification is unavailable rather than half-running the
// dialog.
func WithCustomVerification(v customVerifications) Option {
return func(s *Service) {
if v != nil {
s.customVerification = v
}
}
}
// WithVerifierTargets injects the directory of an applicant's own peers used by
// @verifierbot's subject picker. It is optional: with nothing injected the bot
// falls back to the official verification service's EligibleTargets, which
// enumerates exactly the same peers (only its eligibility verdicts, which answer a
// different question, are ignored).
func WithVerifierTargets(t verifierBotTargets) Option {
return func(s *Service) {
if t != nil {
s.verifierTargets = t
}
}
}
// WithDialogRateLimiter bounds service-bot dialog traffic per user. A zero limit
// or a nil limiter disables the bound, which is what a deployment without Redis
// gets.
func WithDialogRateLimiter(limiter store.RateLimiter, limit int, window time.Duration) Option {
return func(s *Service) {
if limiter == nil || limit <= 0 || window <= 0 {
return
}
s.dialogLimiter = limiter
s.dialogRateLimit = limit
s.dialogRateWindow = window
}
}
// WithTelegramLogin injects the OIDC application service used by BotFather.
// BotFather never writes the login tables directly.
func WithTelegramLogin(login *telegramloginapp.Service) Option {
@ -262,6 +346,33 @@ func (s *Service) SetAIChatGenerator(g aiChatGenerator) {
}
}
// SetVerification injects the official verification service after construction.
// The bots service is built before the peer directories that service depends on,
// so in the shipped process this is the wiring order that actually exists (same
// deferred-injection pattern as SetRouterHooks).
func (s *Service) SetVerification(v verificationApplications) {
if s != nil && v != nil {
s.verification = v
}
}
// SetCustomVerification injects the third-party verification service after
// construction. The bots service is built before the stores and directories that
// service depends on, so in the shipped process this is the wiring order that
// actually exists (same deferred-injection pattern as SetVerification).
func (s *Service) SetCustomVerification(v customVerifications) {
if s != nil && v != nil {
s.customVerification = v
}
}
// SetVerifierTargets injects @verifierbot's subject directory after construction.
func (s *Service) SetVerifierTargets(t verifierBotTargets) {
if s != nil && t != nil {
s.verifierTargets = t
}
}
// NewService 创建 bots 服务。
func NewService(users store.UserStore, bots store.BotStore, messages store.MessageStore, opts ...Option) *Service {
s := &Service{

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,984 @@
package bots
import (
"context"
"errors"
"sort"
"strconv"
"strings"
"testing"
"time"
verificationapp "telesrv/internal/app/verification"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// ---------------------------------------------------------------------------
// Fake verification service
// ---------------------------------------------------------------------------
// fakeVerification is an in-memory stand-in for app/verification.Service. It
// keeps the properties the bot dialog actually leans on: one draft per applicant,
// StartDraft resuming instead of duplicating, optimistic-locking versions, and
// the domain validation of the payload.
type fakeVerification struct {
targets []domain.VerificationTarget
apps map[int64]domain.VerificationApplication
nextID int64
starts int
submits int
targetsErr error
startErr error
}
func newFakeVerification(targets ...domain.VerificationTarget) *fakeVerification {
return &fakeVerification{
targets: targets,
apps: make(map[int64]domain.VerificationApplication),
nextID: 100,
}
}
func (f *fakeVerification) EligibleTargets(_ context.Context, applicantUserID int64) ([]domain.VerificationTarget, error) {
if f.targetsErr != nil {
return nil, f.targetsErr
}
if applicantUserID <= 0 {
return nil, domain.ErrVerificationApplicationInvalid
}
return append([]domain.VerificationTarget(nil), f.targets...), nil
}
func (f *fakeVerification) draftFor(applicantUserID int64) (domain.VerificationApplication, bool) {
for _, app := range f.apps {
if app.ApplicantUserID == applicantUserID && app.Status == domain.VerificationStatusDraft {
return app, true
}
}
return domain.VerificationApplication{}, false
}
func (f *fakeVerification) StartDraft(_ context.Context, req domain.SubmitVerificationApplicationRequest) (domain.VerificationApplication, bool, error) {
f.starts++
if app, found := f.draftFor(req.ApplicantUserID); found {
return app, false, nil
}
if f.startErr != nil {
return domain.VerificationApplication{}, false, f.startErr
}
var target domain.VerificationTarget
for _, candidate := range f.targets {
if candidate.Type == req.TargetType && candidate.ID == req.TargetID {
target = candidate
}
}
if target.ID == 0 {
return domain.VerificationApplication{}, false, domain.ErrVerificationTargetInvalid
}
if !target.Eligible {
return domain.VerificationApplication{}, false, domain.ErrVerificationTargetAlreadyVerified
}
f.nextID++
app := domain.VerificationApplication{
ID: f.nextID,
ApplicantUserID: req.ApplicantUserID,
TargetType: target.Type,
TargetID: target.ID,
TargetTitle: target.Title,
TargetUsername: target.Username,
Status: domain.VerificationStatusDraft,
CreatedAt: time.Date(2026, 7, 26, 10, 0, 0, 0, time.UTC),
Version: 1,
}
f.apps[app.ID] = app
return app, true, nil
}
func (f *fakeVerification) SaveDraft(_ context.Context, applicantUserID, applicationID, version int64, draft domain.VerificationDraftInput) (domain.VerificationApplication, error) {
app, found := f.apps[applicationID]
if !found || app.ApplicantUserID != applicantUserID {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
if app.Version != version {
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
}
if app.Status != domain.VerificationStatusDraft {
return domain.VerificationApplication{}, domain.ErrVerificationStatusInvalid
}
if err := draft.ValidateDraft(); err != nil {
return domain.VerificationApplication{}, err
}
draft = draft.Normalize()
app.Category = draft.Category
app.Description = draft.Description
app.OfficialWebsite = draft.OfficialWebsite
app.SocialLinks = draft.SocialLinks
app.PressLinks = draft.PressLinks
app.AdditionalNote = draft.AdditionalNote
app.Version++
f.apps[applicationID] = app
return app, nil
}
func (f *fakeVerification) Submit(_ context.Context, applicantUserID, applicationID, version int64) (domain.VerificationApplication, error) {
app, found := f.apps[applicationID]
if !found || app.ApplicantUserID != applicantUserID {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
if app.Version != version {
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
}
if !domain.CanTransitionVerificationStatus(app.Status, domain.VerificationStatusSubmitted) {
return domain.VerificationApplication{}, domain.ErrVerificationStatusInvalid
}
if err := (domain.VerificationDraftInput{
Category: app.Category,
Description: app.Description,
OfficialWebsite: app.OfficialWebsite,
SocialLinks: app.SocialLinks,
PressLinks: app.PressLinks,
AdditionalNote: app.AdditionalNote,
}).ValidateForSubmission(); err != nil {
return domain.VerificationApplication{}, err
}
f.submits++
app.Status = domain.VerificationStatusSubmitted
app.SubmittedAt = time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
app.Version++
f.apps[applicationID] = app
return app, nil
}
func (f *fakeVerification) Cancel(_ context.Context, applicantUserID, applicationID, version int64, reason string) (domain.VerificationApplication, error) {
app, found := f.apps[applicationID]
if !found || app.ApplicantUserID != applicantUserID {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
if app.Version != version {
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
}
if !domain.CanTransitionVerificationStatus(app.Status, domain.VerificationStatusCancelled) {
return domain.VerificationApplication{}, domain.ErrVerificationStatusInvalid
}
app.Status = domain.VerificationStatusCancelled
app.DecisionReason = reason
app.Version++
f.apps[applicationID] = app
return app, nil
}
func (f *fakeVerification) Draft(_ context.Context, applicantUserID int64) (domain.VerificationApplication, error) {
if app, found := f.draftFor(applicantUserID); found {
return app, nil
}
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
func (f *fakeVerification) ApplicantApplications(_ context.Context, applicantUserID int64, limit int) ([]domain.VerificationApplication, error) {
out := make([]domain.VerificationApplication, 0, len(f.apps))
for _, app := range f.apps {
if app.ApplicantUserID == applicantUserID {
out = append(out, app)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (f *fakeVerification) Application(_ context.Context, applicationID int64) (domain.VerificationApplication, error) {
if app, found := f.apps[applicationID]; found {
return app, nil
}
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
var _ verificationApplications = (*fakeVerification)(nil)
// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------
func verifyChannelTarget() domain.VerificationTarget {
return domain.VerificationTarget{
Type: domain.VerificationTargetChannel, ID: 7001,
Title: "Example News", Username: "examplenews", AccessHash: 42, Eligible: true,
}
}
func verifyBotTarget() domain.VerificationTarget {
return domain.VerificationTarget{
Type: domain.VerificationTargetBot, ID: 8002,
Title: "Example Bot", Username: "examplebot", Eligible: true,
}
}
func newVerifyBotTestService(t *testing.T, verification verificationApplications, opts ...Option) (*Service, *memory.UserStore, *memory.MessageStore) {
t.Helper()
users := memory.NewUserStore()
bots := memory.NewBotStore(users)
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
all := append([]Option{WithVerification(verification)}, opts...)
return NewService(users, bots, messages, all...), users, messages
}
// verifyBotReplies returns every @verifybot message in the user's box, oldest
// first.
func verifyBotReplies(t *testing.T, messages *memory.MessageStore, userID int64) []domain.Message {
t.Helper()
list, err := messages.ListByUser(context.Background(), userID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.VerifyBotUserID},
Limit: 200,
})
if err != nil {
t.Fatalf("list @verifybot history: %v", err)
}
out := make([]domain.Message, 0, len(list.Messages))
for _, msg := range list.Messages {
if msg.From.ID == domain.VerifyBotUserID {
out = append(out, msg)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func latestVerifyReply(t *testing.T, messages *memory.MessageStore, userID int64) domain.Message {
t.Helper()
replies := verifyBotReplies(t, messages, userID)
if len(replies) == 0 {
t.Fatal("no @verifybot reply")
}
latest := replies[len(replies)-1]
// Every keyboard the bot renders must be a valid, persistable markup: the send
// path validates before storing, so an invalid one would silently vanish.
if err := domain.ValidateReplyMarkup(latest.ReplyMarkup); err != nil {
t.Fatalf("reply markup invalid: %v (%+v)", err, latest.ReplyMarkup)
}
for _, row := range verifyInlineRows(latest) {
for _, button := range row {
if len(button.Data) > domain.MaxCallbackDataLen {
t.Fatalf("callback data %q is %d bytes, limit is %d", button.Data, len(button.Data), domain.MaxCallbackDataLen)
}
}
}
return latest
}
func verifyInlineRows(msg domain.Message) [][]domain.MarkupButton {
if msg.ReplyMarkup == nil {
return nil
}
return msg.ReplyMarkup.Inline
}
// sendToVerifyBot drives the responder synchronously, bypassing the
// OnPrivateMessage goroutine dispatch for determinism (the same shortcut the
// BotFather and @Stickers tests take).
func sendToVerifyBot(t *testing.T, svc *Service, messages *memory.MessageStore, userID int64, text string) domain.Message {
t.Helper()
svc.respondAsVerify(userID, domain.Message{
From: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.VerifyBotUserID},
Body: text,
})
return latestVerifyReply(t, messages, userID)
}
func verifyButtonData(msg domain.Message, label string) ([]byte, bool) {
for _, row := range verifyInlineRows(msg) {
for _, button := range row {
if button.Type == domain.MarkupButtonCallback && strings.Contains(button.Text, label) {
return append([]byte(nil), button.Data...), true
}
}
}
return nil, false
}
// pressVerifyCallbackData drives the internal callback path with raw data, the
// way rpc.Router does once it has validated the click.
func pressVerifyCallbackData(t *testing.T, svc *Service, userID int64, msg domain.Message, data []byte) domain.BotCallbackAnswer {
t.Helper()
if len(data) > domain.MaxCallbackDataLen {
t.Fatalf("callback data too long: %d bytes", len(data))
}
answer, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
ID: 1,
BotUserID: domain.VerifyBotUserID,
UserID: userID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
MessageID: msg.ID,
Data: data,
})
if err != nil {
t.Fatalf("callback query: %v", err)
}
if !handled {
t.Fatal("callback query reported unhandled for @verifybot")
}
return answer
}
func pressVerifyButton(t *testing.T, svc *Service, userID int64, msg domain.Message, label string) domain.BotCallbackAnswer {
t.Helper()
data, found := verifyButtonData(msg, label)
if !found {
t.Fatalf("button %q is not in the keyboard of message %d: %+v", label, msg.ID, msg.ReplyMarkup)
}
return pressVerifyCallbackData(t, svc, userID, msg, data)
}
const (
verifyTestDescription = "Example News is the daily newsroom of the Example Foundation, publishing since 2015."
verifyTestWebsite = "https://news.example.com"
verifyTestPressLinks = "https://press.example.org/story-one\nhttps://media.example.net/story-two"
)
// runVerifyApplication walks the whole dialog up to (but not including) Submit and
// returns the summary message.
func runVerifyApplication(t *testing.T, svc *Service, messages *memory.MessageStore, userID int64) domain.Message {
t.Helper()
intro := sendToVerifyBot(t, svc, messages, userID, "/start")
pressVerifyButton(t, svc, userID, intro, verifyApplyButtonText)
picker := latestVerifyReply(t, messages, userID)
pressVerifyButton(t, svc, userID, picker, "@examplenews")
categories := latestVerifyReply(t, messages, userID)
pressVerifyButton(t, svc, userID, categories, "Media outlet")
if got := latestVerifyReply(t, messages, userID); !strings.Contains(got.Body, "describe the subject") {
t.Fatalf("after category, reply = %q", got.Body)
}
sendToVerifyBot(t, svc, messages, userID, verifyTestDescription)
social := sendToVerifyBot(t, svc, messages, userID, verifyTestWebsite)
if !strings.Contains(social.Body, "social media") {
t.Fatalf("after website, reply = %q", social.Body)
}
pressVerifyButton(t, svc, userID, social, verifySkipButtonText)
if got := latestVerifyReply(t, messages, userID); !strings.Contains(got.Body, "press coverage") {
t.Fatalf("after skipping social links, reply = %q", got.Body)
}
note := sendToVerifyBot(t, svc, messages, userID, verifyTestPressLinks)
pressVerifyButton(t, svc, userID, note, verifySkipButtonText)
return latestVerifyReply(t, messages, userID)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
func TestVerifyBotStartExplainsAndOffersApplyButton(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7100")
if !svc.HandlesBot(domain.VerifyBotUserID) {
t.Fatal("service should handle @verifybot")
}
reply := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
for _, want := range []string{"official", "public @username", "/new", "/help"} {
if !strings.Contains(reply.Body, want) {
t.Fatalf("/start reply missing %q: %q", want, reply.Body)
}
}
data, found := verifyButtonData(reply, verifyApplyButtonText)
if !found {
t.Fatalf("/start reply has no apply button: %+v", reply.ReplyMarkup)
}
if !strings.HasPrefix(string(data), verifyCallbackDataPrefix) {
t.Fatalf("callback data %q is not a @verifybot token", data)
}
if fake.starts != 0 {
t.Fatalf("StartDraft called %d times on /start, want 0", fake.starts)
}
}
func TestVerifyBotFullApplicationFlowFilesExactlyOneApplication(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget(), verifyBotTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7101")
summary := runVerifyApplication(t, svc, messages, owner.ID)
for _, want := range []string{"Example News", "Media outlet", verifyTestWebsite, "press.example.org/story-one", verifySubmitButtonText} {
if !strings.Contains(summary.Body, want) {
t.Fatalf("summary missing %q: %q", want, summary.Body)
}
}
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
filed := latestVerifyReply(t, messages, owner.ID)
if !strings.Contains(filed.Body, "#101") || !strings.Contains(filed.Body, "/status") {
t.Fatalf("submitted reply = %q", filed.Body)
}
if fake.submits != 1 || len(fake.apps) != 1 {
t.Fatalf("submits=%d applications=%d, want exactly one of each", fake.submits, len(fake.apps))
}
app := fake.apps[101]
if app.Status != domain.VerificationStatusSubmitted {
t.Fatalf("application status = %q", app.Status)
}
if app.Category != "media" || app.OfficialWebsite != verifyTestWebsite || len(app.PressLinks) != 2 {
t.Fatalf("stored application = %+v", app)
}
if app.TargetID != 7001 || app.TargetType != domain.VerificationTargetChannel {
t.Fatalf("stored target = %s/%d", app.TargetType, app.TargetID)
}
}
// The target buttons must not leak the peer they stand for: the whole point of the
// token table is that a click cannot name a peer at all.
func TestVerifyBotCallbackDataCarriesNoTargetIdentity(t *testing.T) {
target := verifyChannelTarget()
fake := newFakeVerification(target)
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7102")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
picker := latestVerifyReply(t, messages, owner.ID)
buttons := 0
for _, row := range verifyInlineRows(picker) {
for _, button := range row {
buttons++
data := string(button.Data)
// Structural assertion rather than a substring hunt: the data is the
// prefix plus an opaque hex token and nothing else, so it is incapable of
// encoding a peer id, an access hash, a username or a peer type.
token, ok := strings.CutPrefix(data, verifyCallbackDataPrefix)
if !ok || len(token) != 2*verifyOptionTokenBytes {
t.Fatalf("callback data %q is not <prefix><token>", data)
}
for _, c := range token {
if !strings.ContainsRune("0123456789abcdef", c) {
t.Fatalf("callback data %q carries non-token bytes", data)
}
}
for _, forbidden := range []string{target.Username, string(target.Type)} {
if strings.Contains(data, forbidden) {
t.Fatalf("callback data %q leaks %q", data, forbidden)
}
}
}
}
if buttons == 0 {
t.Fatal("target picker has no buttons")
}
// The token is minted per render, so the same target never has a stable,
// guessable identifier on the wire.
firstData, _ := verifyButtonData(picker, "@"+target.Username)
sendToVerifyBot(t, svc, messages, owner.ID, "/new")
secondData, found := verifyButtonData(latestVerifyReply(t, messages, owner.ID), "@"+target.Username)
if !found {
t.Fatal("re-rendered picker has no target button")
}
if string(firstData) == string(secondData) {
t.Fatalf("token %q is stable across renders", firstData)
}
}
func TestVerifyBotRepeatedButtonPressIsIdempotent(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7103")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
picker := latestVerifyReply(t, messages, owner.ID)
pressVerifyButton(t, svc, owner.ID, picker, "@examplenews")
first := latestVerifyReply(t, messages, owner.ID)
pressVerifyButton(t, svc, owner.ID, picker, "@examplenews")
second := latestVerifyReply(t, messages, owner.ID)
if first.Body != second.Body {
t.Fatalf("repeat target press changed the answer:\nfirst = %q\nsecond = %q", first.Body, second.Body)
}
if len(fake.apps) != 1 {
t.Fatalf("applications = %d after pressing the same target twice, want 1", len(fake.apps))
}
}
// The same must hold for the terminal action: a double-tapped Submit files one
// application and repeats the same confirmation.
func TestVerifyBotRepeatedSubmitFilesOneApplication(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7121")
summary := runVerifyApplication(t, svc, messages, owner.ID)
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
firstFiled := latestVerifyReply(t, messages, owner.ID)
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
secondFiled := latestVerifyReply(t, messages, owner.ID)
if firstFiled.Body != secondFiled.Body {
t.Fatalf("repeat submit changed the answer:\nfirst = %q\nsecond = %q", firstFiled.Body, secondFiled.Body)
}
if fake.submits != 1 || len(fake.apps) != 1 {
t.Fatalf("submits=%d applications=%d after double submit, want 1/1", fake.submits, len(fake.apps))
}
}
func TestVerifyBotForgedCallbackTokenIsRefused(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7104")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
before := len(verifyBotReplies(t, messages, owner.ID))
// A token that was never minted for this user, and a plausible-looking
// hand-written one: both resolve only through the user's own state, so both are
// refused without any side effect.
for _, data := range [][]byte{
[]byte(verifyCallbackDataPrefix + "deadbeefcafe"),
[]byte("tgt:channel:7001"),
[]byte(verifyCallbackDataPrefix),
} {
answer := pressVerifyCallbackData(t, svc, owner.ID, intro, data)
if !answer.Alert || !strings.Contains(answer.Message, "no longer active") {
t.Fatalf("forged data %q answered %+v, want an explaining alert", data, answer)
}
}
if got := len(verifyBotReplies(t, messages, owner.ID)); got != before {
t.Fatalf("forged callbacks produced %d new messages", got-before)
}
if fake.starts != 0 || len(fake.apps) != 0 {
t.Fatalf("forged callbacks touched the service: starts=%d apps=%d", fake.starts, len(fake.apps))
}
}
// A token minted for one applicant must be meaningless for another: resolution
// goes through the clicking user's own chat state only.
func TestVerifyBotTokenFromAnotherUserIsRefused(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
victim := newOwner(t, users, "+7105")
attacker := newOwner(t, users, "+7106")
intro := sendToVerifyBot(t, svc, messages, victim.ID, "/start")
pressVerifyButton(t, svc, victim.ID, intro, verifyApplyButtonText)
picker := latestVerifyReply(t, messages, victim.ID)
stolen, found := verifyButtonData(picker, "@examplenews")
if !found {
t.Fatal("victim picker has no target button")
}
sendToVerifyBot(t, svc, messages, attacker.ID, "/start")
attackerIntro := latestVerifyReply(t, messages, attacker.ID)
answer := pressVerifyCallbackData(t, svc, attacker.ID, attackerIntro, stolen)
if !answer.Alert {
t.Fatalf("stolen token answered %+v, want an alert", answer)
}
for _, app := range fake.apps {
if app.ApplicantUserID == attacker.ID {
t.Fatalf("stolen token created an application for the attacker: %+v", app)
}
}
}
func TestVerifyBotPressLinkMinimumIsEnforced(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7107")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
social := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestWebsite)
pressVerifyButton(t, svc, owner.ID, social, verifySkipButtonText)
tooFew := sendToVerifyBot(t, svc, messages, owner.ID, "https://press.example.org/story-one")
if !strings.Contains(tooFew.Body, strconv.Itoa(domain.MinVerificationPressLinks)) {
t.Fatalf("single press link accepted or unexplained: %q", tooFew.Body)
}
if len(fake.apps[101].PressLinks) != 0 {
t.Fatalf("press links stored despite refusal: %+v", fake.apps[101].PressLinks)
}
accepted := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestPressLinks)
if !strings.Contains(accepted.Body, "reviewers should know") {
t.Fatalf("two press links did not advance the dialog: %q", accepted.Body)
}
if len(fake.apps[101].PressLinks) != 2 {
t.Fatalf("press links = %+v, want two stored", fake.apps[101].PressLinks)
}
}
func TestVerifyBotRejectsInvalidLinksWithAReason(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7108")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
// Not a URL, a non-web scheme, and an address the domain refuses as
// non-public (which is also what keeps a submitted link from becoming an SSRF
// probe).
for _, bad := range []string{"my site", "ftp://example.com", "http://127.0.0.1/admin", "https://localhost/x"} {
reply := sendToVerifyBot(t, svc, messages, owner.ID, bad)
if !strings.Contains(reply.Body, "http:// or https://") {
t.Fatalf("website %q answered %q, want the link rules", bad, reply.Body)
}
if fake.apps[101].OfficialWebsite != "" {
t.Fatalf("website %q was stored", bad)
}
}
// A short description is refused with the actual bar, not a generic error.
shortDesc := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestWebsite)
if !strings.Contains(shortDesc.Body, "social media") {
t.Fatalf("valid website not accepted: %q", shortDesc.Body)
}
}
func TestVerifyBotDescriptionMinimumIsExplained(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7109")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
reply := sendToVerifyBot(t, svc, messages, owner.ID, "a newsroom")
if !strings.Contains(reply.Body, strconv.Itoa(domain.MinVerificationDescriptionLength)) {
t.Fatalf("short description answered %q, want the minimum length", reply.Body)
}
if fake.apps[101].Description != "" {
t.Fatalf("short description was stored: %q", fake.apps[101].Description)
}
}
func TestVerifyBotGlobalCommandsWorkMidStep(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7110")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
// /help in the middle of the description step answers help and keeps the step.
help := sendToVerifyBot(t, svc, messages, owner.ID, "/help")
if help.Body != verifyBotHelpText {
t.Fatalf("/help mid-step = %q", help.Body)
}
status := sendToVerifyBot(t, svc, messages, owner.ID, "/status")
if !strings.Contains(status.Body, "#101") {
t.Fatalf("/status mid-step = %q", status.Body)
}
resumed := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
if !strings.Contains(resumed.Body, "official website") {
t.Fatalf("description not accepted after global commands: %q", resumed.Body)
}
if fake.apps[101].Description != verifyTestDescription {
t.Fatalf("description = %q, want the step to have survived", fake.apps[101].Description)
}
// An unknown command is never swallowed as a field value.
unknown := sendToVerifyBot(t, svc, messages, owner.ID, "/nope")
if !strings.Contains(unknown.Body, "do not know that command") {
t.Fatalf("unknown command = %q", unknown.Body)
}
if fake.apps[101].OfficialWebsite != "" {
t.Fatalf("unknown command stored as a website: %q", fake.apps[101].OfficialWebsite)
}
}
func TestVerifyBotStatusListsApplicationsWithoutInternalNotes(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7111")
if empty := sendToVerifyBot(t, svc, messages, owner.ID, "/status"); empty.Body != verifyNoApplicationsText {
t.Fatalf("/status without applications = %q", empty.Body)
}
fake.apps[500] = domain.VerificationApplication{
ID: 500, ApplicantUserID: owner.ID,
TargetType: domain.VerificationTargetChannel, TargetID: 7001,
TargetTitle: "Example News", TargetUsername: "examplenews",
Status: domain.VerificationStatusRejected,
DecisionReason: "the linked coverage does not mention the channel",
InternalNote: "applicant argued with the reviewer",
ReviewedAt: time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC),
Version: 4,
}
fake.apps[501] = domain.VerificationApplication{
ID: 501, ApplicantUserID: owner.ID,
TargetType: domain.VerificationTargetBot, TargetID: 8002, TargetUsername: "examplebot",
Status: domain.VerificationStatusSubmitted,
SubmittedAt: time.Date(2026, 7, 25, 9, 0, 0, 0, time.UTC),
Version: 2,
}
reply := sendToVerifyBot(t, svc, messages, owner.ID, "/status")
for _, want := range []string{"#500", "#501", "@examplebot", "does not mention the channel", "2026-07-20"} {
if !strings.Contains(reply.Body, want) {
t.Fatalf("/status missing %q: %q", want, reply.Body)
}
}
if strings.Contains(reply.Body, "argued with the reviewer") {
t.Fatalf("/status leaked the internal note: %q", reply.Body)
}
}
func TestVerifyBotCancelWithdrawsTheOpenApplication(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7112")
if nothing := sendToVerifyBot(t, svc, messages, owner.ID, "/cancel"); nothing.Body != verifyNothingToCancelText {
t.Fatalf("/cancel with nothing open = %q", nothing.Body)
}
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
cancelled := sendToVerifyBot(t, svc, messages, owner.ID, "/cancel")
if !strings.Contains(cancelled.Body, "#101") || !strings.Contains(cancelled.Body, "withdrawn") {
t.Fatalf("/cancel = %q", cancelled.Body)
}
if fake.apps[101].Status != domain.VerificationStatusCancelled {
t.Fatalf("application status = %q after /cancel", fake.apps[101].Status)
}
// The dialog is gone with it, so a stale button cannot revive it.
idle := sendToVerifyBot(t, svc, messages, owner.ID, "still here?")
if idle.Body != verifyBotIdleText {
t.Fatalf("after /cancel, plain text = %q", idle.Body)
}
}
func TestVerifyBotCancelButtonWithdrawsFromInsideTheForm(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7113")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
categories := latestVerifyReply(t, messages, owner.ID)
pressVerifyButton(t, svc, owner.ID, categories, verifyCancelButtonText)
if reply := latestVerifyReply(t, messages, owner.ID); !strings.Contains(reply.Body, "withdrawn") {
t.Fatalf("cancel button = %q", reply.Body)
}
if fake.apps[101].Status != domain.VerificationStatusCancelled {
t.Fatalf("application status = %q after the cancel button", fake.apps[101].Status)
}
}
func TestVerifyBotHelpAndIdleText(t *testing.T) {
fake := newFakeVerification()
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7114")
help := sendToVerifyBot(t, svc, messages, owner.ID, "/help")
for _, want := range []string{"/new", "/status", "/cancel", "/help"} {
if !strings.Contains(help.Body, want) {
t.Fatalf("/help missing %q: %q", want, help.Body)
}
}
assertReplyEntityText(t, help, domain.MessageEntityBotCommand, "/new")
// Nothing to verify: the requirement is stated instead of an empty picker.
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/new"); reply.Body != verifyNoTargetsText {
t.Fatalf("/new with no candidates = %q", reply.Body)
}
}
func TestVerifyBotShowsIneligibleTargetsWithTheirReason(t *testing.T) {
verified := verifyChannelTarget()
verified.Eligible = false
verified.Verified = true
verified.Reason = domain.ErrVerificationTargetAlreadyVerified.Error()
fake := newFakeVerification(verified)
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7115")
picker := sendToVerifyBot(t, svc, messages, owner.ID, "/new")
if !strings.Contains(picker.Body, "cannot be filed") && !strings.Contains(picker.Body, verifyNoEligibleText) {
t.Fatalf("picker with only ineligible candidates = %q", picker.Body)
}
answer := pressVerifyButton(t, svc, owner.ID, picker, "unavailable")
if !answer.Alert || !strings.Contains(answer.Message, "already verified") {
t.Fatalf("ineligible button answered %+v, want the reason", answer)
}
if fake.starts != 0 || len(fake.apps) != 0 {
t.Fatalf("ineligible button reached the service: starts=%d apps=%d", fake.starts, len(fake.apps))
}
}
func TestVerifyBotNewResumesTheOpenDraft(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7116")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
resumed := sendToVerifyBot(t, svc, messages, owner.ID, "/new")
if !strings.Contains(resumed.Body, "#101") || !strings.Contains(resumed.Body, "official website") {
t.Fatalf("/new mid-draft = %q, want a resume at the website step", resumed.Body)
}
if len(fake.apps) != 1 {
t.Fatalf("applications = %d after /new mid-draft, want 1", len(fake.apps))
}
}
func TestVerifyBotWithoutServiceReportsUnavailable(t *testing.T) {
svc, users, messages := newVerifyBotTestService(t, nil)
owner := newOwner(t, users, "+7117")
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/new"); reply.Body != verifyUnavailableText {
t.Fatalf("/new without a verification service = %q", reply.Body)
}
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/help"); reply.Body != verifyBotHelpText {
t.Fatalf("/help without a verification service = %q", reply.Body)
}
}
func TestVerifyBotCallbackForForeignBotIsNotClaimed(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, _, _ := newVerifyBotTestService(t, fake)
if _, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
BotUserID: 555111, UserID: 900, Data: []byte("vb:whatever"),
}); handled || err != nil {
t.Fatalf("foreign bot callback handled=%v err=%v, want (false, nil)", handled, err)
}
// A built-in bot with no keyboards is claimed but answered empty, so the click
// cannot hang for the whole callback timeout.
answer, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
BotUserID: domain.BotFatherUserID, UserID: 900, Data: []byte("x"),
})
if !handled || err != nil || answer.Message != "" {
t.Fatalf("BotFather callback = (%+v, %v, %v)", answer, handled, err)
}
}
func TestVerifyBotSendVerificationNoticeNeverLeaksInternalNote(t *testing.T) {
fake := newFakeVerification()
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7118")
ctx := context.Background()
app := domain.VerificationApplication{
ID: 4242, ApplicantUserID: owner.ID,
TargetType: domain.VerificationTargetChannel, TargetID: 7001,
TargetTitle: "Example News", TargetUsername: "examplenews",
DecisionReason: "the coverage you linked does not mention the channel",
InternalNote: "reviewer note: applicant is a repeat filer, escalate next time",
}
if err := svc.SendVerificationNotice(ctx, owner.ID, app, verificationapp.NoticeKindApproved); err != nil {
t.Fatalf("approved notice: %v", err)
}
approved := latestVerifyReply(t, messages, owner.ID)
for _, want := range []string{"#4242", "Example News", "@examplenews", "approved"} {
if !strings.Contains(approved.Body, want) {
t.Fatalf("approved notice missing %q: %q", want, approved.Body)
}
}
if strings.Contains(approved.Body, "repeat filer") {
t.Fatalf("approved notice leaked the internal note: %q", approved.Body)
}
if err := svc.SendVerificationNotice(ctx, owner.ID, app, verificationapp.NoticeKindRejected); err != nil {
t.Fatalf("rejected notice: %v", err)
}
rejected := latestVerifyReply(t, messages, owner.ID)
if !strings.Contains(rejected.Body, "#4242") || !strings.Contains(rejected.Body, "does not mention the channel") {
t.Fatalf("rejected notice = %q", rejected.Body)
}
if strings.Contains(rejected.Body, "repeat filer") || strings.Contains(rejected.Body, "escalate") {
t.Fatalf("rejected notice leaked the internal note: %q", rejected.Body)
}
if err := svc.SendVerificationNotice(ctx, owner.ID, app, verificationapp.NoticeKindRevoked); err != nil {
t.Fatalf("revoked notice: %v", err)
}
revoked := latestVerifyReply(t, messages, owner.ID)
if !strings.Contains(revoked.Body, "revoked") || strings.Contains(revoked.Body, "repeat filer") {
t.Fatalf("revoked notice = %q", revoked.Body)
}
// An unknown kind is reported rather than delivered as an empty message: the
// outbox row must stay pending instead of being marked delivered.
before := len(verifyBotReplies(t, messages, owner.ID))
if err := svc.SendVerificationNotice(ctx, owner.ID, app, "teleported"); err == nil {
t.Fatal("unknown notice kind reported success")
}
if got := len(verifyBotReplies(t, messages, owner.ID)); got != before {
t.Fatalf("unknown notice kind sent %d messages", got-before)
}
if err := svc.SendVerificationNotice(ctx, 0, app, verificationapp.NoticeKindApproved); err == nil {
t.Fatal("empty recipient reported success")
}
}
func TestVerifyBotSubmitBouncesAnIncompleteApplication(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7119")
summary := runVerifyApplication(t, svc, messages, owner.ID)
// Simulate a payload that lost a required field between rendering the summary
// and the press: Submit must send the applicant back, not file a broken record.
app := fake.apps[101]
app.PressLinks = nil
app.Version++
fake.apps[101] = app
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
bounced := latestVerifyReply(t, messages, owner.ID)
if !strings.Contains(bounced.Body, "press coverage") {
t.Fatalf("incomplete submit = %q, want the press step", bounced.Body)
}
if fake.submits != 0 {
t.Fatalf("submits = %d for an incomplete application", fake.submits)
}
}
func TestVerifyBotPolicyRefusalsAreExplained(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
fake.startErr = domain.ErrVerificationRateLimited
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7120")
picker := sendToVerifyBot(t, svc, messages, owner.ID, "/new")
pressVerifyButton(t, svc, owner.ID, picker, "@examplenews")
if reply := latestVerifyReply(t, messages, owner.ID); !strings.Contains(reply.Body, "limit on open applications") {
t.Fatalf("rate-limited StartDraft = %q", reply.Body)
}
fake.targetsErr = verificationapp.ErrDisabled
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/new"); reply.Body != verifyUnavailableText {
t.Fatalf("disabled verification = %q", reply.Body)
}
if !errors.Is(fake.targetsErr, verificationapp.ErrDisabled) {
t.Fatal("test setup lost the sentinel")
}
}