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:
parent
ad9d535edc
commit
9e45da69ef
22 changed files with 985 additions and 12 deletions
|
|
@ -232,6 +232,138 @@ LIMIT $5`, id, phone, phoneRaw, username, accountSearchLimit)
|
|||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type BotRow struct {
|
||||
ID int64
|
||||
Username string
|
||||
FirstName string
|
||||
Verified bool
|
||||
System bool
|
||||
OwnerUserID int64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type BotDetail struct {
|
||||
Bot BotRow
|
||||
About string
|
||||
Description string
|
||||
OwnerUsername string
|
||||
AuditLogs []AuditLogRow
|
||||
}
|
||||
|
||||
// ListBots pages over live bot accounts (users.is_bot, not tombstoned) by
|
||||
// descending id. Bots are excluded from ListAccounts, so this is the dedicated
|
||||
// projection for them.
|
||||
func (s *readStore) ListBots(ctx context.Context, beforeID int64, limit int) ([]BotRow, bool, error) {
|
||||
if limit <= 0 {
|
||||
limit = accountListDefaultLimit
|
||||
}
|
||||
if limit > accountListMaxLimit {
|
||||
limit = accountListMaxLimit
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT u.id, COALESCE(NULLIF(u.username, ''), p.username_lower, ''), u.first_name, u.verified,
|
||||
COALESCE(b.owner_user_id, 0), u.created_at, u.updated_at
|
||||
FROM users u
|
||||
LEFT JOIN bots b ON b.bot_user_id = u.id
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
|
||||
WHERE u.is_bot AND u.deleted_at IS NULL AND ($1::bigint = 0 OR u.id < $1)
|
||||
ORDER BY u.id DESC
|
||||
LIMIT $2`, beforeID, limit+1)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("list bots: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]BotRow, 0, limit+1)
|
||||
for rows.Next() {
|
||||
var item BotRow
|
||||
if err := rows.Scan(&item.ID, &item.Username, &item.FirstName, &item.Verified, &item.OwnerUserID, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
item.System = domain.IsSystemUserID(item.ID)
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
hasMore := len(out) > limit
|
||||
if hasMore {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, hasMore, nil
|
||||
}
|
||||
|
||||
func (s *readStore) SearchBots(ctx context.Context, q string) ([]BotRow, error) {
|
||||
q = strings.TrimSpace(q)
|
||||
if q == "" {
|
||||
return nil, nil
|
||||
}
|
||||
id := int64(-1)
|
||||
if n, err := strconv.ParseInt(q, 10, 64); err == nil {
|
||||
id = n
|
||||
}
|
||||
username := strings.ToLower(strings.TrimPrefix(q, "@"))
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT u.id, COALESCE(NULLIF(u.username, ''), p.username_lower, ''), u.first_name, u.verified,
|
||||
COALESCE(b.owner_user_id, 0), u.created_at, u.updated_at
|
||||
FROM users u
|
||||
LEFT JOIN bots b ON b.bot_user_id = u.id
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
|
||||
WHERE u.is_bot AND u.deleted_at IS NULL AND (u.id = $1 OR lower(u.username) = $2 OR p.username_lower = $2)
|
||||
ORDER BY u.id DESC
|
||||
LIMIT $3`, id, username, accountSearchLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search bots: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]BotRow, 0)
|
||||
for rows.Next() {
|
||||
var item BotRow
|
||||
if err := rows.Scan(&item.ID, &item.Username, &item.FirstName, &item.Verified, &item.OwnerUserID, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.System = domain.IsSystemUserID(item.ID)
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *readStore) BotDetail(ctx context.Context, botUserID int64) (BotDetail, error) {
|
||||
var out BotDetail
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT u.id, COALESCE(NULLIF(u.username, ''), p.username_lower, ''), u.first_name, u.about, u.verified,
|
||||
COALESCE(b.owner_user_id, 0), COALESCE(b.description, ''),
|
||||
u.created_at, u.updated_at
|
||||
FROM users u
|
||||
LEFT JOIN bots b ON b.bot_user_id = u.id
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
|
||||
WHERE u.id = $1 AND u.is_bot AND u.deleted_at IS NULL`, botUserID).Scan(
|
||||
&out.Bot.ID, &out.Bot.Username, &out.Bot.FirstName, &out.About, &out.Bot.Verified,
|
||||
&out.Bot.OwnerUserID, &out.Description, &out.Bot.CreatedAt, &out.Bot.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("get bot: %w", err)
|
||||
}
|
||||
out.Bot.System = domain.IsSystemUserID(out.Bot.ID)
|
||||
if out.Bot.OwnerUserID > 0 {
|
||||
var ownerUsername string
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(NULLIF(u.username, ''), p.username_lower, '')
|
||||
FROM users u
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
|
||||
WHERE u.id = $1`, out.Bot.OwnerUserID).Scan(&ownerUsername); err != nil && err != pgx.ErrNoRows {
|
||||
return out, fmt.Errorf("get bot owner: %w", err)
|
||||
} else {
|
||||
out.OwnerUsername = ownerUsername
|
||||
}
|
||||
}
|
||||
out.AuditLogs, err = s.auditLogs(ctx, botUserID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *readStore) SearchChannels(ctx context.Context, q string) ([]ChannelRow, error) {
|
||||
q = strings.TrimSpace(q)
|
||||
if q == "" {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("GET /api/accounts/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountDetailAPI)))
|
||||
mux.Handle("GET /api/channels", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI)))
|
||||
mux.Handle("GET /api/channels/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleChannelDetailAPI)))
|
||||
mux.Handle("GET /api/bots", s.requireAuthAPI(http.HandlerFunc(s.handleBotsAPI)))
|
||||
mux.Handle("GET /api/bots/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleBotDetailAPI)))
|
||||
mux.Handle("GET /api/messages", s.requireAuthAPI(http.HandlerFunc(s.handleMessagesAPI)))
|
||||
mux.Handle("GET /api/messages/detail", s.requireAuthAPI(http.HandlerFunc(s.handleMessageDetailAPI)))
|
||||
mux.Handle("GET /api/messages/groups", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
|
||||
|
|
@ -67,6 +69,8 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/actions/grant-premium", s.requireAuthAPI(http.HandlerFunc(s.handleGrantPremiumAPI)))
|
||||
mux.Handle("POST /api/actions/grant-stars", s.requireAuthAPI(http.HandlerFunc(s.handleGrantStarsAPI)))
|
||||
mux.Handle("POST /api/actions/set-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetVerifiedAPI)))
|
||||
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/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)))
|
||||
|
|
@ -346,6 +350,108 @@ func (s *server) handleAccountDetailAPI(w http.ResponseWriter, r *http.Request)
|
|||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
func (s *server) handleBotsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
q := r.URL.Query().Get("q")
|
||||
beforeID, _ := parseInt64(r.URL.Query().Get("before_id"))
|
||||
limit, _ := parseInt(r.URL.Query().Get("limit"))
|
||||
rows := []BotRow{}
|
||||
hasMore := false
|
||||
var err error
|
||||
if strings.TrimSpace(q) != "" {
|
||||
rows, err = s.read.SearchBots(r.Context(), q)
|
||||
} else {
|
||||
rows, hasMore, err = s.read.ListBots(r.Context(), beforeID, limit)
|
||||
}
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
nextBeforeID := int64(0)
|
||||
if hasMore && len(rows) > 0 {
|
||||
nextBeforeID = rows[len(rows)-1].ID
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = accountListDefaultLimit
|
||||
}
|
||||
if limit > accountListMaxLimit {
|
||||
limit = accountListMaxLimit
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"query": q,
|
||||
"limit": limit,
|
||||
"rows": rows,
|
||||
"has_more": hasMore,
|
||||
"next_before_id": nextBeforeID,
|
||||
"listing": strings.TrimSpace(q) == "",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleBotDetailAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
botID, err := parseInt64(r.PathValue("id"))
|
||||
if err != nil || botID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
detail, err := s.read.BotDetail(r.Context(), botID)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
type createBotAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
OwnerUserID int64 `json:"owner_user_id"`
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
func (s *server) handleCreateBotAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body createBotAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.CreateBotRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "create-bot"),
|
||||
OwnerUserID: body.OwnerUserID,
|
||||
Name: body.Name,
|
||||
Username: body.Username,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/bots/create", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type deleteBotAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
BotUserID int64 `json:"bot_user_id"`
|
||||
}
|
||||
|
||||
func (s *server) handleDeleteBotAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body deleteBotAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.DeleteBotRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "delete-bot"),
|
||||
BotUserID: body.BotUserID,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/bots/delete", 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")
|
||||
|
|
|
|||
9
cmd/telesrv-admin/web/dist/assets/index-BB8hN3NX.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-BB8hN3NX.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cmd/telesrv-admin/web/dist/assets/index-BlWlOvtx.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-BlWlOvtx.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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
|
|
@ -21,8 +21,8 @@
|
|||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-CqgHld2y.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DuOdm70q.css">
|
||||
<script type="module" crossorigin src="/assets/index-BB8hN3NX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BlWlOvtx.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": "Имя пользователя: 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": "Безвозвратно удаляет созданного пользователем бота и аннулирует его токен. Действие необратимо.",
|
||||
"bots.systemHint": "Системные боты встроены и не могут быть удалены.",
|
||||
"messages.privateTitle": "Личные сообщения",
|
||||
"messages.privateEyebrow": "Личные ящики сообщений",
|
||||
"messages.groupTitle": "Групповые сообщения",
|
||||
|
|
|
|||
108
cmd/telesrv-admin/web/src/pages/BotDetailPage.tsx
Normal file
108
cmd/telesrv-admin/web/src/pages/BotDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
167
cmd/telesrv-admin/web/src/pages/BotsPage.tsx
Normal file
167
cmd/telesrv-admin/web/src/pages/BotsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue