#!/usr/bin/env node /** * 会话记录软删清理(一次性运维脚本) * * ## 用途 * * 把当前账号名下的**会话目录记录**(`FmodeChatThread` 系列表)全部软删, * 用于清理测试过程中堆出来的会话。 * * ## 安全 * * · 默认 **dry-run**:只列出将删除的记录,**不发任何写请求** * · 必须显式传 `--apply` 才真正删除 * · 一律走 `op: 'remove'`(**软删**,`isDeleted=true`),不做物理删除 * · **不碰 Matrix 房间与消息** —— 只删目录索引(与 App 内「删会话」一致) * * ## 用法 * * ```bash * node scripts/purge-chatthreads.mjs # 只列出 * node scripts/purge-chatthreads.mjs --apply # 真的软删 * ``` * * 凭据从 `.claude/dev-login.json` 读取,绝不打印。 */ import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const root = resolve(here, '..'); const FUNCTIONS_URL = 'https://server.fmode.cn/api/functions'; /** 会话目录云函数(与 `parse-api.ts` 的 `FN.chatThreads` 同源) */ const FN_CHAT_THREADS = 'oR5O67i1Cm'; const FN_AGENT_STATUS = 'xlosi3m46q'; const APPLY = process.argv.includes('--apply'); /** * 连群聊一起删(默认**不删**)。 * * 🔴 2026-11-09 教训:用户说「删日常对话」,脚本却把群会话也删了。 * 现在默认只删 `kind !== 'group'` 的日常对话,群聊需要显式加 * `--include-groups` 才会一起处理。 */ const INCLUDE_GROUPS = process.argv.includes('--include-groups'); function loadToken() { const p = resolve(root, '.claude/dev-login.json'); try { const t = JSON.parse(readFileSync(p, 'utf8'))?.token; if (typeof t === 'string' && t.startsWith('r:')) return t; } catch { /* fallthrough */ } console.error(`❌ 读不到会话 Token(期望 ${p} 的 token 且以 "r:" 开头)`); process.exit(2); } const TOKEN = loadToken(); async function callFn(id, params) { const res = await fetch(FUNCTIONS_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, token: TOKEN, params }), }); const text = await res.text(); try { return JSON.parse(text); } catch { return { code: res.status, mess: text.slice(0, 200) }; } } /** 拉当前账号名下的全部容器 */ async function listAgents() { const d = await callFn(FN_AGENT_STATUS, { op: 'query', scope: 'mine' }); if (d.code !== 200) throw new Error(`容器列表读取失败: ${d.mess ?? d.code}`); return (d.agents ?? []).map((a) => ({ id: a.objectId, name: a.name, agentId: a.agentId })); } /** 拉某容器下未删除的会话记录 */ async function listThreads(agentId) { const d = await callFn(FN_CHAT_THREADS, { op: 'list', agentId }); if (d.code !== 200) throw new Error(`会话目录读取失败(${agentId}): ${d.mess ?? d.code}`); const all = (d.threads ?? []).filter((t) => !t.isDeleted); // 群聊默认保护(用户的「日常对话」不包括群聊) return INCLUDE_GROUPS ? all : all.filter((t) => t.kind !== 'group'); } /** 该容器下被脚本跳过的群聊数量(仅用于报告) */ async function countGroupsSkipped(agentId) { const d = await callFn(FN_CHAT_THREADS, { op: 'list', agentId }); if (d.code !== 200) return 0; return (d.threads ?? []).filter((t) => !t.isDeleted && t.kind === 'group').length; } async function main() { console.log(APPLY ? '🔴 APPLY 模式:将真正执行软删' : '🟡 DRY-RUN:只列出,不删除'); const agents = await listAgents(); console.log(`\n当前账号下容器 ${agents.length} 个\n`); const plan = []; for (const a of agents) { let threads = []; try { threads = await listThreads(a.agentId); } catch (e) { console.log(` ⚠️ ${a.name} (${a.agentId}) 跳过:${e.message}`); continue; } if (threads.length === 0) { const g = await countGroupsSkipped(a.agentId).catch(() => 0); if (g > 0) console.log(` ${a.name.padEnd(28)} 跳过 ${g} 个群聊(未加 --include-groups)`); continue; } console.log(` ${a.name.padEnd(28)} ${String(threads.length).padStart(3)} 条`); const g = INCLUDE_GROUPS ? 0 : await countGroupsSkipped(a.agentId).catch(() => 0); if (g > 0) console.log(` ${''.padEnd(28)} (另有 ${g} 个群聊被保护,未加 --include-groups)`); for (const t of threads) { plan.push({ agentId: a.agentId, agentName: a.name, threadRoot: t.threadRoot, roomId: t.roomId, objectId: t.objectId, title: t.title ?? '', messageCount: t.messageCount ?? 0, }); } } console.log(`\n合计待软删:${plan.length} 条`); const outDir = resolve(root, 'test-results/purge-chatthreads'); mkdirSync(outDir, { recursive: true }); const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const dumpPath = resolve(outDir, `plan-${stamp}.json`); writeFileSync(dumpPath, JSON.stringify(plan, null, 2), 'utf8'); console.log(`清单已落盘:${dumpPath}`); if (plan.length === 0) { console.log('没有需要清理的会话。'); return; } if (!APPLY) { console.log('\n加 --apply 才会真正删除。'); return; } let ok = 0; let fail = 0; for (const [i, p] of plan.entries()) { const r = await callFn(FN_CHAT_THREADS, { op: 'remove', threadRoot: p.threadRoot, roomId: p.roomId, agentId: p.agentId, }); if (r.code === 200) { ok++; if ((i + 1) % 10 === 0 || i + 1 === plan.length) { console.log(` 进度 ${i + 1}/${plan.length}`); } } else { fail++; console.log(` ❌ ${p.objectId} ${p.threadRoot}: ${r.mess ?? r.code}`); } } console.log(`\n软删完成:成功 ${ok} 条,失败 ${fail} 条`); // 复核:重新拉一遍,确认列表已不含这些记录 let remain = 0; for (const a of agents) { try { remain += (await listThreads(a.agentId)).length; } catch { /* 忽略 */ } } console.log(`复核:剩余未删除会话 ${remain} 条`); writeFileSync( resolve(outDir, `result-${stamp}.json`), JSON.stringify({ total: plan.length, ok, fail, remain }, null, 2), 'utf8', ); } main().catch((e) => { console.error('❌ 执行失败:', e.message); process.exit(1); });