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:
parent
b0fd3976f1
commit
fff8de783a
169 changed files with 55769 additions and 282 deletions
|
|
@ -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 报告该收件人是否为内置应答 bot(messages.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),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 层实现):
|
||||
// - RevokeBotSessions:token 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{
|
||||
|
|
|
|||
1626
internal/app/bots/verifierbot.go
Normal file
1626
internal/app/bots/verifierbot.go
Normal file
File diff suppressed because it is too large
Load diff
1012
internal/app/bots/verifierbot_test.go
Normal file
1012
internal/app/bots/verifierbot_test.go
Normal file
File diff suppressed because it is too large
Load diff
1493
internal/app/bots/verifybot.go
Normal file
1493
internal/app/bots/verifybot.go
Normal file
File diff suppressed because it is too large
Load diff
984
internal/app/bots/verifybot_test.go
Normal file
984
internal/app/bots/verifybot_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue