import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { addPersonaToLibrary, addStickerToLibrary, approvePeer, approveSticker, clearMemories, clearMessages, clearPrimaryBind, createBindCode, blockUser, deleteMemory, countPendingStickers, createAppSession, createLocalUser, createPersona, createInviteCode, consumeInviteCode, peekInviteCode, listPendingInvites, revokeInviteCode, getInviteSettings, setInviteSettings, getInviteQuotaStatus, forkPersona, createSticker, deleteBotAccount, deleteUserAccount, deleteSticker, destroyAppSession, destroyAllSessionsForUser, doctorSnapshot, ensureStickerContentHash, getAppSession, getAssignmentsMany, getBindByUser, getBotAccount, getBotAccountsByIds, getContextToken, getPersona, getPersonaBySlug, getPersonaLibraryIdSet, getPersonasByIds, getPublishedPrompt, getPublishedPromptsMany, getSticker, getStickerBlob, getStickerBySlug, getStickerLibraryIdSet, getUsageDayStats, dayKey, dayKeyOffset, getUser, getUserByUsername, getUsersByIds, isInPersonaLibrary, isInPersonaLibraryLocal, isInStickerLibrary, isValidStickerSlug, listAuditLogs, listBlockedUserIds, listBotAccounts, hasBotCredentials, hasBotCredentialsMany, listBotsByOwner, countBotsByOwners, countUserMessages, listMemories, listMemoriesMany, listPeers, listPeersForBots, listPersonas, listRecentMessages, listPersonasByOwner, listStickers, listStickersByOwner, listUserPersonaLibrary, listUserStickerLibrary, countUsers, listUsers, isSuperAdmin, resolveSuperAdminId, peerStatsByBots, publishPersonaVersion, rejectSticker, removePersonaFromLibrary, removeStickerFromLibrary, replaceStickerBlob, restorePersona, restoreSticker, setDefaultPersona, saveOauthState, searchPublicPersonas, searchPublicStickers, seedPersonas, setAssignment, setUserAdmin, setUserBanned, isUserBanned, userPublicFields, validateLocalUsername, hashPassword, verifyPassword, assertPasswordPolicy, softDeletePersona, softDeleteSticker, takeOauthState, setBotStatus, setPeerProactiveEnabled, unblockUser, updateBotDisplayName, updateBotProactiveSettings, updatePersonaMeta, updateStickerMeta, userCanUsePersona, upsertUser, writeAudit, cancelBroadcastJob, createBroadcastJob, getBroadcastJob, listBroadcastJobs, listBotSendTargets, previewBroadcast, forceOfflineWorker, clearWorkerFence, listWorkerFences, listLeasedBots, publishWorkerWake, listWorkerWeights, setWorkerWeight, clearWorkerWeight, pruneWorkerWeights, parseWorkerWeightInput, DEFAULT_WORKER_WEIGHT, MIN_WORKER_WEIGHT, MAX_WORKER_WEIGHT, listPollableBotIds, getCurrentRelease, getReleaseMeta, listReleaseVersions, publishRelease, setCurrentRelease, buildReleaseMeta, putBlobChunks, blobExists, enqueueWorkerUpdate, getWorkerUpdateStatus, getWorkerUpdateStatuses, releaseSummary, sha256Buffer, type BroadcastScope, type BroadcastTarget, type Db, type Persona, type Sticker, type User, type ReleaseFileEntry, personaHeatScore, createLlmProvider, updateLlmProvider, deleteLlmProvider, listLlmProvidersByOwner, getLlmProvider, toPublicProvider, getPublishedGraph, } from "@wechat-ai/db"; import { mergeBotProactiveConfig, TryChatError, TryChatService, createDefaultChatflowGraph, validateChatflowGraph, ChatflowError, type ChatService, } from "@wechat-ai/core"; import { probeToolsHealth } from "@wechat-ai/llm"; import type { BotWorkerManager } from "./worker.js"; import type { BotLoginSessionManager } from "./bot-login-sessions.js"; import type { AppConfig } from "./config.js"; import { buildAuthorizeUrl, exchangeCode, fetchUserInfo, loadLinuxDoConfig, newOAuthState, } from "./oauth-linuxdo.js"; import { decodeBase64Image, makeStickerFileName, } from "./sticker-store.js"; import { assertSafeStickerImage, StickerSecurityError, } from "./sticker-security.js"; import { CC_CDN_STICKER, CC_NO_STORE, CC_PRIVATE_NO_STORE, CC_PRIVATE_QR, CC_PRIVATE_STICKER, CDN_CDN_STICKER, etagFromHash, ifNoneMatchHits, setPrivateNoStore, setPublicCache, } from "./cache-headers.js"; import { qrSvg } from "./qrcode.js"; import { RateLimiter } from "./rate-limit.js"; import { RuntimeSettingsUnavailableError } from "./runtime-config.js"; export interface RouteContext { db: Db; chat: ChatService; tryChat: TryChatService; worker: BotWorkerManager; loginSessions: BotLoginSessionManager; cfg: AppConfig; /** Admin live activity stream (optional when DATA_STREAM_ENABLED=false) */ activityBus?: import("./activity-stream.js").ActivityBus | null; /** Redis-backed runtime config overrides (env supplies the defaults) */ settings?: import("./runtime-config.js").RuntimeConfigManager | null; } function personaPublicDto( p: Persona, extra?: Record, ): Record { const forkedFrom = p.forked_from_id ? { id: p.forked_from_id, slug: p.forked_from_slug ?? null, displayName: p.forked_from_name ?? null, } : null; return { id: p.id, slug: p.slug, displayName: p.display_name, description: p.description, tags: p.tags, visibility: p.visibility, ownerUserId: p.owner_user_id, useCount: p.use_count || 0, assignCount: p.assign_count || 0, forkCount: p.fork_count || 0, heatScore: personaHeatScore(p), forkedFrom, mode: p.mode === "chatflow" ? "chatflow" : "prompt", llmProviderId: p.llm_provider_id ?? null, webSearchEnabled: Boolean(p.web_search_enabled), updatedAt: p.updated_at, ...extra, }; } function parseCookie(header: string | undefined): Record { const out: Record = {}; if (!header) return out; for (const part of header.split(";")) { const i = part.indexOf("="); if (i < 0) continue; const k = part.slice(0, i).trim(); const v = part.slice(i + 1).trim(); out[k] = decodeURIComponent(v); } return out; } function setSessionCookie( reply: FastifyReply, cfg: AppConfig, sid: string, ): void { const maxAge = 7 * 24 * 3600; const secure = cfg.cookieSecure ? "; Secure" : ""; reply.header( "Set-Cookie", `${cfg.sessionCookieName}=${encodeURIComponent(sid)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`, ); } function clearSessionCookie(reply: FastifyReply, cfg: AppConfig): void { reply.header( "Set-Cookie", `${cfg.sessionCookieName}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`, ); } async function requireUser( req: FastifyRequest, reply: FastifyReply, ctx: RouteContext, ): Promise { const cookies = parseCookie(req.headers.cookie); const sid = cookies[ctx.cfg.sessionCookieName]; if (!sid) { reply.code(401).send({ error: "login_required" }); return null; } const sess = await getAppSession(ctx.db, sid); if (!sess) { reply.code(401).send({ error: "session_expired" }); return null; } const user = await getUser(ctx.db, sess.userId); if (!user) { reply.code(401).send({ error: "user_not_found" }); return null; } if (isUserBanned(user)) { await destroyAppSession(ctx.db, sid); clearSessionCookie(reply, ctx.cfg); reply.code(403).send({ error: "user_banned", message: user.banned_reason || "账号已被封禁", }); return null; } return user; } function clientIp(req: FastifyRequest): string { return ( (req.headers["cf-connecting-ip"] as string | undefined) || (req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0] ?.trim() || req.ip || "unknown" ); } function inviteDefaultsFromCfg(cfg: AppConfig) { return { quotaWindowHours: cfg.inviteQuotaWindowHours, quotaMax: cfg.inviteQuotaMax, codeTtlSec: cfg.inviteCodeTtlSec, maxPendingPerUser: cfg.inviteMaxPendingPerUser, codeLength: cfg.inviteCodeLength, }; } function inviteUrl(cfg: AppConfig, code: string): string { const base = cfg.publicBaseUrl.replace(/\/$/, ""); return `${base}/app?invite=${encodeURIComponent(code)}`; } async function requireAdmin( req: FastifyRequest, reply: FastifyReply, ctx: RouteContext, ): Promise { const user = await requireUser(req, reply, ctx); if (!user) return null; if (!user.is_admin) { reply.code(403).send({ error: "admin_required" }); return null; } return user; } /** First system admin only (earliest created_at among is_admin users). */ async function requireSuperAdmin( req: FastifyRequest, reply: FastifyReply, ctx: RouteContext, ): Promise { const admin = await requireAdmin(req, reply, ctx); if (!admin) return null; if (!(await isSuperAdmin(ctx.db, admin.id))) { reply.code(403).send({ error: "super_admin_required", message: "仅系统首位管理员(超管)可执行此操作", }); return null; } return admin; } /** * Send non-public sticker bytes (own / pending / admin review) with ETag * revalidation. Sticker blobs are content-addressed, so a matching * If-None-Match lets us answer 304 *without* pulling the blob out of Redis — * these endpoints back thumbnail grids, so that is the whole page's cost. */ async function sendPrivateStickerImage( ctx: RouteContext, req: FastifyRequest, reply: FastifyReply, sticker: Sticker, ): Promise { let s = sticker; if (!s.content_hash) { s = (await ensureStickerContentHash(ctx.db, s)) ?? s; } const etag = s.content_hash ? etagFromHash(s.content_hash) : null; reply.header("Cache-Control", CC_PRIVATE_STICKER); if (etag) { reply.header("ETag", etag); if (ifNoneMatchHits(req.headers["if-none-match"], etag)) { return reply.code(304).send(); } } const buf = await getStickerBlob(ctx.db, s.id); if (!buf) return reply.code(404).send({ error: "blob missing" }); return reply.type(s.mime || "application/octet-stream").send(buf); } /** Parse comma-separated type filters: message,redis,worker,llm or exact types. */ function parseStreamTypeFilter(raw?: string): Set | null { if (!raw?.trim()) return null; const parts = raw .split(",") .map((s) => s.trim().toLowerCase()) .filter(Boolean); if (!parts.length) return null; return new Set(parts); } function streamTypeMatches(type: string, filter: Set): boolean { const t = type.toLowerCase(); if (filter.has(t)) return true; // group prefixes: message → message.in / message.out for (const f of filter) { if (t === f || t.startsWith(f + ".")) return true; } return false; } /** * Shape event for client: short preview by default; full=1 uses fullText (cap 2k). * Worker emits both preview (48) and fullText (≤2000). */ function shapeStreamEventForClient( ev: import("./activity-stream.js").StreamEvent, full: boolean, ): import("./activity-stream.js").StreamEvent { if (!ev.data || !ev.type.startsWith("message.")) { if (!ev.data) return ev; // Strip fullText from non-message if ever present if ("fullText" in ev.data || "text" in ev.data) { const data = { ...ev.data }; delete data.fullText; delete data.text; return { ...ev, data }; } return ev; } const data = { ...ev.data }; const short = typeof data.preview === "string" ? data.preview : ""; const long = typeof data.fullText === "string" ? data.fullText : typeof data.text === "string" ? data.text : short; const len = typeof data.len === "number" ? data.len : Math.max(short.length, long.length); const chosen = full ? long : short || long.slice(0, 48); const cap = full ? 2000 : 48; let preview = chosen; let truncated = len > chosen.length || !!data.truncated; if (preview.length > cap) { preview = preview.slice(0, cap) + "…"; truncated = true; } data.preview = preview; data.len = len; data.truncated = truncated; delete data.fullText; delete data.text; return { ...ev, data }; } export async function registerRoutes( app: FastifyInstance, ctx: RouteContext, ): Promise { // CORS only on /api/v1 — never on HTML shells (keeps CF cache clean) app.addHook("onRequest", async (req, reply) => { const path = req.url.split("?")[0] || ""; if (!path.startsWith("/api/v1")) return; const origin = req.headers.origin; if (origin && ctx.cfg.corsOrigins.has(origin)) { reply.header("Access-Control-Allow-Origin", origin); reply.header("Access-Control-Allow-Credentials", "true"); reply.header("Vary", "Origin"); } reply.header( "Access-Control-Allow-Headers", "Authorization, Content-Type", ); reply.header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS"); if (req.method === "OPTIONS") { return reply.code(204).send(); } }); // Default: private APIs must not be shared-cached app.addHook("onSend", async (req, reply, payload) => { const path = req.url.split("?")[0] || ""; if (!path.startsWith("/api/v1")) return payload; if (!reply.getHeader("cache-control") && !reply.getHeader("Cache-Control")) { reply.header("Cache-Control", CC_PRIVATE_NO_STORE); } return payload; }); app.get("/health", async (_req, reply) => { reply.header("Cache-Control", CC_NO_STORE); return { ok: true, service: "wechat-ai" }; }); /** Cache tools /health so readiness probes stay cheap (TTL 15s). */ let toolsHealthCache: { at: number; ok: boolean } | null = null; let toolsHealthInflight: Promise<{ ok: boolean }> | null = null; const TOOLS_HEALTH_TTL_MS = 15_000; const cachedToolsHealth = async ( baseUrl: string, ): Promise<{ ok: boolean }> => { const now = Date.now(); if (toolsHealthCache && now - toolsHealthCache.at < TOOLS_HEALTH_TTL_MS) { return { ok: toolsHealthCache.ok }; } if (toolsHealthInflight) return toolsHealthInflight; toolsHealthInflight = probeToolsHealth(baseUrl, 4000) .then((r) => { toolsHealthCache = { at: Date.now(), ok: r.ok }; return { ok: r.ok }; }) .catch(() => { toolsHealthCache = { at: Date.now(), ok: false }; return { ok: false }; }) .finally(() => { toolsHealthInflight = null; }); return toolsHealthInflight; }; /** LB / CF Worker readiness: Redis + process identity (short timeout on client). */ app.get("/health/ready", async (_req, reply) => { reply.header("Cache-Control", CC_NO_STORE); const workerId = ctx.worker.getWorkerId(); // LB probes hit this constantly — run the three checks concurrently. // Tools gateway is the only egress for user custom LLM + web search; // cachedToolsHealth keeps probes off HF. const workerRunning = ctx.worker.isRunning(); const [redisOk, tools] = await Promise.all([ ctx.db.ping().then( () => true, () => false, ), ctx.cfg.toolsBaseUrl ? cachedToolsHealth(ctx.cfg.toolsBaseUrl) : Promise.resolve(null), ]); // Required only when the deployment actually depends on tools const toolsRequired = Boolean(ctx.cfg.toolsBaseUrl) && ctx.cfg.webSearchEnabled; const ok = redisOk && (!toolsRequired || tools?.ok === true); const body = { ok, service: "wechat-ai", redis: redisOk, workerId, workerRunning, workerEnabled: ctx.cfg.workerEnabled, tools: ctx.cfg.toolsBaseUrl ? { configured: true, ok: tools?.ok === true, required: toolsRequired } : { configured: false, ok: false, required: false }, }; if (!ok) return reply.code(503).send(body); return body; }); // Public CDN: approved + public + enabled stickers only (no cookie) const cdnLimiter = new RateLimiter(120, 60_000); app.get<{ Params: { id: string }; Querystring: { v?: string } }>( "/cdn/s/:id", async (req, reply) => { const ip = (req.headers["cf-connecting-ip"] as string | undefined) || req.ip || "unknown"; if (!cdnLimiter.tryTake(`cdn:${ip}`)) { return reply.code(429).send({ error: "rate limited" }); } // Strip accidental extension from id (e.g. sticker_xxx.png) let id = req.params.id || ""; id = id.replace(/\.(png|jpe?g|gif|webp|bin)$/i, ""); if (!id) return reply.code(404).send({ error: "not found" }); let s = await getSticker(ctx.db, id); if ( !s || !s.enabled || s.visibility !== "public" || s.review_status !== "approved" ) { return reply.code(404).send({ error: "not found" }); } if (!s.content_hash) { s = (await ensureStickerContentHash(ctx.db, s)) ?? s; } // Answer conditional requests from the content hash alone — no reason to // pull the blob out of Redis just to throw it away on a 304. if (s.content_hash) { const etag = etagFromHash(s.content_hash); setPublicCache(reply, CC_CDN_STICKER, CDN_CDN_STICKER, { etag, cacheTag: `sticker-${s.id}`, }); if (ifNoneMatchHits(req.headers["if-none-match"], etag)) { return reply.code(304).send(); } const buf = await getStickerBlob(ctx.db, s.id); if (!buf) return reply.code(404).send({ error: "not found" }); return reply.type(s.mime || "application/octet-stream").send(buf); } // No hash (blob missing on migrate) — fall back to length-based ETag const buf = await getStickerBlob(ctx.db, s.id); if (!buf) return reply.code(404).send({ error: "not found" }); const etag = etagFromHash(String(buf.length)); setPublicCache(reply, CC_CDN_STICKER, CDN_CDN_STICKER, { etag, cacheTag: `sticker-${s.id}`, }); if (ifNoneMatchHits(req.headers["if-none-match"], etag)) { return reply.code(304).send(); } return reply.type(s.mime || "application/octet-stream").send(buf); }, ); // ── Auth (LINUX DO OAuth + local password + invites) ── const authLoginLimiter = new RateLimiter(20, 60_000); const authRegisterLimiter = new RateLimiter(10, 60_000); const authInvitePeekLimiter = new RateLimiter(40, 60_000); app.get("/api/v1/auth/config", async (_req, reply) => { setPrivateNoStore(reply); const oauth = loadLinuxDoConfig(); return { oauthEnabled: Boolean(oauth) && ctx.cfg.linuxdoAuthEnabled, provider: "linux.do", localAuthEnabled: ctx.cfg.localAuthEnabled, inviteRequiredForLocal: ctx.cfg.inviteRequiredForLocal, passwordMinLength: ctx.cfg.passwordMinLength, }; }); app.get("/api/v1/auth/login", async (req, reply) => { const oauth = loadLinuxDoConfig(); if (!ctx.cfg.linuxdoAuthEnabled) { return reply .code(503) .send({ error: "LINUX DO 登录已关闭(LINUXDO_AUTH_ENABLED=false)" }); } if (!oauth) { return reply .code(503) .send({ error: "LINUX DO OAuth 未配置(LINUXDO_CLIENT_ID/SECRET/REDIRECT_URI)" }); } const state = newOAuthState(); const q = req.query as { redirect?: string }; await saveOauthState(ctx.db, state, { redirect: q.redirect || "/app" }); return reply.redirect(buildAuthorizeUrl(oauth, state)); }); app.get("/api/v1/auth/callback", async (req, reply) => { const oauth = loadLinuxDoConfig(); if (!ctx.cfg.linuxdoAuthEnabled) { return reply.code(503).type("text/plain; charset=utf-8").send("LINUX DO 登录已关闭"); } if (!oauth) return reply.code(503).send("oauth not configured"); const q = req.query as { code?: string; state?: string; error?: string }; if (q.error) return reply.code(400).send(`oauth error: ${q.error}`); if (!q.code || !q.state) return reply.code(400).send("missing code/state"); const st = await takeOauthState(ctx.db, q.state); if (!st) return reply.code(400).send("invalid or expired state"); try { const token = await exchangeCode(oauth, q.code); const info = await fetchUserInfo(oauth, token.access_token); const user = await upsertUser( ctx.db, { id: String(info.id), username: info.username, name: info.name || info.username, avatarUrl: info.avatar_url ?? null, trustLevel: info.trust_level ?? 0, authProvider: "linuxdo", }, ctx.cfg.adminIds, { firstUserIsAdmin: ctx.cfg.firstUserIsAdmin, }, ); if (isUserBanned(user)) { return reply .code(403) .type("text/plain; charset=utf-8") .send( `账号已被封禁${user.banned_reason ? `:${user.banned_reason}` : ""}`, ); } const sid = await createAppSession(ctx.db, user.id); setSessionCookie(reply, ctx.cfg, sid); await writeAudit(ctx.db, "user_login", user.id, { username: user.username, is_admin: user.is_admin, method: "oauth", }); const dest = st.redirect?.startsWith("/") ? st.redirect : "/app"; return reply.redirect(dest); } catch (err) { const msg = err instanceof Error ? err.message : String(err); return reply.code(502).type("text/plain").send(`OAuth failed: ${msg}`); } }); app.post<{ Body: { username?: string; password?: string; inviteCode?: string; name?: string; }; }>("/api/v1/auth/register", async (req, reply) => { if (!ctx.cfg.localAuthEnabled) { return reply.code(503).send({ error: "local_auth_disabled" }); } const ip = clientIp(req); if (!authRegisterLimiter.tryTake(`reg:${ip}`)) { return reply.code(429).send({ error: "rate limited" }); } const usernameRaw = String(req.body?.username || ""); const password = String(req.body?.password || ""); const inviteCode = String(req.body?.inviteCode || ""); const name = req.body?.name ? String(req.body.name).trim() : undefined; let username: string; try { username = validateLocalUsername(usernameRaw); assertPasswordPolicy(password, ctx.cfg.passwordMinLength); } catch (e) { const msg = e instanceof Error ? e.message : "invalid"; if (msg === "weak_password") { return reply.code(400).send({ error: "weak_password", message: `密码至少 ${ctx.cfg.passwordMinLength} 位`, }); } if (msg === "reserved_username") { return reply.code(400).send({ error: "reserved_username", message: "用户名不可用" }); } return reply.code(400).send({ error: "invalid_username", message: "用户名须以字母开头,3–32 位字母/数字/下划线", }); } // Unauthenticated endpoint — never scan the user table here. SCARD is O(1) // and only feeds the `is this the very first user` check. const [settings, totalUsers] = await Promise.all([ getInviteSettings(ctx.db, inviteDefaultsFromCfg(ctx.cfg)), countUsers(ctx.db), ]); const needInvite = ctx.cfg.inviteRequiredForLocal; const bootstrap = totalUsers === 0 && ctx.cfg.firstUserIsAdmin && ctx.cfg.adminIds.size === 0; let inviteRec: Awaited> = null; if (needInvite && !bootstrap) { inviteRec = await peekInviteCode(ctx.db, inviteCode); if (!inviteRec) { return reply.code(400).send({ error: "invalid_invite", message: "邀请码无效或已使用", }); } } const passwordHash = await hashPassword(password); let user; try { // Create user first (claims username). Consume invite after; roll back if race. try { user = await createLocalUser( ctx.db, { username, passwordHash, name, invitedBy: inviteRec?.inviterUserId ?? null, inviteCodeUsed: inviteRec?.code ?? null, }, ctx.cfg.adminIds, { firstUserIsAdmin: ctx.cfg.firstUserIsAdmin }, ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg === "username_taken") { return reply.code(409).send({ error: "username_taken", message: "用户名已被占用", }); } if (msg === "invalid_username" || msg === "reserved_username") { return reply.code(400).send({ error: msg, message: msg === "reserved_username" ? "用户名不可用" : "用户名须以字母开头,3–32 位字母/数字/下划线", }); } throw err; } if (needInvite && !bootstrap) { const consumed = await consumeInviteCode(ctx.db, inviteCode, user.id); if (!consumed) { await deleteUserAccount(ctx.db, user.id); return reply.code(400).send({ error: "invalid_invite", message: "邀请码无效或已使用", }); } } } catch (err) { const msg = err instanceof Error ? err.message : String(err); return reply.code(400).send({ error: msg }); } const sid = await createAppSession(ctx.db, user.id); setSessionCookie(reply, ctx.cfg, sid); await writeAudit(ctx.db, "user_register", user.id, { username: user.username, method: "password", invitedBy: user.invited_by, inviteCode: user.invite_code_used, }); return { user: userPublicFields(user) }; }); app.post<{ Body: { username?: string; password?: string } }>( "/api/v1/auth/password-login", async (req, reply) => { if (!ctx.cfg.localAuthEnabled) { return reply.code(503).send({ error: "local_auth_disabled" }); } const ip = clientIp(req); const usernameRaw = String(req.body?.username || "").trim(); const password = String(req.body?.password || ""); const unameKey = usernameRaw.toLowerCase() || "-"; if ( !authLoginLimiter.tryTake(`login:${ip}`) || !authLoginLimiter.tryTake(`loginu:${unameKey}`) ) { return reply.code(429).send({ error: "rate limited" }); } const user = usernameRaw ? await getUserByUsername(ctx.db, usernameRaw) : undefined; const hash = user?.password_hash || null; // Always verify to reduce timing gap when hash exists; dummy when missing let ok = false; if (hash) { ok = await verifyPassword(password, hash); } else { // burn some CPU with a failed verify against a fixed-format dummy await verifyPassword( password || "x", "scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", ); ok = false; } if (!ok || !user) { return reply.code(401).send({ error: "invalid_credentials" }); } if (isUserBanned(user)) { return reply.code(403).send({ error: "user_banned", message: user.banned_reason || "账号已被封禁", }); } const sid = await createAppSession(ctx.db, user.id); setSessionCookie(reply, ctx.cfg, sid); await writeAudit(ctx.db, "user_login", user.id, { username: user.username, method: "password", }); return { user: userPublicFields(user) }; }, ); app.get<{ Params: { code: string } }>( "/api/v1/auth/invite/:code", async (req, reply) => { const ip = clientIp(req); if (!authInvitePeekLimiter.tryTake(`invpeek:${ip}`)) { return reply.code(429).send({ error: "rate limited" }); } const rec = await peekInviteCode(ctx.db, req.params.code); if (!rec) { return { valid: false }; } return { valid: true, expiresAt: rec.expiresAt, inviterUsername: rec.inviterUsername, }; }, ); app.post("/api/v1/auth/logout", async (req, reply) => { const cookies = parseCookie(req.headers.cookie); const sid = cookies[ctx.cfg.sessionCookieName]; if (sid) await destroyAppSession(ctx.db, sid); clearSessionCookie(reply, ctx.cfg); return { ok: true }; }); app.get("/api/v1/auth/me", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const superAdmin = user.is_admin ? await isSuperAdmin(ctx.db, user.id) : false; return { user: { ...userPublicFields(user), isSuperAdmin: superAdmin, }, }; }); // ── Me: invites ─────────────────────────────────────── app.get("/api/v1/me/invites", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const settings = await getInviteSettings( ctx.db, inviteDefaultsFromCfg(ctx.cfg), ); const [items, quota] = await Promise.all([ listPendingInvites(ctx.db, user.id), getInviteQuotaStatus(ctx.db, user.id, settings), ]); return { items: items.map((i) => ({ code: i.code, createdAt: i.createdAt, expiresAt: i.expiresAt, inviteUrl: inviteUrl(ctx.cfg, i.code), })), quota: { used: quota.used, max: Number.isFinite(quota.max) ? quota.max : null, windowHours: quota.windowHours, remaining: Number.isFinite(quota.remaining) ? quota.remaining : null, retryAfterSec: quota.retryAfterSec, }, settings: { maxPendingPerUser: settings.maxPendingPerUser, codeTtlSec: settings.codeTtlSec, }, }; }); app.post("/api/v1/me/invites", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const settings = await getInviteSettings( ctx.db, inviteDefaultsFromCfg(ctx.cfg), ); try { const result = await createInviteCode(ctx.db, { inviterUserId: user.id, inviterUsername: user.username, settings, }); if (!result.ok) { if (result.error === "invite_quota") { return reply.code(429).send({ error: "invite_quota", message: "邀请生成次数已达上限,请稍后再试", used: result.quota?.used, max: result.quota?.max, windowHours: result.quota?.windowHours, retryAfterSec: result.quota?.retryAfterSec, }); } return reply.code(400).send({ error: "invite_pending_limit", message: `未使用邀请码过多(最多 ${result.maxPending} 个)`, maxPending: result.maxPending, }); } await writeAudit(ctx.db, "invite_created", user.id, { code: result.invite.code, }); return { code: result.invite.code, createdAt: result.invite.createdAt, expiresAt: result.invite.expiresAt, inviteUrl: inviteUrl(ctx.cfg, result.invite.code), quota: { used: result.quota.used, max: Number.isFinite(result.quota.max) ? result.quota.max : null, windowHours: result.quota.windowHours, remaining: Number.isFinite(result.quota.remaining) ? result.quota.remaining : null, retryAfterSec: result.quota.retryAfterSec, }, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return reply.code(500).send({ error: message }); } }); app.delete<{ Params: { code: string } }>( "/api/v1/me/invites/:code", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const ok = await revokeInviteCode(ctx.db, user.id, req.params.code); if (!ok) return reply.code(404).send({ error: "not found" }); await writeAudit(ctx.db, "invite_revoked", user.id, { code: req.params.code, }); return { ok: true }; }, ); // ── WeChat ↔ LINUX DO bind (for @username P2P) ───── app.get("/api/v1/me/wechat-bind", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; if (!ctx.cfg.p2pEnabled) { return { enabled: false, bound: false }; } const bind = await getBindByUser(ctx.db, user.id); if (!bind) { return { enabled: true, bound: false, username: user.username, }; } const ctxToken = await getContextToken(ctx.db, bind.botId, bind.peerId); return { enabled: true, bound: true, username: user.username, botId: bind.botId, peerId: bind.peerId, boundAt: bind.boundAt, reachable: Boolean(ctxToken), }; }); app.post("/api/v1/me/wechat-bind/code", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; if (!ctx.cfg.p2pEnabled) { return reply.code(503).send({ error: "p2p_disabled" }); } try { const rec = await createBindCode( ctx.db, user.id, user.username, ctx.cfg.p2pBindCodeTtlSec, ); await writeAudit(ctx.db, "wechat_bind_code", user.id, { codePrefix: rec.code.slice(0, 2), }); return { code: rec.code, expiresInSec: ctx.cfg.p2pBindCodeTtlSec, username: user.username, instruction: `在微信中给机器人发送:/绑定 ${rec.code}`, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return reply.code(500).send({ error: message }); } }); app.delete("/api/v1/me/wechat-bind", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const prev = await clearPrimaryBind(ctx.db, user.id); await writeAudit(ctx.db, "wechat_bind_clear", user.id, { hadBind: Boolean(prev), botId: prev?.botId, peerId: prev?.peerId, }); return { ok: true, cleared: Boolean(prev) }; }); // ── P2P block list ─────────────────────────────────── app.get("/api/v1/me/blocks", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; if (!ctx.cfg.p2pEnabled) { return { enabled: false, blocks: [] }; } const ids = await listBlockedUserIds(ctx.db, user.id); const map = await getUsersByIds(ctx.db, ids); const blocks = ids.map((id) => { const u = map.get(id); return { userId: id, username: u?.username ?? null, name: u?.name ?? null, avatarUrl: u?.avatar_url ?? null, }; }); return { enabled: true, blocks }; }); app.post<{ Body: { username?: string; userId?: string } }>( "/api/v1/me/blocks", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; if (!ctx.cfg.p2pEnabled) { return reply.code(503).send({ error: "p2p_disabled" }); } const username = (req.body?.username || "").trim(); let targetId = (req.body?.userId || "").trim(); let targetUser = targetId ? await getUser(ctx.db, targetId) : undefined; if (!targetUser && username) { targetUser = await getUserByUsername(ctx.db, username); targetId = targetUser?.id || ""; } if (!targetUser || !targetId) { return reply.code(404).send({ error: "user_not_found" }); } const r = await blockUser(ctx.db, user.id, targetId); if (!r.ok && r.reason === "self") { return reply.code(400).send({ error: "cannot_block_self" }); } await writeAudit(ctx.db, "p2p_block", user.id, { blockedUserId: targetId, username: targetUser.username, already: r.ok === false && r.reason === "already", }); return { ok: true, already: r.ok === false && r.reason === "already", blocked: { userId: targetUser.id, username: targetUser.username, name: targetUser.name, }, }; }, ); app.delete<{ Params: { userId: string } }>( "/api/v1/me/blocks/:userId", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const targetId = req.params.userId; if (!targetId) { return reply.code(400).send({ error: "userId required" }); } const removed = await unblockUser(ctx.db, user.id, targetId); await writeAudit(ctx.db, "p2p_unblock", user.id, { blockedUserId: targetId, removed, }); return { ok: true, removed }; }, ); // ── User bots ──────────────────────────────────────── function proactiveDefaults() { return { idleHours: ctx.cfg.proactiveIdleHours, minIntervalHours: ctx.cfg.proactiveMinIntervalHours, maxPerDay: ctx.cfg.proactiveMaxPerDay, quietHours: ctx.cfg.proactiveQuietHours, }; } function mapBotProactive(b: { proactive_enabled?: number; proactive_idle_hours?: number; proactive_min_interval_hours?: number; proactive_max_per_day?: number; proactive_quiet_hours?: string | null; }) { const merged = mergeBotProactiveConfig(b, proactiveDefaults()); return { globalProactiveEnabled: ctx.cfg.proactiveEnabled, proactiveEnabled: merged.enabled, proactiveIdleHours: merged.idleHours, proactiveMinIntervalHours: merged.minIntervalHours, proactiveMaxPerDay: merged.maxPerDay, proactiveQuietHours: merged.quietHours, }; } app.get("/api/v1/me/bots", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const bots = await listBotsByOwner(ctx.db, user.id); const tokenMap = await hasBotCredentialsMany( ctx.db, bots.map((b) => b.id), ); const mapped = bots.map((b) => ({ id: b.id, displayName: b.display_name, accountRef: b.account_ref, status: b.status, ownerUserId: b.owner_user_id, hasToken: Boolean(tokenMap[b.id]), ...mapBotProactive(b), })); return { bots: mapped }; }); app.post<{ Body: { displayName?: string } }>( "/api/v1/me/bots/login/start", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; try { const session = await ctx.loginSessions.start( user.id, req.body?.displayName, ); return { session }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return reply.code(502).send({ error: message }); } }, ); /** Re-scan QR to refresh token for an existing bot (keeps peers / memories / assignments). */ app.post<{ Params: { botId: string } }>( "/api/v1/me/bots/:botId/relogin/start", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const bot = await getBotAccount(ctx.db, req.params.botId); if (!bot) return reply.code(404).send({ error: "not found" }); if (bot.owner_user_id !== user.id && !user.is_admin) { return reply.code(403).send({ error: "forbidden" }); } try { // Session owner = acting user (so poll/cancel ACL works for owner or admin) const session = await ctx.loginSessions.start( user.id, bot.display_name, { rebindBotId: bot.id }, ); return { session }; } catch (err) { const message = err instanceof Error ? err.message : String(err); if (/not found/i.test(message)) { return reply.code(404).send({ error: message }); } return reply.code(502).send({ error: message }); } }, ); app.get<{ Params: { sessionId: string } }>( "/api/v1/me/bots/login/:sessionId", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const session = await ctx.loginSessions.get(req.params.sessionId); if (!session || session.ownerUserId !== user.id) { return reply.code(404).send({ error: "session not found" }); } return { session }; }, ); /** * Login QR as SVG, rendered on this box. * * The scan link embeds a login ticket. It used to be handed to * api.qrserver.com as a query parameter to get a QR image back, which put a * credential in a third party's logs. Rendering locally keeps it between this * origin and the owner's browser. Served as its own resource (rather than * inlined in the 1.5s status poll) so the browser fetches it once. */ app.get<{ Params: { sessionId: string } }>( "/api/v1/me/bots/login/:sessionId/qr.svg", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const session = await ctx.loginSessions.get(req.params.sessionId); if (!session || session.ownerUserId !== user.id) { return reply.code(404).send({ error: "session not found" }); } if (!session.openUrl) { return reply.code(409).send({ error: "qr not ready" }); } let svg: string; try { svg = qrSvg(session.openUrl, { ec: "M", border: 4, pixelSize: 200 }); } catch (err) { req.log?.warn( { err, sessionId: session.sessionId }, "qr render failed", ); return reply.code(500).send({ error: "qr render failed" }); } reply.header("Content-Type", "image/svg+xml; charset=utf-8"); // Ticket-bearing and per-owner: never shared, never stored. The client // fetches this once per login session, so no-store costs nothing. reply.header("Cache-Control", CC_PRIVATE_QR); return reply.send(svg); }, ); app.post<{ Params: { sessionId: string } }>( "/api/v1/me/bots/login/:sessionId/cancel", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const ok = await ctx.loginSessions.cancel(req.params.sessionId, user.id); if (!ok) return reply.code(404).send({ error: "session not found" }); return { ok: true }; }, ); app.patch<{ Params: { botId: string }; Body: { displayName?: string; proactiveEnabled?: boolean; proactiveIdleHours?: number; proactiveMinIntervalHours?: number; proactiveMaxPerDay?: number; proactiveQuietHours?: string | null; }; }>("/api/v1/me/bots/:botId", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const bot = await getBotAccount(ctx.db, req.params.botId); if (!bot) return reply.code(404).send({ error: "not found" }); if (bot.owner_user_id !== user.id && !user.is_admin) { return reply.code(403).send({ error: "forbidden" }); } const body = req.body ?? {}; const hasName = body.displayName !== undefined; const hasProactive = body.proactiveEnabled !== undefined || body.proactiveIdleHours !== undefined || body.proactiveMinIntervalHours !== undefined || body.proactiveMaxPerDay !== undefined || body.proactiveQuietHours !== undefined; if (!hasName && !hasProactive) { return reply.code(400).send({ error: "displayName or proactive settings required", }); } try { let updated = bot; if (hasName) { if (!body.displayName?.trim()) { return reply.code(400).send({ error: "displayName required" }); } updated = await updateBotDisplayName( ctx.db, bot.id, body.displayName, ); await writeAudit(ctx.db, "bot_renamed", user.id, { botId: bot.id, displayName: updated.display_name, }); } if (hasProactive) { updated = await updateBotProactiveSettings(ctx.db, bot.id, { proactiveEnabled: body.proactiveEnabled, proactiveIdleHours: body.proactiveIdleHours, proactiveMinIntervalHours: body.proactiveMinIntervalHours, proactiveMaxPerDay: body.proactiveMaxPerDay, proactiveQuietHours: body.proactiveQuietHours, }); await writeAudit(ctx.db, "bot_proactive_updated", user.id, { botId: bot.id, proactiveEnabled: updated.proactive_enabled, proactiveIdleHours: updated.proactive_idle_hours, proactiveMinIntervalHours: updated.proactive_min_interval_hours, proactiveMaxPerDay: updated.proactive_max_per_day, proactiveQuietHours: updated.proactive_quiet_hours, }); } return { bot: { id: updated.id, displayName: updated.display_name, accountRef: updated.account_ref, status: updated.status, ownerUserId: updated.owner_user_id, ...mapBotProactive(updated), }, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return reply.code(400).send({ error: message }); } }); app.delete<{ Params: { botId: string } }>( "/api/v1/me/bots/:botId", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const bot = await getBotAccount(ctx.db, req.params.botId); if (!bot) return reply.code(404).send({ error: "not found" }); if (bot.owner_user_id !== user.id && !user.is_admin) { return reply.code(403).send({ error: "forbidden" }); } // deleteBotAccount also removes Redis creds (wa:bot:{id}:creds) await deleteBotAccount(ctx.db, bot.id); ctx.worker.stopBot(bot.id); await writeAudit(ctx.db, "bot_deleted", user.id, { botId: bot.id }); return { ok: true }; }, ); // Peers for my bots app.get("/api/v1/me/peers", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const botId = (req.query as { botId?: string }).botId; const myBots = await listBotsByOwner(ctx.db, user.id); const botIds = new Set(myBots.map((b) => b.id)); if (botId && !botIds.has(botId) && !user.is_admin) { return reply.code(403).send({ error: "forbidden" }); } const peers = botId ? await listPeers(ctx.db, botId) : await listPeersForBots(ctx.db, [...botIds]); const asgMap = await getAssignmentsMany( ctx.db, peers.map((p) => ({ botAccountId: p.bot_account_id, peerId: p.peer_id, })), ); const personaIds = [ ...new Set( peers .map((p) => asgMap.get(`${p.bot_account_id}|${p.peer_id}`)) .filter((id): id is string => Boolean(id)), ), ]; const personaMap = await getPersonasByIds(ctx.db, personaIds); const enriched = peers.map((p) => { const personaId = asgMap.get(`${p.bot_account_id}|${p.peer_id}`) ?? null; const persona = personaId ? personaMap.get(personaId) : undefined; return { ...p, personaId, personaSlug: persona?.slug ?? null, personaName: persona?.display_name ?? null, proactiveEnabled: Boolean(p.proactive_enabled), lastActivityAt: p.last_activity_at ?? null, lastProactiveAt: p.last_proactive_at ?? null, }; }); return { peers: enriched, globalProactiveEnabled: ctx.cfg.proactiveEnabled, }; }); app.patch<{ Body: { botAccountId: string; peerId: string; enabled: boolean }; }>("/api/v1/me/peers/proactive", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const { botAccountId, peerId, enabled } = req.body ?? {}; if (!botAccountId || !peerId || typeof enabled !== "boolean") { return reply .code(400) .send({ error: "botAccountId, peerId, enabled required" }); } const bot = await getBotAccount(ctx.db, botAccountId); if (!bot || (bot.owner_user_id !== user.id && !user.is_admin)) { return reply.code(403).send({ error: "forbidden" }); } try { const peer = await setPeerProactiveEnabled( ctx.db, botAccountId, peerId, enabled, ); await writeAudit(ctx.db, "peer_proactive_toggled", user.id, { botAccountId, peerId, enabled, }); return { peer: { ...peer, proactiveEnabled: Boolean(peer.proactive_enabled), }, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return reply.code(400).send({ error: message }); } }); app.post<{ Body: { botAccountId: string; peerId: string; personaId?: string }; }>("/api/v1/me/peers/approve", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const { botAccountId, peerId, personaId } = req.body ?? {}; if (!botAccountId || !peerId) { return reply.code(400).send({ error: "botAccountId and peerId required" }); } const bot = await getBotAccount(ctx.db, botAccountId); if (!bot || (bot.owner_user_id !== user.id && !user.is_admin)) { return reply.code(403).send({ error: "forbidden" }); } const peer = await approvePeer(ctx.db, botAccountId, peerId); if (personaId) await setAssignment(ctx.db, botAccountId, peerId, personaId); await writeAudit(ctx.db, "peer_approved", user.id, { botAccountId, peerId, }); return { peer }; }); app.put<{ Body: { botAccountId: string; peerId: string; personaId: string }; }>("/api/v1/me/assignments", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const { botAccountId, peerId, personaId } = req.body ?? {}; if (!botAccountId || !peerId || !personaId) { return reply.code(400).send({ error: "missing fields" }); } const bot = await getBotAccount(ctx.db, botAccountId); if (!bot || (bot.owner_user_id !== user.id && !user.is_admin)) { return reply.code(403).send({ error: "forbidden" }); } if (!(await userCanUsePersona(ctx.db, user.id, personaId))) { return reply .code(403) .send({ error: "persona_not_in_library", message: "请先添加人设,或人设已私有" }); } await setAssignment(ctx.db, botAccountId, peerId, personaId); return { ok: true }; }); /** @deprecated use /me/personas — kept for compatibility, returns library */ app.get("/api/v1/personas", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const personas = await listUserPersonaLibrary(ctx.db, user.id); const prompts = await getPublishedPromptsMany(ctx.db, personas); return { personas: personas.map((p) => ({ ...p, systemPromptPreview: (prompts.get(p.id) ?? "").slice(0, 200), })), }; }); // ── Persona Square ─────────────────────────────────── app.get("/api/v1/square/personas", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const q = req.query as { q?: string; page?: string; limit?: string; sort?: string; }; const limit = Math.min(Math.max(Number(q.limit ?? "20") || 20, 1), 50); const page = Math.max(Number(q.page ?? "1") || 1, 1); const offset = (page - 1) * limit; const sort = q.sort === "recent" || q.sort === "name" || q.sort === "use" || q.sort === "heat" ? q.sort : "heat"; const { items, total } = await searchPublicPersonas(ctx.db, { q: q.q, limit, offset, sort, }); const [libIds, prompts] = await Promise.all([ getPersonaLibraryIdSet(ctx.db, user.id), getPublishedPromptsMany(ctx.db, items), ]); const personas = items.map((p) => personaPublicDto(p, { inLibrary: isInPersonaLibraryLocal(p, user.id, libIds), systemPromptPreview: (prompts.get(p.id) ?? "").slice(0, 160), }), ); return { personas, total, page, limit }; }); app.get<{ Params: { id: string } }>( "/api/v1/square/personas/:id", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const p = await getPersona(ctx.db, req.params.id); if (!p || !p.enabled) { return reply.code(404).send({ error: "not found" }); } if (p.visibility === "private" && p.owner_user_id !== user.id) { return reply.code(403).send({ error: "private" }); } const [prompt, graph, inLibrary] = await Promise.all([ getPublishedPrompt(ctx.db, p.id), getPublishedGraph(ctx.db, p.id), isInPersonaLibrary(ctx.db, user.id, p.id), ]); return { persona: personaPublicDto(p, { systemPrompt: prompt, graph: graph ?? null, inLibrary, }), }; }, ); /** GET chatflow graph (owner or public read for try/editor load). */ app.get<{ Params: { id: string } }>( "/api/v1/square/personas/:id/graph", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const p = await getPersona(ctx.db, req.params.id); if (!p || !p.enabled) { return reply.code(404).send({ error: "not found" }); } const canRead = p.owner_user_id === user.id || user.is_admin || (p.visibility === "public" && p.enabled); if (!canRead) { return reply.code(403).send({ error: "forbidden" }); } const stored = await getPublishedGraph(ctx.db, p.id); const graph = stored ?? createDefaultChatflowGraph(); return { personaId: p.id, mode: p.mode === "chatflow" ? "chatflow" : "prompt", graph, isDefault: !stored, editable: p.owner_user_id === user.id || Boolean(user.is_admin), }; }, ); /** PUT chatflow graph (owner only). Also switches mode to chatflow. */ app.put<{ Params: { id: string }; Body: { graph?: unknown; systemPrompt?: string; mode?: "prompt" | "chatflow" }; }>("/api/v1/square/personas/:id/graph", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const p = await getPersona(ctx.db, req.params.id); if (!p) return reply.code(404).send({ error: "not found" }); if (p.owner_user_id !== user.id && !user.is_admin) { return reply.code(403).send({ error: "forbidden" }); } const body = req.body ?? {}; try { const graph = body.graph ?? createDefaultChatflowGraph(); validateChatflowGraph(graph, { maxNodes: ctx.cfg.chatflowMaxNodes, }); const prompt = body.systemPrompt?.trim() || (await getPublishedPrompt(ctx.db, p.id)) || "你是一个有帮助的助手。"; const persona = await updatePersonaMeta(ctx.db, p.id, { systemPrompt: prompt, graphJson: graph, mode: body.mode === "prompt" ? "prompt" : "chatflow", }); await writeAudit(ctx.db, "persona_graph_saved", user.id, { id: p.id, }); return { persona: personaPublicDto(persona), graph, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); const code = err instanceof ChatflowError ? 400 : message.includes("not found") ? 404 : 400; return reply.code(code).send({ error: message }); } }); app.post<{ Params: { id: string }; Body: { displayName?: string }; }>("/api/v1/square/personas/:id/fork", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; if (!ctx.cfg.personaForkEnabled) { return reply.code(503).send({ error: "fork_disabled", message: "人设改编已关闭" }); } try { const { persona, systemPrompt } = await forkPersona(ctx.db, { sourceId: req.params.id, ownerUserId: user.id, displayName: req.body?.displayName, allowPrivateSource: Boolean(user.is_admin), }); await writeAudit(ctx.db, "persona_forked", user.id, { sourceId: req.params.id, personaId: persona.id, }); return { persona: personaPublicDto(persona, { systemPrompt }), systemPrompt, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("not found")) { return reply.code(404).send({ error: message }); } if (message.includes("private")) { return reply.code(403).send({ error: message }); } return reply.code(400).send({ error: message }); } }); app.post<{ Body: { displayName: string; description?: string; systemPrompt: string; visibility?: "public" | "private"; tags?: string[]; mode?: "prompt" | "chatflow"; llmProviderId?: string | null; webSearchEnabled?: boolean; }; }>("/api/v1/square/personas", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const body = req.body ?? {}; if (!body.displayName?.trim() || !body.systemPrompt?.trim()) { return reply .code(400) .send({ error: "displayName and systemPrompt required" }); } try { if (body.llmProviderId) { const prov = await getLlmProvider(ctx.db, body.llmProviderId); if (!prov || prov.owner_user_id !== user.id) { return reply.code(400).send({ error: "invalid llmProviderId" }); } } const persona = await createPersona(ctx.db, { displayName: body.displayName, description: body.description, systemPrompt: body.systemPrompt, visibility: body.visibility === "private" ? "private" : "public", tags: body.tags, ownerUserId: user.id, mode: body.mode === "chatflow" ? "chatflow" : "prompt", llmProviderId: body.llmProviderId ?? null, webSearchEnabled: Boolean(body.webSearchEnabled), }); await writeAudit(ctx.db, "persona_published_square", user.id, { id: persona.id, visibility: persona.visibility, }); return { persona: personaPublicDto(persona) }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return reply.code(400).send({ error: message }); } }); app.put<{ Params: { id: string }; Body: { displayName?: string; description?: string; tags?: string[]; visibility?: "public" | "private"; systemPrompt?: string; mode?: "prompt" | "chatflow"; llmProviderId?: string | null; webSearchEnabled?: boolean; }; }>("/api/v1/square/personas/:id", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const p = await getPersona(ctx.db, req.params.id); if (!p) return reply.code(404).send({ error: "not found" }); if (p.owner_user_id !== user.id && !user.is_admin) { return reply.code(403).send({ error: "forbidden" }); } try { const body = req.body ?? {}; if (body.llmProviderId) { const prov = await getLlmProvider(ctx.db, body.llmProviderId); if (!prov || prov.owner_user_id !== user.id) { return reply.code(400).send({ error: "invalid llmProviderId" }); } } const persona = await updatePersonaMeta(ctx.db, p.id, { displayName: body.displayName, description: body.description, tags: body.tags, visibility: body.visibility, systemPrompt: body.systemPrompt, mode: body.mode, llmProviderId: body.llmProviderId, webSearchEnabled: body.webSearchEnabled, }); return { persona: personaPublicDto(persona) }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return reply.code(400).send({ error: message }); } }); app.delete<{ Params: { id: string } }>( "/api/v1/square/personas/:id", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const p = await getPersona(ctx.db, req.params.id); if (!p) return reply.code(404).send({ error: "not found" }); if (p.owner_user_id === "system") { return reply.code(403).send({ error: "cannot delete system persona" }); } if (p.owner_user_id !== user.id && !user.is_admin) { return reply.code(403).send({ error: "forbidden" }); } await softDeletePersona(ctx.db, p.id); await writeAudit(ctx.db, "persona_soft_deleted", user.id, { id: p.id }); return { ok: true }; }, ); // ── User custom LLM providers (egress via HF tools only) ── app.get("/api/v1/me/llm-providers", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const secret = ctx.cfg.llmProviderSecret; if (!secret) { return { providers: [], configured: false, error: "LLM_PROVIDER_SECRET not configured on server", }; } const rows = await listLlmProvidersByOwner(ctx.db, user.id); return { configured: true, providers: rows.map((r) => toPublicProvider(r, secret)), toolsGateway: Boolean(ctx.cfg.toolsBaseUrl), webSearchEnabled: ctx.cfg.webSearchEnabled, }; }); app.post<{ Body: { name?: string; baseUrl?: string; apiKey?: string; defaultModel?: string; }; }>("/api/v1/me/llm-providers", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const secret = ctx.cfg.llmProviderSecret; if (!secret) { return reply .code(503) .send({ error: "LLM_PROVIDER_SECRET not configured on server" }); } if (!ctx.cfg.toolsBaseUrl || !ctx.cfg.toolsApiKey) { return reply.code(503).send({ error: "TOOLS_BASE_URL / TOOLS_API_KEY required for user custom LLM (HF tools)", }); } const body = req.body ?? {}; try { const row = await createLlmProvider(ctx.db, { ownerUserId: user.id, name: body.name || "", baseUrl: body.baseUrl || "", apiKey: body.apiKey || "", defaultModel: body.defaultModel || "", secret, }); await writeAudit(ctx.db, "llm_provider_create", user.id, { id: row.id, baseUrl: row.base_url, }); return { provider: toPublicProvider(row, secret) }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return reply.code(400).send({ error: message }); } }); app.patch<{ Params: { id: string }; Body: { name?: string; baseUrl?: string; apiKey?: string; defaultModel?: string; enabled?: boolean; }; }>("/api/v1/me/llm-providers/:id", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const secret = ctx.cfg.llmProviderSecret; if (!secret) { return reply .code(503) .send({ error: "LLM_PROVIDER_SECRET not configured" }); } try { const row = await updateLlmProvider(ctx.db, req.params.id, user.id, { ...req.body, secret, }); return { provider: toPublicProvider(row, secret) }; } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message === "not_found") { return reply.code(404).send({ error: "not found" }); } return reply.code(400).send({ error: message }); } }); app.delete<{ Params: { id: string } }>( "/api/v1/me/llm-providers/:id", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const ok = await deleteLlmProvider(ctx.db, req.params.id, user.id); if (!ok) return reply.code(404).send({ error: "not found" }); await writeAudit(ctx.db, "llm_provider_delete", user.id, { id: req.params.id, }); return { ok: true }; }, ); app.get("/api/v1/me/personas", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const [library, created] = await Promise.all([ listUserPersonaLibrary(ctx.db, user.id), listPersonasByOwner(ctx.db, user.id), ]); return { library: library.map((p) => personaPublicDto(p, { enabled: p.enabled, }), ), created: created.map((p) => personaPublicDto(p, { enabled: p.enabled, }), ), }; }); app.post<{ Params: { id: string } }>( "/api/v1/me/personas/:id/add", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; try { const persona = await addPersonaToLibrary( ctx.db, user.id, req.params.id, ); await writeAudit(ctx.db, "persona_added_lib", user.id, { personaId: persona.id, }); return { ok: true, persona }; } catch (err) { const message = err instanceof Error ? err.message : String(err); const code = message.includes("private") || message.includes("not found") ? 403 : 400; return reply.code(code).send({ error: message }); } }, ); app.delete<{ Params: { id: string } }>( "/api/v1/me/personas/:id", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; await removePersonaFromLibrary(ctx.db, user.id, req.params.id); return { ok: true }; }, ); // ── Web try-chat (persona preview, no WeChat) ───────── function tryChatHttpError( reply: FastifyReply, err: unknown, ): FastifyReply | null { if (!(err instanceof TryChatError)) return null; const map: Record = { disabled: 503, not_found: 404, forbidden: 403, quota_day: 429, quota_session: 429, empty: 400, llm: 502, no_prompt: 400, }; return reply .code(map[err.code] ?? 400) .send({ error: err.code, message: err.message }); } app.post<{ Body: { personaId: string; botName?: string }; }>("/api/v1/try-chat/sessions", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; if (!ctx.cfg.tryChatEnabled) { return reply .code(503) .send({ error: "disabled", message: "网页试聊已关闭" }); } const personaId = req.body?.personaId?.trim(); if (!personaId) { return reply.code(400).send({ error: "personaId required" }); } try { // inLibrary rides along so the client doesn't need a follow-up // GET /square/personas/:id just to render the "add" button state. const [result, inLibrary] = await Promise.all([ ctx.tryChat.startSession({ userId: user.id, personaId, botName: req.body?.botName, }), isInPersonaLibrary(ctx.db, user.id, personaId).catch(() => false), ]); return { sessionId: result.sessionId, persona: { id: result.persona.id, slug: result.persona.slug, displayName: result.persona.display_name, description: result.persona.description, inLibrary, }, inLibrary, botName: result.botName, expiresInSec: result.expiresInSec, remainingToday: result.remainingToday, }; } catch (err) { const sent = tryChatHttpError(reply, err); if (sent) return; const message = err instanceof Error ? err.message : String(err); return reply.code(400).send({ error: message }); } }); app.post<{ Params: { sessionId: string }; Body: { text: string }; }>("/api/v1/try-chat/sessions/:sessionId/messages", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; if (!ctx.cfg.tryChatEnabled) { return reply .code(503) .send({ error: "disabled", message: "网页试聊已关闭" }); } try { const result = await ctx.tryChat.sendMessage({ userId: user.id, sessionId: req.params.sessionId, text: req.body?.text ?? "", username: user.username, }); return { messages: result.parts.map((p) => p.kind === "sticker" ? { type: "sticker" as const, slug: p.slug } : { type: "text" as const, text: p.text }, ), displayText: result.displayText, usage: result.usage, remainingToday: result.remainingToday, remainingSession: result.remainingSession, }; } catch (err) { const sent = tryChatHttpError(reply, err); if (sent) return; const message = err instanceof Error ? err.message : String(err); return reply.code(400).send({ error: message }); } }); app.delete<{ Params: { sessionId: string } }>( "/api/v1/try-chat/sessions/:sessionId", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; try { await ctx.tryChat.endSession(user.id, req.params.sessionId); return { ok: true }; } catch (err) { const sent = tryChatHttpError(reply, err); if (sent) return; return { ok: true }; } }, ); // ── Sticker Square ─────────────────────────────────── function publicStickerDto( s: Sticker, extra?: { inLibrary?: boolean }, ) { const isCdnEligible = !!s.enabled && s.visibility === "public" && s.review_status === "approved"; const imageUrl = isCdnEligible ? `/cdn/s/${s.id}${s.content_hash ? `?v=${encodeURIComponent(s.content_hash)}` : ""}` : `/api/v1/square/stickers/${s.id}/image`; return { id: s.id, slug: s.slug, displayName: s.display_name, description: s.description, tags: s.tags, mime: s.mime, sizeBytes: s.size_bytes, visibility: s.visibility, reviewStatus: s.review_status, rejectReason: s.reject_reason, ownerUserId: s.owner_user_id, useCount: s.use_count, enabled: !!s.enabled, contentHash: s.content_hash ?? null, imageUrl, updatedAt: s.updated_at, createdAt: s.created_at, ...extra, }; } function parseStickerUpload( body: { mime?: string; dataBase64?: string; }, maxBytes: number, ): { data: Buffer; mime: string } { if (!body.dataBase64) throw new StickerSecurityError("missing image", "empty"); let data: Buffer; try { data = decodeBase64Image(body.dataBase64); } catch { throw new StickerSecurityError("invalid base64", "bad_base64"); } const { mime } = assertSafeStickerImage(data, body.mime, { maxBytes }); return { data, mime }; } app.get("/api/v1/square/stickers", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const q = req.query as { q?: string; page?: string; limit?: string; sort?: string; }; const limit = Math.min(Math.max(Number(q.limit ?? "20") || 20, 1), 50); const page = Math.max(Number(q.page ?? "1") || 1, 1); const offset = (page - 1) * limit; const sort = q.sort === "recent" || q.sort === "name" || q.sort === "use" ? q.sort : "use"; const [{ items, total }, libIds] = await Promise.all([ searchPublicStickers(ctx.db, { q: q.q, limit, offset, sort }), getStickerLibraryIdSet(ctx.db, user.id), ]); const stickers = items.map((s) => publicStickerDto(s, { inLibrary: libIds.has(s.id), }), ); return { stickers, total, page, limit }; }); app.get<{ Params: { id: string } }>( "/api/v1/square/stickers/:id", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const [s, inLibrary] = await Promise.all([ getSticker(ctx.db, req.params.id), isInStickerLibrary(ctx.db, user.id, req.params.id), ]); if (!s || !s.enabled) { return reply.code(404).send({ error: "not found" }); } const isOwner = s.owner_user_id === user.id; const isPublicApproved = s.visibility === "public" && s.review_status === "approved"; if (!isOwner && !isPublicApproved && !user.is_admin) { return reply.code(403).send({ error: "forbidden" }); } return { sticker: publicStickerDto(s, { inLibrary }) }; }, ); app.get<{ Params: { id: string } }>( "/api/v1/square/stickers/:id/image", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const s = await getSticker(ctx.db, req.params.id); if (!s || !s.enabled) { return reply.code(404).send({ error: "not found" }); } const isOwner = s.owner_user_id === user.id; const isPublicApproved = s.visibility === "public" && s.review_status === "approved"; if (!isOwner && !isPublicApproved && !user.is_admin) { return reply.code(403).send({ error: "forbidden" }); } return sendPrivateStickerImage(ctx, req, reply, s); }, ); app.post<{ Body: { slug?: string; displayName: string; description?: string; tags?: string[]; visibility?: "public" | "private"; mime?: string; dataBase64: string; }; }>("/api/v1/square/stickers", { bodyLimit: ctx.cfg.uploadBodyLimit }, async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const body = req.body ?? {}; if (!body.displayName?.trim() || !body.dataBase64) { return reply .code(400) .send({ error: "displayName and dataBase64 required" }); } try { const { data, mime } = parseStickerUpload(body, ctx.cfg.stickerMaxBytes); const sticker = await createSticker(ctx.db, { slug: body.slug, displayName: body.displayName, description: body.description, tags: Array.isArray(body.tags) ? body.tags : undefined, visibility: body.visibility === "private" ? "private" : "public", mime, sizeBytes: data.length, ownerUserId: user.id, autoApprove: false, data, }); await writeAudit(ctx.db, "sticker_submit", user.id, { id: sticker.id, visibility: sticker.visibility, reviewStatus: sticker.review_status, }); return { sticker: publicStickerDto(sticker) }; } catch (err) { if (err instanceof StickerSecurityError) { return reply .code(400) .send({ error: "unsafe_image", code: err.code, message: err.message }); } const message = err instanceof Error ? err.message : String(err); const code = message.includes("slug") ? 409 : 400; return reply.code(code).send({ error: message }); } }); app.put<{ Params: { id: string }; Body: { slug?: string; displayName?: string; description?: string; tags?: string[]; visibility?: "public" | "private"; mime?: string; dataBase64?: string; }; }>("/api/v1/square/stickers/:id", { bodyLimit: ctx.cfg.uploadBodyLimit }, async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const cur = await getSticker(ctx.db, req.params.id); if (!cur) return reply.code(404).send({ error: "not found" }); if (cur.owner_user_id !== user.id && !user.is_admin) { return reply.code(403).send({ error: "forbidden" }); } const body = req.body ?? {}; try { if (body.dataBase64) { const { data, mime } = parseStickerUpload(body, ctx.cfg.stickerMaxBytes); await replaceStickerBlob(ctx.db, cur.id, data, { mime, fileName: makeStickerFileName(cur.id, mime), }); } const sticker = await updateStickerMeta(ctx.db, cur.id, { slug: body.slug, displayName: body.displayName, description: body.description, tags: body.tags, visibility: body.visibility, rePending: true, }); await writeAudit(ctx.db, "sticker_update_user", user.id, { id: sticker.id, }); return { sticker: publicStickerDto(sticker) }; } catch (err) { if (err instanceof StickerSecurityError) { return reply .code(400) .send({ error: "unsafe_image", code: err.code, message: err.message }); } const message = err instanceof Error ? err.message : String(err); return reply.code(400).send({ error: message }); } }); app.delete<{ Params: { id: string } }>( "/api/v1/square/stickers/:id", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const cur = await getSticker(ctx.db, req.params.id); if (!cur) return reply.code(404).send({ error: "not found" }); if (cur.owner_user_id === "system") { return reply.code(403).send({ error: "cannot delete system sticker" }); } if (cur.owner_user_id !== user.id && !user.is_admin) { return reply.code(403).send({ error: "forbidden" }); } await softDeleteSticker(ctx.db, cur.id); await writeAudit(ctx.db, "sticker_soft_delete", user.id, { id: cur.id }); return { ok: true }; }, ); app.get("/api/v1/me/stickers", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const [library, created] = await Promise.all([ listUserStickerLibrary(ctx.db, user.id), listStickersByOwner(ctx.db, user.id), ]); return { library: library.map((s) => publicStickerDto(s)), created: created.map((s) => publicStickerDto(s)), }; }); app.post<{ Params: { id: string } }>( "/api/v1/me/stickers/:id/add", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; try { const sticker = await addStickerToLibrary( ctx.db, user.id, req.params.id, ); await writeAudit(ctx.db, "sticker_lib_add", user.id, { stickerId: sticker.id, }); return { ok: true, sticker: publicStickerDto(sticker) }; } catch (err) { const message = err instanceof Error ? err.message : String(err); const code = message.includes("private") || message.includes("not available") ? 403 : 400; return reply.code(code).send({ error: message }); } }, ); app.delete<{ Params: { id: string } }>( "/api/v1/me/stickers/:id", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; await removeStickerFromLibrary(ctx.db, user.id, req.params.id); return { ok: true }; }, ); // ── Admin ──────────────────────────────────────────── app.get("/api/v1/admin/dashboard", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; // Everything here is independent — one wave instead of 4 chained waits. // redisOk doubles as the liveness probe, so it rides along too. const [snap, today, yesterday, workerIds, workerStats, nodes, botOwners, redisOk] = await Promise.all([ doctorSnapshot(ctx.db), getUsageDayStats(ctx.db, dayKey()), getUsageDayStats(ctx.db, dayKeyOffset(-1)), ctx.worker.listActiveBotIdsAsync(), ctx.worker.getFleetStats(), ctx.worker.listFleetNodes().catch(() => []), listLeasedBots(ctx.db).catch(() => ({} as Record)), ctx.db.ping().then( () => true, () => false, ), ]); const botMap = await getBotAccountsByIds(ctx.db, workerIds); const workerBots = workerIds.map((id) => { const b = botMap.get(id); return { id, displayName: b?.display_name ?? id, status: b?.status ?? "unknown", workerId: botOwners[id] ?? null, }; }); const nodesOnline = nodes.filter((n) => n.online && !n.fenced).length; return { snapshot: snap, usage: { today, yesterday }, workers: workerIds, workerBots, workerId: ctx.worker.getWorkerId(), workerStats, nodes, nodesOnline, nodesTotal: nodes.length, redisOk, safeConfig: { publicBaseUrl: ctx.cfg.publicBaseUrl, workerEnabled: ctx.cfg.workerEnabled, maxBotsPerWorker: ctx.cfg.maxBotsPerWorker, replyConcurrency: ctx.cfg.replyConcurrency, inboxMaxLen: ctx.cfg.inboxMaxLen, llmModel: ctx.cfg.llmModel, llmBaseUrl: ctx.cfg.llmBaseUrl, multiBubbleJson: ctx.cfg.multiBubbleJson, splitReply: ctx.cfg.splitReply, allowUnapproved: ctx.cfg.allowUnapproved, }, }; }); /** Global peer list for ops (filter unapproved by default via query). */ app.get("/api/v1/admin/peers", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const q = req.query as { status?: string; limit?: string }; const status = (q.status || "unapproved").toLowerCase(); const limit = Math.min(Math.max(Number(q.limit) || 100, 1), 300); let peers = await listPeers(ctx.db); if (status === "unapproved") peers = peers.filter((p) => !p.approved); else if (status === "approved") peers = peers.filter((p) => p.approved); peers = peers.slice(0, limit); const botIds = [...new Set(peers.map((p) => p.bot_account_id))]; const [botMap, asgMap] = await Promise.all([ getBotAccountsByIds(ctx.db, botIds), getAssignmentsMany( ctx.db, peers.map((p) => ({ botAccountId: p.bot_account_id, peerId: p.peer_id, })), ), ]); const ownerIds = [ ...new Set( [...botMap.values()] .map((b) => b.owner_user_id) .filter(Boolean), ), ]; const owners = await getUsersByIds(ctx.db, ownerIds); const items = peers.map((p) => { const bot = botMap.get(p.bot_account_id); const owner = bot?.owner_user_id ? owners.get(bot.owner_user_id) : undefined; return { botAccountId: p.bot_account_id, peerId: p.peer_id, approved: Boolean(p.approved), botName: bot?.display_name ?? p.bot_account_id, ownerUserId: bot?.owner_user_id ?? null, ownerUsername: owner?.username ?? null, personaId: asgMap.get(`${p.bot_account_id}|${p.peer_id}`) ?? null, createdAt: p.created_at ?? null, }; }); return { total: items.length, peers: items }; }); app.post<{ Body: { botAccountId: string; peerId: string }; }>("/api/v1/admin/peers/approve", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const { botAccountId, peerId } = req.body ?? {}; if (!botAccountId || !peerId) { return reply.code(400).send({ error: "botAccountId and peerId required" }); } const bot = await getBotAccount(ctx.db, botAccountId); if (!bot) return reply.code(404).send({ error: "bot not found" }); await approvePeer(ctx.db, botAccountId, peerId); await writeAudit(ctx.db, "admin_peer_approve", admin.id, { botAccountId, peerId, }); return { ok: true }; }); /** Approve all currently unapproved peers (ops convenience). */ app.post("/api/v1/admin/peers/approve-all", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const peers = (await listPeers(ctx.db)).filter((p) => !p.approved); // Batched, not serialized: 500 pending peers used to be ~1000 chained // round trips (~40s, past most proxy timeouts). Still goes through // approvePeer so each row is re-read — writing back the stale rows we // already hold would clobber last_activity_at written by another node. let approved = 0; const BATCH = 100; for (let off = 0; off < peers.length; off += BATCH) { const slice = peers.slice(off, off + BATCH); const res = await Promise.allSettled( slice.map((p) => approvePeer(ctx.db, p.bot_account_id, p.peer_id)), ); approved += res.filter((r) => r.status === "fulfilled").length; } await writeAudit(ctx.db, "admin_peer_approve_all", admin.id, { approved, }); return { ok: true, approved }; }); app.get("/api/v1/admin/system", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; // Independent — one wave instead of three chained waits const [snap, workerIds, workerStats, nodes, redisOk] = await Promise.all([ doctorSnapshot(ctx.db), ctx.worker.listActiveBotIdsAsync(), ctx.worker.getFleetStats(), ctx.worker.listFleetNodes().catch(() => []), ctx.db.ping().then( () => true, () => false, ), ]); return { snapshot: snap, workers: workerIds, workerId: ctx.worker.getWorkerId(), workerStats, nodes, nodesOnline: nodes.filter((n) => n.online && !n.fenced).length, nodesTotal: nodes.length, redisOk, uptimeSec: Math.floor(process.uptime()), node: process.version, safeConfig: { publicBaseUrl: ctx.cfg.publicBaseUrl, workerEnabled: ctx.cfg.workerEnabled, maxBotsPerWorker: ctx.cfg.maxBotsPerWorker, replyConcurrency: ctx.cfg.replyConcurrency, inboxMaxLen: ctx.cfg.inboxMaxLen, llmModel: ctx.cfg.llmModel, llmBaseUrl: ctx.cfg.llmBaseUrl, multiBubbleJson: ctx.cfg.multiBubbleJson, splitReply: ctx.cfg.splitReply, allowUnapproved: ctx.cfg.allowUnapproved, maxReplyChunks: ctx.cfg.maxReplyChunks, maxChunkChars: ctx.cfg.maxChunkChars, }, }; }); /** Deployment nodes — super-admin only. */ app.get("/api/v1/admin/nodes", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; // targetShare divides the bots that actually want polling, so the panel's // "目标" column matches what the fleet is really distributing. const pollableTotal = await listPollableBotIds(ctx.db) .then((ids) => ids.length) .catch(() => undefined); const [nodes, fences, weights, currentRelease] = await Promise.all([ ctx.worker.listFleetNodes({ totalBots: pollableTotal }), listWorkerFences(ctx.db).catch(() => []), listWorkerWeights(ctx.db).catch(() => ({})), getCurrentRelease(ctx.db).catch(() => null), ]); const desiredVersion = currentRelease?.version ?? null; const statusMap = await getWorkerUpdateStatuses( ctx.db, nodes.map((n) => n.id), ).catch(() => new Map()); const enriched = nodes.map((n) => { const st = statusMap.get(n.id) ?? null; const outdated = Boolean( desiredVersion && (n.version || "") !== desiredVersion && !n.fenced, ); return { ...n, update: { outdated, desiredVersion, status: st?.phase ?? null, error: st?.error ?? null, progress: st?.progress ?? null, message: st?.message ?? null, targetVersion: st?.version ?? null, }, }; }); const online = nodes.filter((n) => n.online && !n.fenced); return { nodes: enriched, nodesOnline: online.length, nodesTotal: nodes.length, nodesFenced: nodes.filter((n) => n.fenced).length, nodesWeighted: nodes.filter((n) => n.weightOverride).length, fences, weights, pollableTotal: pollableTotal ?? null, /** Sum of weights across online nodes — the denominator of each share */ weightTotal: online.reduce((a, n) => a + n.weight, 0), weightLimits: { min: MIN_WORKER_WEIGHT, max: MAX_WORKER_WEIGHT, default: DEFAULT_WORKER_WEIGHT, }, /** Weights only take effect while the rebalancer may move leases */ rebalanceEnabled: ctx.cfg.rebalanceEnabled, rebalanceIntervalSec: ctx.cfg.rebalanceIntervalSec, /** Seconds a weight survives after its node stops heartbeating */ weightTtlSec: ctx.cfg.workerWeightTtlSec, selfWorkerId: ctx.worker.getWorkerId(), leaseTtlSec: ctx.cfg.leaseTtlSec, release: releaseSummary(currentRelease), appVersion: ctx.cfg.appVersion, otaEnabled: ctx.cfg.otaEnabled, }; }); /** * Set a node's load weight (percent of an even share; 100 = default). * * Weights are relative between *online* nodes: with A=200 and B=100 the * fleet aims for a 2:1 split. 0 drains a node without fencing it — it keeps * heartbeating and can be restored instantly. */ app.post<{ Params: { workerId: string }; Body: { weight?: number | string; percent?: number | string }; }>("/api/v1/admin/nodes/:workerId/weight", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; const workerId = decodeURIComponent(req.params.workerId || "").trim(); if (!workerId) { return reply.code(400).send({ error: "workerId required" }); } const parsed = parseWorkerWeightInput( req.body?.weight ?? req.body?.percent, ); if (!parsed.ok) { return reply.code(400).send({ error: parsed.error, message: parsed.error === "weight_required" ? "weight required" : parsed.error === "weight_out_of_range" ? `weight must be between ${MIN_WORKER_WEIGHT} and ${MAX_WORKER_WEIGHT}` : "weight must be a number", }); } const weight = parsed.value; const record = await setWorkerWeight(ctx.db, workerId, weight, { byUserId: admin.id, byUsername: admin.username ?? null, }); await writeAudit(ctx.db, "admin_node_set_weight", admin.id, { workerId, weight, }); try { // Nodes drop their cached weight on wake, so this lands next tick await publishWorkerWake(ctx.db); } catch { /* optional */ } const note = ctx.cfg.rebalanceEnabled ? `约 ${ctx.cfg.rebalanceIntervalSec}s 内生效` : "但 REBALANCE_ENABLED=false,租约不会自动迁移"; return { ok: true, workerId, weight, cleared: record === null, message: weight === DEFAULT_WORKER_WEIGHT ? `已恢复默认权重(100%);${note}` : weight === 0 ? `已设为 0%(腾空节点,不再认领 bot);${note}` : `已设为 ${weight}%;${note}`, }; }); /** Remove the override so the node returns to an even share. */ app.delete<{ Params: { workerId: string } }>( "/api/v1/admin/nodes/:workerId/weight", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; const workerId = decodeURIComponent(req.params.workerId || "").trim(); if (!workerId) { return reply.code(400).send({ error: "workerId required" }); } const cleared = await clearWorkerWeight(ctx.db, workerId); await writeAudit(ctx.db, "admin_node_clear_weight", admin.id, { workerId, cleared, }); try { await publishWorkerWake(ctx.db); } catch { /* optional */ } return { ok: true, workerId, cleared, weight: DEFAULT_WORKER_WEIGHT, message: cleared ? "已恢复默认权重(100%)" : "该节点没有权重覆盖", }; }, ); /** Drop weight overrides for nodes that are gone (neither online nor fenced). */ app.post("/api/v1/admin/nodes/weights/prune", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; const removed = await pruneWorkerWeights(ctx.db); if (removed.length) { await writeAudit(ctx.db, "admin_node_prune_weights", admin.id, { removed, }); } return { ok: true, removed, message: removed.length ? `已清理 ${removed.length} 个失效节点的权重` : "没有失效的权重记录", }; }); // ── OTA releases (super-admin) ───────────────────────── app.get("/api/v1/admin/releases/current", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; const cur = await getCurrentRelease(ctx.db); return { release: cur, summary: releaseSummary(cur), appVersion: ctx.cfg.appVersion, otaEnabled: ctx.cfg.otaEnabled, }; }); app.get("/api/v1/admin/releases", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; const q = req.query as { limit?: string }; const limit = Number(q.limit ?? "20"); const [versions, current] = await Promise.all([ listReleaseVersions(ctx.db, limit), getCurrentRelease(ctx.db), ]); return { versions, currentVersion: current?.version ?? null, appVersion: ctx.cfg.appVersion, }; }); /** * Publish release: either full JSON (small packs) or multi-step blob upload. * * Body modes: * 1) { mode: "blob", sha256, dataBase64 } — upload one content-addressed file * 2) { mode: "publish", version, files: [{path,sha256,size}], setCurrent?, createdBy? } * (blobs must already exist) * 3) { mode: "pack", version, files: [{path,sha256,size,dataBase64}], setCurrent? } * — inline file bodies (CLI convenience for moderate packs) */ app.post<{ Body: { mode?: string; sha256?: string; dataBase64?: string; version?: string; files?: Array<{ path: string; sha256: string; size: number; dataBase64?: string; }>; setCurrent?: boolean; }; }>("/api/v1/admin/releases", { bodyLimit: ctx.cfg.uploadBodyLimit }, async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; const body = req.body || {}; const mode = (body.mode || "pack").trim(); try { if (mode === "blob") { const sha = (body.sha256 || "").trim().toLowerCase(); const b64 = body.dataBase64 || ""; if (!/^[a-f0-9]{64}$/.test(sha) || !b64) { return reply.code(400).send({ error: "sha256_and_dataBase64_required" }); } const data = Buffer.from(b64, "base64"); if (sha256Buffer(data) !== sha) { return reply.code(400).send({ error: "sha256_mismatch" }); } if (data.length > 8 * 1024 * 1024) { return reply.code(400).send({ error: "blob_too_large", max: 8 * 1024 * 1024 }); } const meta = await putBlobChunks(ctx.db, sha, data); return { ok: true, blob: meta, existed: await blobExists(ctx.db, sha) }; } if (mode === "publish") { const files = (body.files || []).map((f) => ({ path: f.path, sha256: f.sha256, size: f.size, })); const meta = buildReleaseMeta({ version: body.version || "", files, createdBy: admin.username || admin.id, }); await publishRelease(ctx.db, meta, { setCurrent: body.setCurrent !== false, }); await writeAudit(ctx.db, "admin_release_publish", admin.id, { version: meta.version, fileCount: meta.fileCount, totalBytes: meta.totalBytes, }); return { ok: true, release: meta, summary: releaseSummary(meta) }; } // mode === pack: files with inline dataBase64 if (mode === "pack") { const rawFiles = body.files || []; if (!rawFiles.length) { return reply.code(400).send({ error: "files_required" }); } const entries: ReleaseFileEntry[] = []; for (const f of rawFiles) { if (!f.dataBase64) { return reply .code(400) .send({ error: "dataBase64_required", path: f.path }); } const data = Buffer.from(f.dataBase64, "base64"); if (data.length > 8 * 1024 * 1024) { return reply .code(400) .send({ error: "file_too_large", path: f.path }); } const hash = sha256Buffer(data); if (f.sha256 && f.sha256.toLowerCase() !== hash) { return reply .code(400) .send({ error: "sha256_mismatch", path: f.path }); } await putBlobChunks(ctx.db, hash, data); entries.push({ path: f.path, sha256: hash, size: data.length, }); } const meta = buildReleaseMeta({ version: body.version || "", files: entries, createdBy: admin.username || admin.id, }); await publishRelease(ctx.db, meta, { setCurrent: body.setCurrent !== false, }); await writeAudit(ctx.db, "admin_release_publish", admin.id, { version: meta.version, fileCount: meta.fileCount, totalBytes: meta.totalBytes, mode: "pack", }); return { ok: true, release: meta, summary: releaseSummary(meta) }; } return reply.code(400).send({ error: "unknown_mode", mode }); } catch (err) { const message = err instanceof Error ? err.message : String(err); return reply.code(400).send({ error: message }); } }); app.post<{ Body: { version?: string } }>( "/api/v1/admin/releases/current", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; const version = (req.body?.version || "").trim(); if (!version) { return reply.code(400).send({ error: "version required" }); } try { const meta = await setCurrentRelease(ctx.db, version); await writeAudit(ctx.db, "admin_release_set_current", admin.id, { version, }); return { ok: true, release: meta, summary: releaseSummary(meta) }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return reply.code(400).send({ error: message }); } }, ); app.post<{ Params: { workerId: string }; Body: { version?: string; force?: boolean; confirm?: string }; }>("/api/v1/admin/nodes/:workerId/update", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; if (!ctx.cfg.otaEnabled) { return reply.code(400).send({ error: "OTA_ENABLED=false" }); } const workerId = decodeURIComponent(req.params.workerId || "").trim(); if (!workerId) { return reply.code(400).send({ error: "workerId required" }); } const confirm = (req.body?.confirm || "").trim(); if (confirm && confirm !== workerId) { return reply.code(400).send({ error: "confirm_mismatch", message: "confirm must equal workerId", }); } let version = (req.body?.version || "").trim(); if (!version) { const cur = await getCurrentRelease(ctx.db); if (!cur) { return reply .code(400) .send({ error: "no_current_release", message: "先发布 release 包" }); } version = cur.version; } else { const meta = await getReleaseMeta(ctx.db, version); if (!meta) { return reply.code(400).send({ error: "release_not_found" }); } } try { const status = await enqueueWorkerUpdate(ctx.db, workerId, { version, requestedAt: new Date().toISOString(), requestedBy: admin.id, requestedByUsername: admin.username ?? null, force: Boolean(req.body?.force), }); await writeAudit(ctx.db, "admin_node_update", admin.id, { workerId, version, force: Boolean(req.body?.force), }); try { await publishWorkerWake(ctx.db); } catch { /* */ } return { ok: true, workerId, version, status, message: `已下发更新任务 → ${version},节点将在下一心跳周期内应用并重启`, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return reply.code(400).send({ error: message }); } }); app.post<{ Body: { version?: string; force?: boolean }; }>("/api/v1/admin/nodes/update-outdated", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; if (!ctx.cfg.otaEnabled) { return reply.code(400).send({ error: "OTA_ENABLED=false" }); } let version = (req.body?.version || "").trim(); if (!version) { const cur = await getCurrentRelease(ctx.db); if (!cur) { return reply.code(400).send({ error: "no_current_release" }); } version = cur.version; } const nodes = await ctx.worker.listFleetNodes(); const targets = nodes.filter( (n) => n.online && !n.fenced && (req.body?.force || !n.version || n.version !== version), ); const results: Array<{ workerId: string; ok: boolean; error?: string }> = []; for (const n of targets) { try { await enqueueWorkerUpdate(ctx.db, n.id, { version, requestedAt: new Date().toISOString(), requestedBy: admin.id, requestedByUsername: admin.username ?? null, force: Boolean(req.body?.force), }); results.push({ workerId: n.id, ok: true }); } catch (err) { results.push({ workerId: n.id, ok: false, error: err instanceof Error ? err.message : String(err), }); } } await writeAudit(ctx.db, "admin_nodes_update_outdated", admin.id, { version, count: results.filter((r) => r.ok).length, targets: results.map((r) => r.workerId), }); try { await publishWorkerWake(ctx.db); } catch { /* */ } return { ok: true, version, queued: results.filter((r) => r.ok).length, results, }; }); app.get<{ Params: { workerId: string } }>( "/api/v1/admin/nodes/:workerId/update-status", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; const workerId = decodeURIComponent(req.params.workerId || "").trim(); if (!workerId) { return reply.code(400).send({ error: "workerId required" }); } const status = await getWorkerUpdateStatus(ctx.db, workerId); return { workerId, status }; }, ); /** * Force a deployment node offline (super-admin only). */ app.post<{ Params: { workerId: string }; Body: { reason?: string; confirm?: string }; }>("/api/v1/admin/nodes/:workerId/force-offline", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; const workerId = decodeURIComponent(req.params.workerId || "").trim(); if (!workerId) { return reply.code(400).send({ error: "workerId required" }); } // Require confirm match to avoid misclick (UI sends confirm=workerId) const confirm = (req.body?.confirm || "").trim(); if (confirm && confirm !== workerId) { return reply.code(400).send({ error: "confirm_mismatch", message: "confirm must equal workerId", }); } if (workerId === ctx.worker.getWorkerId()) { // Allow fencing self (drain this node via another admin path), but warn // — request may hang if this process is the only one; still OK for multi-node. } try { const { released, fence } = await forceOfflineWorker(ctx.db, workerId, { reason: req.body?.reason, byUserId: admin.id, byUsername: admin.username ?? null, }); await writeAudit(ctx.db, "admin_node_force_offline", admin.id, { workerId, released, reason: fence.reason, }); return { ok: true, workerId, released, fence, message: released > 0 ? `已强制下线,释放 ${released} 个 bot 租约;目标进程将停止认领` : "已强制下线(无租约或已释放);目标进程将停止认领", }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return reply.code(400).send({ error: message }); } }); /** Clear force-offline fence so the node may rejoin the fleet. */ app.post<{ Params: { workerId: string } }>( "/api/v1/admin/nodes/:workerId/clear-fence", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; const workerId = decodeURIComponent(req.params.workerId || "").trim(); if (!workerId) { return reply.code(400).send({ error: "workerId required" }); } const cleared = await clearWorkerFence(ctx.db, workerId); await writeAudit(ctx.db, "admin_node_clear_fence", admin.id, { workerId, cleared, }); try { await publishWorkerWake(ctx.db); } catch { /* optional */ } return { ok: true, workerId, cleared, message: cleared ? "已解除下线封锁,节点将在下一心跳重新加入" : "该节点没有封锁记录", }; }, ); /** Start/restart workers for every active bot that has Redis credentials. */ app.post("/api/v1/admin/workers/restart-all", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; if (!ctx.cfg.workerEnabled) { return reply.code(400).send({ error: "WORKER_ENABLED=false" }); } const bots = await listBotAccounts(ctx.db); const tokenMap = await hasBotCredentialsMany( ctx.db, bots.map((b) => b.id), ); let started = 0; let skipped = 0; for (const b of bots) { if (b.status !== "active") { skipped++; continue; } if (!tokenMap[b.id]) { skipped++; continue; } ctx.worker.restartBot(b.id); started++; } await writeAudit(ctx.db, "admin_workers_restart_all", admin.id, { started, skipped, }); return { ok: true, started, skipped, workers: await ctx.worker.listActiveBotIdsAsync(), }; }); app.post("/api/v1/admin/workers/stop-all", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; const before = await ctx.worker.stopAllBots(); await writeAudit(ctx.db, "admin_workers_stop_all", admin.id, { count: before.length, botIds: before, }); return { ok: true, stopped: before.length }; }); /** Idempotent official persona seed (catgirl / girlfriend if missing). */ app.post("/api/v1/admin/system/seed-personas", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const before = (await listPersonas(ctx.db)).length; await seedPersonas(ctx.db); const afterList = await listPersonas(ctx.db); const after = afterList.length; await writeAudit(ctx.db, "admin_seed_personas", admin.id, { before, after, added: Math.max(0, after - before), }); return { ok: true, before, after, added: Math.max(0, after - before), defaultPersona: afterList.find((p) => p.is_default)?.slug ?? afterList.find((p) => p.enabled)?.slug ?? null, }; }); app.get("/api/v1/admin/memories", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const q = req.query as { botAccountId?: string; peerId?: string; personaId?: string; }; if (!q.botAccountId || !q.peerId) { return reply.code(400).send({ error: "botAccountId and peerId required" }); } const bot = await getBotAccount(ctx.db, q.botAccountId); if (!bot) return reply.code(404).send({ error: "bot not found" }); if (q.personaId) { return { memories: await listMemories( ctx.db, q.botAccountId, q.peerId, q.personaId, ), }; } // All personas for this peer — one pipelined wave, not one LRANGE each const personas = await listPersonas(ctx.db); const memMap = await listMemoriesMany( ctx.db, q.botAccountId, q.peerId, personas.map((p) => p.id), ); const groups: { personaId: string; personaName: string; memories: { id: string; content: string }[]; }[] = []; let total = 0; for (const p of personas) { const mems = memMap.get(p.id); if (mems?.length) { groups.push({ personaId: p.id, personaName: p.display_name, memories: mems.map((m) => ({ id: m.id, content: m.content })), }); total += mems.length; } } return { total, groups }; }); app.post<{ Body: { botAccountId: string; peerId: string; personaId?: string }; }>("/api/v1/admin/memories/reset", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const { botAccountId, peerId, personaId } = req.body ?? {}; if (!botAccountId || !peerId) { return reply.code(400).send({ error: "botAccountId and peerId required" }); } const bot = await getBotAccount(ctx.db, botAccountId); if (!bot) return reply.code(404).send({ error: "bot not found" }); await clearMemories(ctx.db, botAccountId, peerId, personaId); await writeAudit(ctx.db, "admin_memory_reset", admin.id, { botAccountId, peerId, personaId: personaId || null, }); return { ok: true }; }); app.post<{ Body: { botAccountId: string; peerId: string }; }>("/api/v1/admin/messages/clear", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const { botAccountId, peerId } = req.body ?? {}; if (!botAccountId || !peerId) { return reply.code(400).send({ error: "botAccountId and peerId required" }); } const bot = await getBotAccount(ctx.db, botAccountId); if (!bot) return reply.code(404).send({ error: "bot not found" }); await clearMessages(ctx.db, botAccountId, peerId); await writeAudit(ctx.db, "admin_messages_clear", admin.id, { botAccountId, peerId, }); return { ok: true }; }); app.get("/api/v1/admin/users", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const users = await listUsers(ctx.db); const [botCounts, superAdminId] = await Promise.all([ countBotsByOwners( ctx.db, users.map((u) => u.id), ), resolveSuperAdminId(ctx.db), ]); const mapped = users.map((u) => ({ id: u.id, username: u.username, name: u.name, isAdmin: Boolean(u.is_admin), isSuperAdmin: Boolean(superAdminId && u.id === superAdminId), trustLevel: u.trust_level, avatarUrl: u.avatar_url, botCount: botCounts[u.id] ?? 0, createdAt: u.created_at, authProvider: u.auth_provider === "local" ? "local" : "linuxdo", isBanned: Boolean(u.is_banned), bannedAt: u.banned_at ?? null, bannedReason: u.banned_reason ?? null, invitedBy: u.invited_by ?? null, })); return { users: mapped }; }); app.get<{ Params: { id: string } }>( "/api/v1/admin/users/:id", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const u = await getUser(ctx.db, req.params.id); if (!u) return reply.code(404).send({ error: "not found" }); const bots = await listBotsByOwner(ctx.db, u.id); const [active, tokenMap] = await Promise.all([ ctx.worker.listActiveBotIdsAsync().then((ids) => new Set(ids)), hasBotCredentialsMany( ctx.db, bots.map((b) => b.id), ), ]); return { user: { ...userPublicFields(u), invitedBy: u.invited_by ?? null, inviteCodeUsed: u.invite_code_used ?? null, }, bots: bots.map((b) => ({ id: b.id, displayName: b.display_name, status: b.status, hasToken: Boolean(tokenMap[b.id]), workerActive: active.has(b.id), })), }; }, ); app.patch<{ Params: { id: string }; Body: { isAdmin?: boolean }; }>("/api/v1/admin/users/:id", async (req, reply) => { // Grant/revoke admin is super-admin only. const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; const target = await getUser(ctx.db, req.params.id); if (!target) return reply.code(404).send({ error: "not found" }); if (typeof req.body?.isAdmin !== "boolean") { return reply.code(400).send({ error: "isAdmin boolean required" }); } const next = req.body.isAdmin; if (!next && target.id === admin.id) { return reply.code(400).send({ error: "cannot revoke your own admin" }); } // Super-admin (earliest-created admin) cannot be demoted by anyone. if (!next && target.is_admin && (await isSuperAdmin(ctx.db, target.id))) { return reply.code(400).send({ error: "cannot_revoke_super_admin", message: "系统首位管理员(超管)不可撤销", }); } if (!next && target.is_admin) { const all = await listUsers(ctx.db); const adminCount = all.filter((u) => u.is_admin).length; if (adminCount <= 1) { return reply .code(400) .send({ error: "cannot revoke the last admin" }); } } const updated = await setUserAdmin(ctx.db, target.id, next); await writeAudit( ctx.db, next ? "user_admin_grant" : "user_admin_revoke", admin.id, { userId: target.id, username: target.username }, ); return { user: userPublicFields(updated), }; }); app.post<{ Params: { id: string }; Body: { reason?: string; cascadeBots?: boolean }; }>("/api/v1/admin/users/:id/ban", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const target = await getUser(ctx.db, req.params.id); if (!target) return reply.code(404).send({ error: "not found" }); if (target.id === admin.id) { return reply.code(400).send({ error: "cannot ban yourself" }); } // Super-admin cannot be banned by anyone (including other admins). if (target.is_admin && (await isSuperAdmin(ctx.db, target.id))) { return reply.code(400).send({ error: "cannot_ban_super_admin", message: "系统首位管理员(超管)不可封禁", }); } if (target.is_admin) { const all = await listUsers(ctx.db); const adminCount = all.filter((u) => u.is_admin && !u.is_banned).length; if (adminCount <= 1) { return reply.code(400).send({ error: "cannot ban the last admin" }); } } const updated = await setUserBanned(ctx.db, target.id, true, { reason: req.body?.reason, actorId: admin.id, }); await destroyAllSessionsForUser(ctx.db, target.id); // Default cascade: deactivate all bots const cascade = req.body?.cascadeBots !== false; if (cascade) { const bots = await listBotsByOwner(ctx.db, target.id); for (const b of bots) { if (b.status === "active") { await setBotStatus(ctx.db, b.id, "inactive"); ctx.worker.stopBot(b.id); } } } await writeAudit(ctx.db, "user_banned", admin.id, { userId: target.id, username: target.username, reason: req.body?.reason ?? null, cascadeBots: cascade, }); return { user: userPublicFields(updated) }; }); app.post<{ Params: { id: string } }>( "/api/v1/admin/users/:id/unban", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const target = await getUser(ctx.db, req.params.id); if (!target) return reply.code(404).send({ error: "not found" }); const updated = await setUserBanned(ctx.db, target.id, false); await writeAudit(ctx.db, "user_unbanned", admin.id, { userId: target.id, username: target.username, }); return { user: userPublicFields(updated) }; }, ); app.delete<{ Params: { id: string }; Querystring: { confirm?: string }; }>("/api/v1/admin/users/:id", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const target = await getUser(ctx.db, req.params.id); if (!target) return reply.code(404).send({ error: "not found" }); if (target.id === admin.id) { return reply.code(400).send({ error: "cannot delete yourself" }); } // Super-admin = earliest-created admin; cannot be deleted by anyone. if (target.is_admin && (await isSuperAdmin(ctx.db, target.id))) { return reply.code(400).send({ error: "cannot_delete_super_admin", message: "系统首位管理员(超管)不可删除", }); } if (target.is_admin) { const all = await listUsers(ctx.db); const adminCount = all.filter((u) => u.is_admin).length; if (adminCount <= 1) { return reply.code(400).send({ error: "cannot delete the last admin" }); } } const confirm = (req.query?.confirm || "").trim(); if (!confirm || confirm.toLowerCase() !== target.username.toLowerCase()) { return reply.code(400).send({ error: "confirm_required", message: "请在 query confirm= 中传入目标用户名以确认删除", }); } // Stop workers for owned bots before delete const bots = await listBotsByOwner(ctx.db, target.id); for (const b of bots) { ctx.worker.stopBot(b.id); } const ok = await deleteUserAccount(ctx.db, target.id); if (!ok) return reply.code(404).send({ error: "not found" }); await writeAudit(ctx.db, "user_deleted", admin.id, { userId: target.id, username: target.username, }); return { ok: true }; }); // Invite settings (admin) app.get("/api/v1/admin/settings/invites", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const settings = await getInviteSettings( ctx.db, inviteDefaultsFromCfg(ctx.cfg), ); return { settings }; }); app.patch<{ Body: { quotaWindowHours?: number; quotaMax?: number; codeTtlSec?: number; maxPendingPerUser?: number; codeLength?: number; }; }>("/api/v1/admin/settings/invites", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const body = req.body || {}; const settings = await setInviteSettings( ctx.db, { quotaWindowHours: body.quotaWindowHours, quotaMax: body.quotaMax, codeTtlSec: body.codeTtlSec, maxPendingPerUser: body.maxPendingPerUser, codeLength: body.codeLength, }, inviteDefaultsFromCfg(ctx.cfg), ); await writeAudit(ctx.db, "invite_settings_updated", admin.id, { settings, }); return { settings }; }); // ── Runtime config (super-admin-only env overrides, stored in Redis) ── // Read included: the payload names every tuning knob and which ones are // overridden, and this surface can disable the worker fleet-wide. app.get("/api/v1/admin/settings/runtime", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; if (!ctx.settings) { return reply .code(503) .send({ error: "runtime settings manager not initialized" }); } return { ...ctx.settings.view(), warnings: ctx.settings.currentWarnings(), canEdit: true, }; }); app.patch<{ Body: { patch?: Record; reset?: string[]; }; }>("/api/v1/admin/settings/runtime", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; if (!ctx.settings) { return reply .code(503) .send({ error: "runtime settings manager not initialized" }); } const body = req.body || {}; let result; try { result = await ctx.settings.patch({ patch: body.patch, reset: Array.isArray(body.reset) ? body.reset : undefined, actor: admin.username || admin.id, }); } catch (err) { // Never write a merge derived from a failed read — that would wipe every // other override fleet-wide. Ask the admin to retry instead. if (err instanceof RuntimeSettingsUnavailableError) { return reply.code(503).send({ error: err.message }); } throw err; } if (result.changed.length) { await writeAudit(ctx.db, "runtime_settings_updated", admin.id, { changed: result.changed, restartRequired: result.restartRequired, }); } return { ...result.view, warnings: result.warnings, changed: result.changed, restartRequired: result.restartRequired, canEdit: true, }; }); app.post("/api/v1/admin/settings/runtime/reset", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; if (!ctx.settings) { return reply .code(503) .send({ error: "runtime settings manager not initialized" }); } const result = await ctx.settings.patch({ resetAll: true, actor: admin.username || admin.id, }); await writeAudit(ctx.db, "runtime_settings_reset", admin.id, { changed: result.changed, }); return { ...result.view, warnings: result.warnings, changed: result.changed, restartRequired: result.restartRequired, canEdit: true, }; }); app.get("/api/v1/admin/bots", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; // listActiveBotIdsAsync is itself 2 chained RTTs — don't chain it after // the bot listing as well. const [bots, activeIds] = await Promise.all([ listBotAccounts(ctx.db), ctx.worker.listActiveBotIdsAsync(), ]); const active = new Set(activeIds); const botIds = bots.map((b) => b.id); const [owners, tokenMap, peerStats] = await Promise.all([ getUsersByIds( ctx.db, bots.map((b) => b.owner_user_id).filter(Boolean), ), hasBotCredentialsMany(ctx.db, botIds), peerStatsByBots(ctx.db, botIds), ]); const mapped = bots.map((b) => { const owner = b.owner_user_id ? owners.get(b.owner_user_id) : undefined; const stats = peerStats[b.id] ?? { peerCount: 0, unapprovedPeerCount: 0, }; return { id: b.id, displayName: b.display_name, ownerUserId: b.owner_user_id, ownerUsername: owner?.username ?? null, ownerName: owner?.name ?? null, status: b.status, accountRef: b.account_ref, workerActive: active.has(b.id), hasToken: Boolean(tokenMap[b.id]), peerCount: stats.peerCount, unapprovedPeerCount: stats.unapprovedPeerCount, updatedAt: b.updated_at, }; }); return { bots: mapped }; }); app.get<{ Params: { botId: string } }>( "/api/v1/admin/bots/:botId", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const bot = await getBotAccount(ctx.db, req.params.botId); if (!bot) return reply.code(404).send({ error: "not found" }); const owner = bot.owner_user_id ? await getUser(ctx.db, bot.owner_user_id) : undefined; const peers = await listPeers(ctx.db, bot.id); const [active, hasToken, asgMap] = await Promise.all([ ctx.worker.listActiveBotIdsAsync().then((ids) => new Set(ids)), hasBotCredentials(ctx.db, bot.id), getAssignmentsMany( ctx.db, peers.map((p) => ({ botAccountId: p.bot_account_id, peerId: p.peer_id, })), ), ]); // Message counts / last message still per-peer but parallelized const peerItems = await Promise.all( peers.map(async (p) => { const [msgCount, recent] = await Promise.all([ countUserMessages(ctx.db, p.bot_account_id, p.peer_id), listRecentMessages(ctx.db, p.bot_account_id, p.peer_id, 1), ]); const last = recent[recent.length - 1]; return { peerId: p.peer_id, approved: Boolean(p.approved), personaId: asgMap.get(`${p.bot_account_id}|${p.peer_id}`) ?? null, createdAt: p.created_at ?? null, messageCount: msgCount, lastMessageAt: last?.created_at ?? null, lastRole: last?.role ?? null, }; }), ); return { bot: { id: bot.id, displayName: bot.display_name, ownerUserId: bot.owner_user_id, ownerUsername: owner?.username ?? null, status: bot.status, accountRef: bot.account_ref, baseUrl: bot.base_url, workerActive: active.has(bot.id), hasToken, updatesCursor: bot.updates_cursor ? "set" : "empty", createdAt: bot.created_at, updatedAt: bot.updated_at, }, peers: peerItems, }; }, ); app.patch<{ Params: { botId: string }; Body: { displayName?: string; status?: "active" | "inactive" }; }>("/api/v1/admin/bots/:botId", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const bot = await getBotAccount(ctx.db, req.params.botId); if (!bot) return reply.code(404).send({ error: "not found" }); const displayName = req.body?.displayName; const status = req.body?.status; if (!displayName?.trim() && status !== "active" && status !== "inactive") { return reply .code(400) .send({ error: "displayName or status required" }); } try { let updated = bot; if (displayName?.trim()) { updated = await updateBotDisplayName(ctx.db, bot.id, displayName); await writeAudit(ctx.db, "admin_bot_renamed", admin.id, { botId: bot.id, displayName: updated.display_name, }); } if (status === "active" || status === "inactive") { updated = await setBotStatus(ctx.db, bot.id, status); if (status === "inactive") { ctx.worker.stopBot(bot.id); } else if (await hasBotCredentials(ctx.db, bot.id)) { // Always mark pollable; worker process claims if enabled ctx.worker.restartBot(bot.id); } await writeAudit(ctx.db, "admin_bot_status", admin.id, { botId: bot.id, status, }); } return { bot: { id: updated.id, displayName: updated.display_name, status: updated.status, ownerUserId: updated.owner_user_id, }, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return reply.code(400).send({ error: message }); } }); app.post<{ Params: { botId: string } }>( "/api/v1/admin/bots/:botId/stop-worker", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; const bot = await getBotAccount(ctx.db, req.params.botId); if (!bot) return reply.code(404).send({ error: "not found" }); ctx.worker.stopBot(bot.id); await writeAudit(ctx.db, "admin_bot_stop_worker", admin.id, { botId: bot.id, }); return { ok: true }; }, ); app.post<{ Params: { botId: string } }>( "/api/v1/admin/bots/:botId/start-worker", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; const bot = await getBotAccount(ctx.db, req.params.botId); if (!bot) return reply.code(404).send({ error: "not found" }); if (bot.status !== "active") { return reply.code(400).send({ error: "bot is not active" }); } if (!(await hasBotCredentials(ctx.db, bot.id))) { return reply .code(400) .send({ error: "missing token — user must re-scan login" }); } ctx.worker.restartBot(bot.id); await writeAudit(ctx.db, "admin_bot_start_worker", admin.id, { botId: bot.id, }); return { ok: true, workerActive: true }; }, ); app.delete<{ Params: { botId: string } }>( "/api/v1/admin/bots/:botId", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const bot = await getBotAccount(ctx.db, req.params.botId); if (!bot) return reply.code(404).send({ error: "not found" }); ctx.worker.stopBot(bot.id); await deleteBotAccount(ctx.db, bot.id); await writeAudit(ctx.db, "admin_bot_deleted", admin.id, { botId: bot.id, ownerUserId: bot.owner_user_id, }); return { ok: true }; }, ); // ── Admin broadcast (async text push) ────────────────── function publicBroadcastJob(job: Awaited>) { if (!job) return null; return { id: job.id, createdBy: job.createdBy, createdAt: job.createdAt, updatedAt: job.updatedAt, status: job.status, text: job.text, scope: job.scope, botIds: job.botIds, targetCount: job.targets?.length ?? 0, stats: job.stats, error: job.error ?? null, startedAt: job.startedAt ?? null, finishedAt: job.finishedAt ?? null, failures: job.failures ?? [], cursor: job.cursor ?? 0, recipientCount: job.recipients?.length ?? 0, }; } app.get("/api/v1/admin/broadcast", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; const limit = Number((req.query as { limit?: string }).limit ?? "30"); const jobs = await listBroadcastJobs(ctx.db, Math.min(100, Math.max(1, limit))); return { jobs: jobs.map((j) => publicBroadcastJob(j)) }; }); app.get<{ Params: { id: string } }>( "/api/v1/admin/broadcast/:id", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; const job = await getBroadcastJob(ctx.db, req.params.id); if (!job) return reply.code(404).send({ error: "not found" }); return { job: publicBroadcastJob(job) }; }, ); app.post<{ Body: { text?: string; scope?: BroadcastScope; botIds?: string[]; targets?: BroadcastTarget[]; /** If true, only expand & return counts — do not create */ preview?: boolean; }; }>("/api/v1/admin/broadcast", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; const text = (req.body?.text ?? "").trim(); const maxText = Math.max(1, ctx.cfg.broadcastMaxText || 2000); if (!text) { return reply.code(400).send({ error: "text required" }); } if (text.length > maxText) { return reply .code(400) .send({ error: `text too long (max ${maxText})` }); } const scope = req.body?.scope; if (scope !== "all_bots" && scope !== "bots" && scope !== "targets") { return reply .code(400) .send({ error: "scope must be all_bots | bots | targets" }); } const botIds = Array.isArray(req.body?.botIds) ? req.body!.botIds.map(String) : []; const targets = Array.isArray(req.body?.targets) ? req.body!.targets .filter( (t) => t && typeof t === "object" && typeof (t as BroadcastTarget).botId === "string" && typeof (t as BroadcastTarget).peerId === "string", ) .map((t) => ({ botId: String((t as BroadcastTarget).botId).trim(), peerId: String((t as BroadcastTarget).peerId).trim(), })) : []; if (scope === "bots" && !botIds.length) { return reply.code(400).send({ error: "botIds required for scope=bots" }); } if (scope === "targets" && !targets.length) { return reply .code(400) .send({ error: "targets required for scope=targets" }); } if (req.body?.preview) { const preview = await previewBroadcast(ctx.db, { scope, botIds, targets, }); return { preview: { deliverable: preview.deliverable, skippedNoToken: preview.skippedNoToken, missingBots: preview.missingBots, textLength: text.length, }, }; } try { const job = await createBroadcastJob(ctx.db, { createdBy: admin.id, text, scope, botIds, targets, historyLimit: ctx.cfg.broadcastHistory, }); await writeAudit(ctx.db, "admin_broadcast_create", admin.id, { jobId: job.id, scope: job.scope, total: job.stats.total, textLen: text.length, botIds: job.botIds, }); // Nudge worker to start immediately (same process or next poll) try { ctx.worker.wakeBroadcast(); } catch { /* worker may be disabled */ } return reply.code(201).send({ job: publicBroadcastJob(job) }); } catch (err) { const message = err instanceof Error ? err.message : String(err); return reply.code(400).send({ error: message }); } }); app.post<{ Params: { id: string } }>( "/api/v1/admin/broadcast/:id/cancel", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; try { const job = await cancelBroadcastJob(ctx.db, req.params.id); if (!job) return reply.code(404).send({ error: "not found" }); await writeAudit(ctx.db, "admin_broadcast_cancel", admin.id, { jobId: job.id, stats: job.stats, }); return { job: publicBroadcastJob(job) }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return reply.code(400).send({ error: message }); } }, ); app.get<{ Params: { botId: string } }>( "/api/v1/admin/bots/:botId/send-targets", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; const bot = await getBotAccount(ctx.db, req.params.botId); if (!bot) return reply.code(404).send({ error: "not found" }); const targets = await listBotSendTargets(ctx.db, bot.id); return { botId: bot.id, displayName: bot.display_name, targets, deliverable: targets.filter((t) => t.hasContextToken).length, total: targets.length, }; }, ); app.get("/api/v1/admin/audit", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const limit = Number((req.query as { limit?: string }).limit ?? "50"); return { logs: await listAuditLogs(ctx.db, Math.min(limit, 200)) }; }); /** * Recent activity stream backlog (super-admin). Merges Redis LIST + local ring. * Query: limit, types=message,redis,worker,llm, full=1 */ app.get("/api/v1/admin/stream/recent", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; if (!ctx.cfg.dataStreamEnabled || !ctx.activityBus) { return reply.code(503).send({ error: "data_stream_disabled" }); } const q = req.query as { limit?: string; types?: string; full?: string; }; const limit = Math.min(Math.max(Number(q.limit) || 100, 1), 300); const full = q.full === "1" || q.full === "true"; const typeFilter = parseStreamTypeFilter(q.types); let events = await ctx.activityBus.recentMerged(limit); if (typeFilter) { events = events.filter((e) => streamTypeMatches(e.type, typeFilter)); } events = events.map((e) => shapeStreamEventForClient(e, full)); return { events, enabled: true, source: ctx.activityBus.getSource(), }; }); /** * Live SSE activity stream (super-admin). * Query: types, full=1, heartbeat=15 */ app.get("/api/v1/admin/stream", async (req, reply) => { const admin = await requireSuperAdmin(req, reply, ctx); if (!admin) return; if (!ctx.cfg.dataStreamEnabled || !ctx.activityBus) { return reply.code(503).send({ error: "data_stream_disabled" }); } const q = req.query as { types?: string; full?: string; heartbeat?: string; limit?: string; }; const full = q.full === "1" || q.full === "true"; const typeFilter = parseStreamTypeFilter(q.types); const heartbeatSec = Math.min( Math.max(Number(q.heartbeat) || 15, 5), 60, ); const backlogLimit = Math.min(Math.max(Number(q.limit) || 80, 0), 200); const bus = ctx.activityBus; // Disable compression buffering for this response reply.hijack(); const raw = reply.raw; raw.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no", }); let closed = false; const write = (chunk: string) => { if (closed) return; try { raw.write(chunk); } catch { closed = true; } }; const sendEvent = (ev: import("./activity-stream.js").StreamEvent) => { if (typeFilter && !streamTypeMatches(ev.type, typeFilter)) return; const shaped = shapeStreamEventForClient(ev, full); write(`id: ${shaped.id}\n`); write(`event: activity\n`); write(`data: ${JSON.stringify(shaped)}\n\n`); }; write(`event: meta\ndata: ${JSON.stringify({ ok: true, source: bus.getSource(), full, types: q.types || "all", ts: new Date().toISOString(), })}\n\n`); if (backlogLimit > 0) { try { let backlog = await bus.recentMerged(backlogLimit); if (typeFilter) { backlog = backlog.filter((e) => streamTypeMatches(e.type, typeFilter)); } // Send oldest first so UI can append chronologically for (const ev of backlog.slice().reverse()) { sendEvent(ev); } } catch { /* */ } } const unsub = bus.subscribe((ev) => { sendEvent(ev); }); const heartbeat = setInterval(() => { write(`: ping ${Date.now()}\n\n`); }, heartbeatSec * 1000); const onClose = () => { if (closed) return; closed = true; clearInterval(heartbeat); unsub(); try { raw.end(); } catch { /* */ } }; req.raw.on("close", onClose); req.raw.on("error", onClose); }); app.get("/api/v1/admin/usage", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const q = req.query as { day?: string; days?: string }; if (q.days) { const n = Math.min(Math.max(Number(q.days) || 7, 1), 30); // Each day is an independent multi-RTT read — fan them out. 30 days // used to be ~30x the latency of one. const days = await Promise.all( Array.from({ length: n }, (_unused, i) => getUsageDayStats(ctx.db, dayKeyOffset(-i)), ), ); return { days }; } const day = q.day || dayKey(); return { usage: await getUsageDayStats(ctx.db, day) }; }); app.get("/api/v1/admin/personas", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const q = ((req.query as { q?: string }).q ?? "").trim().toLowerCase(); const includeDisabled = (req.query as { includeDisabled?: string }).includeDisabled === "1" || (req.query as { includeDisabled?: string }).includeDisabled === "true"; const all = await listPersonas(ctx.db); let personas = includeDisabled ? all : all.filter((p) => p.enabled); if (q) { personas = personas.filter((p) => { const hay = [p.display_name, p.description, p.slug, ...(p.tags || [])] .join(" ") .toLowerCase(); return hay.includes(q); }); } return { total: personas.length, personas: personas.map((p) => personaPublicDto(p, { enabled: p.enabled !== 0, isDefault: Boolean(p.is_default), }), ), }; }); app.get<{ Params: { id: string } }>( "/api/v1/admin/personas/:id", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const p = await getPersona(ctx.db, req.params.id); if (!p) return reply.code(404).send({ error: "not found" }); const prompt = await getPublishedPrompt(ctx.db, p.id); return { persona: personaPublicDto(p, { enabled: p.enabled !== 0, isDefault: Boolean(p.is_default), systemPrompt: prompt, }), }; }, ); app.post<{ Params: { id: string } }>( "/api/v1/admin/personas/:id/set-default", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; try { const persona = await setDefaultPersona(ctx.db, req.params.id); await writeAudit(ctx.db, "admin_persona_set_default", admin.id, { id: persona.id, }); return { ok: true, persona: { id: persona.id, displayName: persona.display_name, isDefault: true, }, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return reply.code(400).send({ error: message }); } }, ); app.put<{ Params: { id: string }; Body: { displayName?: string; description?: string; tags?: string[]; visibility?: "public" | "private"; systemPrompt?: string; }; }>("/api/v1/admin/personas/:id", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const p = await getPersona(ctx.db, req.params.id); if (!p) return reply.code(404).send({ error: "not found" }); try { const persona = await updatePersonaMeta(ctx.db, p.id, req.body ?? {}); await writeAudit(ctx.db, "admin_persona_updated", admin.id, { id: p.id, }); return { persona }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return reply.code(400).send({ error: message }); } }); app.post<{ Params: { id: string } }>( "/api/v1/admin/personas/:id/takedown", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const p = await getPersona(ctx.db, req.params.id); if (!p) return reply.code(404).send({ error: "not found" }); if (p.owner_user_id === "system") { return reply.code(403).send({ error: "cannot takedown system persona" }); } await softDeletePersona(ctx.db, p.id); await writeAudit(ctx.db, "persona_takedown", admin.id, { id: p.id }); return { ok: true }; }, ); app.post<{ Params: { id: string } }>( "/api/v1/admin/personas/:id/restore", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; try { const persona = await restorePersona(ctx.db, req.params.id); await writeAudit(ctx.db, "persona_restore", admin.id, { id: persona.id, }); return { ok: true, persona }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return reply.code(404).send({ error: message }); } }, ); app.post<{ Body: { slug: string; displayName: string; description?: string; systemPrompt: string; isDefault?: boolean; contentPolicy?: string; tags?: string[]; }; }>("/api/v1/admin/personas", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const body = req.body; if (!body?.slug || !body.displayName || !body.systemPrompt) { return reply.code(400).send({ error: "missing fields" }); } if (await getPersonaBySlug(ctx.db, body.slug)) { return reply.code(409).send({ error: "slug exists" }); } const persona = await createPersona(ctx.db, { slug: body.slug, displayName: body.displayName, description: body.description, systemPrompt: body.systemPrompt, isDefault: body.isDefault, contentPolicy: body.contentPolicy ?? "standard", ownerUserId: "system", visibility: "public", tags: Array.isArray(body.tags) ? body.tags : undefined, }); await writeAudit(ctx.db, "persona_created", admin.id, { id: persona.id }); return { persona }; }); app.post<{ Params: { id: string }; Body: { systemPrompt: string }; }>("/api/v1/admin/personas/:id/publish", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; if (!req.body?.systemPrompt) { return reply.code(400).send({ error: "systemPrompt required" }); } try { const version = await publishPersonaVersion( ctx.db, req.params.id, req.body.systemPrompt, ); await writeAudit(ctx.db, "admin_persona_publish", admin.id, { id: req.params.id, }); return { version }; } catch { return reply.code(404).send({ error: "not found" }); } }); // ── Admin stickers (review + official upload) ──────── app.get("/api/v1/admin/stickers", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const q = req.query as { q?: string; enabled?: string; status?: string; }; const reviewStatus = q.status === "pending" || q.status === "approved" || q.status === "rejected" ? q.status : "all"; let [stickers, pendingCount] = await Promise.all([ listStickers(ctx.db, { q: q.q, reviewStatus }), countPendingStickers(ctx.db), ]); if (q.enabled === "1" || q.enabled === "true") { stickers = stickers.filter((s) => s.enabled); } else if (q.enabled === "0" || q.enabled === "false") { stickers = stickers.filter((s) => !s.enabled); } return { stickers, pendingCount }; }); app.get<{ Params: { id: string } }>( "/api/v1/admin/stickers/:id", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const sticker = await getSticker(ctx.db, req.params.id); if (!sticker) return reply.code(404).send({ error: "not found" }); return { sticker }; }, ); app.get<{ Params: { id: string } }>( "/api/v1/admin/stickers/:id/image", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const sticker = await getSticker(ctx.db, req.params.id); if (!sticker) return reply.code(404).send({ error: "not found" }); return sendPrivateStickerImage(ctx, req, reply, sticker); }, ); app.post<{ Body: { slug?: string; displayName: string; description?: string; tags?: string[]; mime?: string; dataBase64: string; enabled?: boolean; autoApprove?: boolean; visibility?: "public" | "private"; }; }>("/api/v1/admin/stickers", { bodyLimit: ctx.cfg.uploadBodyLimit }, async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const body = req.body; if (!body?.displayName || !body.dataBase64) { return reply.code(400).send({ error: "missing fields" }); } if (body.slug && !isValidStickerSlug(body.slug.trim().toLowerCase())) { return reply .code(400) .send({ error: "invalid slug", hint: "use a-z0-9_- , 2-64 chars" }); } if (body.slug && (await getStickerBySlug(ctx.db, body.slug))) { return reply.code(409).send({ error: "slug exists" }); } try { const { data, mime } = parseStickerUpload(body, ctx.cfg.stickerMaxBytes); const sticker = await createSticker(ctx.db, { slug: body.slug, displayName: body.displayName, description: body.description, tags: Array.isArray(body.tags) ? body.tags : undefined, mime, sizeBytes: data.length, enabled: body.enabled !== false, ownerUserId: "system", visibility: body.visibility === "private" ? "private" : "public", autoApprove: body.autoApprove !== false, data, }); await writeAudit(ctx.db, "sticker_create", admin.id, { id: sticker.id, slug: sticker.slug, }); return { sticker }; } catch (err) { if (err instanceof StickerSecurityError) { return reply .code(400) .send({ error: "unsafe_image", code: err.code, message: err.message }); } const msg = (err as Error).message; if (msg === "slug exists") { return reply.code(409).send({ error: "slug exists" }); } if (msg === "invalid slug") { return reply.code(400).send({ error: "invalid slug" }); } throw err; } }); app.put<{ Params: { id: string }; Body: { slug?: string; displayName?: string; description?: string; tags?: string[]; enabled?: boolean; mime?: string; dataBase64?: string; visibility?: "public" | "private"; }; }>("/api/v1/admin/stickers/:id", { bodyLimit: ctx.cfg.uploadBodyLimit }, async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const cur = await getSticker(ctx.db, req.params.id); if (!cur) return reply.code(404).send({ error: "not found" }); const body = req.body ?? {}; try { if (body.dataBase64) { const { data, mime } = parseStickerUpload(body, ctx.cfg.stickerMaxBytes); await replaceStickerBlob(ctx.db, cur.id, data, { mime, fileName: makeStickerFileName(cur.id, mime), }); } // Admin meta edits do not force re-pending for system stickers const sticker = await updateStickerMeta(ctx.db, cur.id, { slug: body.slug, displayName: body.displayName, description: body.description, tags: body.tags, enabled: body.enabled, visibility: body.visibility, rePending: cur.owner_user_id !== "system", }); await writeAudit(ctx.db, "sticker_update", admin.id, { id: sticker.id, slug: sticker.slug, }); return { sticker }; } catch (err) { if (err instanceof StickerSecurityError) { return reply .code(400) .send({ error: "unsafe_image", code: err.code, message: err.message }); } const msg = (err as Error).message; if (msg === "slug exists") { return reply.code(409).send({ error: "slug exists" }); } if (msg === "invalid slug") { return reply.code(400).send({ error: "invalid slug" }); } if (msg === "not found") { return reply.code(404).send({ error: "not found" }); } throw err; } }); app.post<{ Params: { id: string } }>( "/api/v1/admin/stickers/:id/approve", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; try { const sticker = await approveSticker(ctx.db, req.params.id, admin.id); await writeAudit(ctx.db, "sticker_approve", admin.id, { id: sticker.id, }); return { sticker }; } catch { return reply.code(404).send({ error: "not found" }); } }, ); app.post<{ Params: { id: string }; Body: { reason?: string }; }>("/api/v1/admin/stickers/:id/reject", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; try { const sticker = await rejectSticker( ctx.db, req.params.id, admin.id, req.body?.reason, ); await writeAudit(ctx.db, "sticker_reject", admin.id, { id: sticker.id, reason: req.body?.reason, }); return { sticker }; } catch { return reply.code(404).send({ error: "not found" }); } }); app.post<{ Params: { id: string } }>( "/api/v1/admin/stickers/:id/takedown", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; try { const sticker = await softDeleteSticker(ctx.db, req.params.id); await writeAudit(ctx.db, "sticker_takedown", admin.id, { id: sticker.id, }); return { sticker }; } catch { return reply.code(404).send({ error: "not found" }); } }, ); app.post<{ Params: { id: string } }>( "/api/v1/admin/stickers/:id/restore", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; try { const sticker = await restoreSticker(ctx.db, req.params.id); await writeAudit(ctx.db, "sticker_restore", admin.id, { id: sticker.id, }); return { sticker }; } catch { return reply.code(404).send({ error: "not found" }); } }, ); app.delete<{ Params: { id: string } }>( "/api/v1/admin/stickers/:id", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const removed = await deleteSticker(ctx.db, req.params.id); if (!removed) return reply.code(404).send({ error: "not found" }); await writeAudit(ctx.db, "sticker_delete", admin.id, { id: removed.id, slug: removed.slug, }); return { ok: true }; }, ); app.get("/api/v1/me/memories", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const q = req.query as { botAccountId?: string; peerId?: string; personaId?: string; }; if (!q.botAccountId || !q.peerId) { return reply.code(400).send({ error: "botAccountId and peerId required" }); } const bot = await getBotAccount(ctx.db, q.botAccountId); if (!bot || (bot.owner_user_id !== user.id && !user.is_admin)) { return reply.code(403).send({ error: "forbidden" }); } if (q.personaId) { return { memories: await listMemories( ctx.db, q.botAccountId, q.peerId, q.personaId, ), }; } const personas = await listPersonas(ctx.db); // One pipelined wave instead of one LRANGE per persona on the platform const memMap = await listMemoriesMany( ctx.db, q.botAccountId, q.peerId, personas.map((p) => p.id), ); const groups: { personaId: string; personaName: string; memories: { id: string; content: string }[]; }[] = []; let total = 0; for (const p of personas) { const mems = memMap.get(p.id); if (mems?.length) { groups.push({ personaId: p.id, personaName: p.display_name, memories: mems.map((m) => ({ id: m.id, content: m.content })), }); total += mems.length; } } return { total, groups }; }); app.post<{ Body: { botAccountId: string; peerId: string; personaId?: string }; }>("/api/v1/me/memories/reset", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const { botAccountId, peerId, personaId } = req.body ?? {}; if (!botAccountId || !peerId) { return reply.code(400).send({ error: "missing fields" }); } const bot = await getBotAccount(ctx.db, botAccountId); if (!bot || (bot.owner_user_id !== user.id && !user.is_admin)) { return reply.code(403).send({ error: "forbidden" }); } await ctx.chat.resetMemory(botAccountId, peerId, personaId); return { ok: true }; }); app.delete<{ Body: { botAccountId: string; peerId: string; personaId: string; memoryId: string; }; }>("/api/v1/me/memories", async (req, reply) => { const user = await requireUser(req, reply, ctx); if (!user) return; const { botAccountId, peerId, personaId, memoryId } = req.body ?? {}; if (!botAccountId || !peerId || !personaId || !memoryId) { return reply.code(400).send({ error: "missing fields" }); } const bot = await getBotAccount(ctx.db, botAccountId); if (!bot || (bot.owner_user_id !== user.id && !user.is_admin)) { return reply.code(403).send({ error: "forbidden" }); } const ok = await deleteMemory( ctx.db, botAccountId, peerId, personaId, memoryId, ); if (!ok) return reply.code(404).send({ error: "memory not found" }); await writeAudit(ctx.db, "memory_delete", user.id, { botAccountId, peerId, personaId, memoryId, }); return { ok: true }; }); app.delete<{ Body: { botAccountId: string; peerId: string; personaId: string; memoryId: string; }; }>("/api/v1/admin/memories", async (req, reply) => { const admin = await requireAdmin(req, reply, ctx); if (!admin) return; const { botAccountId, peerId, personaId, memoryId } = req.body ?? {}; if (!botAccountId || !peerId || !personaId || !memoryId) { return reply .code(400) .send({ error: "botAccountId, peerId, personaId, memoryId required" }); } const bot = await getBotAccount(ctx.db, botAccountId); if (!bot) return reply.code(404).send({ error: "bot not found" }); const ok = await deleteMemory( ctx.db, botAccountId, peerId, personaId, memoryId, ); if (!ok) return reply.code(404).send({ error: "memory not found" }); await writeAudit(ctx.db, "admin_memory_delete", admin.id, { botAccountId, peerId, personaId, memoryId, }); return { ok: true }; }); }