added ability to copy bot token

This commit is contained in:
onysd 2026-08-05 22:33:33 +03:00
parent bb1f680d3b
commit 8aad71643f
16 changed files with 294 additions and 23 deletions

View file

@ -58,6 +58,7 @@ const (
ActionGiveGift = "gifts.give"
ActionCreateBot = "bot.create"
ActionDeleteBot = "bot.delete"
ActionExportBotToken = "bot.export_token"
ActionSetStickerSetArchived = "stickers.set_archived"
ActionSetStickerSetSortOrder = "stickers.set_sort_order"
ActionRenameStickerSet = "stickers.rename"
@ -333,6 +334,10 @@ type StickerSetsService interface {
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)
// AdminExportBotToken returns a non-system bot's current token with no
// ownership check. Used by ExportBotToken; the token never enters the
// audit/replay record (see CommandResult.transientDetails).
AdminExportBotToken(ctx context.Context, botUserID int64) (string, error)
}
// EmojiService renders custom-emoji document animations for the admin emoji
@ -1027,6 +1032,11 @@ type DeleteBotRequest struct {
BotUserID int64 `json:"bot_user_id"`
}
type ExportBotTokenRequest struct {
CommandMeta
BotUserID int64 `json:"bot_user_id"`
}
// MintCollectibleUsernameRequest mints a collectible username asset. At most one
// of OwnerUserID / OwnerChannelID may be set: neither mints into the operator
// vault, one assigns the asset to that holder in the same command.
@ -2039,6 +2049,39 @@ func (s *Service) DeleteBot(ctx context.Context, req DeleteBotRequest) (CommandR
})
}
// ExportBotToken returns a non-system bot's current token (unrotated) via the
// audited runCommand wrapper. Like CreateBot's token, it travels only in
// transientDetails -- excluded from the stored/replayed command JSON so it
// never lands in audit storage. The admin console's own UI additionally never
// renders this token on screen; it copies the response straight to the
// clipboard.
func (s *Service) ExportBotToken(ctx context.Context, req ExportBotTokenRequest) (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 have no exportable token")
}
return s.runCommand(ctx, req.CommandMeta, ActionExportBotToken, req.BotUserID, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"bot_user_id": req.BotUserID}
if req.DryRun {
return CommandResult{Message: "token export validated", Details: details}, nil
}
token, err := s.bots.AdminExportBotToken(ctx, req.BotUserID)
if err != nil {
return CommandResult{Details: details}, err
}
return CommandResult{
Message: "token exported",
Details: details,
transientDetails: map[string]any{"token": token},
}, nil
})
}
// MintCollectibleUsername creates a collectible username asset and optionally
// assigns it in the same command. Shape validation runs before the command is
// journalled; occupancy is checked inside it, so a dry-run reports a taken name

View file

@ -617,6 +617,7 @@ type fakeBotService struct {
token string
createCalls int
deleteCalls int
exportCalls int
}
func (f *fakeBotService) CreateBot(_ context.Context, _ int64, name, username string) (domain.User, string, error) {
@ -629,6 +630,11 @@ func (f *fakeBotService) DeleteBot(_ context.Context, botUserID int64) (domain.U
return domain.User{ID: botUserID, Bot: true, Deleted: true}, nil
}
func (f *fakeBotService) AdminExportBotToken(_ context.Context, botUserID int64) (string, error) {
f.exportCalls++
return f.token, nil
}
type fakeRestrictionStore struct {
items map[int64]domain.AccountFreeze
setCalls int

View file

@ -51,6 +51,7 @@ type Service interface {
SetChannelFlags(ctx context.Context, req admin.SetChannelFlagsRequest) (admin.CommandResult, error)
CreateBot(ctx context.Context, req admin.CreateBotRequest) (admin.CommandResult, error)
DeleteBot(ctx context.Context, req admin.DeleteBotRequest) (admin.CommandResult, error)
ExportBotToken(ctx context.Context, req admin.ExportBotTokenRequest) (admin.CommandResult, error)
SetSupport(ctx context.Context, req admin.SetSupportRequest) (admin.CommandResult, error)
SetUsername(ctx context.Context, req admin.SetUsernameRequest) (admin.CommandResult, error)
SetProfile(ctx context.Context, req admin.SetProfileRequest) (admin.CommandResult, error)
@ -215,6 +216,7 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /v1/channels/set-emoji-status", s.authenticated(s.handleSetChannelEmojiStatus))
mux.HandleFunc("POST /v1/bots/create", s.authenticated(s.handleCreateBot))
mux.HandleFunc("POST /v1/bots/delete", s.authenticated(s.handleDeleteBot))
mux.HandleFunc("POST /v1/bots/export-token", s.authenticated(s.handleExportBotToken))
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))
@ -585,6 +587,15 @@ func (s *Server) handleDeleteBot(w http.ResponseWriter, r *http.Request) {
writeCommandResult(w, result, err)
}
func (s *Server) handleExportBotToken(w http.ResponseWriter, r *http.Request) {
var req admin.ExportBotTokenRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.ExportBotToken(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

@ -463,6 +463,10 @@ func (fakeService) DeleteBot(_ context.Context, req admin.DeleteBotRequest) (adm
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) ExportBotToken(_ context.Context, req admin.ExportBotTokenRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetUserFlags(_ context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}

View file

@ -613,6 +613,26 @@ func (s *Service) ExportBotToken(ctx context.Context, ownerUserID, botUserID int
return domain.FormatBotToken(botUserID, profile.TokenSecret), nil
}
// AdminExportBotToken returns a non-system bot's current token through the
// admin path (no owner check), without rotating it. System bots (built-in,
// seeded at reserved ids) have no exportable token.
func (s *Service) AdminExportBotToken(ctx context.Context, botUserID int64) (string, error) {
if s == nil || s.bots == nil || botUserID <= 0 {
return "", domain.ErrBotNotFound
}
if domain.IsSystemUserID(botUserID) || botUserID == domain.BotFatherUserID {
return "", fmt.Errorf("system bots have no exportable token")
}
profile, found, err := s.botProfile(ctx, botUserID)
if err != nil {
return "", err
}
if !found || profile.TokenSecret == "" {
return "", domain.ErrBotNotFound
}
return domain.FormatBotToken(botUserID, profile.TokenSecret), nil
}
// RevokeBotToken 生成新 token 随机段并落库;旧 token 立即不可登录,并踢掉所有
// 已凭旧 token 登录的 session经注入的 SessionRevoker
func (s *Service) RevokeBotToken(ctx context.Context, ownerUserID, botUserID int64) (string, error) {