mirror of
https://github.com/SMNETSTUDIO/WeChat-AI.git
synced 2026-08-22 11:03:43 +08:00
4907 lines
157 KiB
TypeScript
4907 lines
157 KiB
TypeScript
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<string, unknown>,
|
||
): Record<string, unknown> {
|
||
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<string, string> {
|
||
const out: Record<string, string> = {};
|
||
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<User | null> {
|
||
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<User | null> {
|
||
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<User | null> {
|
||
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<unknown> {
|
||
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<string> | 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<string>): 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<void> {
|
||
// 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<ReturnType<typeof peekInviteCode>> = 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<string, number> = {
|
||
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<string, string>)),
|
||
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<string, unknown>;
|
||
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<ReturnType<typeof getBroadcastJob>>) {
|
||
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 };
|
||
});
|
||
}
|