// Bot 画像 (BotPersona) 共享模块 // 从 bot-affinity-update.mjs 抽出,bot-activity.mjs 也能复用 // 用法: // import { updatePersona, schedulePersonaRefresh } from "./lib/bot-persona.mjs"; // await updatePersona(botUser, botConfig.id); // schedulePersonaRefresh(botUserId, botConfig.id); // 异步、防抖 import { PrismaClient } from "@prisma/client"; import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import "dotenv/config"; import { tokenize } from "./chinese-text.mjs"; const base = (process.env.DATABASE_URL || "").replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=2&pool_timeout=10`; // 共享一个 Prisma 客户端(连接池小,只为后台异步刷新服务) let _prisma = null; function getPrisma() { if (!_prisma) { const adapter = new PrismaMariaDb(connectionString); _prisma = new PrismaClient({ adapter }); } return _prisma; } const PERSONA_LOOKBACK_DAYS = 30; const TOP_KEYWORDS = 12; const TOP_HUMAN_USERS = 5; const TOP_TOPIC_TYPES = 5; const LAST_TITLES_COUNT = 5; function pickTopWithCount(arr, topN) { const counts = new Map(); for (const item of arr) { if (!item) continue; counts.set(item, (counts.get(item) || 0) + 1); } return Array.from(counts.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, topN) .map(([k, v]) => ({ key: k, count: v })); } function ts() { return `[${new Date().toISOString()}]`; } /** * 拉取过去 N 天该 bot 的所有发帖/回复,统计画像数据 * @returns persona object (or empty persona when no posts) */ export async function computePersona(botUser, botConfigId) { const prisma = getPrisma(); const since = new Date(Date.now() - PERSONA_LOOKBACK_DAYS * 24 * 60 * 60 * 1000); const topics = await prisma.forumTopic.findMany({ where: { userId: botUser.id, createdAt: { gt: since } }, include: { category: { select: { name: true, slug: true } }, posts: { include: { user: { select: { id: true, name: true, isBot: true } } }, }, }, orderBy: { createdAt: "desc" }, }); if (topics.length === 0) { return { avgReplyLength: 0, questionRatio: 0, exclamationCount: 0, avgTopicLength: 0, stanceKeywords: [], topHumanUsers: [], topTopicTypes: [], lastTopicTitles: [], postSampleCount: 0, }; } const allPosts = []; for (const t of topics) { allPosts.push({ content: t.content, isTopic: true, createdAt: t.createdAt, forumSlug: t.category.slug, authorId: t.userId, }); for (const p of t.posts) { allPosts.push({ content: p.content, isTopic: false, createdAt: p.createdAt, forumSlug: t.category.slug, authorId: p.userId, humanAuthor: !p.user.isBot ? p.user : null, }); } } const myPosts = allPosts.filter((p) => p.authorId === botUser.id); const replyLengths = myPosts.filter((p) => !p.isTopic).map((p) => p.content.length); const topicLengths = myPosts.filter((p) => p.isTopic).map((p) => p.content.length); const fullText = myPosts.map((p) => p.content).join(" "); const questionMarks = (fullText.match(/[??]/g) || []).length; const exclamationMarks = (fullText.match(/[!!]/g) || []).length; const questionRatio = myPosts.length > 0 ? questionMarks / myPosts.length : 0; const keywords = tokenize(fullText); const stanceKeywords = pickTopWithCount(keywords, TOP_KEYWORDS); const humanRepliers = allPosts .filter((p) => p.humanAuthor) .map((p) => ({ id: p.humanAuthor.id, name: p.humanAuthor.name || "匿名" })); const userCount = new Map(); for (const h of humanRepliers) { const k = `${h.id}|${h.name}`; userCount.set(k, (userCount.get(k) || 0) + 1); } const topHumanUsers = Array.from(userCount.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, TOP_HUMAN_USERS) .map(([k, c]) => { const [id, name] = k.split("|"); return { id: Number(id), name, count: c }; }); const forumCount = new Map(); for (const t of topics) { const k = `${t.category.slug}|${t.category.name}`; forumCount.set(k, (forumCount.get(k) || 0) + 1); } const topTopicTypes = Array.from(forumCount.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, TOP_TOPIC_TYPES) .map(([k, c]) => { const [slug, name] = k.split("|"); return { slug, name, count: c }; }); const lastTopicTitles = topics .slice(0, LAST_TITLES_COUNT) .map((t) => t.title); return { avgReplyLength: replyLengths.length ? Math.round(replyLengths.reduce((s, n) => s + n, 0) / replyLengths.length) : 0, questionRatio: Math.round(questionRatio * 100) / 100, exclamationCount: exclamationMarks, avgTopicLength: topicLengths.length ? Math.round(topicLengths.reduce((s, n) => s + n, 0) / topicLengths.length) : 0, stanceKeywords, topHumanUsers, topTopicTypes, lastTopicTitles, postSampleCount: myPosts.length, }; } /** * 计算并写回 BotPersona(同步版本,返回是否成功) * @param botUser - bot User 实体(含 id) * @param botConfigId - BotConfig.id * @returns { success: boolean, postSampleCount: number } */ export async function updatePersona(botUser, botConfigId) { const prisma = getPrisma(); const persona = await computePersona(botUser, botConfigId); if (persona.postSampleCount === 0) { return { success: false, postSampleCount: 0 }; } await prisma.botPersona.upsert({ where: { botId: botConfigId }, create: { botId: botConfigId, ...persona }, update: persona, }); return { success: true, postSampleCount: persona.postSampleCount }; } // ============ 异步防抖调度 ============ // 每次发帖/回复后调用 schedulePersonaRefresh(botUserId, botConfigId): // - 同一 bot 在 COOLDOWN_MS 内多次触发,只跑一次(防抖) // - 失败时静默记录,不影响主流程 const COOLDOWN_MS = 5 * 60 * 1000; // 5 分钟防抖 const lastRunMap = new Map(); // botConfigId -> timestamp(ms) const inFlightSet = new Set(); // 正在跑 refresh 的 botConfigId /** * 异步 + 防抖触发 persona 刷新 * - 5 分钟内同一 bot 只跑一次 * - 不阻塞调用方,立即返回 * - 失败时 console.warn,不抛出 * @returns true 表示本次触发了真实刷新,false 表示被防抖跳过 */ export function schedulePersonaRefresh(botUserId, botConfigId, options = {}) { const cooldown = options.cooldownMs ?? COOLDOWN_MS; const now = Date.now(); const last = lastRunMap.get(botConfigId) || 0; if (now - last < cooldown) { return false; } if (inFlightSet.has(botConfigId)) { return false; } inFlightSet.add(botConfigId); lastRunMap.set(botConfigId, now); // 推迟到下一个 tick,避免阻塞发帖主流程 setImmediate(async () => { try { const prisma = getPrisma(); const botUser = await prisma.user.findUnique({ where: { id: botUserId } }); if (!botUser) return; const result = await updatePersona(botUser, botConfigId); if (result.success) { console.log( `${ts()} 🪞 persona 异步刷新: bot#${botConfigId} (sample=${result.postSampleCount})` ); } } catch (err) { console.warn( `${ts()} ⚠️ persona 异步刷新失败 bot#${botConfigId}:`, err.message ); } finally { inFlightSet.delete(botConfigId); } }); return true; } /** * 强制立即刷新(绕开防抖),用于定时任务兜底 */ export async function forcePersonaRefresh(botUserId, botConfigId) { const prisma = getPrisma(); const botUser = await prisma.user.findUnique({ where: { id: botUserId } }); if (!botUser) return { success: false, postSampleCount: 0 }; return updatePersona(botUser, botConfigId); } /** * 清空防抖状态(测试 / 主流程退出时用) */ export function resetPersonaDebounce() { lastRunMap.clear(); inFlightSet.clear(); } /** * 优雅关闭 prisma 客户端(主进程退出前调用) */ export async function disconnectBotPersona() { if (_prisma) { await _prisma.$disconnect(); _prisma = null; } }