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")