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 = [ "ecommerce", "ai-tech", "real-estate", "finance", "food", "content", "local-life", "health", "education", "cross-border", ]; const FORUM_NAMES = { ecommerce: "电商零售", "ai-tech": "AI智能科技", "real-estate": "房地产", finance: "金融投资", food: "餐饮食品", content: "自媒体内容", "local-life": "本地生活", health: "大健康", education: "教育培训", "cross-border": "外贸跨境", }; const SUB_CATEGORY_MAP = { ecommerce: ["ec-platform", "ec-livestream", "ec-private", "ec-group"], "ai-tech": ["ai-tools", "ai-startup", "ai-saas", "ai-hardware"], "real-estate": ["re-residential", "re-commercial", "re-renovation", "re-property"], finance: ["fi-stock", "fi-crypto", "fi-insurance", "fi-pe"], food: ["fd-restaurant", "fd-tea", "fd-prepared", "fd-supply"], content: ["ct-short-video", "ct-livestream", "ct-writing", "ct-mcn"], "local-life": ["ll-beauty", "ll-fitness", "ll-pet", "ll-housekeeping"], health: ["hl-cosmetic", "hl-wellness", "hl-elderly", "hl-rehab"], education: ["edu-skills", "edu-knowledge", "edu-abroad", "edu-corporate"], "cross-border": ["cb-ecommerce", "cb-factory", "cb-logistics", "cb-payment"], }; 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 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) ); 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 main() { console.log(`${ts()} Bot Activity Engine starting...`); const { characters: botCharacters } = await loadBotCharacters(); console.log(`${ts()} Loaded ${botCharacters.length} bot characters.`); const activeForums = FORUM_SLUGS.sort(() => Math.random() - 0.5).slice(0, 2); console.log(`${ts()} Processing ${activeForums.length} forums: ${activeForums.join(", ")}`); for (const forumSlug of activeForums) { await processForum(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(); });