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

@ -606,3 +606,369 @@ func (fakeService) SubmitModerationAppeal(context.Context, int64, int64, string)
func (fakeService) ReviewModerationAppeal(context.Context, domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
return domain.ModerationCaseDetail{}, true, nil
}
type captureCollectibleUsernameService struct {
fakeService
mint admin.MintCollectibleUsernameRequest
transfer admin.TransferCollectibleUsernameRequest
revoke admin.RevokeCollectibleUsernameRequest
del admin.DeleteCollectibleUsernameRequest
filter domain.CollectibleUsernameFilter
assetID int64
}
func (s *captureCollectibleUsernameService) MintCollectibleUsername(_ context.Context, req admin.MintCollectibleUsernameRequest) (admin.CommandResult, error) {
s.mint = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureCollectibleUsernameService) TransferCollectibleUsername(_ context.Context, req admin.TransferCollectibleUsernameRequest) (admin.CommandResult, error) {
s.transfer = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureCollectibleUsernameService) RevokeCollectibleUsername(_ context.Context, req admin.RevokeCollectibleUsernameRequest) (admin.CommandResult, error) {
s.revoke = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureCollectibleUsernameService) DeleteCollectibleUsername(_ context.Context, req admin.DeleteCollectibleUsernameRequest) (admin.CommandResult, error) {
s.del = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureCollectibleUsernameService) CollectibleUsernames(_ context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
s.filter = filter
return []domain.CollectibleUsername{maxInt64Collectible()}, nil
}
func (s *captureCollectibleUsernameService) CollectibleUsernameByID(_ context.Context, id int64) (domain.CollectibleUsername, error) {
s.assetID = id
asset := maxInt64Collectible()
asset.ID = id
return asset, nil
}
func (s *captureCollectibleUsernameService) CollectibleUsernameTransfers(_ context.Context, collectibleID int64, _ int) ([]domain.CollectibleUsernameTransfer, error) {
return []domain.CollectibleUsernameTransfer{{
ID: 9223372036854775807,
CollectibleID: collectibleID,
Kind: domain.CollectibleUsernameKindMint,
To: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
Currency: domain.CollectibleCurrencyTON,
Amount: 9223372036854775807,
Actor: "ops",
}}, nil
}
func maxInt64Collectible() domain.CollectibleUsername {
return domain.CollectibleUsername{
ID: 9223372036854775807,
Username: "durov",
Status: domain.CollectibleUsernameStatusOwned,
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
Currency: domain.CollectibleCurrencyTON,
Amount: 9223372036854775807,
CryptoCurrency: domain.CollectibleCryptoCurrencyTON,
CryptoAmount: 9223372036854775807,
Version: 9223372036854775807,
}
}
type captureAccountRatingService struct {
fakeService
recompute admin.RecomputeAccountRatingRequest
adjust admin.AdjustAccountRatingRequest
filter domain.AccountRatingFilter
}
func (s *captureAccountRatingService) RecomputeAccountRating(_ context.Context, req admin.RecomputeAccountRatingRequest) (admin.CommandResult, error) {
s.recompute = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureAccountRatingService) AdjustAccountRating(_ context.Context, req admin.AdjustAccountRatingRequest) (admin.CommandResult, error) {
s.adjust = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureAccountRatingService) AccountRatings(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
s.filter = filter
return []domain.AccountRating{maxInt64Rating()}, nil
}
func (s *captureAccountRatingService) AccountRating(_ context.Context, userID int64) (domain.AccountRating, error) {
rating := maxInt64Rating()
rating.UserID = userID
return rating, nil
}
func (s *captureAccountRatingService) AccountRatingEvents(_ context.Context, userID int64, _ int) ([]domain.AccountRatingEvent, error) {
return []domain.AccountRatingEvent{{
ID: 9223372036854775807, UserID: userID,
Kind: domain.AccountRatingEventManual, Amount: -9223372036854775807,
Actor: "ops", Reason: "abuse",
}}, nil
}
func maxInt64Rating() domain.AccountRating {
return domain.AccountRating{
UserID: 1001,
Level: 7,
Stars: 9223372036854775807,
CurrentLevelStars: 4900,
NextLevelStars: 6400,
HasNextLevel: true,
StarsComponent: 9223372036854775807,
ManualComponent: -1500,
Version: 9223372036854775807,
}
}
func TestAdminAPICollectibleUsernameCommandsRequireToken(t *testing.T) {
srv := &Server{token: "secret", svc: fakeService{}}
for _, path := range []string{
"/v1/collectible-usernames/mint",
"/v1/collectible-usernames/transfer",
"/v1/collectible-usernames/revoke",
"/v1/account-ratings/recompute",
"/v1/account-ratings/adjust",
} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s status=%d, want 401", path, rec.Code)
}
}
for _, path := range []string{
"/v1/collectible-usernames",
"/v1/collectible-usernames/7",
"/v1/account-ratings",
"/v1/account-ratings/7",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s status=%d, want 401", path, rec.Code)
}
}
}
func TestAdminAPIMintCollectibleUsernameForwardsExactInt64AndDryRun(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
svc := &captureCollectibleUsernameService{}
srv := &Server{token: "secret", svc: svc}
req := httptest.NewRequest(http.MethodPost, "/v1/collectible-usernames/mint", strings.NewReader(`{
"command_id":"mint-1","actor":"ops","reason":"fragment import","dry_run":true,
"username":"durov","owner_user_id":"1001","currency":"TON","amount":"9223372036854775807",
"crypto_currency":"TON","crypto_amount":"250000000000",
"url":"https://fragment.example/durov","purchase_date":1700000000
}`))
req.Header.Set("Authorization", "Bearer secret")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"command_id":"mint-1"`) {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"dry_run":true`) {
t.Fatalf("dry-run was not propagated: %s", rec.Body.String())
}
if svc.mint.Username != "durov" || svc.mint.OwnerUserID != 1001 || svc.mint.Amount != maxInt64 ||
svc.mint.CryptoAmount != 250000000000 || svc.mint.PurchaseDate != 1700000000 || !svc.mint.DryRun {
t.Fatalf("decoded mint request = %+v", svc.mint)
}
}
func TestAdminAPITransferAndRevokeCollectibleUsername(t *testing.T) {
svc := &captureCollectibleUsernameService{}
srv := &Server{token: "secret", svc: svc}
transfer := httptest.NewRequest(http.MethodPost, "/v1/collectible-usernames/transfer", strings.NewReader(
`{"command_id":"t-1","actor":"ops","reason":"sold","username":"durov","to_channel_id":"2002"}`))
transfer.Header.Set("Authorization", "Bearer secret")
transferRec := httptest.NewRecorder()
srv.routes().ServeHTTP(transferRec, transfer)
if transferRec.Code != http.StatusOK || svc.transfer.ToChannelID != 2002 || svc.transfer.Username != "durov" {
t.Fatalf("transfer status=%d request=%+v", transferRec.Code, svc.transfer)
}
revoke := httptest.NewRequest(http.MethodPost, "/v1/collectible-usernames/revoke", strings.NewReader(
`{"command_id":"r-1","actor":"ops","reason":"fraud","username":"durov","burn":true}`))
revoke.Header.Set("Authorization", "Bearer secret")
revokeRec := httptest.NewRecorder()
srv.routes().ServeHTTP(revokeRec, revoke)
if revokeRec.Code != http.StatusOK || !svc.revoke.Burn || svc.revoke.CommandID != "r-1" {
t.Fatalf("revoke status=%d request=%+v", revokeRec.Code, svc.revoke)
}
}
func TestAdminAPIAccountRatingCommands(t *testing.T) {
svc := &captureAccountRatingService{}
srv := &Server{token: "secret", svc: svc}
recompute := httptest.NewRequest(http.MethodPost, "/v1/account-ratings/recompute", strings.NewReader(
`{"command_id":"rc-1","actor":"ops","reason":"support ticket","dry_run":true,"user_id":"1001"}`))
recompute.Header.Set("Authorization", "Bearer secret")
recomputeRec := httptest.NewRecorder()
srv.routes().ServeHTTP(recomputeRec, recompute)
if recomputeRec.Code != http.StatusOK || svc.recompute.UserID != 1001 || !svc.recompute.DryRun {
t.Fatalf("recompute status=%d request=%+v body=%s", recomputeRec.Code, svc.recompute, recomputeRec.Body.String())
}
adjust := httptest.NewRequest(http.MethodPost, "/v1/account-ratings/adjust", strings.NewReader(
`{"command_id":"adj-1","actor":"ops","reason":"manual penalty","user_id":"1001","amount":"-2500"}`))
adjust.Header.Set("Authorization", "Bearer secret")
adjustRec := httptest.NewRecorder()
srv.routes().ServeHTTP(adjustRec, adjust)
if adjustRec.Code != http.StatusOK || svc.adjust.Amount != -2500 || svc.adjust.DryRun {
t.Fatalf("adjust status=%d request=%+v body=%s", adjustRec.Code, svc.adjust, adjustRec.Body.String())
}
}
func TestAdminAPICollectibleUsernameReadsUseDecimalStrings(t *testing.T) {
svc := &captureCollectibleUsernameService{}
srv := &Server{token: "secret", svc: svc}
list := httptest.NewRequest(http.MethodGet,
"/v1/collectible-usernames?status=owned&owner_user_id=1001&q=%40Durov&limit=25&before_id=42", nil)
list.Header.Set("Authorization", "Bearer secret")
listRec := httptest.NewRecorder()
srv.routes().ServeHTTP(listRec, list)
if listRec.Code != http.StatusOK {
t.Fatalf("list status=%d body=%s", listRec.Code, listRec.Body.String())
}
if svc.filter.Status != domain.CollectibleUsernameStatusOwned ||
svc.filter.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: 1001}) ||
svc.filter.Query != "@Durov" || svc.filter.Limit != 25 || svc.filter.BeforeID != 42 {
t.Fatalf("collectible filter = %+v", svc.filter)
}
if !strings.Contains(listRec.Body.String(), `"id":"9223372036854775807"`) ||
!strings.Contains(listRec.Body.String(), `"amount":"9223372036854775807"`) {
t.Fatalf("list body lost int64 precision: %s", listRec.Body.String())
}
detail := httptest.NewRequest(http.MethodGet, "/v1/collectible-usernames/77", nil)
detail.Header.Set("Authorization", "Bearer secret")
detailRec := httptest.NewRecorder()
srv.routes().ServeHTTP(detailRec, detail)
if detailRec.Code != http.StatusOK || svc.assetID != 77 {
t.Fatalf("detail status=%d assetID=%d body=%s", detailRec.Code, svc.assetID, detailRec.Body.String())
}
var payload struct {
Asset map[string]any `json:"asset"`
Transfers []map[string]any `json:"transfers"`
}
if err := json.Unmarshal(detailRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode detail: %v", err)
}
if payload.Asset["id"] != "77" || len(payload.Transfers) != 1 ||
payload.Transfers[0]["amount"] != "9223372036854775807" ||
payload.Transfers[0]["collectible_id"] != "77" {
t.Fatalf("detail payload = %+v", payload)
}
}
func TestAdminAPIAccountRatingReadsUseDecimalStrings(t *testing.T) {
svc := &captureAccountRatingService{}
srv := &Server{token: "secret", svc: svc}
list := httptest.NewRequest(http.MethodGet, "/v1/account-ratings?min_level=3&user_id=1001&limit=10&before_id=99", nil)
list.Header.Set("Authorization", "Bearer secret")
listRec := httptest.NewRecorder()
srv.routes().ServeHTTP(listRec, list)
if listRec.Code != http.StatusOK {
t.Fatalf("list status=%d body=%s", listRec.Code, listRec.Body.String())
}
if svc.filter.MinLevel != 3 || svc.filter.UserID != 1001 || svc.filter.Limit != 10 || svc.filter.BeforeID != 99 {
t.Fatalf("rating filter = %+v", svc.filter)
}
if !strings.Contains(listRec.Body.String(), `"stars":"9223372036854775807"`) {
t.Fatalf("rating list lost int64 precision: %s", listRec.Body.String())
}
detail := httptest.NewRequest(http.MethodGet, "/v1/account-ratings/1001", nil)
detail.Header.Set("Authorization", "Bearer secret")
detailRec := httptest.NewRecorder()
srv.routes().ServeHTTP(detailRec, detail)
if detailRec.Code != http.StatusOK {
t.Fatalf("detail status=%d body=%s", detailRec.Code, detailRec.Body.String())
}
var payload struct {
Rating map[string]any `json:"rating"`
Events []map[string]any `json:"events"`
}
if err := json.Unmarshal(detailRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode detail: %v", err)
}
if payload.Rating["user_id"] != "1001" || payload.Rating["stars"] != "9223372036854775807" ||
len(payload.Events) != 1 || payload.Events[0]["amount"] != "-9223372036854775807" {
t.Fatalf("rating detail payload = %+v", payload)
}
}
func TestAdminAPIMissingCollectibleAndRatingReportCodedErrors(t *testing.T) {
srv := &Server{token: "secret", svc: fakeService{}}
asset := httptest.NewRequest(http.MethodGet, "/v1/collectible-usernames/5", nil)
asset.Header.Set("Authorization", "Bearer secret")
assetRec := httptest.NewRecorder()
srv.routes().ServeHTTP(assetRec, asset)
if assetRec.Code != http.StatusNotFound ||
!strings.Contains(assetRec.Body.String(), `"code":"`+admin.CodeCollectibleNotFound+`"`) {
t.Fatalf("missing asset status=%d body=%s", assetRec.Code, assetRec.Body.String())
}
rating := httptest.NewRequest(http.MethodGet, "/v1/account-ratings/5", nil)
rating.Header.Set("Authorization", "Bearer secret")
ratingRec := httptest.NewRecorder()
srv.routes().ServeHTTP(ratingRec, rating)
if ratingRec.Code != http.StatusNotFound ||
!strings.Contains(ratingRec.Body.String(), `"code":"`+admin.CodeRatingNotFound+`"`) {
t.Fatalf("missing rating status=%d body=%s", ratingRec.Code, ratingRec.Body.String())
}
}
func (fakeService) MintCollectibleUsername(_ context.Context, req admin.MintCollectibleUsernameRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) TransferCollectibleUsername(_ context.Context, req admin.TransferCollectibleUsernameRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RevokeCollectibleUsername(_ context.Context, req admin.RevokeCollectibleUsernameRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) DeleteCollectibleUsername(_ context.Context, req admin.DeleteCollectibleUsernameRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) CollectibleUsernames(context.Context, domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
return nil, nil
}
func (fakeService) CollectibleUsernameByID(context.Context, int64) (domain.CollectibleUsername, error) {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
func (fakeService) CollectibleUsernameTransfers(context.Context, int64, int) ([]domain.CollectibleUsernameTransfer, error) {
return nil, nil
}
func (fakeService) RecomputeAccountRating(_ context.Context, req admin.RecomputeAccountRatingRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) AdjustAccountRating(_ context.Context, req admin.AdjustAccountRatingRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) AccountRating(context.Context, int64) (domain.AccountRating, error) {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
func (fakeService) AccountRatings(context.Context, domain.AccountRatingFilter) ([]domain.AccountRating, error) {
return nil, nil
}
func (fakeService) AccountRatingEvents(context.Context, int64, int) ([]domain.AccountRatingEvent, error) {
return nil, nil
}