qiwei-group-management-run.js 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188
  1. const fs = require('fs');
  2. const path = require('path');
  3. const { okResult, errorResult } = require('../core/result-envelope');
  4. const { createRunDir, latestPath, outputsRoot, writeRunManifest } = require('../core/output-paths');
  5. const { recordGroupSync, recordCustomersFromGroups } = require('../core/dashboard-state');
  6. const {
  7. buildContext,
  8. gatewayCall,
  9. requireGuid,
  10. assertMethodsInCatalog,
  11. safeResult
  12. } = require('../core/shared-gateway');
  13. const REQUIRED_METHODS = {
  14. getRoomList: '/room/getRoomList',
  15. batchGetRoomDetail: '/room/batchGetRoomDetail',
  16. syncMsg: '/msg/syncMsg',
  17. getSessionPage: '/session/getSessionPage'
  18. };
  19. const DEFAULT_KEYWORD_CONFIG = {
  20. matchMode: 'threshold',
  21. threshold: 2,
  22. highConfidenceTerms: ['客户群', '服务群'],
  23. keywords: [
  24. '客户群', '服务群', '售后群', '咨询群', 'VIP群', '专属群',
  25. '业主群', '项目群', '订单群', '用户群', '粉丝群', '会员群',
  26. '客户', '服务', '售后', '咨询'
  27. ]
  28. };
  29. function decodeRoomName(raw) {
  30. if (!raw) return '';
  31. try {
  32. const decoded = Buffer.from(raw, 'base64').toString('utf8');
  33. if (decoded && /[一-龥]/.test(decoded)) return decoded;
  34. } catch {
  35. // ignore
  36. }
  37. return raw;
  38. }
  39. function groupsDir() {
  40. return path.join(outputsRoot(), 'groups');
  41. }
  42. function messagesDir() {
  43. return path.join(outputsRoot(), 'messages');
  44. }
  45. function roomMessagesDir(roomId) {
  46. return path.join(messagesDir(), roomId);
  47. }
  48. function ensureGroupsDir() {
  49. fs.mkdirSync(groupsDir(), { recursive: true });
  50. }
  51. function ensureMessagesDir() {
  52. fs.mkdirSync(messagesDir(), { recursive: true });
  53. }
  54. function ensureRoomMessagesDir(roomId) {
  55. return fs.mkdirSync(roomMessagesDir(roomId), { recursive: true });
  56. }
  57. function messageFilePath(roomId, seq, msgUniqueId) {
  58. ensureRoomMessagesDir(roomId);
  59. const safeUniqueId = String(msgUniqueId || '').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80);
  60. return path.join(roomMessagesDir(roomId), `${seq}-${safeUniqueId}.json`);
  61. }
  62. let gbkEncodeMap = null;
  63. function getGbkEncodeMap() {
  64. if (gbkEncodeMap) return gbkEncodeMap;
  65. gbkEncodeMap = new Map();
  66. const decoder = new TextDecoder('gbk');
  67. for (let first = 0x81; first <= 0xfe; first++) {
  68. for (let second = 0x40; second <= 0xfe; second++) {
  69. if (second === 0x7f) continue;
  70. const bytes = Buffer.from([first, second]);
  71. const char = decoder.decode(bytes);
  72. if (!char || char === '\uFFFD' || char.length !== 1) continue;
  73. if (!gbkEncodeMap.has(char)) gbkEncodeMap.set(char, bytes);
  74. }
  75. }
  76. return gbkEncodeMap;
  77. }
  78. function repairUtf8DecodedAsGbk(value) {
  79. const text = String(value || '');
  80. if (!/[\u4e00-\u9fa5]/.test(text)) return text;
  81. const map = getGbkEncodeMap();
  82. const chunks = [];
  83. for (const char of text) {
  84. const code = char.codePointAt(0);
  85. if (code <= 0x7f) {
  86. chunks.push(Buffer.from([code]));
  87. continue;
  88. }
  89. const bytes = map.get(char);
  90. if (!bytes) return text;
  91. chunks.push(bytes);
  92. }
  93. const repaired = Buffer.concat(chunks).toString('utf8');
  94. if (repaired.includes('\uFFFD')) return text;
  95. return /[\u4e00-\u9fa5]/.test(repaired) ? repaired : text;
  96. }
  97. function cleanExtractedText(value) {
  98. const cleaned = String(value || '').replace(/[\u0000-\u001f\u007f]/g, '').trim();
  99. return repairUtf8DecodedAsGbk(cleaned);
  100. }
  101. function extractUtf8TextFromBase64(value) {
  102. if (!value) return '';
  103. let buffer;
  104. try {
  105. buffer = Buffer.from(String(value), 'base64');
  106. } catch {
  107. return '';
  108. }
  109. const candidates = [];
  110. for (let i = 0; i < buffer.length - 1; i++) {
  111. const len = buffer[i];
  112. if (!len || len > 240 || i + 1 + len > buffer.length) continue;
  113. const text = cleanExtractedText(buffer.slice(i + 1, i + 1 + len).toString('utf8'));
  114. if (!text || text.includes('\uFFFD')) continue;
  115. if (/[\u4e00-\u9fa5]/.test(text)) candidates.push(text);
  116. }
  117. return candidates.sort((a, b) => b.length - a.length)[0] || '';
  118. }
  119. function extractMessageContent(msg = {}) {
  120. const rawText = extractUtf8TextFromBase64(msg.base64RawData || msg.msgData?.extras?.base64RawData);
  121. if (rawText) return rawText;
  122. if (typeof msg.content === 'string' && msg.content) return cleanExtractedText(msg.content);
  123. if (typeof msg.msgContent === 'string' && msg.msgContent) return cleanExtractedText(msg.msgContent);
  124. if (msg.msgData) {
  125. if (typeof msg.msgData.content === 'string' && msg.msgData.content) return cleanExtractedText(msg.msgData.content);
  126. if (typeof msg.msgData.text === 'string' && msg.msgData.text) return cleanExtractedText(msg.msgData.text);
  127. if (Array.isArray(msg.msgData.moreDetail)) {
  128. return cleanExtractedText(msg.msgData.moreDetail.map(item => item && item.text).filter(Boolean).join(''));
  129. }
  130. if (typeof msg.msgData.notifyTitle === 'string' && msg.msgData.notifyTitle) return cleanExtractedText(msg.msgData.notifyTitle);
  131. }
  132. return '';
  133. }
  134. function appendWebhookMessage(roomId, message) {
  135. if (!roomId || !message) return null;
  136. const seq = Number(message.seq) || 0;
  137. const msgUniqueId = message.msgUniqueIdentifier || message.msgId || `${seq}`;
  138. const filePath = messageFilePath(roomId, seq, msgUniqueId);
  139. if (fs.existsSync(filePath)) {
  140. const existing = safeReadJson(filePath, null);
  141. if (!existing) return null;
  142. const merged = {
  143. ...existing,
  144. content: existing.content || message.content || '',
  145. rawData: existing.rawData || message.rawData,
  146. fromRoomId: existing.fromRoomId || message.fromRoomId,
  147. receiverId: existing.receiverId || message.receiverId || ''
  148. };
  149. const improved = (!existing.content && merged.content) || (!existing.rawData && merged.rawData);
  150. if (!improved) return null;
  151. fs.writeFileSync(filePath, JSON.stringify(merged, null, 2), 'utf8');
  152. return filePath;
  153. }
  154. fs.writeFileSync(filePath, JSON.stringify(message, null, 2), 'utf8');
  155. return filePath;
  156. }
  157. function messageExists(roomId, msgUniqueId) {
  158. if (!roomId || !msgUniqueId) return false;
  159. const dir = roomMessagesDir(roomId);
  160. if (!fs.existsSync(dir)) return false;
  161. const safeUniqueId = String(msgUniqueId).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80);
  162. const files = fs.readdirSync(dir);
  163. return files.some(f => f.endsWith(`-${safeUniqueId}.json`));
  164. }
  165. function listRoomMessageFiles(roomId) {
  166. const dir = roomMessagesDir(roomId);
  167. if (!fs.existsSync(dir)) return [];
  168. return fs.readdirSync(dir)
  169. .filter(f => f.endsWith('.json'))
  170. .map(f => ({ name: f, path: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtime }))
  171. .sort((a, b) => a.name.localeCompare(b.name));
  172. }
  173. function readRoomMessages(roomId) {
  174. const files = listRoomMessageFiles(roomId);
  175. const messages = [];
  176. for (const file of files) {
  177. try {
  178. const msg = JSON.parse(fs.readFileSync(file.path, 'utf8'));
  179. messages.push(msg);
  180. } catch {
  181. // ignore
  182. }
  183. }
  184. return messages;
  185. }
  186. function safeReadJson(filePath, fallback) {
  187. if (!filePath || !fs.existsSync(filePath)) return fallback;
  188. try {
  189. return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  190. } catch {
  191. return fallback;
  192. }
  193. }
  194. function customerKeywordsPath() {
  195. return path.join(groupsDir(), 'customer-keywords.json');
  196. }
  197. function loadCustomerKeywords() {
  198. const stored = safeReadJson(customerKeywordsPath(), null);
  199. if (!stored || !stored.config) return { ...DEFAULT_KEYWORD_CONFIG };
  200. return {
  201. matchMode: stored.config.matchMode === 'any' ? 'any' : 'threshold',
  202. threshold: Math.max(1, Number(stored.config.threshold) || DEFAULT_KEYWORD_CONFIG.threshold),
  203. highConfidenceTerms: Array.isArray(stored.config.highConfidenceTerms)
  204. ? stored.config.highConfidenceTerms
  205. : DEFAULT_KEYWORD_CONFIG.highConfidenceTerms,
  206. keywords: Array.isArray(stored.config.keywords)
  207. ? stored.config.keywords
  208. : DEFAULT_KEYWORD_CONFIG.keywords
  209. };
  210. }
  211. function saveCustomerKeywords(config) {
  212. ensureGroupsDir();
  213. const payload = {
  214. version: '1.0',
  215. updatedAt: new Date().toISOString(),
  216. config: {
  217. matchMode: config.matchMode === 'any' ? 'any' : 'threshold',
  218. threshold: Math.max(1, Number(config.threshold) || DEFAULT_KEYWORD_CONFIG.threshold),
  219. highConfidenceTerms: Array.isArray(config.highConfidenceTerms)
  220. ? config.highConfidenceTerms
  221. : DEFAULT_KEYWORD_CONFIG.highConfidenceTerms,
  222. keywords: Array.isArray(config.keywords)
  223. ? config.keywords
  224. : DEFAULT_KEYWORD_CONFIG.keywords
  225. }
  226. };
  227. fs.writeFileSync(customerKeywordsPath(), JSON.stringify(payload, null, 2), 'utf8');
  228. return payload.config;
  229. }
  230. function buildClassifierConfig(input = {}) {
  231. const persisted = loadCustomerKeywords();
  232. const matchMode = input.matchMode === 'any' || input.matchMode === 'threshold'
  233. ? input.matchMode
  234. : persisted.matchMode;
  235. const threshold = Math.max(1, Number(input.threshold || persisted.threshold) || 1);
  236. return {
  237. keywords: Array.isArray(input.customerKeywords) && input.customerKeywords.length
  238. ? input.customerKeywords
  239. : persisted.keywords,
  240. highConfidenceTerms: Array.isArray(input.highConfidenceTerms) && input.highConfidenceTerms.length
  241. ? input.highConfidenceTerms
  242. : persisted.highConfidenceTerms,
  243. matchMode,
  244. threshold
  245. };
  246. }
  247. function normalizeMatchResult(classification) {
  248. return {
  249. isCustomerGroup: Boolean(classification.isCustomerGroup),
  250. reviewStatus: classification.reviewStatus || 'IMPORTED',
  251. confidence: typeof classification.confidence === 'number' ? classification.confidence : 0,
  252. reason: classification.reason || '',
  253. matchedKeywords: Array.isArray(classification.matchedKeywords) ? classification.matchedKeywords : []
  254. };
  255. }
  256. function classifyGroupByName(roomName, classifierConfig = buildClassifierConfig()) {
  257. const text = String(roomName || '');
  258. const { keywords, highConfidenceTerms, matchMode, threshold } = classifierConfig;
  259. const highMatched = [...new Set((highConfidenceTerms || []).filter(kw => text.includes(kw)))];
  260. if (highMatched.length > 0) {
  261. return normalizeMatchResult({
  262. isCustomerGroup: true,
  263. reviewStatus: 'AUTO_CONFIRMED',
  264. confidence: 1.0,
  265. reason: `高置信命中:${highMatched.slice(0, 5).join('、')}`,
  266. matchedKeywords: highMatched
  267. });
  268. }
  269. const matched = [...new Set((keywords || []).filter(kw => text.includes(kw)))];
  270. const hitCount = matched.length;
  271. if (matchMode === 'any') {
  272. if (hitCount > 0) {
  273. return normalizeMatchResult({
  274. isCustomerGroup: true,
  275. reviewStatus: 'AUTO_CONFIRMED',
  276. confidence: 0.8,
  277. reason: `命中关键词:${matched.slice(0, 5).join('、')}`,
  278. matchedKeywords: matched
  279. });
  280. }
  281. } else if (hitCount >= threshold) {
  282. return normalizeMatchResult({
  283. isCustomerGroup: true,
  284. reviewStatus: 'AUTO_CONFIRMED',
  285. confidence: Math.min(0.95, 0.5 + 0.12 * hitCount),
  286. reason: `命中 ${hitCount} 个关键词(阈值 ${threshold})`,
  287. matchedKeywords: matched
  288. });
  289. } else if (hitCount > 0) {
  290. return normalizeMatchResult({
  291. isCustomerGroup: true,
  292. reviewStatus: 'SUGGESTED',
  293. confidence: 0.35,
  294. reason: `仅命中 ${hitCount} 个弱关键词,建议人工确认`,
  295. matchedKeywords: matched
  296. });
  297. }
  298. return normalizeMatchResult({
  299. isCustomerGroup: false,
  300. reviewStatus: 'IMPORTED',
  301. confidence: 0,
  302. reason: '未命中客户群关键词',
  303. matchedKeywords: []
  304. });
  305. }
  306. function createRoomRecord(raw, source) {
  307. const members = Array.isArray(raw.roomMemberList)
  308. ? raw.roomMemberList
  309. : Array.isArray(raw.memberList)
  310. ? raw.memberList
  311. : Array.isArray(raw.members)
  312. ? raw.members
  313. : [];
  314. return {
  315. roomId: String(raw.roomId || ''),
  316. roomName: decodeRoomName(raw.roomName || raw.sessionName || ''),
  317. memberCount: Number(raw.roomMemberCount) || 0,
  318. roomHeadimgUrl: raw.roomHeadimgUrl || raw.roomAvatarUrl || undefined,
  319. roomExtType: raw.roomExtType !== undefined ? Number(raw.roomExtType) : undefined,
  320. members: members.map(m => ({
  321. userId: String(m.userId || m.wxId || m.id || ''),
  322. userName: String(m.userName || m.nickname || m.name || ''),
  323. type: Number(m.userType || m.type || m.memberType || 0)
  324. })),
  325. source,
  326. sources: [source],
  327. seenAt: new Date().toISOString()
  328. };
  329. }
  330. function dedupRooms(rooms) {
  331. const map = new Map();
  332. for (const room of rooms) {
  333. if (!room.roomId) continue;
  334. const existing = map.get(room.roomId);
  335. if (!existing) {
  336. map.set(room.roomId, room);
  337. } else {
  338. existing.sources = [...new Set([...existing.sources, ...room.sources])];
  339. existing.source = existing.sources.join(',');
  340. if (room.roomName && room.roomName !== existing.roomName) {
  341. existing.roomName = room.roomName;
  342. }
  343. if (room.memberCount && room.memberCount > existing.memberCount) {
  344. existing.memberCount = room.memberCount;
  345. }
  346. if (room.roomHeadimgUrl && !existing.roomHeadimgUrl) {
  347. existing.roomHeadimgUrl = room.roomHeadimgUrl;
  348. }
  349. if (room.roomExtType !== undefined && existing.roomExtType === undefined) {
  350. existing.roomExtType = room.roomExtType;
  351. }
  352. if (room.seenAt && (!existing.seenAt || room.seenAt > existing.seenAt)) {
  353. existing.seenAt = room.seenAt;
  354. }
  355. if (Array.isArray(room.members) && room.members.length > (existing.members?.length || 0)) {
  356. existing.members = room.members;
  357. }
  358. }
  359. }
  360. return Array.from(map.values());
  361. }
  362. function isExternalRoom(room, includeInternalGroups = false) {
  363. if (includeInternalGroups) return true;
  364. return room.roomExtType === undefined || room.roomExtType === 2;
  365. }
  366. async function scanRoomsFromRoomList(ctx, maxPages = 100) {
  367. assertMethodsInCatalog({ getRoomList: REQUIRED_METHODS.getRoomList });
  368. const rooms = [];
  369. let nextStartIndex = 0;
  370. let hasMore = true;
  371. let pages = 0;
  372. while (hasMore && pages < maxPages) {
  373. pages++;
  374. const data = await gatewayCall(ctx, REQUIRED_METHODS.getRoomList, {
  375. guid: ctx.guid,
  376. nextStartIndex
  377. });
  378. const roomList = Array.isArray(data && data.roomList) ? data.roomList : [];
  379. for (const room of roomList) {
  380. rooms.push(createRoomRecord(room, 'roomList'));
  381. }
  382. hasMore = data.hasMore === 1 || data.hasMore === true;
  383. nextStartIndex = data.nextStartIndex ?? -1;
  384. if (!roomList.length || nextStartIndex < 0) break;
  385. }
  386. return { rooms, pages };
  387. }
  388. function extractRoomIdFromSession(session) {
  389. if (!session) return '';
  390. return String(session.roomId || session.sessionId || session.fromRoomId || session.id || '');
  391. }
  392. function isGroupSession(session) {
  393. if (!session) return false;
  394. if (session.sessionType === 1 || session.chatType === 2 || session.isGroup === true) return true;
  395. return false;
  396. }
  397. async function scanRoomsFromSessions(ctx, maxPages = 100) {
  398. assertMethodsInCatalog({ getSessionPage: REQUIRED_METHODS.getSessionPage });
  399. const rooms = [];
  400. let currentSeq = 0;
  401. let hasMore = true;
  402. let pages = 0;
  403. while (hasMore && pages < maxPages) {
  404. pages++;
  405. const data = await gatewayCall(ctx, REQUIRED_METHODS.getSessionPage, {
  406. guid: ctx.guid,
  407. sessionType: 1,
  408. currentSeq
  409. });
  410. const sessionList = Array.isArray(data && data.sessionList)
  411. ? data.sessionList
  412. : Array.isArray(data && data.list)
  413. ? data.list
  414. : [];
  415. for (const session of sessionList) {
  416. const roomId = extractRoomIdFromSession(session);
  417. if (!roomId || !isGroupSession(session)) continue;
  418. const roomName = decodeRoomName(session.sessionName || session.roomName || '');
  419. if (!roomName) continue; // 跳过无名称的会话(通常是单聊或系统通知)
  420. rooms.push({
  421. roomId,
  422. roomName,
  423. memberCount: Number(session.roomMemberCount || session.memberCount) || 0,
  424. roomHeadimgUrl: session.roomHeadimgUrl || session.sessionAvatar || undefined,
  425. roomExtType: session.roomExtType !== undefined ? Number(session.roomExtType) : undefined,
  426. source: 'session',
  427. sources: ['session'],
  428. seenAt: new Date().toISOString()
  429. });
  430. }
  431. hasMore = data.hasMore === 1 || data.hasMore === true;
  432. currentSeq = data.currentSeq ?? -1;
  433. if (!sessionList.length || currentSeq < 0) break;
  434. }
  435. return { rooms, pages };
  436. }
  437. async function enrichRoomsWithDetails(ctx, roomIds, chunkSize = 50) {
  438. if (!roomIds.length) return [];
  439. assertMethodsInCatalog({ batchGetRoomDetail: REQUIRED_METHODS.batchGetRoomDetail });
  440. const rooms = [];
  441. for (let i = 0; i < roomIds.length; i += chunkSize) {
  442. const chunk = roomIds.slice(i, i + chunkSize);
  443. const data = await gatewayCall(ctx, REQUIRED_METHODS.batchGetRoomDetail, {
  444. guid: ctx.guid,
  445. roomIdList: chunk
  446. });
  447. const roomList = Array.isArray(data && data.roomList) ? data.roomList : [];
  448. for (const room of roomList) {
  449. rooms.push(createRoomRecord(room, 'messages'));
  450. }
  451. }
  452. return rooms;
  453. }
  454. async function scanRoomsFromMessages(ctx, maxPages = 300, maxTotalMessages = 20000) {
  455. assertMethodsInCatalog({ syncMsg: REQUIRED_METHODS.syncMsg });
  456. const roomIds = new Set();
  457. let msgSeq = 0;
  458. let hasMore = true;
  459. let pages = 0;
  460. let totalMessages = 0;
  461. while (hasMore && pages < maxPages && totalMessages < maxTotalMessages) {
  462. pages++;
  463. const data = await gatewayCall(ctx, REQUIRED_METHODS.syncMsg, {
  464. guid: ctx.guid,
  465. msgSeq,
  466. limit: 500
  467. });
  468. const msgList = Array.isArray(data && data.syncMsgList) ? data.syncMsgList : [];
  469. hasMore = Boolean(data.hasMore);
  470. msgSeq = data.travelSyncKey ?? msgSeq + 1;
  471. totalMessages += msgList.length;
  472. for (const msg of msgList) {
  473. const roomId = String(msg.fromRoomId || '');
  474. if (roomId) roomIds.add(roomId);
  475. }
  476. if (!msgList.length) break;
  477. }
  478. const roomIdList = Array.from(roomIds);
  479. const details = await enrichRoomsWithDetails(ctx, roomIdList, 50);
  480. return { rooms: details, pages, rawRoomIds: roomIdList, totalMessages };
  481. }
  482. function listGroupFiles() {
  483. ensureGroupsDir();
  484. const files = fs.readdirSync(groupsDir())
  485. .filter(f => /^rooms-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.json$/.test(f))
  486. .map(f => ({ name: f, path: path.join(groupsDir(), f), mtime: fs.statSync(path.join(groupsDir(), f)).mtime }))
  487. .sort((a, b) => b.mtime - a.mtime);
  488. return files;
  489. }
  490. function readLatestRooms() {
  491. return safeReadJson(latestPath('groups', 'rooms-latest.json'), []);
  492. }
  493. function confirmedMappingPath() {
  494. ensureGroupsDir();
  495. return path.join(groupsDir(), 'confirmed-mapping.json');
  496. }
  497. function readConfirmedMapping() {
  498. return safeReadJson(confirmedMappingPath(), {});
  499. }
  500. function writeConfirmedMapping(mapping) {
  501. ensureGroupsDir();
  502. fs.writeFileSync(confirmedMappingPath(), JSON.stringify(mapping, null, 2), 'utf8');
  503. }
  504. function rejectedMappingPath() {
  505. ensureGroupsDir();
  506. return path.join(groupsDir(), 'rejected-mapping.json');
  507. }
  508. function readRejectedMapping() {
  509. return safeReadJson(rejectedMappingPath(), {});
  510. }
  511. function writeRejectedMapping(mapping) {
  512. ensureGroupsDir();
  513. fs.writeFileSync(rejectedMappingPath(), JSON.stringify(mapping, null, 2), 'utf8');
  514. }
  515. function resolveScope(input) {
  516. const validScopes = ['self', 'session', 'messages', 'all'];
  517. if (input.scope && validScopes.includes(input.scope)) return input.scope;
  518. if (input.fromMessages === true) return 'messages';
  519. return 'all';
  520. }
  521. function timestampFileName() {
  522. return `rooms-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.json`;
  523. }
  524. function countBySource(rooms) {
  525. return rooms.reduce((acc, room) => {
  526. for (const s of room.sources || [room.source]) {
  527. acc[s] = (acc[s] || 0) + 1;
  528. }
  529. return acc;
  530. }, {});
  531. }
  532. const qiweiSyncExternalGroups = safeResult(async function qiweiSyncExternalGroups(input = {}) {
  533. assertMethodsInCatalog({
  534. getRoomList: REQUIRED_METHODS.getRoomList,
  535. batchGetRoomDetail: REQUIRED_METHODS.batchGetRoomDetail,
  536. getSessionPage: REQUIRED_METHODS.getSessionPage,
  537. syncMsg: REQUIRED_METHODS.syncMsg
  538. });
  539. const ctx = buildContext(input);
  540. requireGuid(ctx);
  541. const scope = resolveScope(input);
  542. const maxPages = Math.max(1, Math.min(300, Number(input.maxPages || 100)));
  543. const includeInternalGroups = input.includeInternalGroups === true;
  544. const autoClassify = input.autoClassify !== false;
  545. const scanFromMessages = input.scanFromMessages === true;
  546. const previewMessages = Math.max(0, Math.min(100, Number(input.previewMessages || 0)));
  547. const previewOnlyForUnsure = input.previewOnlyForUnsure !== false;
  548. const classifier = buildClassifierConfig(input);
  549. const collected = [];
  550. const perSource = { roomList: { rooms: [], pages: 0 }, session: { rooms: [], pages: 0 }, messages: { rooms: [], pages: 0, totalMessages: 0 } };
  551. if (scope === 'self' || scope === 'all') {
  552. try {
  553. const result = await scanRoomsFromRoomList(ctx, maxPages);
  554. perSource.roomList = result;
  555. collected.push(...result.rooms);
  556. } catch (err) {
  557. // 继续其他来源
  558. }
  559. }
  560. if (scope === 'session' || scope === 'all') {
  561. try {
  562. const result = await scanRoomsFromSessions(ctx, maxPages);
  563. perSource.session = result;
  564. collected.push(...result.rooms);
  565. } catch (err) {
  566. // 继续其他来源
  567. }
  568. }
  569. if (scope === 'messages' || (scope === 'all' && scanFromMessages)) {
  570. try {
  571. const result = await scanRoomsFromMessages(ctx, maxPages);
  572. perSource.messages = result;
  573. collected.push(...result.rooms);
  574. } catch (err) {
  575. // 继续其他来源
  576. }
  577. }
  578. const deduped = dedupRooms(collected);
  579. const roomsNeedingDetails = deduped
  580. .filter(r => Number(r.memberCount || 0) > 0 && (!Array.isArray(r.members) || !r.members.length))
  581. .map(r => r.roomId)
  582. .filter(Boolean);
  583. if (roomsNeedingDetails.length) {
  584. try {
  585. const detailRooms = await enrichRoomsWithDetails(ctx, roomsNeedingDetails, 20);
  586. const detailMap = new Map(detailRooms.map(r => [r.roomId, r]));
  587. for (let i = 0; i < deduped.length; i++) {
  588. const detail = detailMap.get(deduped[i].roomId);
  589. if (!detail) continue;
  590. deduped[i] = {
  591. ...deduped[i],
  592. ...detail,
  593. roomName: deduped[i].roomName || detail.roomName,
  594. source: deduped[i].source,
  595. sources: [...new Set([...(deduped[i].sources || []), ...(detail.sources || [])])],
  596. seenAt: deduped[i].seenAt || detail.seenAt
  597. };
  598. }
  599. } catch {
  600. // 群详情不是同步列表的硬依赖;拿不到成员明细时仍保留群列表结果。
  601. }
  602. }
  603. const filtered = deduped.filter(r => isExternalRoom(r, includeInternalGroups));
  604. const classifiedRooms = [];
  605. for (const room of filtered) {
  606. if (!autoClassify) {
  607. classifiedRooms.push({
  608. ...room,
  609. reviewStatus: 'IMPORTED',
  610. confidence: 0,
  611. reason: '自动分类已关闭',
  612. matchedKeywords: []
  613. });
  614. continue;
  615. }
  616. let text = room.roomName || '';
  617. let classification = classifyGroupByName(text, classifier);
  618. if (previewMessages > 0 && previewOnlyForUnsure && classification.reviewStatus !== 'AUTO_CONFIRMED') {
  619. try {
  620. const previews = await fetchPreviewMessages(ctx, room.roomId, previewMessages);
  621. if (previews.length) {
  622. text += ' ' + previews.join(' ');
  623. classification = classifyGroupByName(text, classifier);
  624. }
  625. } catch {
  626. // 忽略消息拉取失败,保留初次分类结果
  627. }
  628. }
  629. classifiedRooms.push({ ...room, ...classification });
  630. }
  631. const runDir = createRunDir('groups', 'sync-external-groups');
  632. const fileName = timestampFileName();
  633. const filePath = path.join(runDir, fileName);
  634. const manifest = {
  635. scope,
  636. maxPages,
  637. includeInternalGroups,
  638. autoClassify,
  639. previewMessages,
  640. previewOnlyForUnsure,
  641. matchMode: classifier.matchMode,
  642. threshold: classifier.threshold,
  643. total: classifiedRooms.length,
  644. sources: countBySource(classifiedRooms),
  645. files: [fileName]
  646. };
  647. fs.writeFileSync(filePath, JSON.stringify(classifiedRooms, null, 2), 'utf8');
  648. const manifestPath = writeRunManifest(runDir, manifest);
  649. const latestFile = latestPath('groups', 'rooms-latest.json');
  650. fs.writeFileSync(latestFile, JSON.stringify(classifiedRooms, null, 2), 'utf8');
  651. const scanManifestPath = path.join(groupsDir(), 'group-scan-manifest.json');
  652. fs.writeFileSync(scanManifestPath, JSON.stringify({
  653. lastRunAt: new Date().toISOString(),
  654. lastRunDir: path.relative(outputsRoot(), runDir),
  655. scope,
  656. total: classifiedRooms.length,
  657. autoConfirmed: classifiedRooms.filter(r => r.reviewStatus === 'AUTO_CONFIRMED').length,
  658. suggested: classifiedRooms.filter(r => r.reviewStatus === 'SUGGESTED').length,
  659. imported: classifiedRooms.filter(r => r.reviewStatus === 'IMPORTED').length
  660. }, null, 2), 'utf8');
  661. const discoveredCustomers = recordCustomersFromGroups(classifiedRooms, { source: 'group-sync' });
  662. return okResult({
  663. assistantMessage: `外部群同步完成:扫描到 ${classifiedRooms.length} 个群(scope=${scope})。`,
  664. summary: {
  665. scanned: classifiedRooms.length,
  666. selfCount: perSource.roomList.rooms.length,
  667. sessionCount: perSource.session.rooms.length,
  668. messageCount: perSource.messages.rooms.length,
  669. mergedCount: deduped.length,
  670. externalCount: filtered.length,
  671. autoConfirmed: classifiedRooms.filter(r => r.reviewStatus === 'AUTO_CONFIRMED').length,
  672. suggested: classifiedRooms.filter(r => r.reviewStatus === 'SUGGESTED').length,
  673. imported: classifiedRooms.filter(r => r.reviewStatus === 'IMPORTED').length,
  674. discoveredCustomers: discoveredCustomers.discovered,
  675. previewMessages,
  676. previewOnlyForUnsure,
  677. pages: perSource.roomList.pages + perSource.session.pages + perSource.messages.pages
  678. },
  679. data: {
  680. rooms: classifiedRooms.slice(0, 20),
  681. filePath: path.relative(outputsRoot(), filePath),
  682. manifestPath: path.relative(outputsRoot(), manifestPath)
  683. },
  684. files: [filePath, manifestPath]
  685. });
  686. });
  687. const qiweiListExternalGroups = safeResult(async function qiweiListExternalGroups(input = {}) {
  688. const rooms = readLatestRooms();
  689. const confirmed = readConfirmedMapping();
  690. const rejected = readRejectedMapping();
  691. const classifier = buildClassifierConfig(input);
  692. let result = rooms.map(room => {
  693. const mapped = confirmed[room.roomId];
  694. const rejectedRecord = rejected[room.roomId];
  695. if (rejectedRecord) {
  696. return {
  697. ...room,
  698. reviewStatus: 'REJECTED',
  699. confidence: 0,
  700. reason: rejectedRecord.reason || '经纪人手动拒绝',
  701. matchedKeywords: [],
  702. customerId: undefined,
  703. externalUserId: undefined
  704. };
  705. }
  706. if (mapped) {
  707. return {
  708. ...room,
  709. reviewStatus: 'CONFIRMED',
  710. confidence: 1.0,
  711. reason: '经纪人手动确认',
  712. matchedKeywords: [],
  713. customerId: mapped.customerId,
  714. externalUserId: mapped.externalUserId,
  715. customerName: mapped.customerName
  716. };
  717. }
  718. const classification = classifyGroupByName(room.roomName, classifier);
  719. return { ...room, ...classification };
  720. });
  721. const keyword = String(input.keyword || '').trim();
  722. if (keyword) {
  723. result = result.filter(r => (r.roomName || '').includes(keyword) || (r.roomId || '').includes(keyword));
  724. }
  725. const statusFilter = String(input.status || '').trim();
  726. if (statusFilter) {
  727. result = result.filter(r => r.reviewStatus === statusFilter);
  728. }
  729. const sourceFilter = String(input.source || '').trim();
  730. if (sourceFilter) {
  731. result = result.filter(r => r.sources && r.sources.includes(sourceFilter));
  732. }
  733. if (input.includeRejected !== true) {
  734. result = result.filter(r => r.reviewStatus !== 'REJECTED');
  735. }
  736. return okResult({
  737. assistantMessage: `共 ${result.length} 个外部群(已同步 ${rooms.length})。`,
  738. summary: {
  739. total: result.length,
  740. imported: result.filter(r => r.reviewStatus === 'IMPORTED').length,
  741. suggested: result.filter(r => r.reviewStatus === 'SUGGESTED').length,
  742. autoConfirmed: result.filter(r => r.reviewStatus === 'AUTO_CONFIRMED').length,
  743. confirmed: result.filter(r => r.reviewStatus === 'CONFIRMED').length,
  744. rejected: result.filter(r => r.reviewStatus === 'REJECTED').length
  745. },
  746. data: { groups: result }
  747. });
  748. });
  749. async function fetchPreviewMessages(ctx, roomId, previewMessages) {
  750. const texts = [];
  751. let msgSeq = 0;
  752. let hasMore = true;
  753. let pages = 0;
  754. const maxPages = Math.min(10, Math.ceil(previewMessages / 100));
  755. while (hasMore && pages < maxPages && texts.length < previewMessages) {
  756. pages++;
  757. const data = await gatewayCall(ctx, REQUIRED_METHODS.syncMsg, {
  758. guid: ctx.guid,
  759. msgSeq,
  760. limit: 100
  761. });
  762. const msgList = Array.isArray(data && data.syncMsgList) ? data.syncMsgList : [];
  763. hasMore = Boolean(data.hasMore);
  764. msgSeq = data.travelSyncKey ?? msgSeq + 1;
  765. for (const msg of msgList) {
  766. if (String(msg.fromRoomId || '') !== roomId) continue;
  767. const content = String(msg.content || msg.msgContent || '').trim();
  768. if (content) texts.push(content);
  769. if (texts.length >= previewMessages) break;
  770. }
  771. if (!msgList.length) break;
  772. }
  773. return texts;
  774. }
  775. const qiweiAnalyzeGroupMembers = safeResult(async function qiweiAnalyzeGroupMembers(input = {}) {
  776. assertMethodsInCatalog({ batchGetRoomDetail: REQUIRED_METHODS.batchGetRoomDetail });
  777. const ctx = buildContext(input);
  778. requireGuid(ctx);
  779. const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String).filter(Boolean) : [];
  780. if (!roomIds.length) return errorResult('缺少 roomIds');
  781. const classifier = buildClassifierConfig(input);
  782. const autoClassify = input.autoClassify !== false;
  783. const previewMessages = Math.max(0, Math.min(100, Number(input.previewMessages || 0)));
  784. const updateSnapshot = input.updateSnapshot === true;
  785. const details = await enrichRoomsWithDetails(ctx, roomIds, 20);
  786. const classified = [];
  787. for (const room of details) {
  788. let text = room.roomName || '';
  789. if (previewMessages > 0) {
  790. try {
  791. const previews = await fetchPreviewMessages(ctx, room.roomId, previewMessages);
  792. if (previews.length) text += ' ' + previews.join(' ');
  793. } catch {
  794. // 忽略消息拉取失败
  795. }
  796. }
  797. if (!autoClassify) {
  798. classified.push({ ...room, reviewStatus: 'IMPORTED', confidence: 0, reason: '自动分类已关闭', matchedKeywords: [] });
  799. continue;
  800. }
  801. const classification = classifyGroupByName(text, classifier);
  802. classified.push({ ...room, ...classification });
  803. }
  804. if (updateSnapshot) {
  805. const snapshot = readLatestRooms();
  806. const snapshotMap = new Map(snapshot.map(r => [r.roomId, r]));
  807. for (const room of classified) {
  808. snapshotMap.set(room.roomId, room);
  809. }
  810. fs.writeFileSync(latestPath('groups', 'rooms-latest.json'), JSON.stringify(Array.from(snapshotMap.values()), null, 2), 'utf8');
  811. }
  812. const discoveredCustomers = recordCustomersFromGroups(classified, { source: 'group-analysis' });
  813. return okResult({
  814. assistantMessage: `群成员分析完成:分析 ${classified.length} 个群。`,
  815. summary: {
  816. analyzed: classified.length,
  817. autoConfirmed: classified.filter(d => d.reviewStatus === 'AUTO_CONFIRMED').length,
  818. suggested: classified.filter(d => d.reviewStatus === 'SUGGESTED').length,
  819. imported: classified.filter(d => d.reviewStatus === 'IMPORTED').length,
  820. discoveredCustomers: discoveredCustomers.discovered
  821. },
  822. data: { details: classified }
  823. });
  824. });
  825. function updateConfirmedMapping(roomId, fields) {
  826. const mapping = readConfirmedMapping();
  827. mapping[roomId] = { ...(mapping[roomId] || {}), ...fields, confirmedAt: new Date().toISOString() };
  828. writeConfirmedMapping(mapping);
  829. return mapping[roomId];
  830. }
  831. function importedMappingPath() {
  832. ensureGroupsDir();
  833. return path.join(groupsDir(), 'imported-mapping.json');
  834. }
  835. function readImportedMapping() {
  836. return safeReadJson(importedMappingPath(), {});
  837. }
  838. function writeImportedMapping(mapping) {
  839. ensureGroupsDir();
  840. fs.writeFileSync(importedMappingPath(), JSON.stringify(mapping, null, 2), 'utf8');
  841. }
  842. function updateConfirmedMapping(roomId, fields) {
  843. const mapping = readConfirmedMapping();
  844. mapping[roomId] = { ...(mapping[roomId] || {}), ...fields, confirmedAt: new Date().toISOString() };
  845. writeConfirmedMapping(mapping);
  846. return mapping[roomId];
  847. }
  848. function updateImportedMapping(roomId, fields) {
  849. const mapping = readImportedMapping();
  850. mapping[roomId] = { ...(mapping[roomId] || {}), ...fields, importedAt: new Date().toISOString() };
  851. writeImportedMapping(mapping);
  852. return mapping[roomId];
  853. }
  854. const qiweiConfirmExternalGroup = safeResult(async function qiweiConfirmExternalGroup(input = {}) {
  855. const roomId = String(input.roomId || '').trim();
  856. if (!roomId) return errorResult('缺少 roomId');
  857. const customerId = String(input.customerId || '').trim() || undefined;
  858. const externalUserId = String(input.externalUserId || '').trim() || undefined;
  859. const customerName = String(input.customerName || '').trim() || undefined;
  860. const rooms = readLatestRooms();
  861. const room = rooms.find(r => r.roomId === roomId);
  862. if (!room) return errorResult(`roomId ${roomId} 不在最近一次同步的群列表中,请先调用 qiwei_sync_external_groups 或改用 qiwei_add_external_group`);
  863. const record = updateConfirmedMapping(roomId, { customerId, externalUserId, customerName, roomName: room.roomName });
  864. const roomsForDiscovery = rooms.map(item => item.roomId === roomId
  865. ? {
  866. ...item,
  867. reviewStatus: 'CONFIRMED',
  868. customerId,
  869. externalUserId,
  870. customerName,
  871. roomName: item.roomName || room.roomName
  872. }
  873. : item);
  874. const discoveredCustomers = recordCustomersFromGroups(roomsForDiscovery, { source: 'group-confirm' });
  875. return okResult({
  876. assistantMessage: `已确认外部群为客户群:${room.roomName || roomId}。`,
  877. summary: { roomId, roomName: room.roomName, reviewStatus: 'CONFIRMED', discoveredCustomers: discoveredCustomers.discovered },
  878. data: record
  879. });
  880. });
  881. const qiweiAddExternalGroup = safeResult(async function qiweiAddExternalGroup(input = {}) {
  882. const roomId = String(input.roomId || '').trim();
  883. if (!roomId) return errorResult('缺少 roomId');
  884. const customerId = String(input.customerId || '').trim() || undefined;
  885. const externalUserId = String(input.externalUserId || '').trim() || undefined;
  886. const customerName = String(input.customerName || '').trim() || undefined;
  887. const roomName = String(input.roomName || '').trim() || undefined;
  888. const record = updateConfirmedMapping(roomId, { customerId, externalUserId, customerName, roomName });
  889. return okResult({
  890. assistantMessage: `已手动添加外部群:${roomName || roomId}。`,
  891. summary: { roomId, reviewStatus: 'CONFIRMED' },
  892. data: record
  893. });
  894. });
  895. const qiweiConfigureGroupKeywords = safeResult(async function qiweiConfigureGroupKeywords(input = {}) {
  896. if (input.reset === true) {
  897. saveCustomerKeywords(DEFAULT_KEYWORD_CONFIG);
  898. return okResult({
  899. assistantMessage: '已重置为客户群关键词默认配置。',
  900. summary: { ...DEFAULT_KEYWORD_CONFIG, keywordCount: DEFAULT_KEYWORD_CONFIG.keywords.length },
  901. data: { config: DEFAULT_KEYWORD_CONFIG }
  902. });
  903. }
  904. const current = loadCustomerKeywords();
  905. const next = {
  906. matchMode: input.matchMode === 'any' || input.matchMode === 'threshold'
  907. ? input.matchMode
  908. : current.matchMode,
  909. threshold: Math.max(1, Number(input.threshold || current.threshold) || 1),
  910. highConfidenceTerms: Array.isArray(input.highConfidenceTerms)
  911. ? input.highConfidenceTerms
  912. : current.highConfidenceTerms,
  913. keywords: Array.isArray(input.keywords)
  914. ? input.keywords
  915. : current.keywords
  916. };
  917. saveCustomerKeywords(next);
  918. return okResult({
  919. assistantMessage: `已更新客户群关键词配置:模式 ${next.matchMode},关键词 ${next.keywords.length} 个,高置信度 ${next.highConfidenceTerms.length} 个。`,
  920. summary: {
  921. matchMode: next.matchMode,
  922. threshold: next.threshold,
  923. keywordCount: next.keywords.length,
  924. highConfidenceCount: next.highConfidenceTerms.length
  925. },
  926. data: { config: next }
  927. });
  928. });
  929. const qiweiRejectExternalGroup = safeResult(async function qiweiRejectExternalGroup(input = {}) {
  930. const roomId = String(input.roomId || '').trim();
  931. if (!roomId) return errorResult('缺少 roomId');
  932. const rooms = readLatestRooms();
  933. const room = rooms.find(r => r.roomId === roomId);
  934. if (!room) return errorResult(`roomId ${roomId} 不在最近一次同步的群列表中`);
  935. const mapping = readRejectedMapping();
  936. mapping[roomId] = {
  937. roomId,
  938. roomName: room.roomName,
  939. rejectedAt: new Date().toISOString(),
  940. reason: String(input.reason || '经纪人手动拒绝')
  941. };
  942. writeRejectedMapping(mapping);
  943. return okResult({
  944. assistantMessage: `已拒绝外部群:${room.roomName || roomId}。`,
  945. summary: { roomId, roomName: room.roomName, reviewStatus: 'REJECTED' },
  946. data: mapping[roomId]
  947. });
  948. });
  949. const qiweiSyncGroupMessages = safeResult(async function qiweiSyncGroupMessages(input = {}) {
  950. assertMethodsInCatalog({ syncMsg: REQUIRED_METHODS.syncMsg });
  951. const ctx = buildContext(input);
  952. requireGuid(ctx);
  953. const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String).filter(Boolean) : null;
  954. const maxPages = Math.max(1, Math.min(300, Number(input.maxPages || 100)));
  955. const maxMessagesPerRoom = Math.max(1, Math.min(5000, Number(input.maxMessagesPerRoom || 1000)));
  956. let targetRoomIds = roomIds;
  957. if (!targetRoomIds) {
  958. const mapping = readConfirmedMapping();
  959. targetRoomIds = Object.keys(mapping);
  960. if (!targetRoomIds.length) return errorResult('未指定 roomIds 且没有已确认的客户群映射,请先调用 qiwei_confirm_external_group');
  961. }
  962. const results = [];
  963. for (const roomId of targetRoomIds) {
  964. let hasMore = true;
  965. let msgSeq = 0;
  966. let pages = 0;
  967. let newCount = 0;
  968. const messages = [];
  969. while (hasMore && pages < maxPages && newCount < maxMessagesPerRoom) {
  970. pages++;
  971. const data = await gatewayCall(ctx, REQUIRED_METHODS.syncMsg, {
  972. guid: ctx.guid,
  973. msgSeq,
  974. limit: 200
  975. });
  976. const msgList = Array.isArray(data && data.syncMsgList) ? data.syncMsgList : [];
  977. hasMore = Boolean(data.hasMore);
  978. msgSeq = data.travelSyncKey ?? msgSeq + 1;
  979. for (const msg of msgList) {
  980. const msgRoomId = String(msg.fromRoomId || '');
  981. if (msgRoomId !== roomId) continue;
  982. messages.push({
  983. msgId: msg.msgUniqueIdentifier || msg.msgServerId || `${roomId}_${msg.seq}`,
  984. seq: msg.seq,
  985. senderId: msg.senderId || '',
  986. senderName: msg.senderName || '',
  987. receiverId: msg.receiverId || '',
  988. fromRoomId: msg.fromRoomId || roomId,
  989. msgType: String(msg.msgType),
  990. content: extractMessageContent(msg),
  991. timestamp: msg.timestamp ? new Date(msg.timestamp * 1000).toISOString() : new Date().toISOString(),
  992. rawData: msg
  993. });
  994. newCount++;
  995. }
  996. if (!msgList.length) break;
  997. }
  998. if (messages.length) {
  999. ensureMessagesDir();
  1000. let writtenCount = 0;
  1001. for (const message of messages) {
  1002. if (appendWebhookMessage(roomId, message)) writtenCount++;
  1003. }
  1004. // 更新 confirmed-mapping 的 lastMsgAt / lastSyncSeq
  1005. const mapping = readConfirmedMapping();
  1006. if (mapping[roomId]) {
  1007. const maxSeq = Math.max(...messages.map(m => Number(m.seq) || 0));
  1008. const lastMsg = messages[messages.length - 1];
  1009. mapping[roomId].lastMsgAt = lastMsg.timestamp || new Date().toISOString();
  1010. mapping[roomId].lastSyncSeq = Math.max(mapping[roomId].lastSyncSeq || 0, maxSeq);
  1011. writeConfirmedMapping(mapping);
  1012. recordGroupSync(roomId, {
  1013. messageCount: writtenCount,
  1014. lastMsgAt: lastMsg.timestamp || new Date().toISOString(),
  1015. lastSyncSeq: mapping[roomId].lastSyncSeq
  1016. });
  1017. }
  1018. results.push({ roomId, newCount: writtenCount, fileCount: messages.length });
  1019. } else {
  1020. results.push({ roomId, newCount: 0, fileCount: 0 });
  1021. }
  1022. }
  1023. const totalNew = results.reduce((sum, r) => sum + r.newCount, 0);
  1024. return okResult({
  1025. assistantMessage: `群消息同步完成:${targetRoomIds.length} 个群,共 ${totalNew} 条新消息。`,
  1026. summary: { rooms: targetRoomIds.length, totalNew, syncedRooms: results.filter(r => r.newCount > 0).length },
  1027. data: { results },
  1028. files: results.filter(r => r.filePath).map(r => path.join(outputsRoot(), r.filePath))
  1029. });
  1030. });
  1031. module.exports = {
  1032. qiweiSyncExternalGroups,
  1033. qiweiListExternalGroups,
  1034. qiweiAnalyzeGroupMembers,
  1035. qiweiConfirmExternalGroup,
  1036. qiweiAddExternalGroup,
  1037. qiweiConfigureGroupKeywords,
  1038. qiweiRejectExternalGroup,
  1039. qiweiSyncGroupMessages,
  1040. readConfirmedMapping,
  1041. writeConfirmedMapping,
  1042. updateConfirmedMapping,
  1043. readImportedMapping,
  1044. writeImportedMapping,
  1045. updateImportedMapping
  1046. };