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

@ -108,6 +108,7 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/set-channel-emoji-status", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelEmojiStatusAPI))) mux.Handle("POST /api/actions/set-channel-emoji-status", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelEmojiStatusAPI)))
mux.Handle("POST /api/actions/create-bot", s.requireAuthAPI(http.HandlerFunc(s.handleCreateBotAPI))) mux.Handle("POST /api/actions/create-bot", s.requireAuthAPI(http.HandlerFunc(s.handleCreateBotAPI)))
mux.Handle("POST /api/actions/delete-bot", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteBotAPI))) mux.Handle("POST /api/actions/delete-bot", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteBotAPI)))
mux.Handle("POST /api/actions/export-bot-token", s.requireAuthAPI(http.HandlerFunc(s.handleExportBotTokenAPI)))
mux.Handle("POST /api/actions/set-channel-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelVerifiedAPI))) mux.Handle("POST /api/actions/set-channel-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelVerifiedAPI)))
mux.Handle("POST /api/actions/revoke-sessions", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeSessionsAPI))) mux.Handle("POST /api/actions/revoke-sessions", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeSessionsAPI)))
mux.Handle("POST /api/actions/delete-messages", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteMessagesAPI))) mux.Handle("POST /api/actions/delete-messages", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteMessagesAPI)))
@ -897,6 +898,26 @@ func (s *server) handleDeleteBotAPI(w http.ResponseWriter, r *http.Request) {
writeCommandResultAPI(w, result, err) writeCommandResultAPI(w, result, err)
} }
type exportBotTokenAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
BotUserID int64 `json:"bot_user_id"`
}
func (s *server) handleExportBotTokenAPI(w http.ResponseWriter, r *http.Request) {
var body exportBotTokenAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.ExportBotTokenRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "export-bot-token"),
BotUserID: body.BotUserID,
}
result, err := s.callAdminAPI(r.Context(), "/v1/bots/export-token", req)
writeCommandResultAPI(w, result, err)
}
func (s *server) handleChannelsAPI(w http.ResponseWriter, r *http.Request) { func (s *server) handleChannelsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil { if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured") writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -23,8 +23,8 @@
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-CUbSxjdM.js"></script> <script type="module" crossorigin src="/assets/index-DL3Lv8wS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BpSP1ojC.css"> <link rel="stylesheet" crossorigin href="/assets/index-D8Q_54bE.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -1,4 +1,4 @@
import { CheckCircle2, CircleAlert, FileJson, Loader2, Play, X } from "lucide-react"; import { Check, CheckCircle2, CircleAlert, Copy, FileJson, Loader2, Play, X } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
@ -17,7 +17,8 @@ export function ActionButton({
tone = "danger", tone = "danger",
disabled = false, disabled = false,
onDone, onDone,
onError onError,
secretField
}: { }: {
label: string; label: string;
path: string; path: string;
@ -34,17 +35,24 @@ export function ActionButton({
// form — an optimistic-locking 409, say — and replace the raw backend text with // form — an optimistic-locking 409, say — and replace the raw backend text with
// an explanation by returning it. // an explanation by returning it.
onError?: (error: unknown) => string | undefined; onError?: (error: unknown) => string | undefined;
// secretField names a key in result.details that holds a one-time secret
// (e.g. a bot token) -- when present, it's pulled out of the generic JSON
// dump and rendered instead as its own copy-to-clipboard callout, so it
// doesn't get lost among the other fields.
secretField?: string;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [result, setResult] = useState<CommandResult | null>(null); const [result, setResult] = useState<CommandResult | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [secretCopied, setSecretCopied] = useState(false);
function reset() { function reset() {
setReason(""); setReason("");
setResult(null); setResult(null);
setError(""); setError("");
setSecretCopied(false);
} }
async function run(confirm: boolean) { async function run(confirm: boolean) {
@ -78,6 +86,18 @@ export function ActionButton({
} }
}, [open, payload]); }, [open, payload]);
const secretValue = secretField && result?.details && typeof result.details[secretField] === "string"
? (result.details[secretField] as string)
: "";
const visibleDetails = secretValue && result?.details
? Object.fromEntries(Object.entries(result.details).filter(([key]) => key !== secretField))
: result?.details;
async function copySecret() {
await navigator.clipboard.writeText(secretValue);
setSecretCopied(true);
}
return ( return (
<> <>
<button <button
@ -133,7 +153,19 @@ export function ActionButton({
<div className="result-line"><span>{"Status"}</span><strong>{result.status}</strong></div> <div className="result-line"><span>{"Status"}</span><strong>{result.status}</strong></div>
<div className="result-line"><span>{"Dry-run"}</span><strong>{result.dry_run ? "Yes" : "No"}</strong></div> <div className="result-line"><span>{"Dry-run"}</span><strong>{result.dry_run ? "Yes" : "No"}</strong></div>
<div className="result-message">{result.message || result.error}</div> <div className="result-message">{result.message || result.error}</div>
{result.details && <JsonBlock value={JSON.stringify(result.details, null, 2)} />} {secretValue && (
<div className="secret-reveal">
<div className="secret-reveal-label">{"One-time secret — copy it now, it won't be shown again"}</div>
<div className="secret-reveal-row">
<code className="secret-reveal-value">{"•".repeat(Math.min(secretValue.length, 40))}</code>
<button className="btn icon-text" type="button" onClick={() => void copySecret()}>
{secretCopied ? <Check size={15} /> : <Copy size={15} />}
{secretCopied ? "Copied" : "Copy"}
</button>
</div>
</div>
)}
{visibleDetails && Object.keys(visibleDetails).length > 0 && <JsonBlock value={JSON.stringify(visibleDetails, null, 2)} />}
</div> </div>
)} )}
</div> </div>

View file

@ -0,0 +1,79 @@
import { Check, Copy, X } from "lucide-react";
import { useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { Alert } from "./ui";
// CopyBotTokenModal writes a non-system bot's token straight to the
// clipboard without ever rendering it on screen -- the value only ever
// lives in the fetch response and the clipboard API call; React state only
// ever tracks whether the copy succeeded, never the token itself.
export function CopyBotTokenModal({ botID, onClose }: { botID: number; onClose: () => void }) {
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [copied, setCopied] = useState(false);
async function copyToken() {
if (!reason.trim()) {
setError("Please enter an operation reason");
return;
}
setBusy(true);
setError("");
setCopied(false);
try {
const result = await api.action("/api/actions/export-bot-token", {
command_id: "",
reason: reason.trim(),
confirm: true,
bot_user_id: botID
});
const token = result.details?.token;
if (result.error || typeof token !== "string" || !token) {
setError(result.error || "No token returned.");
return;
}
await navigator.clipboard.writeText(token);
setCopied(true);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Copy bot token"}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Bot"}</div>
<h2>{"Copy bot token"}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
<p>{"The token is written straight to your clipboard and is never shown on screen. Paste it wherever it's needed right after copying."}</p>
<label className="form-field">
<span>{"Operation reason"}</span>
<textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} placeholder={"Describe why this token is being retrieved"} />
</label>
{error && <Alert>{error}</Alert>}
{copied && (
<div className="secret-reveal">
<div className="secret-reveal-label"><Check size={14} /> {"Token copied to clipboard."}</div>
</div>
)}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={onClose} disabled={busy}>{"Close"}</button>
<button className="btn primary icon-text" type="button" onClick={() => void copyToken()} disabled={busy}>
<Copy size={15} /> {copied ? "Copy again" : "Copy token"}
</button>
</div>
</section>
</div>,
document.body
);
}

View file

@ -14,11 +14,11 @@ export function CreateBotModal({ onClose, onCreated }: { onClose: () => void; on
return createPortal( return createPortal(
<div className="modal-backdrop" role="presentation"> <div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Create a system bot"}> <section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Create bot"}>
<div className="modal-head"> <div className="modal-head">
<div> <div>
<div className="eyebrow">{"Bots"}</div> <div className="eyebrow">{"Bots"}</div>
<h2>{"Create a system bot"}</h2> <h2>{"Create bot"}</h2>
</div> </div>
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button> <button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
</div> </div>
@ -58,6 +58,7 @@ export function CreateBotModal({ onClose, onCreated }: { onClose: () => void; on
name: botName.trim(), name: botName.trim(),
username: botUsername.trim().replace(/^@/, "") username: botUsername.trim().replace(/^@/, "")
})} })}
secretField="token"
onDone={onCreated} onDone={onCreated}
/> />
</div> </div>

View file

@ -1,9 +1,10 @@
import { ArrowLeft, BadgeCheck, ImagePlus, ScrollText, Settings2, Trash2, UserRound } from "lucide-react"; import { ArrowLeft, BadgeCheck, Copy, ImagePlus, ScrollText, Settings2, Trash2, UserRound } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton"; import { ActionButton } from "../components/ActionButton";
import { Avatar } from "../components/Avatar"; import { Avatar } from "../components/Avatar";
import { AvatarModal } from "../components/AvatarModal"; import { AvatarModal } from "../components/AvatarModal";
import { CopyBotTokenModal } from "../components/CopyBotTokenModal";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui"; import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
import { ScamFakeActions, ScamFakeBadges } from "../components/flags"; import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes"; import { ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes";
@ -20,6 +21,7 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
const [tab, setTab] = useState<Tab>("profile"); const [tab, setTab] = useState<Tab>("profile");
const [avatarModalOpen, setAvatarModalOpen] = useState(false); const [avatarModalOpen, setAvatarModalOpen] = useState(false);
const [avatarVersion, setAvatarVersion] = useState(0); const [avatarVersion, setAvatarVersion] = useState(0);
const [copyTokenModalOpen, setCopyTokenModalOpen] = useState(false);
async function load() { async function load() {
setBusy(true); setBusy(true);
@ -145,6 +147,18 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
<EmojiStatusAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-emoji-status" onDone={load} /> <EmojiStatusAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-emoji-status" onDone={load} />
</section> </section>
{!bot.System && (
<section className="section-block">
<SectionHead title={"Credentials"} />
<div className="action-stack">
<button className="btn icon-text" type="button" onClick={() => setCopyTokenModalOpen(true)}>
<Copy size={15} /> {"Copy token"}
</button>
</div>
<p className="bot-create-note">{"Copies straight to the clipboard through a dedicated confirmation step -- the token itself is never shown on this page."}</p>
</section>
)}
<section className="section-block"> <section className="section-block">
<SectionHead title={"Danger Zone"} /> <SectionHead title={"Danger Zone"} />
{bot.System ? ( {bot.System ? (
@ -183,6 +197,10 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
}} }}
/> />
)} )}
{copyTokenModalOpen && (
<CopyBotTokenModal botID={bot.ID} onClose={() => setCopyTokenModalOpen(false)} />
)}
</PageFrame> </PageFrame>
); );
} }

View file

@ -172,10 +172,11 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
{createModalOpen && ( {createModalOpen && (
<CreateBotModal <CreateBotModal
onClose={() => setCreateModalOpen(false)} onClose={() => setCreateModalOpen(false)}
onCreated={() => { // Deliberately does not close the modal -- the token is only ever
setCreateModalOpen(false); // shown once, inside ActionButton's own result panel, and closing
void loadFresh(); // immediately would yank it away before it can be copied. The
}} // operator closes both modals manually once they're done with it.
onCreated={() => void loadFresh()}
/> />
)} )}
</PageFrame> </PageFrame>

View file

@ -168,6 +168,41 @@
color: var(--text-soft); color: var(--text-soft);
} }
.secret-reveal {
display: grid;
gap: 6px;
padding: 10px;
background: var(--warn-tint);
border: 1px solid var(--warn-border);
border-radius: var(--radius);
}
.secret-reveal-label {
color: var(--warn);
font-size: 12px;
font-weight: 800;
}
.secret-reveal-row {
display: flex;
align-items: center;
gap: 10px;
}
.secret-reveal-value {
overflow: hidden;
flex: 1 1 auto;
padding: 6px 10px;
color: var(--text-soft);
letter-spacing: .12em;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.modal-actions { .modal-actions {
justify-content: flex-end; justify-content: flex-end;
padding: 12px 18px; padding: 12px 18px;

View file

@ -58,6 +58,7 @@ const (
ActionGiveGift = "gifts.give" ActionGiveGift = "gifts.give"
ActionCreateBot = "bot.create" ActionCreateBot = "bot.create"
ActionDeleteBot = "bot.delete" ActionDeleteBot = "bot.delete"
ActionExportBotToken = "bot.export_token"
ActionSetStickerSetArchived = "stickers.set_archived" ActionSetStickerSetArchived = "stickers.set_archived"
ActionSetStickerSetSortOrder = "stickers.set_sort_order" ActionSetStickerSetSortOrder = "stickers.set_sort_order"
ActionRenameStickerSet = "stickers.rename" ActionRenameStickerSet = "stickers.rename"
@ -333,6 +334,10 @@ type StickerSetsService interface {
type BotService interface { type BotService interface {
CreateBot(ctx context.Context, ownerUserID int64, name, username string) (domain.User, string, error) CreateBot(ctx context.Context, ownerUserID int64, name, username string) (domain.User, string, error)
DeleteBot(ctx context.Context, botUserID int64) (domain.User, 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 // EmojiService renders custom-emoji document animations for the admin emoji
@ -1027,6 +1032,11 @@ type DeleteBotRequest struct {
BotUserID int64 `json:"bot_user_id"` 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 // MintCollectibleUsernameRequest mints a collectible username asset. At most one
// of OwnerUserID / OwnerChannelID may be set: neither mints into the operator // of OwnerUserID / OwnerChannelID may be set: neither mints into the operator
// vault, one assigns the asset to that holder in the same command. // 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 // MintCollectibleUsername creates a collectible username asset and optionally
// assigns it in the same command. Shape validation runs before the command is // 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 // 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 token string
createCalls int createCalls int
deleteCalls int deleteCalls int
exportCalls int
} }
func (f *fakeBotService) CreateBot(_ context.Context, _ int64, name, username string) (domain.User, string, error) { 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 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 { type fakeRestrictionStore struct {
items map[int64]domain.AccountFreeze items map[int64]domain.AccountFreeze
setCalls int setCalls int

View file

@ -51,6 +51,7 @@ type Service interface {
SetChannelFlags(ctx context.Context, req admin.SetChannelFlagsRequest) (admin.CommandResult, error) SetChannelFlags(ctx context.Context, req admin.SetChannelFlagsRequest) (admin.CommandResult, error)
CreateBot(ctx context.Context, req admin.CreateBotRequest) (admin.CommandResult, error) CreateBot(ctx context.Context, req admin.CreateBotRequest) (admin.CommandResult, error)
DeleteBot(ctx context.Context, req admin.DeleteBotRequest) (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) SetSupport(ctx context.Context, req admin.SetSupportRequest) (admin.CommandResult, error)
SetUsername(ctx context.Context, req admin.SetUsernameRequest) (admin.CommandResult, error) SetUsername(ctx context.Context, req admin.SetUsernameRequest) (admin.CommandResult, error)
SetProfile(ctx context.Context, req admin.SetProfileRequest) (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/channels/set-emoji-status", s.authenticated(s.handleSetChannelEmojiStatus))
mux.HandleFunc("POST /v1/bots/create", s.authenticated(s.handleCreateBot)) 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/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", s.authenticated(s.handleDeleteMessages))
mux.HandleFunc("POST /v1/messages/delete-history", s.authenticated(s.handleDeleteHistory)) mux.HandleFunc("POST /v1/messages/delete-history", s.authenticated(s.handleDeleteHistory))
mux.HandleFunc("POST /v1/gifts/import", s.authenticated(s.handleImportStarGift)) 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) 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) { func (s *Server) handleRevokeSessions(w http.ResponseWriter, r *http.Request) {
var req admin.RevokeSessionsRequest var req admin.RevokeSessionsRequest
if !decodeJSON(w, r, &req) { 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 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) { func (fakeService) SetUserFlags(_ context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil 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 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 立即不可登录,并踢掉所有 // RevokeBotToken 生成新 token 随机段并落库;旧 token 立即不可登录,并踢掉所有
// 已凭旧 token 登录的 session经注入的 SessionRevoker // 已凭旧 token 登录的 session经注入的 SessionRevoker
func (s *Service) RevokeBotToken(ctx context.Context, ownerUserID, botUserID int64) (string, error) { func (s *Service) RevokeBotToken(ctx context.Context, ownerUserID, botUserID int64) (string, error) {