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

@ -1,6 +1,8 @@
import type {
AccountDetail,
AccountListResponse,
BotDetail,
BotListResponse,
ChannelDetail,
ChannelListResponse,
CommandResult,
@ -56,6 +58,8 @@ export const api = {
account: (id: number) => request<AccountDetail>(`/api/accounts/${id}`),
channels: (params: URLSearchParams) => request<ChannelListResponse>(`/api/channels?${params.toString()}`),
channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`),
bots: (params: URLSearchParams) => request<BotListResponse>(`/api/bots?${params.toString()}`),
bot: (id: number) => request<BotDetail>(`/api/bots/${id}`),
messages: (params: URLSearchParams) => request<MessageListResponse>(`/api/messages?${params.toString()}`),
message: (ownerUserID: number, msgID: number) => {
const params = new URLSearchParams({ owner_user_id: String(ownerUserID), msg_id: String(msgID) });

View file

@ -1,4 +1,5 @@
import {
Bot,
ChevronDown,
Database,
LayoutDashboard,
@ -76,6 +77,7 @@ export function Shell({
<NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}>{t("layout.dashboard")}</NavLink>
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{t("layout.accounts")}</NavLink>
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{t("layout.channels")}</NavLink>
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{t("layout.bots")}</NavLink>
<NavLink icon={<Gift size={16} />} href="/gifts" route={route} navigate={navigate}>{t("layout.gifts")}</NavLink>
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
<button

View file

@ -183,6 +183,43 @@ const translations: Record<Language, Record<string, string>> = {
"channel.kind.forum": "Supergroup / Forum",
"channel.kind.megagroup": "Supergroup",
"channel.kind.generic": "Channel / Group",
"route.bots": "Bots",
"route.botsSubtitle": "Console / Bots",
"layout.bots": "Bots",
"bots.pageTitle": "Bots",
"bots.queryResults": "Search results",
"bots.recent": "Recently created bots",
"bots.currentPage": "Bots on page",
"bots.banned": "Banned",
"bots.active": "Active",
"bots.createTitle": "Create a system bot",
"bots.createHint": "Provision a bot account owned by the given user. The token is shown once after confirmation.",
"bots.ownerUserID": "Owner user ID",
"bots.name": "Display name",
"bots.namePlaceholder": "e.g. Service Bot",
"bots.username": "Username",
"bots.usernameHint": "Username must be 5-32 characters and end with 'bot'.",
"bots.create": "Create bot",
"bots.searchPlaceholder": "Bot ID / username",
"bots.botID": "Bot ID",
"bots.owner": "Owner",
"bots.status": "Status",
"bots.detailTitle": "Bot #{id}",
"bots.profile": "Bot Profile",
"bots.loadingDetail": "Loading bot detail",
"bots.unnamed": "Unnamed bot",
"bots.restriction": "Restriction",
"bots.actionDock": "Bot Actions",
"bots.banUntil": "Ban until",
"bots.ban": "Ban bot",
"bots.updateBan": "Update ban",
"bots.unban": "Unban bot",
"bots.type": "Type",
"bots.system": "System",
"bots.user": "User",
"bots.delete": "Delete bot",
"bots.deleteHint": "Permanently deletes this user-created bot and invalidates its token. This cannot be undone.",
"bots.systemHint": "System bots are built in and cannot be deleted.",
"messages.privateTitle": "Private Messages",
"messages.privateEyebrow": "Private message boxes",
"messages.groupTitle": "Group Messages",
@ -557,6 +594,43 @@ const translations: Record<Language, Record<string, string>> = {
"channel.kind.forum": "超级群/论坛",
"channel.kind.megagroup": "超级群",
"channel.kind.generic": "频道/群",
"route.bots": "机器人",
"route.botsSubtitle": "控制台 / 机器人",
"layout.bots": "机器人",
"bots.pageTitle": "机器人",
"bots.queryResults": "查询结果",
"bots.recent": "最近创建的机器人",
"bots.currentPage": "当前页机器人",
"bots.banned": "已封禁",
"bots.active": "正常",
"bots.createTitle": "创建系统机器人",
"bots.createHint": "为指定用户创建机器人账号。确认后 token 只显示一次。",
"bots.ownerUserID": "所属用户 ID",
"bots.name": "显示名称",
"bots.namePlaceholder": "例如:服务机器人",
"bots.username": "用户名",
"bots.usernameHint": "用户名需 5-32 个字符,且以 bot 结尾。",
"bots.create": "创建机器人",
"bots.searchPlaceholder": "机器人 ID / 用户名",
"bots.botID": "机器人 ID",
"bots.owner": "所属用户",
"bots.status": "状态",
"bots.detailTitle": "机器人 #{id}",
"bots.profile": "机器人档案",
"bots.loadingDetail": "加载机器人详情",
"bots.unnamed": "未命名机器人",
"bots.restriction": "限制状态",
"bots.actionDock": "机器人操作",
"bots.banUntil": "封禁至",
"bots.ban": "封禁机器人",
"bots.updateBan": "更新封禁",
"bots.unban": "解封机器人",
"bots.type": "类型",
"bots.system": "系统",
"bots.user": "用户",
"bots.delete": "删除机器人",
"bots.deleteHint": "永久删除该用户创建的机器人并使其 token 失效。此操作不可撤销。",
"bots.systemHint": "系统内置机器人不可删除。",
"messages.privateTitle": "私聊消息",
"messages.privateEyebrow": "私聊消息盒",
"messages.groupTitle": "群聊消息",
@ -931,6 +1005,43 @@ const translations: Record<Language, Record<string, string>> = {
"channel.kind.forum": "Супергруппа / Форум",
"channel.kind.megagroup": "Супергруппа",
"channel.kind.generic": "Канал / Группа",
"route.bots": "Боты",
"route.botsSubtitle": "Консоль / Боты",
"layout.bots": "Боты",
"bots.pageTitle": "Боты",
"bots.queryResults": "Результаты поиска",
"bots.recent": "Недавно созданные боты",
"bots.currentPage": "Боты на странице",
"bots.banned": "Забанен",
"bots.active": "Активен",
"bots.createTitle": "Создать системного бота",
"bots.createHint": "Создаёт бота, принадлежащего указанному пользователю. Токен показывается один раз после подтверждения.",
"bots.ownerUserID": "ID владельца",
"bots.name": "Отображаемое имя",
"bots.namePlaceholder": "например, Service Bot",
"bots.username": "Имя пользователя",
"bots.usernameHint": "Имя пользователя: 532 символа, обязательно оканчивается на «bot».",
"bots.create": "Создать бота",
"bots.searchPlaceholder": "ID бота / имя пользователя",
"bots.botID": "ID бота",
"bots.owner": "Владелец",
"bots.status": "Статус",
"bots.detailTitle": "Бот #{id}",
"bots.profile": "Профиль бота",
"bots.loadingDetail": "Загрузка данных бота",
"bots.unnamed": "Без имени",
"bots.restriction": "Ограничение",
"bots.actionDock": "Действия с ботом",
"bots.banUntil": "Забанить до",
"bots.ban": "Забанить бота",
"bots.updateBan": "Обновить бан",
"bots.unban": "Разбанить бота",
"bots.type": "Тип",
"bots.system": "Системный",
"bots.user": "Пользовательский",
"bots.delete": "Удалить бота",
"bots.deleteHint": "Безвозвратно удаляет созданного пользователем бота и аннулирует его токен. Действие необратимо.",
"bots.systemHint": "Системные боты встроены и не могут быть удалены.",
"messages.privateTitle": "Личные сообщения",
"messages.privateEyebrow": "Личные ящики сообщений",
"messages.groupTitle": "Групповые сообщения",

View file

@ -0,0 +1,108 @@
import { ArrowLeft, BadgeCheck, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { useI18n } from "../i18n";
import { displayUsername, formatDate } from "../lib/format";
import type { Navigate } from "../routing";
import type { BotDetail } from "../types";
export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const { t } = useI18n();
const [detail, setDetail] = useState<BotDetail | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function load() {
setBusy(true);
setError("");
try {
setDetail(await api.bot(id));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load();
}, [id]);
if (error) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={busy ? t("bots.loadingDetail") : t("account.waitingData")} />;
}
const bot = detail.Bot;
return (
<PageFrame
title={t("bots.detailTitle", { id: bot.ID })}
eyebrow={t("bots.profile")}
actions={<button className="btn icon-text" onClick={() => navigate("/bots")}><ArrowLeft size={15} /> {t("common.backToList")}</button>}
>
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{bot.FirstName || t("bots.unnamed")}</div>
<div className="entity-subtitle">{displayUsername(bot.Username) || t("account.noUsername")}</div>
</div>
<div className="entity-badges">
<Badge tone={bot.System ? "warn" : "neutral"}>{bot.System ? t("bots.system") : t("bots.user")}</Badge>
{bot.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}
</div>
</section>
<div className="summary-grid">
<Summary label={t("bots.botID")} value={String(bot.ID)} mono />
<Summary label={t("bots.owner")} value={bot.OwnerUserID > 0 ? `${bot.OwnerUserID} ${displayUsername(detail.OwnerUsername)}`.trim() : t("common.none")} />
<Summary label={t("bots.type")} value={bot.System ? t("bots.system") : t("bots.user")} />
<Summary label={t("common.updatedAt")} value={formatDate(bot.UpdatedAt) || "-"} />
<Summary label={t("account.createdAt")} value={formatDate(bot.CreatedAt) || "-"} />
</div>
{detail.About && <p className="about-text">{detail.About}</p>}
{detail.Description && detail.Description.trim() !== detail.About.trim() && <p className="about-text">{detail.Description}</p>}
<section className="section-block">
<SectionHead title={t("account.recentAdminOps")} text={t("account.recent30Audit")} />
<AuditTable rows={detail.AuditLogs} />
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title">{t("bots.actionDock")}</div>
<div className="action-stack">
<ActionButton
label={bot.Verified ? t("account.clearVerified") : t("account.setVerified")}
icon={<BadgeCheck size={15} />}
tone="neutral"
path="/api/actions/set-verified"
payload={() => ({ user_id: bot.ID, verified: !bot.Verified })}
onDone={load}
/>
</div>
{bot.System ? (
<p className="bot-create-note">{t("bots.systemHint")}</p>
) : (
<div className="danger-zone">
<ActionButton
label={t("bots.delete")}
icon={<Trash2 size={15} />}
tone="danger"
path="/api/actions/delete-bot"
payload={() => ({ bot_user_id: bot.ID })}
onDone={() => navigate("/bots")}
/>
<p className="bot-create-note">{t("bots.deleteHint")}</p>
</div>
)}
</section>
}
/>
</PageFrame>
);
}

View file

@ -0,0 +1,167 @@
import { BadgeCheck, Bot, ChevronRight, Loader2, Plus, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import { displayUsername, formatDate, toInt } from "../lib/format";
import type { Navigate } from "../routing";
import type { BotListResponse } from "../types";
export function BotsPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [data, setData] = useState<BotListResponse | null>(null);
const [cursor, setCursor] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [ownerID, setOwnerID] = useState("");
const [botName, setBotName] = useState("");
const [botUsername, setBotUsername] = useState("");
async function load(next = false) {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit });
if (q.trim()) {
params.set("q", q.trim());
} else if (next) {
params.set("before_id", String(cursor));
}
try {
const result = await api.bots(params);
setData(result);
setCursor(result.next_before_id);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
}, []);
const rows = data?.rows ?? [];
const verified = rows.filter((row) => row.Verified).length;
const systemCount = rows.filter((row) => row.System).length;
return (
<PageFrame
title={t("bots.pageTitle")}
eyebrow={data?.listing === false ? t("bots.queryResults") : t("bots.recent")}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} /> {t("common.refresh")}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={t("bots.currentPage")} value={String(rows.length)} />
<Metric label={t("common.verified")} value={String(verified)} tone="good" />
<Metric label={t("bots.system")} value={String(systemCount)} />
</div>
<section className="section-block">
<div className="section-head">
<div>
<h2>{t("bots.createTitle")}</h2>
<p>{t("bots.createHint")}</p>
</div>
</div>
<div className="bot-create-fields">
<label className="duration-field">
<span>{t("bots.ownerUserID")}</span>
<input
value={ownerID}
onChange={(event) => setOwnerID(event.target.value)}
type="number"
min="1"
placeholder="123456789"
/>
</label>
<label className="duration-field">
<span>{t("bots.name")}</span>
<input value={botName} onChange={(event) => setBotName(event.target.value)} placeholder={t("bots.namePlaceholder")} maxLength={64} />
</label>
<label className="duration-field">
<span>{t("bots.username")}</span>
<input value={botUsername} onChange={(event) => setBotUsername(event.target.value)} placeholder="my_service_bot" />
</label>
</div>
<div className="bot-create-actions">
<span className="bot-create-note">{t("bots.usernameHint")}</span>
<ActionButton
label={t("bots.create")}
icon={<Plus size={15} />}
tone="neutral"
path="/api/actions/create-bot"
payload={() => ({
owner_user_id: toInt(ownerID),
name: botName.trim(),
username: botUsername.trim().replace(/^@/, "")
})}
onDone={() => load(false)}
/>
</div>
</section>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("bots.searchPlaceholder")} />
</label>
<label className="field-inline">
<span>{t("common.limit")}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
</button>
{data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} /> {t("messages.nextPage")}
</button>
)}
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("bots.botID")}</th>
<th>{t("common.username")}</th>
<th>{t("common.name")}</th>
<th>{t("bots.owner")}</th>
<th>{t("common.verified")}</th>
<th>{t("bots.type")}</th>
<th>{t("account.createdAt")}</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.ID}>
<td className="mono">{row.ID}</td>
<td>{displayUsername(row.Username) || "-"}</td>
<td>{row.FirstName || "-"}</td>
<td className="mono">{row.OwnerUserID > 0 ? row.OwnerUserID : "-"}</td>
<td>{row.Verified ? <Badge tone="good"><BadgeCheck size={12} /> {t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}</td>
<td>{row.System ? <Badge tone="warn">{t("bots.system")}</Badge> : <Badge>{t("bots.user")}</Badge>}</td>
<td>{formatDate(row.CreatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/bots/${row.ID}`)}><Bot size={14} /> {t("common.detail")} <ChevronRight size={14} /></button></td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
</PageFrame>
);
}

View file

@ -3,6 +3,8 @@ import { AccountDetailPage } from "./AccountDetailPage";
import { AccountsPage } from "./AccountsPage";
import { ChannelDetailPage } from "./ChannelDetailPage";
import { ChannelsPage } from "./ChannelsPage";
import { BotDetailPage } from "./BotDetailPage";
import { BotsPage } from "./BotsPage";
import { Dashboard } from "./Dashboard";
import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
import { GroupMessagesPage } from "./GroupMessagesPage";
@ -13,17 +15,24 @@ import { GiftsPage } from "./GiftsPage";
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1];
const botID = route.path.match(/^\/bots\/(\d+)$/)?.[1];
if (accountID) {
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
}
if (channelID) {
return <ChannelDetailPage id={Number(channelID)} navigate={navigate} />;
}
if (botID) {
return <BotDetailPage id={Number(botID)} navigate={navigate} />;
}
if (route.path === "/accounts") {
return <AccountsPage navigate={navigate} />;
}
if (route.path === "/channels") {
return <ChannelsPage navigate={navigate} />;
}
if (route.path === "/bots") {
return <BotsPage navigate={navigate} />;
}
if (route.path === "/gifts") {
return <GiftsPage />;

View file

@ -19,6 +19,7 @@ export function currentRoute(): RouteState {
export function routeTitle(pathname: string, t: TFunction): string {
if (pathname.startsWith("/accounts")) return t("route.accounts");
if (pathname.startsWith("/channels")) return t("route.channels");
if (pathname.startsWith("/bots")) return t("route.bots");
if (pathname.startsWith("/messages")) return t("route.messages");
if (pathname.startsWith("/gifts")) return t("route.gifts");
return t("route.dashboard");
@ -27,6 +28,7 @@ export function routeTitle(pathname: string, t: TFunction): string {
export function routeSubtitle(pathname: string, t: TFunction): string {
if (pathname.startsWith("/accounts")) return t("route.accountsSubtitle");
if (pathname.startsWith("/channels")) return t("route.channelsSubtitle");
if (pathname.startsWith("/bots")) return t("route.botsSubtitle");
if (pathname.startsWith("/messages")) return t("route.messagesSubtitle");
if (pathname.startsWith("/gifts")) return t("route.giftsSubtitle");
return t("route.dashboardSubtitle");

View file

@ -577,3 +577,40 @@ textarea:focus {
color: var(--muted);
text-align: center;
}
.bot-create-fields {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.bot-create-fields .duration-field input {
width: 100%;
}
.bot-create-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--line);
}
.bot-create-note {
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
@media (max-width: 760px) {
.bot-create-fields {
grid-template-columns: 1fr;
}
.bot-create-actions {
flex-direction: column;
align-items: stretch;
}
}

View file

@ -99,6 +99,25 @@ export type ChannelDetail = {
AuditLogs: AuditLogRow[];
};
export type BotRow = {
ID: number;
Username: string;
FirstName: string;
Verified: boolean;
System: boolean;
OwnerUserID: number;
CreatedAt: string;
UpdatedAt: string;
};
export type BotDetail = {
Bot: BotRow;
About: string;
Description: string;
OwnerUsername: string;
AuditLogs: AuditLogRow[];
};
export type MessageRow = {
OwnerUserID: number;
BoxID: number;
@ -285,6 +304,15 @@ export type ChannelListResponse = {
listing: boolean;
};
export type BotListResponse = {
query: string;
limit: number;
rows: BotRow[];
has_more: boolean;
next_before_id: number;
listing: boolean;
};
export type MessageListResponse = {
owner_user_id: number;
peer_id: number;