277 lines
8.8 KiB
JavaScript
277 lines
8.8 KiB
JavaScript
// 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";
|
||
|
||
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;
|
||
|
||
const STOP_WORDS = new Set([
|
||
"的", "了", "是", "在", "和", "与", "或", "也", "都", "就", "不", "没", "要", "我", "你", "他", "她", "它", "们",
|
||
"这", "那", "有", "为", "到", "对", "及", "等", "把", "被", "从", "向", "以", "其", "之", "于", "上", "下",
|
||
"里", "外", "中", "一个", "一些", "我们", "你们", "他们", "什么", "怎么", "为什么", "啊", "吗", "呢", "吧",
|
||
"嗯", "哦", "哈", "啦", "嘿", "唉", "这个", "那个", "一个", "真的", "应该", "可能", "觉得", "认为",
|
||
]);
|
||
|
||
function tokenize(text) {
|
||
if (!text) return [];
|
||
return text
|
||
.replace(/[,。!?、;:""''【】《》()()\[\]·—\.\,\!\?\;\:\'\"\\\/]/g, " ")
|
||
.split(/\s+/)
|
||
.filter((w) => w.length >= 2 && !STOP_WORDS.has(w));
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|