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

@ -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 == "" {

View file

@ -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")

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

File diff suppressed because one or more lines are too long

View file

@ -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>

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;

View file

@ -921,6 +921,7 @@ func run(logger *zap.Logger) error {
ChannelNotifier: router,
Messages: messagesService,
Gifts: giftsService,
Bots: botsService,
})
// bot session 撤销、在线通知与 @ChatBot 流式草稿推送经 router 实现(需 tg.* 边界),
// router 创建后注入。

View file

@ -32,6 +32,8 @@ const (
ActionPublishGiftCollectibles = "gifts.collectibles.publish"
ActionSetStarGiftEnabled = "gifts.set_enabled"
ActionSetStarGiftSortOrder = "gifts.set_sort_order"
ActionCreateBot = "bot.create"
ActionDeleteBot = "bot.delete"
maxCommandIDLength = 128
maxActorLength = 128
@ -127,6 +129,14 @@ type OfficialGiftsSource interface {
Bundle(ctx context.Context, giftID int64, includeCollectible bool) (officialgifts.Bundle, error)
}
// BotService creates bot accounts on behalf of the admin. It mirrors the
// owner-scoped /newbot flow: a bot is a users row (is_bot=true) plus a bots row
// owned by ownerUserID, and the returned token is shown once to the operator.
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)
}
type Dependencies struct {
Commands CommandRepository
Restrictions RestrictionStore
@ -142,6 +152,7 @@ type Dependencies struct {
Messages MessagesService
Gifts GiftsService
OfficialGifts OfficialGiftsSource
Bots BotService
Now func() time.Time
}
@ -160,6 +171,7 @@ type Service struct {
messages MessagesService
gifts GiftsService
officialGifts OfficialGiftsSource
bots BotService
now func() time.Time
}
@ -211,6 +223,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
if deps.OfficialGifts != nil {
s.officialGifts = deps.OfficialGifts
}
if deps.Bots != nil {
s.bots = deps.Bots
}
if deps.Now != nil {
s.now = deps.Now
}
@ -346,6 +361,18 @@ type SetChannelVerifiedRequest struct {
Verified bool `json:"verified"`
}
type CreateBotRequest struct {
CommandMeta
OwnerUserID int64 `json:"owner_user_id"`
Name string `json:"name"`
Username string `json:"username"`
}
type DeleteBotRequest struct {
CommandMeta
BotUserID int64 `json:"bot_user_id"`
}
type RevokeSessionsRequest struct {
CommandMeta
UserID int64 `json:"user_id"`
@ -691,6 +718,93 @@ func (s *Service) SetVerified(ctx context.Context, req SetVerifiedRequest) (Comm
})
}
// CreateBot provisions a new bot account owned by ownerUserID. The dry-run stage
// only validates the display name and username; the confirm stage creates the
// users+bots rows and returns the freshly minted token in the result details so
// the operator can copy it once.
func (s *Service) CreateBot(ctx context.Context, req CreateBotRequest) (CommandResult, error) {
if s == nil || s.bots == nil {
return CommandResult{}, fmt.Errorf("admin bot dependency is not configured")
}
if req.OwnerUserID <= 0 {
return CommandResult{}, fmt.Errorf("owner_user_id is required")
}
name := strings.TrimSpace(req.Name)
if name == "" || len([]rune(name)) > domain.MaxBotNameLength {
return CommandResult{}, domain.ErrBotNameInvalid
}
username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@"))
if !domain.ValidBotUsername(username) {
return CommandResult{}, domain.ErrBotUsernameInvalid
}
req.Name = name
req.Username = username
return s.runCommand(ctx, req.CommandMeta, ActionCreateBot, req.OwnerUserID, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{
"owner_user_id": req.OwnerUserID,
"name": name,
"username": username,
}
if req.DryRun {
return CommandResult{Message: "bot creation validated", Details: details}, nil
}
bot, token, err := s.bots.CreateBot(ctx, req.OwnerUserID, name, username)
if err != nil {
return CommandResult{Details: details}, err
}
details["bot_user_id"] = bot.ID
// The token is a credential. It is surfaced once so the operator can copy
// it; it is also persisted in the audit result, so treat admin audit logs
// as sensitive.
details["token"] = token
if err := s.notifyUserChanged(ctx, bot); err != nil {
details["notify_error"] = err.Error()
}
return CommandResult{Message: "bot created", Details: details}, nil
})
}
// DeleteBot permanently removes a user-created bot. The dry-run stage verifies
// the target is a non-system bot; the confirm stage tombstones the account and
// invalidates its token. System bots are rejected outright.
func (s *Service) DeleteBot(ctx context.Context, req DeleteBotRequest) (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 cannot be deleted")
}
return s.runCommand(ctx, req.CommandMeta, ActionDeleteBot, req.BotUserID, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"bot_user_id": req.BotUserID}
if s.users != nil {
u, found, err := s.users.AdminUser(ctx, req.BotUserID)
if err != nil {
return CommandResult{}, err
}
if !found || !u.Bot {
return CommandResult{}, domain.ErrBotNotFound
}
details["username"] = u.Username
details["name"] = u.FirstName
}
if req.DryRun {
return CommandResult{Message: "bot deletion validated", Details: details}, nil
}
deleted, err := s.bots.DeleteBot(ctx, req.BotUserID)
if err != nil {
return CommandResult{Details: details}, err
}
details["deleted"] = true
if err := s.notifyUserChanged(ctx, deleted); err != nil {
details["notify_error"] = err.Error()
}
return CommandResult{Message: "bot deleted", Details: details}, nil
})
}
func (s *Service) SetChannelVerified(ctx context.Context, req SetChannelVerifiedRequest) (CommandResult, error) {
if req.ChannelID <= 0 {
return CommandResult{}, fmt.Errorf("channel_id is required")

View file

@ -30,6 +30,8 @@ type Service interface {
GrantStars(ctx context.Context, req admin.GrantStarsRequest) (admin.CommandResult, error)
SetVerified(ctx context.Context, req admin.SetVerifiedRequest) (admin.CommandResult, error)
SetChannelVerified(ctx context.Context, req admin.SetChannelVerifiedRequest) (admin.CommandResult, error)
CreateBot(ctx context.Context, req admin.CreateBotRequest) (admin.CommandResult, error)
DeleteBot(ctx context.Context, req admin.DeleteBotRequest) (admin.CommandResult, error)
RevokeSessions(ctx context.Context, req admin.RevokeSessionsRequest) (admin.CommandResult, error)
DeletePrivateMessages(ctx context.Context, req admin.DeletePrivateMessagesRequest) (admin.CommandResult, error)
DeletePrivateHistory(ctx context.Context, req admin.DeletePrivateHistoryRequest) (admin.CommandResult, error)
@ -97,6 +99,8 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /v1/accounts/set-verified", s.authenticated(s.handleSetVerified))
mux.HandleFunc("POST /v1/accounts/revoke-sessions", s.authenticated(s.handleRevokeSessions))
mux.HandleFunc("POST /v1/channels/set-verified", s.authenticated(s.handleSetChannelVerified))
mux.HandleFunc("POST /v1/bots/create", s.authenticated(s.handleCreateBot))
mux.HandleFunc("POST /v1/bots/delete", s.authenticated(s.handleDeleteBot))
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))
@ -168,6 +172,24 @@ func (s *Server) handleSetChannelVerified(w http.ResponseWriter, r *http.Request
writeCommandResult(w, result, err)
}
func (s *Server) handleCreateBot(w http.ResponseWriter, r *http.Request) {
var req admin.CreateBotRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.CreateBot(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleDeleteBot(w http.ResponseWriter, r *http.Request) {
var req admin.DeleteBotRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.DeleteBot(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) {

View file

@ -250,6 +250,14 @@ func (fakeService) SetChannelVerified(_ context.Context, req admin.SetChannelVer
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) CreateBot(_ context.Context, req admin.CreateBotRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) DeleteBot(_ context.Context, req admin.DeleteBotRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RevokeSessions(context.Context, admin.RevokeSessionsRequest) (admin.CommandResult, error) {
return admin.CommandResult{}, nil
}

View file

@ -448,6 +448,42 @@ func (s *Service) ListOwnedBots(ctx context.Context, ownerUserID int64) ([]domai
return out, nil
}
// botAccountDeleter is the optional store capability used to permanently delete
// a user-created bot. Only the Postgres store implements it, so the memory store
// and other BotStore mocks are unaffected.
type botAccountDeleter interface {
DeleteBotAccount(ctx context.Context, botUserID int64) (domain.User, error)
}
// DeleteBot permanently removes a user-created bot. System service bots are
// rejected. Live sessions are dropped and the bot's caches are invalidated so
// the deletion is visible immediately. Returns the tombstoned user.
func (s *Service) DeleteBot(ctx context.Context, botUserID int64) (domain.User, error) {
if s == nil || s.bots == nil || botUserID == 0 {
return domain.User{}, domain.ErrBotNotFound
}
if domain.IsSystemUserID(botUserID) {
return domain.User{}, domain.ErrBotNotFound
}
deleter, ok := s.bots.(botAccountDeleter)
if !ok {
return domain.User{}, fmt.Errorf("bot deletion is not supported by the configured store")
}
// Drop live sessions up front so the token stops working even if a caller
// races the tombstone; DeleteBotAccount also revokes the authorization rows.
if s.hooks != nil {
if err := s.hooks.RevokeBotSessions(ctx, botUserID); err != nil {
s.log.Warn("revoke bot sessions before delete", zap.Int64("bot_user_id", botUserID), zap.Error(err))
}
}
u, err := deleter.DeleteBotAccount(ctx, botUserID)
if err != nil {
return domain.User{}, err
}
s.invalidateBotReadCaches(ctx, botUserID)
return u, nil
}
// ExportBotToken 返回 bot tokenrevoke=true 时先轮换 secret 并撤销已登录 session。
func (s *Service) ExportBotToken(ctx context.Context, ownerUserID, botUserID int64, revoke bool) (string, error) {
if revoke {

View file

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
@ -87,6 +88,91 @@ func (s *BotStore) CreateBotAccount(ctx context.Context, user domain.User, profi
return userFromModel(row), profile, nil
}
// DeleteBotAccount permanently removes a user-created bot in one transaction:
// it revokes the bot's sessions, purges its private state, releases its
// username, drops the bots row (which invalidates the token) and tombstones the
// users row. System service bots and non-bot users are rejected. The reused
// helpers are the same vetted primitives that back account deletion, so the
// tombstone satisfies users_deletion_state_check. Returns the tombstoned user
// for change notifications.
func (s *BotStore) DeleteBotAccount(ctx context.Context, botUserID int64) (domain.User, error) {
if botUserID == 0 || domain.IsSystemUserID(botUserID) {
return domain.User{}, domain.ErrBotNotFound
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.User{}, fmt.Errorf("delete bot account: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.User{}, fmt.Errorf("delete bot account: begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err := lockUsersForUpdate(ctx, tx, botUserID); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: lock: %w", err)
}
u, found, err := NewUserStore(tx).ByID(ctx, botUserID)
if err != nil {
return domain.User{}, err
}
if !found || !u.Bot || u.Deleted {
return domain.User{}, domain.ErrBotNotFound
}
// Only bots backed by a bots row (created via /newbot or the admin) are
// deletable here; system service bots are already excluded above.
var hasBotRow bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bots WHERE bot_user_id = $1)`, botUserID).Scan(&hasBotRow); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: probe bots row: %w", err)
}
if !hasBotRow {
return domain.User{}, domain.ErrBotNotFound
}
now := time.Now().UTC()
if err := enqueueAccountDeletionNotifications(ctx, tx, botUserID); err != nil {
return domain.User{}, err
}
if _, err := revokeByUserExceptTx(ctx, tx, botUserID, 0); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: revoke sessions: %w", err)
}
if err := purgeDeletedAccountPrivateState(ctx, tx, botUserID, now); err != nil {
return domain.User{}, err
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, botUserID, ""); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: release username: %w", err)
}
// Drop the bots row so the token can no longer authenticate a login.
if _, err := tx.Exec(ctx, `DELETE FROM bots WHERE bot_user_id = $1`, botUserID); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: delete bots row: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE users SET
phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '',
verified = false, support = false, last_seen_at = 0,
premium_expires_at = NULL, emoji_status_document_id = 0, emoji_status_until = 0,
emoji_status_collectible_id = NULL, emoji_status_collectible = '{}'::jsonb,
color_set = false, color = 0, color_background_emoji_id = 0,
profile_color_set = false, profile_color = 0, profile_color_background_emoji_id = 0,
birthday_day = 0, birthday_month = 0, birthday_year = 0, personal_channel_id = 0,
deleted_at = $2, deletion_source = 'manual', deletion_reason = 'admin bot deletion',
account_delete_at = NULL, updated_at = $2
WHERE id = $1 AND deleted_at IS NULL`, botUserID, now); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: tombstone: %w", err)
}
u, found, err = NewUserStore(tx).ByID(ctx, botUserID)
if err != nil || !found {
if err == nil {
err = domain.ErrUserNotFound
}
return domain.User{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: commit: %w", err)
}
return u, nil
}
func (s *BotStore) GetBot(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) {
if botUserID == 0 {
return domain.BotProfile{}, false, nil