purge-chatthreads.mjs 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. #!/usr/bin/env node
  2. /**
  3. * 会话记录软删清理(一次性运维脚本)
  4. *
  5. * ## 用途
  6. *
  7. * 把当前账号名下的**会话目录记录**(`FmodeChatThread` 系列表)全部软删,
  8. * 用于清理测试过程中堆出来的会话。
  9. *
  10. * ## 安全
  11. *
  12. * · 默认 **dry-run**:只列出将删除的记录,**不发任何写请求**
  13. * · 必须显式传 `--apply` 才真正删除
  14. * · 一律走 `op: 'remove'`(**软删**,`isDeleted=true`),不做物理删除
  15. * · **不碰 Matrix 房间与消息** —— 只删目录索引(与 App 内「删会话」一致)
  16. *
  17. * ## 用法
  18. *
  19. * ```bash
  20. * node scripts/purge-chatthreads.mjs # 只列出
  21. * node scripts/purge-chatthreads.mjs --apply # 真的软删
  22. * ```
  23. *
  24. * 凭据从 `.claude/dev-login.json` 读取,绝不打印。
  25. */
  26. import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
  27. import { dirname, resolve } from 'node:path';
  28. import { fileURLToPath } from 'node:url';
  29. const here = dirname(fileURLToPath(import.meta.url));
  30. const root = resolve(here, '..');
  31. const FUNCTIONS_URL = 'https://server.fmode.cn/api/functions';
  32. /** 会话目录云函数(与 `parse-api.ts` 的 `FN.chatThreads` 同源) */
  33. const FN_CHAT_THREADS = 'oR5O67i1Cm';
  34. const FN_AGENT_STATUS = 'xlosi3m46q';
  35. const APPLY = process.argv.includes('--apply');
  36. /**
  37. * 连群聊一起删(默认**不删**)。
  38. *
  39. * 🔴 2026-11-09 教训:用户说「删日常对话」,脚本却把群会话也删了。
  40. * 现在默认只删 `kind !== 'group'` 的日常对话,群聊需要显式加
  41. * `--include-groups` 才会一起处理。
  42. */
  43. const INCLUDE_GROUPS = process.argv.includes('--include-groups');
  44. function loadToken() {
  45. const p = resolve(root, '.claude/dev-login.json');
  46. try {
  47. const t = JSON.parse(readFileSync(p, 'utf8'))?.token;
  48. if (typeof t === 'string' && t.startsWith('r:')) return t;
  49. } catch {
  50. /* fallthrough */
  51. }
  52. console.error(`❌ 读不到会话 Token(期望 ${p} 的 token 且以 "r:" 开头)`);
  53. process.exit(2);
  54. }
  55. const TOKEN = loadToken();
  56. async function callFn(id, params) {
  57. const res = await fetch(FUNCTIONS_URL, {
  58. method: 'POST',
  59. headers: { 'Content-Type': 'application/json' },
  60. body: JSON.stringify({ id, token: TOKEN, params }),
  61. });
  62. const text = await res.text();
  63. try {
  64. return JSON.parse(text);
  65. } catch {
  66. return { code: res.status, mess: text.slice(0, 200) };
  67. }
  68. }
  69. /** 拉当前账号名下的全部容器 */
  70. async function listAgents() {
  71. const d = await callFn(FN_AGENT_STATUS, { op: 'query', scope: 'mine' });
  72. if (d.code !== 200) throw new Error(`容器列表读取失败: ${d.mess ?? d.code}`);
  73. return (d.agents ?? []).map((a) => ({ id: a.objectId, name: a.name, agentId: a.agentId }));
  74. }
  75. /** 拉某容器下未删除的会话记录 */
  76. async function listThreads(agentId) {
  77. const d = await callFn(FN_CHAT_THREADS, { op: 'list', agentId });
  78. if (d.code !== 200) throw new Error(`会话目录读取失败(${agentId}): ${d.mess ?? d.code}`);
  79. const all = (d.threads ?? []).filter((t) => !t.isDeleted);
  80. // 群聊默认保护(用户的「日常对话」不包括群聊)
  81. return INCLUDE_GROUPS ? all : all.filter((t) => t.kind !== 'group');
  82. }
  83. /** 该容器下被脚本跳过的群聊数量(仅用于报告) */
  84. async function countGroupsSkipped(agentId) {
  85. const d = await callFn(FN_CHAT_THREADS, { op: 'list', agentId });
  86. if (d.code !== 200) return 0;
  87. return (d.threads ?? []).filter((t) => !t.isDeleted && t.kind === 'group').length;
  88. }
  89. async function main() {
  90. console.log(APPLY ? '🔴 APPLY 模式:将真正执行软删' : '🟡 DRY-RUN:只列出,不删除');
  91. const agents = await listAgents();
  92. console.log(`\n当前账号下容器 ${agents.length} 个\n`);
  93. const plan = [];
  94. for (const a of agents) {
  95. let threads = [];
  96. try {
  97. threads = await listThreads(a.agentId);
  98. } catch (e) {
  99. console.log(` ⚠️ ${a.name} (${a.agentId}) 跳过:${e.message}`);
  100. continue;
  101. }
  102. if (threads.length === 0) {
  103. const g = await countGroupsSkipped(a.agentId).catch(() => 0);
  104. if (g > 0) console.log(` ${a.name.padEnd(28)} 跳过 ${g} 个群聊(未加 --include-groups)`);
  105. continue;
  106. }
  107. console.log(` ${a.name.padEnd(28)} ${String(threads.length).padStart(3)} 条`);
  108. const g = INCLUDE_GROUPS ? 0 : await countGroupsSkipped(a.agentId).catch(() => 0);
  109. if (g > 0) console.log(` ${''.padEnd(28)} (另有 ${g} 个群聊被保护,未加 --include-groups)`);
  110. for (const t of threads) {
  111. plan.push({
  112. agentId: a.agentId,
  113. agentName: a.name,
  114. threadRoot: t.threadRoot,
  115. roomId: t.roomId,
  116. objectId: t.objectId,
  117. title: t.title ?? '',
  118. messageCount: t.messageCount ?? 0,
  119. });
  120. }
  121. }
  122. console.log(`\n合计待软删:${plan.length} 条`);
  123. const outDir = resolve(root, 'test-results/purge-chatthreads');
  124. mkdirSync(outDir, { recursive: true });
  125. const stamp = new Date().toISOString().replace(/[:.]/g, '-');
  126. const dumpPath = resolve(outDir, `plan-${stamp}.json`);
  127. writeFileSync(dumpPath, JSON.stringify(plan, null, 2), 'utf8');
  128. console.log(`清单已落盘:${dumpPath}`);
  129. if (plan.length === 0) {
  130. console.log('没有需要清理的会话。');
  131. return;
  132. }
  133. if (!APPLY) {
  134. console.log('\n加 --apply 才会真正删除。');
  135. return;
  136. }
  137. let ok = 0;
  138. let fail = 0;
  139. for (const [i, p] of plan.entries()) {
  140. const r = await callFn(FN_CHAT_THREADS, {
  141. op: 'remove',
  142. threadRoot: p.threadRoot,
  143. roomId: p.roomId,
  144. agentId: p.agentId,
  145. });
  146. if (r.code === 200) {
  147. ok++;
  148. if ((i + 1) % 10 === 0 || i + 1 === plan.length) {
  149. console.log(` 进度 ${i + 1}/${plan.length}`);
  150. }
  151. } else {
  152. fail++;
  153. console.log(` ❌ ${p.objectId} ${p.threadRoot}: ${r.mess ?? r.code}`);
  154. }
  155. }
  156. console.log(`\n软删完成:成功 ${ok} 条,失败 ${fail} 条`);
  157. // 复核:重新拉一遍,确认列表已不含这些记录
  158. let remain = 0;
  159. for (const a of agents) {
  160. try {
  161. remain += (await listThreads(a.agentId)).length;
  162. } catch {
  163. /* 忽略 */
  164. }
  165. }
  166. console.log(`复核:剩余未删除会话 ${remain} 条`);
  167. writeFileSync(
  168. resolve(outDir, `result-${stamp}.json`),
  169. JSON.stringify({ total: plan.length, ok, fail, remain }, null, 2),
  170. 'utf8',
  171. );
  172. }
  173. main().catch((e) => {
  174. console.error('❌ 执行失败:', e.message);
  175. process.exit(1);
  176. });