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/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
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>
|
||||||
|
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
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(
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue