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:
parent
ad9d535edc
commit
9e45da69ef
22 changed files with 985 additions and 12 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 token;revoke=true 时先轮换 secret 并撤销已登录 session。
|
||||
func (s *Service) ExportBotToken(ctx context.Context, ownerUserID, botUserID int64, revoke bool) (string, error) {
|
||||
if revoke {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue