问题排查与修复: 1. 🔴 bot-activity从未运行 — 新增cron任务(9-23点每小时15轮) 2. 🔴 task7报错'板块不存在: ai-tools' — 更新所有slug为新的51子板块格式 3. 🟡 install-cron.sh旧cron清理不完整 — 改为覆盖模式 4. 🟡 Google News RSS被墙 — 优化fallback提示词用DeepSeek知识+当前日期 效果: - 8个定时任务全部正常运行 - 首次运行产出10个话题(7+3),社区从12→24帖 - 每小时15轮持续产出,预计每天~45帖
694 lines
27 KiB
JavaScript
694 lines
27 KiB
JavaScript
import { PrismaClient } from "@prisma/client";
|
|
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
|
import { readFileSync } from "fs";
|
|
import { resolve, dirname } from "path";
|
|
import { fileURLToPath } from "url";
|
|
import OpenAI from "openai";
|
|
import "dotenv/config";
|
|
import { withRetry } from "./lib/retry.mjs";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
|
|
const sep = base.includes("?") ? "&" : "?";
|
|
const connectionString = `${base}${sep}connection_limit=10&pool_timeout=30`;
|
|
const adapter = new PrismaMariaDb(connectionString);
|
|
const prisma = new PrismaClient({ adapter });
|
|
|
|
const openai = new OpenAI({
|
|
apiKey: process.env.DEEPSEEK_API_KEY,
|
|
baseURL: "https://api.deepseek.com/v1",
|
|
});
|
|
|
|
const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
|
|
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat";
|
|
|
|
const FORUM_SLUGS = [
|
|
"ec-platform", "ec-livestream", "ec-private", "ec-group", "ec-dtc", "ec-supply",
|
|
"ai-tools-app", "ai-startup-forum", "ai-saas", "ai-hardware", "ai-llm",
|
|
"ct-short-video", "ct-live", "ct-writing", "ct-mcn", "ct-podcast",
|
|
"fi-stock", "fi-crypto", "fi-insurance", "fi-pe", "fi-forex",
|
|
"cb-ecommerce", "cb-factory", "cb-logistics", "cb-payment", "cb-brand",
|
|
"re-residential", "re-commercial", "re-renovation", "re-property",
|
|
"fd-restaurant", "fd-tea", "fd-prepared", "fd-supply", "fd-delivery",
|
|
"ll-beauty", "ll-fitness", "ll-pet", "ll-housekeeping", "ll-repair", "ll-edu-local",
|
|
"hl-cosmetic", "hl-wellness", "hl-elderly", "hl-rehab", "hl-mental",
|
|
"edu-skills", "edu-knowledge", "edu-abroad", "edu-corporate", "edu-k12",
|
|
];
|
|
|
|
const FORUM_NAMES = {
|
|
"ec-platform": "平台电商", "ec-livestream": "直播带货", "ec-private": "私域电商",
|
|
"ec-group": "社区团购", "ec-dtc": "独立站/DTC", "ec-supply": "选品与供应链",
|
|
"ai-tools-app": "AI工具与应用", "ai-startup-forum": "AI创业", "ai-saas": "SaaS出海",
|
|
"ai-hardware": "智能硬件", "ai-llm": "大模型与开源",
|
|
"ct-short-video": "短视频创作", "ct-live": "直播运营", "ct-writing": "图文内容",
|
|
"ct-mcn": "MCN机构", "ct-podcast": "播客与音频",
|
|
"fi-stock": "股票与基金", "fi-crypto": "加密货币/Web3", "fi-insurance": "保险与理财",
|
|
"fi-pe": "PE与VC", "fi-forex": "外汇与期货",
|
|
"cb-ecommerce": "跨境电商平台", "cb-factory": "外贸工厂/B2B", "cb-logistics": "海外仓与物流",
|
|
"cb-payment": "跨境支付合规", "cb-brand": "品牌出海/DTC",
|
|
"re-residential": "住宅买卖", "re-commercial": "商业地产",
|
|
"re-renovation": "装修与家居", "re-property": "物业与租赁",
|
|
"fd-restaurant": "餐饮开店", "fd-tea": "茶饮与咖啡", "fd-prepared": "预制菜加工",
|
|
"fd-supply": "食品供应链", "fd-delivery": "外卖运营",
|
|
"ll-beauty": "美容美发", "ll-fitness": "健身运动", "ll-pet": "宠物经济",
|
|
"ll-housekeeping": "家政服务", "ll-repair": "维修与数码", "ll-edu-local": "本地教培",
|
|
"hl-cosmetic": "医疗美容", "hl-wellness": "健康管理", "hl-elderly": "养老服务",
|
|
"hl-rehab": "运动康复", "hl-mental": "心理健康",
|
|
"edu-skills": "职业技能", "edu-knowledge": "知识付费", "edu-abroad": "留学与语言",
|
|
"edu-corporate": "企业培训", "edu-k12": "K12与家庭",
|
|
};
|
|
|
|
const SUB_CATEGORY_MAP = {
|
|
"ec-platform": ["ec-platform"], "ec-livestream": ["ec-livestream"], "ec-private": ["ec-private"],
|
|
"ec-group": ["ec-group"], "ec-dtc": ["ec-dtc"], "ec-supply": ["ec-supply"],
|
|
"ai-tools-app": ["ai-tools-app"], "ai-startup-forum": ["ai-startup-forum"], "ai-saas": ["ai-saas"],
|
|
"ai-hardware": ["ai-hardware"], "ai-llm": ["ai-llm"],
|
|
"ct-short-video": ["ct-short-video"], "ct-live": ["ct-live"], "ct-writing": ["ct-writing"],
|
|
"ct-mcn": ["ct-mcn"], "ct-podcast": ["ct-podcast"],
|
|
"fi-stock": ["fi-stock"], "fi-crypto": ["fi-crypto"], "fi-insurance": ["fi-insurance"],
|
|
"fi-pe": ["fi-pe"], "fi-forex": ["fi-forex"],
|
|
"cb-ecommerce": ["cb-ecommerce"], "cb-factory": ["cb-factory"], "cb-logistics": ["cb-logistics"],
|
|
"cb-payment": ["cb-payment"], "cb-brand": ["cb-brand"],
|
|
"re-residential": ["re-residential"], "re-commercial": ["re-commercial"],
|
|
"re-renovation": ["re-renovation"], "re-property": ["re-property"],
|
|
"fd-restaurant": ["fd-restaurant"], "fd-tea": ["fd-tea"], "fd-prepared": ["fd-prepared"],
|
|
"fd-supply": ["fd-supply"], "fd-delivery": ["fd-delivery"],
|
|
"ll-beauty": ["ll-beauty"], "ll-fitness": ["ll-fitness"], "ll-pet": ["ll-pet"],
|
|
"ll-housekeeping": ["ll-housekeeping"], "ll-repair": ["ll-repair"], "ll-edu-local": ["ll-edu-local"],
|
|
"hl-cosmetic": ["hl-cosmetic"], "hl-wellness": ["hl-wellness"], "hl-elderly": ["hl-elderly"],
|
|
"hl-rehab": ["hl-rehab"], "hl-mental": ["hl-mental"],
|
|
"edu-skills": ["edu-skills"], "edu-knowledge": ["edu-knowledge"], "edu-abroad": ["edu-abroad"],
|
|
"edu-corporate": ["edu-corporate"], "edu-k12": ["edu-k12"],
|
|
};
|
|
|
|
const NEWS_SEARCH_QUERIES = {
|
|
"ec-platform": "电商 淘宝 京东 拼多多 平台 最新",
|
|
"ec-livestream": "直播带货 抖音直播 快手电商 最新",
|
|
"ec-private": "私域流量 微信电商 社群运营 最新",
|
|
"ec-group": "社区团购 美团优选 多多买菜 最新",
|
|
"ec-dtc": "DTC品牌 Shopify 独立站 电商出海",
|
|
"ec-supply": "供应链 选品 电商货源 库存管理",
|
|
"ai-tools-app": "AI工具 ChatGPT Claude AI应用 最新",
|
|
"ai-startup-forum": "AI创业 人工智能创业 融资 最新",
|
|
"ai-saas": "SaaS AI出海 软件服务 最新",
|
|
"ai-hardware": "AI硬件 智能设备 机器人 IoT",
|
|
"ai-llm": "大模型 LLM 开源模型 人工智能",
|
|
"ct-short-video": "短视频 抖音 B站 创作者 最新",
|
|
"ct-live": "直播运营 直播带货 主播 最新",
|
|
"ct-writing": "公众号 小红书 自媒体 图文创作",
|
|
"ct-mcn": "MCN机构 达人孵化 网红经济",
|
|
"ct-podcast": "播客 小宇宙 音频创作 最新",
|
|
"fi-stock": "A股 港股 美股 股票 基金 最新",
|
|
"fi-crypto": "比特币 加密货币 区块链 Web3",
|
|
"fi-insurance": "保险 理财 资产配置 最新",
|
|
"fi-pe": "PE VC 风险投资 融资 最新",
|
|
"fi-forex": "外汇 期货 大宗商品 黄金 最新",
|
|
"cb-ecommerce": "跨境电商 Amazon Temu TikTokShop",
|
|
"cb-factory": "外贸工厂 制造业 出口 B2B 最新",
|
|
"cb-logistics": "跨境物流 海外仓 FBA 供应链",
|
|
"cb-payment": "跨境支付 收款 汇率 税务合规",
|
|
"cb-brand": "品牌出海 DTC 全球化 本地化",
|
|
"re-residential": "房价 楼市 买房 学区房 最新",
|
|
"re-commercial": "商铺 写字楼 商业地产 最新",
|
|
"re-renovation": "装修 家居 建材 设计 最新",
|
|
"re-property": "物业 租赁 租房 资产托管",
|
|
"fd-restaurant": "餐饮创业 开店 餐厅 加盟 最新",
|
|
"fd-tea": "茶饮 咖啡 奶茶 新茶饮 饮品",
|
|
"fd-prepared": "预制菜 中央厨房 食品加工",
|
|
"fd-supply": "食品供应链 食材 冷链 配送",
|
|
"fd-delivery": "外卖 美团 饿了么 外卖运营",
|
|
"ll-beauty": "美容 美发 美甲 皮肤管理 最新",
|
|
"ll-fitness": "健身 瑜伽 健身房 运动 最新",
|
|
"ll-pet": "宠物 宠物店 宠物医疗 最新",
|
|
"ll-housekeeping": "家政 保洁 月嫂 保姆 最新",
|
|
"ll-repair": "手机维修 家电维修 数码维修",
|
|
"ll-edu-local": "培训 艺术培训 体育培训 托管",
|
|
"hl-cosmetic": "医美 轻医美 热玛吉 水光针",
|
|
"hl-wellness": "健康管理 保健品 慢病 体检",
|
|
"hl-elderly": "养老 老龄化 居家养老 适老化",
|
|
"hl-rehab": "运动康复 产后康复 物理治疗",
|
|
"hl-mental": "心理健康 心理咨询 情绪管理",
|
|
"edu-skills": "职业技能 编程培训 设计培训 考证",
|
|
"edu-knowledge": "知识付费 在线教育 课程 社群",
|
|
"edu-abroad": "留学 出国 语言培训 游学",
|
|
"edu-corporate": "企业培训 管理培训 团建 内训",
|
|
"edu-k12": "K12教育 学科辅导 素质教育 家庭",
|
|
};
|
|
|
|
function ts() {
|
|
return `[${new Date().toISOString()}]`;
|
|
}
|
|
|
|
function pick(arr) {
|
|
return arr[Math.floor(Math.random() * arr.length)];
|
|
}
|
|
|
|
function slugify(str) {
|
|
const clean = str.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-").replace(/^-|-$/g, "").substring(0, 80);
|
|
return `${clean}-${Date.now().toString(36)}`;
|
|
}
|
|
|
|
async function fetchGoogleNewsRSS(query, lang = "zh-CN", country = "CN") {
|
|
try {
|
|
const url = `https://news.google.com/rss/search?q=${encodeURIComponent(query)}&hl=${lang}&gl=${country}&ceid=${country}:${lang === "zh-CN" ? "zh-Hans" : "en"}`;
|
|
const response = await fetch(url, {
|
|
headers: { "User-Agent": "Mozilla/5.0 (compatible; PasserbyBot/1.0)" },
|
|
signal: AbortSignal.timeout(8000),
|
|
});
|
|
if (!response.ok) return [];
|
|
const xml = await response.text();
|
|
const items = [];
|
|
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
|
|
let match;
|
|
while ((match = itemRegex.exec(xml)) !== null) {
|
|
const block = match[1];
|
|
const titleMatch = block.match(/<title><!\[CDATA\[([\s\S]*?)\]\]><\/title>/) || block.match(/<title>([\s\S]*?)<\/title>/);
|
|
const linkMatch = block.match(/<link>([\s\S]*?)<\/link>/);
|
|
const pubDateMatch = block.match(/<pubDate>([\s\S]*?)<\/pubDate>/);
|
|
if (titleMatch) {
|
|
items.push({
|
|
title: titleMatch[1].trim(),
|
|
url: linkMatch ? linkMatch[1].trim() : "",
|
|
pubDate: pubDateMatch ? pubDateMatch[1].trim() : "",
|
|
});
|
|
}
|
|
}
|
|
return items.filter(item => {
|
|
if (!item.pubDate) return true;
|
|
const pubTime = new Date(item.pubDate).getTime();
|
|
const threeDaysAgo = Date.now() - 3 * 24 * 60 * 60 * 1000;
|
|
return pubTime > threeDaysAgo;
|
|
}).slice(0, 8);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function fetchRecentNewsForCategory(forumSlug) {
|
|
const query = NEWS_SEARCH_QUERIES[forumSlug];
|
|
if (!query) return [];
|
|
console.log(`${ts()} Searching news: "${query}"`);
|
|
const cnNews = await fetchGoogleNewsRSS(query, "zh-CN", "CN");
|
|
console.log(`${ts()} Found ${cnNews.length} recent Chinese news items`);
|
|
return cnNews;
|
|
}
|
|
|
|
function buildPasserbyTopicPrompt(bot, forumName, forumDescription, newsItems) {
|
|
const p = bot.personality;
|
|
const newsText = newsItems.length > 0
|
|
? newsItems.map((n, i) => `${i + 1}. [${n.pubDate || "近期"}] ${n.title}`).join("\n")
|
|
: `(搜索不到实时新闻,请充分利用DeepSeek知识库中截至2025年6月的最新行业信息。今天是${today},你必须让话题看起来像是今天或最近几天发生的。可以提及:今年发生的重大事件、近几个月出台的新政策、当前季节性的行业趋势、最近热门的讨论。在你的发言中自然地加入时间锚点,比如"上个月刚出来的…"、"最近大家都在说…"、"这个月的数据显示…"等,让读者第一眼就感受到这是当前的话题而不是老帖)`;
|
|
|
|
const today = new Date().toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" });
|
|
const passerbyType = p.passerbyType || "observer";
|
|
|
|
const typeInstructions = {
|
|
news: "你是一个善于发现行业动态的人。请从上面的近期新闻中选一个最有话题性的,用你的视角提出讨论。话题必须引用具体新闻事件,不能泛泛而谈",
|
|
observer: "你是一个观察敏锐的人。请结合上面新闻中反映的行业趋势,提出一个值得深挖的问题。话题要有洞察力,引导大家思考现象背后的原因",
|
|
problem: "你是一个正在这个行业里遇到实际困难的人。请结合上面的近期新闻和行业现状,提出一个具体的、有代表性的困扰或难题。问题要有真实感,让人感同身受",
|
|
};
|
|
|
|
const instruction = typeInstructions[passerbyType] || typeInstructions.observer;
|
|
|
|
return `你正在参与一个叫"追光AI行业论坛"的中文社区。今天是${today}。你现在在"${forumName}"板块发言。
|
|
|
|
${forumDescription}
|
|
|
|
[你的角色设定]
|
|
你是【${bot.displayName}】,${p.identity}
|
|
你的角色定位:${p.stance}
|
|
你说话的风格:${p.speakingStyle}
|
|
你的口头禅:${p.catchphrase}
|
|
|
|
[近期行业新闻参考]
|
|
${newsText}
|
|
|
|
[发言要求]
|
|
${instruction}
|
|
请以${bot.displayName}的身份在这个板块发起一个新话题。要求:
|
|
1. 话题标题10-20字,要像真人发的帖子,有真实感,不能写成新闻标题或论文标题
|
|
2. 内容180-400字,结合上面的新闻素材,有具体细节和你的个人感受
|
|
3. 如果引用了具体新闻,用自己的话说出来,不要像官方报道
|
|
4. 使用你的说话风格,自然口语化,可以有情绪和困惑
|
|
5. 结尾留一个问题引导大家参与讨论
|
|
|
|
[禁止事项]
|
|
- 禁止使用"首先/其次/最后"等格式化连接词
|
|
- 禁止使用"从以下几个方面分析""综上所述"等教科书句式
|
|
- 禁止表现得像AI助手或官方新闻报道
|
|
- 话题中必须让人感受到"这是最近/今天发生的事",不能像泛泛而谈的老话题
|
|
${p.forbiddenPatterns ? `- 禁止使用${p.forbiddenPatterns.join("、")}` : ""}
|
|
|
|
请只返回JSON格式,不要包含其他任何内容:
|
|
{
|
|
"title": "话题标题",
|
|
"content": "话题内容"
|
|
}`;
|
|
}
|
|
|
|
async function logTask(action, status, detail) {
|
|
try {
|
|
await prisma.taskLog.create({
|
|
data: {
|
|
taskKey: "bot-activity",
|
|
taskName: "Bot活跃度调度",
|
|
status,
|
|
startedAt: new Date(),
|
|
finishedAt: new Date(),
|
|
triggerBy: "cron",
|
|
result: { action, detail },
|
|
},
|
|
});
|
|
} catch (e) {
|
|
console.error(`${ts()} TaskLog error:`, e.message);
|
|
}
|
|
}
|
|
|
|
async function loadBotCharacters() {
|
|
const raw = readFileSync(BOT_DATA_PATH, "utf-8");
|
|
const { characters } = JSON.parse(raw);
|
|
const map = {};
|
|
for (const c of characters) {
|
|
map[c.key] = c;
|
|
}
|
|
return { characters, map };
|
|
}
|
|
|
|
function buildTopicPrompt(bot, forumName, forumDescription) {
|
|
const p = bot.personality;
|
|
return `你正在参与一个叫"追光AI行业论坛"的中文社区。你现在在"${forumName}"板块发言。
|
|
|
|
${forumDescription}
|
|
|
|
[你的角色设定]
|
|
你是【${bot.displayName}】,${p.identity}
|
|
你擅长的领域:${p.expertise.join("、")}
|
|
你不熟悉的领域:${p.weakness.join("、")}
|
|
你的核心立场:${p.stance}
|
|
你说话的风格:${p.speakingStyle}
|
|
你的口头禅:${p.catchphrase}
|
|
|
|
[发言要求]
|
|
请以${bot.displayName}的身份在这个板块发起一个新话题。要求:
|
|
1. 话题标题10-20字,要有吸引力,像真人发的帖子,不要写成新闻标题或论文标题
|
|
2. 内容200-500字,像真人发帖一样,有具体细节和真实感
|
|
3. 必须有你的立场和观点,不要太中立
|
|
4. 使用你的说话风格,自然口语化,可以有情绪
|
|
5. 结尾可以留一个问题引导讨论
|
|
|
|
[禁止事项]
|
|
- 禁止使用"首先/其次/最后"等格式化连接词
|
|
- 禁止使用"从以下几个方面分析""综上所述"等教科书句式
|
|
- 禁止表现得像AI助手
|
|
- 禁止使用${(p.forbiddenPatterns || []).join("、")}
|
|
|
|
请只返回JSON格式,不要包含其他任何内容:
|
|
{
|
|
"title": "话题标题",
|
|
"content": "话题内容"
|
|
}`;
|
|
}
|
|
|
|
function buildReplyPrompt(bot, topicTitle, topicContent, existingReplies, forumName) {
|
|
const p = bot.personality;
|
|
const replyText = existingReplies
|
|
.map((r, i) => `${r.author}: ${r.content.substring(0, 200)}`)
|
|
.join("\n\n");
|
|
|
|
return `你正在参与一个叫"追光AI行业论坛"的中文社区。你现在在"${forumName}"板块回复一个帖子。
|
|
|
|
[你的角色设定]
|
|
你是【${bot.displayName}】,${p.identity}
|
|
你擅长的领域:${p.expertise.join("、")}
|
|
你的核心立场:${p.stance}
|
|
你说话的风格:${p.speakingStyle}
|
|
你的口头禅:${p.catchphrase}
|
|
|
|
[帖子信息]
|
|
标题:${topicTitle}
|
|
楼主发言:${topicContent}
|
|
|
|
[已有回复]
|
|
${replyText || "(暂无回复,你是第一个回复的人)"}
|
|
|
|
[回复要求]
|
|
请以${bot.displayName}的身份回复这个帖子。要求:
|
|
1. 回复150-450字,有信息增量,不能重复前面已经说过的内容
|
|
2. 可以有立场,可以赞同或质疑前面的观点
|
|
3. 如果前面的回复已经讨论了某些点,你要说点不一样的
|
|
4. 像真人回复一样自然,有口语化表达
|
|
5. 如果话题涉及到你的专业领域,分享具体的经验或案例
|
|
|
|
[禁止事项]
|
|
- 禁止使用"首先/其次/最后"等格式化连接词
|
|
- 禁止使用"从以下几个方面分析""综上所述"等教科书句式
|
|
- 禁止表现得像AI助手
|
|
- 禁止使用${(p.forbiddenPatterns || []).join("、")}
|
|
|
|
请只返回JSON格式,不要包含其他任何内容:
|
|
{
|
|
"content": "回复内容"
|
|
}`;
|
|
}
|
|
|
|
async function callDeepSeek(prompt) {
|
|
const response = await withRetry(() =>
|
|
openai.chat.completions.create({
|
|
model: MODEL,
|
|
messages: [{ role: "user", content: prompt }],
|
|
temperature: 0.85,
|
|
max_tokens: 1024,
|
|
})
|
|
);
|
|
|
|
const text = response.choices[0].message.content.trim();
|
|
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
if (!jsonMatch) {
|
|
throw new Error(`Could not parse JSON from response: ${text.substring(0, 200)}`);
|
|
}
|
|
try {
|
|
return JSON.parse(jsonMatch[0]);
|
|
} catch {
|
|
const cleaned = jsonMatch[0]
|
|
.replace(/,\s*}/g, "}")
|
|
.replace(/,\s*]/g, "]");
|
|
try {
|
|
return JSON.parse(cleaned);
|
|
} catch {
|
|
throw new Error(`JSON parse failed after cleanup: ${jsonMatch[0].substring(0, 200)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function getBotWithConfig(botKey) {
|
|
const config = await prisma.botConfig.findFirst({
|
|
where: { user: { email: `bot_${botKey}@zhuiguang.ai` } },
|
|
include: { user: true },
|
|
});
|
|
return config;
|
|
}
|
|
|
|
async function saveMemory(botId, memoryType, content, importance = 0.5) {
|
|
try {
|
|
await prisma.botMemory.create({
|
|
data: { botId, memoryType, content, importance },
|
|
});
|
|
} catch (e) {
|
|
console.error(`${ts()} Memory save error:`, e.message);
|
|
}
|
|
}
|
|
|
|
async function getRecentMemories(botId, limit = 3) {
|
|
try {
|
|
return await prisma.botMemory.findMany({
|
|
where: { botId },
|
|
orderBy: { createdAt: "desc" },
|
|
take: limit,
|
|
});
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function buildMemoryContext(memories, forumName) {
|
|
if (memories.length === 0) return "";
|
|
const lines = memories.map(m => {
|
|
const c = m.content;
|
|
if (m.memoryType === "experience") {
|
|
return `• 你最近在"${c.forum}"板块发起了话题"${c.title}"`;
|
|
}
|
|
return `• 你最近在"${c.forum}"板块回复了关于"${c.topicTitle}"的讨论`;
|
|
});
|
|
return `\n[你最近的讨论记忆]\n${lines.join("\n")}\n`;
|
|
}
|
|
|
|
async function getCategoryBySlug(slug) {
|
|
return prisma.forumCategory.findUnique({ where: { slug } });
|
|
}
|
|
|
|
async function getRandomSubCategory(forumSlug) {
|
|
const subs = SUB_CATEGORY_MAP[forumSlug] || [];
|
|
if (subs.length === 0) return null;
|
|
const slug = pick(subs);
|
|
return prisma.forumCategory.findUnique({ where: { slug } });
|
|
}
|
|
|
|
async function getRecentTopicsInForum(forumSlug, limit = 5) {
|
|
const cat = await getCategoryBySlug(forumSlug);
|
|
if (!cat) return [];
|
|
return prisma.forumTopic.findMany({
|
|
where: { categoryId: cat.id },
|
|
include: {
|
|
user: { select: { id: true, name: true } },
|
|
posts: {
|
|
include: { user: { select: { id: true, name: true } } },
|
|
orderBy: { createdAt: "asc" },
|
|
},
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
take: limit,
|
|
});
|
|
}
|
|
|
|
async function createTopic(categoryId, userId, title, content) {
|
|
return prisma.forumTopic.create({
|
|
data: {
|
|
categoryId,
|
|
userId,
|
|
title,
|
|
slug: slugify(title),
|
|
content,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function createPost(topicId, userId, content) {
|
|
return prisma.forumPost.create({
|
|
data: { topicId, userId, content },
|
|
});
|
|
}
|
|
|
|
async function processForum(forumSlug, botCharacters, forceNewTopic = false) {
|
|
const category = await getCategoryBySlug(forumSlug);
|
|
if (!category) {
|
|
console.log(`${ts()} Forum "${forumSlug}" not found, skipping.`);
|
|
return;
|
|
}
|
|
|
|
console.log(`${ts()} Processing forum: ${category.name}`);
|
|
|
|
const forumBots = botCharacters.filter(
|
|
c => c.primaryForums && c.primaryForums.includes(forumSlug) && c.role !== "passerby"
|
|
);
|
|
|
|
if (forumBots.length === 0) {
|
|
console.log(`${ts()} No bots assigned to this forum.`);
|
|
return;
|
|
}
|
|
|
|
const existingTopics = await getRecentTopicsInForum(forumSlug, 10);
|
|
const needsNewTopic = forceNewTopic || existingTopics.length < 3 ||
|
|
existingTopics[0] && (Date.now() - existingTopics[0].createdAt.getTime()) > 24 * 60 * 60 * 1000;
|
|
|
|
if (needsNewTopic) {
|
|
const topicAuthor = pick(forumBots.filter(c => c.activityLevel === "high" || c.activityLevel === "medium"));
|
|
const botConf = await getBotWithConfig(topicAuthor.key);
|
|
if (!botConf) {
|
|
console.log(`${ts()} Bot "${topicAuthor.key}" not found in DB.`);
|
|
return;
|
|
}
|
|
|
|
console.log(`${ts()} ${topicAuthor.displayName} creating new topic...`);
|
|
|
|
try {
|
|
const recentMemories = await getRecentMemories(botConf.id, 2);
|
|
const memoryCtx = buildMemoryContext(recentMemories, category.name);
|
|
|
|
let prompt = buildTopicPrompt(topicAuthor, category.name, category.description);
|
|
if (memoryCtx) {
|
|
prompt = prompt.replace("[你的角色设定]", memoryCtx + "[你的角色设定]");
|
|
} else {
|
|
prompt = prompt.replace("[你的角色设定]", "\n[你的角色设定]");
|
|
}
|
|
|
|
const result = await callDeepSeek(prompt);
|
|
|
|
const subCat = await getRandomSubCategory(forumSlug);
|
|
const catId = subCat ? subCat.id : category.id;
|
|
|
|
const topic = await createTopic(catId, botConf.userId, result.title, result.content);
|
|
console.log(`${ts()} Created topic: "${result.title}"`);
|
|
|
|
await saveMemory(botConf.id, "experience", {
|
|
action: "create_topic",
|
|
forum: category.name,
|
|
title: result.title,
|
|
summary: result.content.substring(0, 100),
|
|
}, 0.6);
|
|
|
|
await logTask("create_topic", "success",
|
|
`${topicAuthor.displayName} created topic in ${category.name}: ${result.title}`);
|
|
} catch (err) {
|
|
console.error(`${ts()} Error creating topic:`, err.message);
|
|
await logTask("create_topic", "error", err.message);
|
|
}
|
|
}
|
|
|
|
if (existingTopics.length > 0) {
|
|
const repliers = forumBots
|
|
.filter(() => Math.random() < 0.5)
|
|
.slice(0, 3);
|
|
|
|
for (const replierBot of repliers) {
|
|
const botConf = await getBotWithConfig(replierBot.key);
|
|
if (!botConf) continue;
|
|
|
|
const targetTopic = pick(existingTopics);
|
|
|
|
const authorName = targetTopic.user.name;
|
|
const topicContent = targetTopic.content;
|
|
const replies = targetTopic.posts.map(p => ({
|
|
author: p.user.name,
|
|
content: p.content,
|
|
}));
|
|
|
|
const alreadyReplied = targetTopic.posts.some(p => p.userId === botConf.userId);
|
|
if (alreadyReplied) {
|
|
console.log(`${ts()} ${replierBot.displayName} already replied to "${targetTopic.title}", skipping.`);
|
|
continue;
|
|
}
|
|
|
|
console.log(`${ts()} ${replierBot.displayName} replying to "${targetTopic.title}"...`);
|
|
|
|
try {
|
|
const recentMemories = await getRecentMemories(botConf.id, 3);
|
|
const memoryCtx = buildMemoryContext(recentMemories, category.name);
|
|
|
|
let prompt = buildReplyPrompt(replierBot, targetTopic.title, topicContent, replies, category.name);
|
|
if (memoryCtx) {
|
|
prompt = prompt.replace("[你的角色设定]", memoryCtx + "[你的角色设定]");
|
|
} else {
|
|
prompt = prompt.replace("[你的角色设定]", "\n[你的角色设定]");
|
|
}
|
|
|
|
const result = await callDeepSeek(prompt);
|
|
|
|
const post = await createPost(targetTopic.id, botConf.userId, result.content);
|
|
console.log(`${ts()} Reply saved (${result.content.length} chars).`);
|
|
|
|
await saveMemory(botConf.id, "interaction", {
|
|
action: "reply",
|
|
forum: category.name,
|
|
topicTitle: targetTopic.title,
|
|
repliedTo: authorName,
|
|
summary: result.content.substring(0, 100),
|
|
}, 0.5);
|
|
|
|
await logTask("create_reply", "success",
|
|
`${replierBot.displayName} replied to ${authorName}'s topic in ${category.name}`);
|
|
} catch (err) {
|
|
console.error(`${ts()} Error creating reply:`, err.message);
|
|
await logTask("create_reply", "error", err.message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function processPasserbyForum(forumSlug, botCharacters) {
|
|
const category = await getCategoryBySlug(forumSlug);
|
|
if (!category) {
|
|
console.log(`${ts()} Passerby: Forum "${forumSlug}" not found, skipping.`);
|
|
return;
|
|
}
|
|
|
|
console.log(`${ts()} Passerby processing: ${category.name}`);
|
|
|
|
const passerbyBots = botCharacters.filter(
|
|
c => c.role === "passerby" && c.primaryForums && c.primaryForums.includes(forumSlug)
|
|
);
|
|
|
|
if (passerbyBots.length === 0) {
|
|
console.log(`${ts()} No passerby bots for this forum.`);
|
|
return;
|
|
}
|
|
|
|
const recentNews = await fetchRecentNewsForCategory(forumSlug);
|
|
if (recentNews.length === 0) {
|
|
console.log(`${ts()} No recent news found, will generate from bot knowledge.`);
|
|
}
|
|
|
|
const posterBot = pick(passerbyBots);
|
|
const botConf = await getBotWithConfig(posterBot.key);
|
|
if (!botConf) {
|
|
console.log(`${ts()} Passerby bot "${posterBot.key}" not found in DB.`);
|
|
return;
|
|
}
|
|
|
|
console.log(`${ts()} ${posterBot.displayName} (${posterBot.personality.passerbyType}) creating topic...`);
|
|
|
|
try {
|
|
const recentMemories = await getRecentMemories(botConf.id, 2);
|
|
const memoryCtx = buildMemoryContext(recentMemories, category.name);
|
|
|
|
let prompt = buildPasserbyTopicPrompt(posterBot, category.name, category.description, recentNews);
|
|
if (memoryCtx) {
|
|
prompt = prompt.replace("[你的角色设定]", memoryCtx + "[你的角色设定]");
|
|
} else {
|
|
prompt = prompt.replace("[你的角色设定]", "\n[你的角色设定]");
|
|
}
|
|
|
|
const result = await callDeepSeek(prompt);
|
|
|
|
const subCat = await getRandomSubCategory(forumSlug);
|
|
const catId = subCat ? subCat.id : category.id;
|
|
|
|
const topic = await createTopic(catId, botConf.userId, result.title, result.content);
|
|
console.log(`${ts()} Passerby topic created: "${result.title}"`);
|
|
|
|
await saveMemory(botConf.id, "experience", {
|
|
action: "create_topic_passerby",
|
|
forum: category.name,
|
|
title: result.title,
|
|
summary: result.content.substring(0, 100),
|
|
newsCount: recentNews.length,
|
|
}, 0.5);
|
|
|
|
await logTask("create_topic_passerby", "success",
|
|
`${posterBot.displayName}(${posterBot.personality.passerbyType}) posted in ${category.name}: ${result.title}`);
|
|
} catch (err) {
|
|
console.error(`${ts()} Passerby topic error:`, err.message);
|
|
await logTask("create_topic_passerby", "error", err.message);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
console.log(`${ts()} Bot Activity Engine starting...`);
|
|
|
|
const { characters: botCharacters } = await loadBotCharacters();
|
|
console.log(`${ts()} Loaded ${botCharacters.length} bot characters.`);
|
|
|
|
const expertForums = FORUM_SLUGS.sort(() => Math.random() - 0.5).slice(0, 2);
|
|
console.log(`${ts()} Processing ${expertForums.length} forums for experts: ${expertForums.join(", ")}`);
|
|
|
|
for (const forumSlug of expertForums) {
|
|
await processForum(forumSlug, botCharacters);
|
|
}
|
|
|
|
const passerbyForums = FORUM_SLUGS.sort(() => Math.random() - 0.5).slice(0, 1);
|
|
console.log(`${ts()} Processing ${passerbyForums.length} forums for passerbys: ${passerbyForums.join(", ")}`);
|
|
|
|
for (const forumSlug of passerbyForums) {
|
|
await processPasserbyForum(forumSlug, botCharacters);
|
|
}
|
|
|
|
console.log(`${ts()} Bot Activity Engine done.`);
|
|
}
|
|
|
|
main()
|
|
.catch((e) => {
|
|
console.error(`${ts()} Fatal error:`, e);
|
|
process.exit(1);
|
|
})
|
|
.finally(async () => {
|
|
await prisma.$disconnect();
|
|
}); |