feat(admin): bot management (list, verify, create, delete)

- Add a Bots admin tab: list/search bots with a dedicated read query
  (users.is_bot, excluded from the accounts list), showing owner and
  system-vs-user type
- Create system bots from the admin via a new bot.create command that
  reuses the existing bot provisioning flow; the token is shown once
- Delete user-created bots via a new bot.delete command backed by a
  dedicated Postgres DeleteBotAccount (revokes sessions, purges private
  state, releases username, drops the bots row, tombstones the user);
  system service bots are rejected
- Verified badge toggling reuses the existing set-verified command
- All write paths go through the dry-run/confirm + audit command pipeline
- Rebuild dist bundle
This commit is contained in:
epilepticseizureee 2026-07-23 00:34:05 +03:00
parent ad9d535edc
commit 9e45da69ef
22 changed files with 985 additions and 12 deletions

View file

@ -32,6 +32,8 @@ const (
ActionPublishGiftCollectibles = "gifts.collectibles.publish"
ActionSetStarGiftEnabled = "gifts.set_enabled"
ActionSetStarGiftSortOrder = "gifts.set_sort_order"
ActionCreateBot = "bot.create"
ActionDeleteBot = "bot.delete"
maxCommandIDLength = 128
maxActorLength = 128
@ -127,6 +129,14 @@ type OfficialGiftsSource interface {
Bundle(ctx context.Context, giftID int64, includeCollectible bool) (officialgifts.Bundle, error)
}
// BotService creates bot accounts on behalf of the admin. It mirrors the
// owner-scoped /newbot flow: a bot is a users row (is_bot=true) plus a bots row
// owned by ownerUserID, and the returned token is shown once to the operator.
type BotService interface {
CreateBot(ctx context.Context, ownerUserID int64, name, username string) (domain.User, string, error)
DeleteBot(ctx context.Context, botUserID int64) (domain.User, error)
}
type Dependencies struct {
Commands CommandRepository
Restrictions RestrictionStore
@ -142,6 +152,7 @@ type Dependencies struct {
Messages MessagesService
Gifts GiftsService
OfficialGifts OfficialGiftsSource
Bots BotService
Now func() time.Time
}
@ -160,6 +171,7 @@ type Service struct {
messages MessagesService
gifts GiftsService
officialGifts OfficialGiftsSource
bots BotService
now func() time.Time
}
@ -211,6 +223,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
if deps.OfficialGifts != nil {
s.officialGifts = deps.OfficialGifts
}
if deps.Bots != nil {
s.bots = deps.Bots
}
if deps.Now != nil {
s.now = deps.Now
}
@ -346,6 +361,18 @@ type SetChannelVerifiedRequest struct {
Verified bool `json:"verified"`
}
type CreateBotRequest struct {
CommandMeta
OwnerUserID int64 `json:"owner_user_id"`
Name string `json:"name"`
Username string `json:"username"`
}
type DeleteBotRequest struct {
CommandMeta
BotUserID int64 `json:"bot_user_id"`
}
type RevokeSessionsRequest struct {
CommandMeta
UserID int64 `json:"user_id"`
@ -691,6 +718,93 @@ func (s *Service) SetVerified(ctx context.Context, req SetVerifiedRequest) (Comm
})
}
// CreateBot provisions a new bot account owned by ownerUserID. The dry-run stage
// only validates the display name and username; the confirm stage creates the
// users+bots rows and returns the freshly minted token in the result details so
// the operator can copy it once.
func (s *Service) CreateBot(ctx context.Context, req CreateBotRequest) (CommandResult, error) {
if s == nil || s.bots == nil {
return CommandResult{}, fmt.Errorf("admin bot dependency is not configured")
}
if req.OwnerUserID <= 0 {
return CommandResult{}, fmt.Errorf("owner_user_id is required")
}
name := strings.TrimSpace(req.Name)
if name == "" || len([]rune(name)) > domain.MaxBotNameLength {
return CommandResult{}, domain.ErrBotNameInvalid
}
username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@"))
if !domain.ValidBotUsername(username) {
return CommandResult{}, domain.ErrBotUsernameInvalid
}
req.Name = name
req.Username = username
return s.runCommand(ctx, req.CommandMeta, ActionCreateBot, req.OwnerUserID, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{
"owner_user_id": req.OwnerUserID,
"name": name,
"username": username,
}
if req.DryRun {
return CommandResult{Message: "bot creation validated", Details: details}, nil
}
bot, token, err := s.bots.CreateBot(ctx, req.OwnerUserID, name, username)
if err != nil {
return CommandResult{Details: details}, err
}
details["bot_user_id"] = bot.ID
// The token is a credential. It is surfaced once so the operator can copy
// it; it is also persisted in the audit result, so treat admin audit logs
// as sensitive.
details["token"] = token
if err := s.notifyUserChanged(ctx, bot); err != nil {
details["notify_error"] = err.Error()
}
return CommandResult{Message: "bot created", Details: details}, nil
})
}
// DeleteBot permanently removes a user-created bot. The dry-run stage verifies
// the target is a non-system bot; the confirm stage tombstones the account and
// invalidates its token. System bots are rejected outright.
func (s *Service) DeleteBot(ctx context.Context, req DeleteBotRequest) (CommandResult, error) {
if s == nil || s.bots == nil {
return CommandResult{}, fmt.Errorf("admin bot dependency is not configured")
}
if req.BotUserID <= 0 {
return CommandResult{}, fmt.Errorf("bot_user_id is required")
}
if domain.IsSystemUserID(req.BotUserID) {
return CommandResult{}, fmt.Errorf("system bots cannot be deleted")
}
return s.runCommand(ctx, req.CommandMeta, ActionDeleteBot, req.BotUserID, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"bot_user_id": req.BotUserID}
if s.users != nil {
u, found, err := s.users.AdminUser(ctx, req.BotUserID)
if err != nil {
return CommandResult{}, err
}
if !found || !u.Bot {
return CommandResult{}, domain.ErrBotNotFound
}
details["username"] = u.Username
details["name"] = u.FirstName
}
if req.DryRun {
return CommandResult{Message: "bot deletion validated", Details: details}, nil
}
deleted, err := s.bots.DeleteBot(ctx, req.BotUserID)
if err != nil {
return CommandResult{Details: details}, err
}
details["deleted"] = true
if err := s.notifyUserChanged(ctx, deleted); err != nil {
details["notify_error"] = err.Error()
}
return CommandResult{Message: "bot deleted", Details: details}, nil
})
}
func (s *Service) SetChannelVerified(ctx context.Context, req SetChannelVerifiedRequest) (CommandResult, error) {
if req.ChannelID <= 0 {
return CommandResult{}, fmt.Errorf("channel_id is required")

View file

@ -30,6 +30,8 @@ type Service interface {
GrantStars(ctx context.Context, req admin.GrantStarsRequest) (admin.CommandResult, error)
SetVerified(ctx context.Context, req admin.SetVerifiedRequest) (admin.CommandResult, error)
SetChannelVerified(ctx context.Context, req admin.SetChannelVerifiedRequest) (admin.CommandResult, error)
CreateBot(ctx context.Context, req admin.CreateBotRequest) (admin.CommandResult, error)
DeleteBot(ctx context.Context, req admin.DeleteBotRequest) (admin.CommandResult, error)
RevokeSessions(ctx context.Context, req admin.RevokeSessionsRequest) (admin.CommandResult, error)
DeletePrivateMessages(ctx context.Context, req admin.DeletePrivateMessagesRequest) (admin.CommandResult, error)
DeletePrivateHistory(ctx context.Context, req admin.DeletePrivateHistoryRequest) (admin.CommandResult, error)
@ -97,6 +99,8 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /v1/accounts/set-verified", s.authenticated(s.handleSetVerified))
mux.HandleFunc("POST /v1/accounts/revoke-sessions", s.authenticated(s.handleRevokeSessions))
mux.HandleFunc("POST /v1/channels/set-verified", s.authenticated(s.handleSetChannelVerified))
mux.HandleFunc("POST /v1/bots/create", s.authenticated(s.handleCreateBot))
mux.HandleFunc("POST /v1/bots/delete", s.authenticated(s.handleDeleteBot))
mux.HandleFunc("POST /v1/messages/delete", s.authenticated(s.handleDeleteMessages))
mux.HandleFunc("POST /v1/messages/delete-history", s.authenticated(s.handleDeleteHistory))
mux.HandleFunc("POST /v1/gifts/import", s.authenticated(s.handleImportStarGift))
@ -168,6 +172,24 @@ func (s *Server) handleSetChannelVerified(w http.ResponseWriter, r *http.Request
writeCommandResult(w, result, err)
}
func (s *Server) handleCreateBot(w http.ResponseWriter, r *http.Request) {
var req admin.CreateBotRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.CreateBot(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleDeleteBot(w http.ResponseWriter, r *http.Request) {
var req admin.DeleteBotRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.DeleteBot(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleRevokeSessions(w http.ResponseWriter, r *http.Request) {
var req admin.RevokeSessionsRequest
if !decodeJSON(w, r, &req) {

View file

@ -250,6 +250,14 @@ func (fakeService) SetChannelVerified(_ context.Context, req admin.SetChannelVer
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) CreateBot(_ context.Context, req admin.CreateBotRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) DeleteBot(_ context.Context, req admin.DeleteBotRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RevokeSessions(context.Context, admin.RevokeSessionsRequest) (admin.CommandResult, error) {
return admin.CommandResult{}, nil
}

View file

@ -448,6 +448,42 @@ func (s *Service) ListOwnedBots(ctx context.Context, ownerUserID int64) ([]domai
return out, nil
}
// botAccountDeleter is the optional store capability used to permanently delete
// a user-created bot. Only the Postgres store implements it, so the memory store
// and other BotStore mocks are unaffected.
type botAccountDeleter interface {
DeleteBotAccount(ctx context.Context, botUserID int64) (domain.User, error)
}
// DeleteBot permanently removes a user-created bot. System service bots are
// rejected. Live sessions are dropped and the bot's caches are invalidated so
// the deletion is visible immediately. Returns the tombstoned user.
func (s *Service) DeleteBot(ctx context.Context, botUserID int64) (domain.User, error) {
if s == nil || s.bots == nil || botUserID == 0 {
return domain.User{}, domain.ErrBotNotFound
}
if domain.IsSystemUserID(botUserID) {
return domain.User{}, domain.ErrBotNotFound
}
deleter, ok := s.bots.(botAccountDeleter)
if !ok {
return domain.User{}, fmt.Errorf("bot deletion is not supported by the configured store")
}
// Drop live sessions up front so the token stops working even if a caller
// races the tombstone; DeleteBotAccount also revokes the authorization rows.
if s.hooks != nil {
if err := s.hooks.RevokeBotSessions(ctx, botUserID); err != nil {
s.log.Warn("revoke bot sessions before delete", zap.Int64("bot_user_id", botUserID), zap.Error(err))
}
}
u, err := deleter.DeleteBotAccount(ctx, botUserID)
if err != nil {
return domain.User{}, err
}
s.invalidateBotReadCaches(ctx, botUserID)
return u, nil
}
// ExportBotToken 返回 bot tokenrevoke=true 时先轮换 secret 并撤销已登录 session。
func (s *Service) ExportBotToken(ctx context.Context, ownerUserID, botUserID int64, revoke bool) (string, error) {
if revoke {

View file

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
@ -87,6 +88,91 @@ func (s *BotStore) CreateBotAccount(ctx context.Context, user domain.User, profi
return userFromModel(row), profile, nil
}
// DeleteBotAccount permanently removes a user-created bot in one transaction:
// it revokes the bot's sessions, purges its private state, releases its
// username, drops the bots row (which invalidates the token) and tombstones the
// users row. System service bots and non-bot users are rejected. The reused
// helpers are the same vetted primitives that back account deletion, so the
// tombstone satisfies users_deletion_state_check. Returns the tombstoned user
// for change notifications.
func (s *BotStore) DeleteBotAccount(ctx context.Context, botUserID int64) (domain.User, error) {
if botUserID == 0 || domain.IsSystemUserID(botUserID) {
return domain.User{}, domain.ErrBotNotFound
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.User{}, fmt.Errorf("delete bot account: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.User{}, fmt.Errorf("delete bot account: begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err := lockUsersForUpdate(ctx, tx, botUserID); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: lock: %w", err)
}
u, found, err := NewUserStore(tx).ByID(ctx, botUserID)
if err != nil {
return domain.User{}, err
}
if !found || !u.Bot || u.Deleted {
return domain.User{}, domain.ErrBotNotFound
}
// Only bots backed by a bots row (created via /newbot or the admin) are
// deletable here; system service bots are already excluded above.
var hasBotRow bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bots WHERE bot_user_id = $1)`, botUserID).Scan(&hasBotRow); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: probe bots row: %w", err)
}
if !hasBotRow {
return domain.User{}, domain.ErrBotNotFound
}
now := time.Now().UTC()
if err := enqueueAccountDeletionNotifications(ctx, tx, botUserID); err != nil {
return domain.User{}, err
}
if _, err := revokeByUserExceptTx(ctx, tx, botUserID, 0); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: revoke sessions: %w", err)
}
if err := purgeDeletedAccountPrivateState(ctx, tx, botUserID, now); err != nil {
return domain.User{}, err
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, botUserID, ""); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: release username: %w", err)
}
// Drop the bots row so the token can no longer authenticate a login.
if _, err := tx.Exec(ctx, `DELETE FROM bots WHERE bot_user_id = $1`, botUserID); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: delete bots row: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE users SET
phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '',
verified = false, support = false, last_seen_at = 0,
premium_expires_at = NULL, emoji_status_document_id = 0, emoji_status_until = 0,
emoji_status_collectible_id = NULL, emoji_status_collectible = '{}'::jsonb,
color_set = false, color = 0, color_background_emoji_id = 0,
profile_color_set = false, profile_color = 0, profile_color_background_emoji_id = 0,
birthday_day = 0, birthday_month = 0, birthday_year = 0, personal_channel_id = 0,
deleted_at = $2, deletion_source = 'manual', deletion_reason = 'admin bot deletion',
account_delete_at = NULL, updated_at = $2
WHERE id = $1 AND deleted_at IS NULL`, botUserID, now); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: tombstone: %w", err)
}
u, found, err = NewUserStore(tx).ByID(ctx, botUserID)
if err != nil || !found {
if err == nil {
err = domain.ErrUserNotFound
}
return domain.User{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: commit: %w", err)
}
return u, nil
}
func (s *BotStore) GetBot(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) {
if botUserID == 0 {
return domain.BotProfile{}, false, nil