added ability to copy bot token
This commit is contained in:
parent
bb1f680d3b
commit
8aad71643f
16 changed files with 294 additions and 23 deletions
|
|
@ -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/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/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/revoke-sessions", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeSessionsAPI)))
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
if s.read == nil {
|
||||
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
10
cmd/telesrv-admin/web/dist/assets/index-DL3Lv8wS.js
vendored
Normal file
10
cmd/telesrv-admin/web/dist/assets/index-DL3Lv8wS.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,8 +23,8 @@
|
|||
})();
|
||||
</script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-CUbSxjdM.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BpSP1ojC.css">
|
||||
<script type="module" crossorigin src="/assets/index-DL3Lv8wS.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D8Q_54bE.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -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 { useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
|
@ -17,7 +17,8 @@ export function ActionButton({
|
|||
tone = "danger",
|
||||
disabled = false,
|
||||
onDone,
|
||||
onError
|
||||
onError,
|
||||
secretField
|
||||
}: {
|
||||
label: string;
|
||||
path: string;
|
||||
|
|
@ -34,17 +35,24 @@ export function ActionButton({
|
|||
// form — an optimistic-locking 409, say — and replace the raw backend text with
|
||||
// an explanation by returning it.
|
||||
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 [reason, setReason] = useState("");
|
||||
const [result, setResult] = useState<CommandResult | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [secretCopied, setSecretCopied] = useState(false);
|
||||
|
||||
function reset() {
|
||||
setReason("");
|
||||
setResult(null);
|
||||
setError("");
|
||||
setSecretCopied(false);
|
||||
}
|
||||
|
||||
async function run(confirm: boolean) {
|
||||
|
|
@ -78,6 +86,18 @@ export function ActionButton({
|
|||
}
|
||||
}, [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 (
|
||||
<>
|
||||
<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>{"Dry-run"}</span><strong>{result.dry_run ? "Yes" : "No"}</strong></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>
|
||||
|
|
|
|||
79
cmd/telesrv-admin/web/src/components/CopyBotTokenModal.tsx
Normal file
79
cmd/telesrv-admin/web/src/components/CopyBotTokenModal.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -14,11 +14,11 @@ export function CreateBotModal({ onClose, onCreated }: { onClose: () => void; on
|
|||
|
||||
return createPortal(
|
||||
<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>
|
||||
<div className="eyebrow">{"Bots"}</div>
|
||||
<h2>{"Create a system bot"}</h2>
|
||||
<h2>{"Create bot"}</h2>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
|
|
@ -58,6 +58,7 @@ export function CreateBotModal({ onClose, onCreated }: { onClose: () => void; on
|
|||
name: botName.trim(),
|
||||
username: botUsername.trim().replace(/^@/, "")
|
||||
})}
|
||||
secretField="token"
|
||||
onDone={onCreated}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { AvatarModal } from "../components/AvatarModal";
|
||||
import { CopyBotTokenModal } from "../components/CopyBotTokenModal";
|
||||
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
|
||||
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
|
||||
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 [avatarModalOpen, setAvatarModalOpen] = useState(false);
|
||||
const [avatarVersion, setAvatarVersion] = useState(0);
|
||||
const [copyTokenModalOpen, setCopyTokenModalOpen] = useState(false);
|
||||
|
||||
async function load() {
|
||||
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} />
|
||||
</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">
|
||||
<SectionHead title={"Danger Zone"} />
|
||||
{bot.System ? (
|
||||
|
|
@ -183,6 +197,10 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{copyTokenModalOpen && (
|
||||
<CopyBotTokenModal botID={bot.ID} onClose={() => setCopyTokenModalOpen(false)} />
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,10 +172,11 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
|
|||
{createModalOpen && (
|
||||
<CreateBotModal
|
||||
onClose={() => setCreateModalOpen(false)}
|
||||
onCreated={() => {
|
||||
setCreateModalOpen(false);
|
||||
void loadFresh();
|
||||
}}
|
||||
// Deliberately does not close the modal -- the token is only ever
|
||||
// shown once, inside ActionButton's own result panel, and closing
|
||||
// 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>
|
||||
|
|
|
|||
|
|
@ -168,6 +168,41 @@
|
|||
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 {
|
||||
justify-content: flex-end;
|
||||
padding: 12px 18px;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue