| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188 |
- const fs = require('fs');
- const path = require('path');
- const { okResult, errorResult } = require('../core/result-envelope');
- const { createRunDir, latestPath, outputsRoot, writeRunManifest } = require('../core/output-paths');
- const { recordGroupSync, recordCustomersFromGroups } = require('../core/dashboard-state');
- const {
- buildContext,
- gatewayCall,
- requireGuid,
- assertMethodsInCatalog,
- safeResult
- } = require('../core/shared-gateway');
- const REQUIRED_METHODS = {
- getRoomList: '/room/getRoomList',
- batchGetRoomDetail: '/room/batchGetRoomDetail',
- syncMsg: '/msg/syncMsg',
- getSessionPage: '/session/getSessionPage'
- };
- const DEFAULT_KEYWORD_CONFIG = {
- matchMode: 'threshold',
- threshold: 2,
- highConfidenceTerms: ['客户群', '服务群'],
- keywords: [
- '客户群', '服务群', '售后群', '咨询群', 'VIP群', '专属群',
- '业主群', '项目群', '订单群', '用户群', '粉丝群', '会员群',
- '客户', '服务', '售后', '咨询'
- ]
- };
- function decodeRoomName(raw) {
- if (!raw) return '';
- try {
- const decoded = Buffer.from(raw, 'base64').toString('utf8');
- if (decoded && /[一-龥]/.test(decoded)) return decoded;
- } catch {
- // ignore
- }
- return raw;
- }
- function groupsDir() {
- return path.join(outputsRoot(), 'groups');
- }
- function messagesDir() {
- return path.join(outputsRoot(), 'messages');
- }
- function roomMessagesDir(roomId) {
- return path.join(messagesDir(), roomId);
- }
- function ensureGroupsDir() {
- fs.mkdirSync(groupsDir(), { recursive: true });
- }
- function ensureMessagesDir() {
- fs.mkdirSync(messagesDir(), { recursive: true });
- }
- function ensureRoomMessagesDir(roomId) {
- return fs.mkdirSync(roomMessagesDir(roomId), { recursive: true });
- }
- function messageFilePath(roomId, seq, msgUniqueId) {
- ensureRoomMessagesDir(roomId);
- const safeUniqueId = String(msgUniqueId || '').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80);
- return path.join(roomMessagesDir(roomId), `${seq}-${safeUniqueId}.json`);
- }
- let gbkEncodeMap = null;
- function getGbkEncodeMap() {
- if (gbkEncodeMap) return gbkEncodeMap;
- gbkEncodeMap = new Map();
- const decoder = new TextDecoder('gbk');
- for (let first = 0x81; first <= 0xfe; first++) {
- for (let second = 0x40; second <= 0xfe; second++) {
- if (second === 0x7f) continue;
- const bytes = Buffer.from([first, second]);
- const char = decoder.decode(bytes);
- if (!char || char === '\uFFFD' || char.length !== 1) continue;
- if (!gbkEncodeMap.has(char)) gbkEncodeMap.set(char, bytes);
- }
- }
- return gbkEncodeMap;
- }
- function repairUtf8DecodedAsGbk(value) {
- const text = String(value || '');
- if (!/[\u4e00-\u9fa5]/.test(text)) return text;
- const map = getGbkEncodeMap();
- const chunks = [];
- for (const char of text) {
- const code = char.codePointAt(0);
- if (code <= 0x7f) {
- chunks.push(Buffer.from([code]));
- continue;
- }
- const bytes = map.get(char);
- if (!bytes) return text;
- chunks.push(bytes);
- }
- const repaired = Buffer.concat(chunks).toString('utf8');
- if (repaired.includes('\uFFFD')) return text;
- return /[\u4e00-\u9fa5]/.test(repaired) ? repaired : text;
- }
- function cleanExtractedText(value) {
- const cleaned = String(value || '').replace(/[\u0000-\u001f\u007f]/g, '').trim();
- return repairUtf8DecodedAsGbk(cleaned);
- }
- function extractUtf8TextFromBase64(value) {
- if (!value) return '';
- let buffer;
- try {
- buffer = Buffer.from(String(value), 'base64');
- } catch {
- return '';
- }
- const candidates = [];
- for (let i = 0; i < buffer.length - 1; i++) {
- const len = buffer[i];
- if (!len || len > 240 || i + 1 + len > buffer.length) continue;
- const text = cleanExtractedText(buffer.slice(i + 1, i + 1 + len).toString('utf8'));
- if (!text || text.includes('\uFFFD')) continue;
- if (/[\u4e00-\u9fa5]/.test(text)) candidates.push(text);
- }
- return candidates.sort((a, b) => b.length - a.length)[0] || '';
- }
- function extractMessageContent(msg = {}) {
- const rawText = extractUtf8TextFromBase64(msg.base64RawData || msg.msgData?.extras?.base64RawData);
- if (rawText) return rawText;
- if (typeof msg.content === 'string' && msg.content) return cleanExtractedText(msg.content);
- if (typeof msg.msgContent === 'string' && msg.msgContent) return cleanExtractedText(msg.msgContent);
- if (msg.msgData) {
- if (typeof msg.msgData.content === 'string' && msg.msgData.content) return cleanExtractedText(msg.msgData.content);
- if (typeof msg.msgData.text === 'string' && msg.msgData.text) return cleanExtractedText(msg.msgData.text);
- if (Array.isArray(msg.msgData.moreDetail)) {
- return cleanExtractedText(msg.msgData.moreDetail.map(item => item && item.text).filter(Boolean).join(''));
- }
- if (typeof msg.msgData.notifyTitle === 'string' && msg.msgData.notifyTitle) return cleanExtractedText(msg.msgData.notifyTitle);
- }
- return '';
- }
- function appendWebhookMessage(roomId, message) {
- if (!roomId || !message) return null;
- const seq = Number(message.seq) || 0;
- const msgUniqueId = message.msgUniqueIdentifier || message.msgId || `${seq}`;
- const filePath = messageFilePath(roomId, seq, msgUniqueId);
- if (fs.existsSync(filePath)) {
- const existing = safeReadJson(filePath, null);
- if (!existing) return null;
- const merged = {
- ...existing,
- content: existing.content || message.content || '',
- rawData: existing.rawData || message.rawData,
- fromRoomId: existing.fromRoomId || message.fromRoomId,
- receiverId: existing.receiverId || message.receiverId || ''
- };
- const improved = (!existing.content && merged.content) || (!existing.rawData && merged.rawData);
- if (!improved) return null;
- fs.writeFileSync(filePath, JSON.stringify(merged, null, 2), 'utf8');
- return filePath;
- }
- fs.writeFileSync(filePath, JSON.stringify(message, null, 2), 'utf8');
- return filePath;
- }
- function messageExists(roomId, msgUniqueId) {
- if (!roomId || !msgUniqueId) return false;
- const dir = roomMessagesDir(roomId);
- if (!fs.existsSync(dir)) return false;
- const safeUniqueId = String(msgUniqueId).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80);
- const files = fs.readdirSync(dir);
- return files.some(f => f.endsWith(`-${safeUniqueId}.json`));
- }
- function listRoomMessageFiles(roomId) {
- const dir = roomMessagesDir(roomId);
- if (!fs.existsSync(dir)) return [];
- return fs.readdirSync(dir)
- .filter(f => f.endsWith('.json'))
- .map(f => ({ name: f, path: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtime }))
- .sort((a, b) => a.name.localeCompare(b.name));
- }
- function readRoomMessages(roomId) {
- const files = listRoomMessageFiles(roomId);
- const messages = [];
- for (const file of files) {
- try {
- const msg = JSON.parse(fs.readFileSync(file.path, 'utf8'));
- messages.push(msg);
- } catch {
- // ignore
- }
- }
- return messages;
- }
- function safeReadJson(filePath, fallback) {
- if (!filePath || !fs.existsSync(filePath)) return fallback;
- try {
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
- } catch {
- return fallback;
- }
- }
- function customerKeywordsPath() {
- return path.join(groupsDir(), 'customer-keywords.json');
- }
- function loadCustomerKeywords() {
- const stored = safeReadJson(customerKeywordsPath(), null);
- if (!stored || !stored.config) return { ...DEFAULT_KEYWORD_CONFIG };
- return {
- matchMode: stored.config.matchMode === 'any' ? 'any' : 'threshold',
- threshold: Math.max(1, Number(stored.config.threshold) || DEFAULT_KEYWORD_CONFIG.threshold),
- highConfidenceTerms: Array.isArray(stored.config.highConfidenceTerms)
- ? stored.config.highConfidenceTerms
- : DEFAULT_KEYWORD_CONFIG.highConfidenceTerms,
- keywords: Array.isArray(stored.config.keywords)
- ? stored.config.keywords
- : DEFAULT_KEYWORD_CONFIG.keywords
- };
- }
- function saveCustomerKeywords(config) {
- ensureGroupsDir();
- const payload = {
- version: '1.0',
- updatedAt: new Date().toISOString(),
- config: {
- matchMode: config.matchMode === 'any' ? 'any' : 'threshold',
- threshold: Math.max(1, Number(config.threshold) || DEFAULT_KEYWORD_CONFIG.threshold),
- highConfidenceTerms: Array.isArray(config.highConfidenceTerms)
- ? config.highConfidenceTerms
- : DEFAULT_KEYWORD_CONFIG.highConfidenceTerms,
- keywords: Array.isArray(config.keywords)
- ? config.keywords
- : DEFAULT_KEYWORD_CONFIG.keywords
- }
- };
- fs.writeFileSync(customerKeywordsPath(), JSON.stringify(payload, null, 2), 'utf8');
- return payload.config;
- }
- function buildClassifierConfig(input = {}) {
- const persisted = loadCustomerKeywords();
- const matchMode = input.matchMode === 'any' || input.matchMode === 'threshold'
- ? input.matchMode
- : persisted.matchMode;
- const threshold = Math.max(1, Number(input.threshold || persisted.threshold) || 1);
- return {
- keywords: Array.isArray(input.customerKeywords) && input.customerKeywords.length
- ? input.customerKeywords
- : persisted.keywords,
- highConfidenceTerms: Array.isArray(input.highConfidenceTerms) && input.highConfidenceTerms.length
- ? input.highConfidenceTerms
- : persisted.highConfidenceTerms,
- matchMode,
- threshold
- };
- }
- function normalizeMatchResult(classification) {
- return {
- isCustomerGroup: Boolean(classification.isCustomerGroup),
- reviewStatus: classification.reviewStatus || 'IMPORTED',
- confidence: typeof classification.confidence === 'number' ? classification.confidence : 0,
- reason: classification.reason || '',
- matchedKeywords: Array.isArray(classification.matchedKeywords) ? classification.matchedKeywords : []
- };
- }
- function classifyGroupByName(roomName, classifierConfig = buildClassifierConfig()) {
- const text = String(roomName || '');
- const { keywords, highConfidenceTerms, matchMode, threshold } = classifierConfig;
- const highMatched = [...new Set((highConfidenceTerms || []).filter(kw => text.includes(kw)))];
- if (highMatched.length > 0) {
- return normalizeMatchResult({
- isCustomerGroup: true,
- reviewStatus: 'AUTO_CONFIRMED',
- confidence: 1.0,
- reason: `高置信命中:${highMatched.slice(0, 5).join('、')}`,
- matchedKeywords: highMatched
- });
- }
- const matched = [...new Set((keywords || []).filter(kw => text.includes(kw)))];
- const hitCount = matched.length;
- if (matchMode === 'any') {
- if (hitCount > 0) {
- return normalizeMatchResult({
- isCustomerGroup: true,
- reviewStatus: 'AUTO_CONFIRMED',
- confidence: 0.8,
- reason: `命中关键词:${matched.slice(0, 5).join('、')}`,
- matchedKeywords: matched
- });
- }
- } else if (hitCount >= threshold) {
- return normalizeMatchResult({
- isCustomerGroup: true,
- reviewStatus: 'AUTO_CONFIRMED',
- confidence: Math.min(0.95, 0.5 + 0.12 * hitCount),
- reason: `命中 ${hitCount} 个关键词(阈值 ${threshold})`,
- matchedKeywords: matched
- });
- } else if (hitCount > 0) {
- return normalizeMatchResult({
- isCustomerGroup: true,
- reviewStatus: 'SUGGESTED',
- confidence: 0.35,
- reason: `仅命中 ${hitCount} 个弱关键词,建议人工确认`,
- matchedKeywords: matched
- });
- }
- return normalizeMatchResult({
- isCustomerGroup: false,
- reviewStatus: 'IMPORTED',
- confidence: 0,
- reason: '未命中客户群关键词',
- matchedKeywords: []
- });
- }
- function createRoomRecord(raw, source) {
- const members = Array.isArray(raw.roomMemberList)
- ? raw.roomMemberList
- : Array.isArray(raw.memberList)
- ? raw.memberList
- : Array.isArray(raw.members)
- ? raw.members
- : [];
- return {
- roomId: String(raw.roomId || ''),
- roomName: decodeRoomName(raw.roomName || raw.sessionName || ''),
- memberCount: Number(raw.roomMemberCount) || 0,
- roomHeadimgUrl: raw.roomHeadimgUrl || raw.roomAvatarUrl || undefined,
- roomExtType: raw.roomExtType !== undefined ? Number(raw.roomExtType) : undefined,
- members: members.map(m => ({
- userId: String(m.userId || m.wxId || m.id || ''),
- userName: String(m.userName || m.nickname || m.name || ''),
- type: Number(m.userType || m.type || m.memberType || 0)
- })),
- source,
- sources: [source],
- seenAt: new Date().toISOString()
- };
- }
- function dedupRooms(rooms) {
- const map = new Map();
- for (const room of rooms) {
- if (!room.roomId) continue;
- const existing = map.get(room.roomId);
- if (!existing) {
- map.set(room.roomId, room);
- } else {
- existing.sources = [...new Set([...existing.sources, ...room.sources])];
- existing.source = existing.sources.join(',');
- if (room.roomName && room.roomName !== existing.roomName) {
- existing.roomName = room.roomName;
- }
- if (room.memberCount && room.memberCount > existing.memberCount) {
- existing.memberCount = room.memberCount;
- }
- if (room.roomHeadimgUrl && !existing.roomHeadimgUrl) {
- existing.roomHeadimgUrl = room.roomHeadimgUrl;
- }
- if (room.roomExtType !== undefined && existing.roomExtType === undefined) {
- existing.roomExtType = room.roomExtType;
- }
- if (room.seenAt && (!existing.seenAt || room.seenAt > existing.seenAt)) {
- existing.seenAt = room.seenAt;
- }
- if (Array.isArray(room.members) && room.members.length > (existing.members?.length || 0)) {
- existing.members = room.members;
- }
- }
- }
- return Array.from(map.values());
- }
- function isExternalRoom(room, includeInternalGroups = false) {
- if (includeInternalGroups) return true;
- return room.roomExtType === undefined || room.roomExtType === 2;
- }
- async function scanRoomsFromRoomList(ctx, maxPages = 100) {
- assertMethodsInCatalog({ getRoomList: REQUIRED_METHODS.getRoomList });
- const rooms = [];
- let nextStartIndex = 0;
- let hasMore = true;
- let pages = 0;
- while (hasMore && pages < maxPages) {
- pages++;
- const data = await gatewayCall(ctx, REQUIRED_METHODS.getRoomList, {
- guid: ctx.guid,
- nextStartIndex
- });
- const roomList = Array.isArray(data && data.roomList) ? data.roomList : [];
- for (const room of roomList) {
- rooms.push(createRoomRecord(room, 'roomList'));
- }
- hasMore = data.hasMore === 1 || data.hasMore === true;
- nextStartIndex = data.nextStartIndex ?? -1;
- if (!roomList.length || nextStartIndex < 0) break;
- }
- return { rooms, pages };
- }
- function extractRoomIdFromSession(session) {
- if (!session) return '';
- return String(session.roomId || session.sessionId || session.fromRoomId || session.id || '');
- }
- function isGroupSession(session) {
- if (!session) return false;
- if (session.sessionType === 1 || session.chatType === 2 || session.isGroup === true) return true;
- return false;
- }
- async function scanRoomsFromSessions(ctx, maxPages = 100) {
- assertMethodsInCatalog({ getSessionPage: REQUIRED_METHODS.getSessionPage });
- const rooms = [];
- let currentSeq = 0;
- let hasMore = true;
- let pages = 0;
- while (hasMore && pages < maxPages) {
- pages++;
- const data = await gatewayCall(ctx, REQUIRED_METHODS.getSessionPage, {
- guid: ctx.guid,
- sessionType: 1,
- currentSeq
- });
- const sessionList = Array.isArray(data && data.sessionList)
- ? data.sessionList
- : Array.isArray(data && data.list)
- ? data.list
- : [];
- for (const session of sessionList) {
- const roomId = extractRoomIdFromSession(session);
- if (!roomId || !isGroupSession(session)) continue;
- const roomName = decodeRoomName(session.sessionName || session.roomName || '');
- if (!roomName) continue; // 跳过无名称的会话(通常是单聊或系统通知)
- rooms.push({
- roomId,
- roomName,
- memberCount: Number(session.roomMemberCount || session.memberCount) || 0,
- roomHeadimgUrl: session.roomHeadimgUrl || session.sessionAvatar || undefined,
- roomExtType: session.roomExtType !== undefined ? Number(session.roomExtType) : undefined,
- source: 'session',
- sources: ['session'],
- seenAt: new Date().toISOString()
- });
- }
- hasMore = data.hasMore === 1 || data.hasMore === true;
- currentSeq = data.currentSeq ?? -1;
- if (!sessionList.length || currentSeq < 0) break;
- }
- return { rooms, pages };
- }
- async function enrichRoomsWithDetails(ctx, roomIds, chunkSize = 50) {
- if (!roomIds.length) return [];
- assertMethodsInCatalog({ batchGetRoomDetail: REQUIRED_METHODS.batchGetRoomDetail });
- const rooms = [];
- for (let i = 0; i < roomIds.length; i += chunkSize) {
- const chunk = roomIds.slice(i, i + chunkSize);
- const data = await gatewayCall(ctx, REQUIRED_METHODS.batchGetRoomDetail, {
- guid: ctx.guid,
- roomIdList: chunk
- });
- const roomList = Array.isArray(data && data.roomList) ? data.roomList : [];
- for (const room of roomList) {
- rooms.push(createRoomRecord(room, 'messages'));
- }
- }
- return rooms;
- }
- async function scanRoomsFromMessages(ctx, maxPages = 300, maxTotalMessages = 20000) {
- assertMethodsInCatalog({ syncMsg: REQUIRED_METHODS.syncMsg });
- const roomIds = new Set();
- let msgSeq = 0;
- let hasMore = true;
- let pages = 0;
- let totalMessages = 0;
- while (hasMore && pages < maxPages && totalMessages < maxTotalMessages) {
- pages++;
- const data = await gatewayCall(ctx, REQUIRED_METHODS.syncMsg, {
- guid: ctx.guid,
- msgSeq,
- limit: 500
- });
- const msgList = Array.isArray(data && data.syncMsgList) ? data.syncMsgList : [];
- hasMore = Boolean(data.hasMore);
- msgSeq = data.travelSyncKey ?? msgSeq + 1;
- totalMessages += msgList.length;
- for (const msg of msgList) {
- const roomId = String(msg.fromRoomId || '');
- if (roomId) roomIds.add(roomId);
- }
- if (!msgList.length) break;
- }
- const roomIdList = Array.from(roomIds);
- const details = await enrichRoomsWithDetails(ctx, roomIdList, 50);
- return { rooms: details, pages, rawRoomIds: roomIdList, totalMessages };
- }
- function listGroupFiles() {
- ensureGroupsDir();
- const files = fs.readdirSync(groupsDir())
- .filter(f => /^rooms-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.json$/.test(f))
- .map(f => ({ name: f, path: path.join(groupsDir(), f), mtime: fs.statSync(path.join(groupsDir(), f)).mtime }))
- .sort((a, b) => b.mtime - a.mtime);
- return files;
- }
- function readLatestRooms() {
- return safeReadJson(latestPath('groups', 'rooms-latest.json'), []);
- }
- function confirmedMappingPath() {
- ensureGroupsDir();
- return path.join(groupsDir(), 'confirmed-mapping.json');
- }
- function readConfirmedMapping() {
- return safeReadJson(confirmedMappingPath(), {});
- }
- function writeConfirmedMapping(mapping) {
- ensureGroupsDir();
- fs.writeFileSync(confirmedMappingPath(), JSON.stringify(mapping, null, 2), 'utf8');
- }
- function rejectedMappingPath() {
- ensureGroupsDir();
- return path.join(groupsDir(), 'rejected-mapping.json');
- }
- function readRejectedMapping() {
- return safeReadJson(rejectedMappingPath(), {});
- }
- function writeRejectedMapping(mapping) {
- ensureGroupsDir();
- fs.writeFileSync(rejectedMappingPath(), JSON.stringify(mapping, null, 2), 'utf8');
- }
- function resolveScope(input) {
- const validScopes = ['self', 'session', 'messages', 'all'];
- if (input.scope && validScopes.includes(input.scope)) return input.scope;
- if (input.fromMessages === true) return 'messages';
- return 'all';
- }
- function timestampFileName() {
- return `rooms-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.json`;
- }
- function countBySource(rooms) {
- return rooms.reduce((acc, room) => {
- for (const s of room.sources || [room.source]) {
- acc[s] = (acc[s] || 0) + 1;
- }
- return acc;
- }, {});
- }
- const qiweiSyncExternalGroups = safeResult(async function qiweiSyncExternalGroups(input = {}) {
- assertMethodsInCatalog({
- getRoomList: REQUIRED_METHODS.getRoomList,
- batchGetRoomDetail: REQUIRED_METHODS.batchGetRoomDetail,
- getSessionPage: REQUIRED_METHODS.getSessionPage,
- syncMsg: REQUIRED_METHODS.syncMsg
- });
- const ctx = buildContext(input);
- requireGuid(ctx);
- const scope = resolveScope(input);
- const maxPages = Math.max(1, Math.min(300, Number(input.maxPages || 100)));
- const includeInternalGroups = input.includeInternalGroups === true;
- const autoClassify = input.autoClassify !== false;
- const scanFromMessages = input.scanFromMessages === true;
- const previewMessages = Math.max(0, Math.min(100, Number(input.previewMessages || 0)));
- const previewOnlyForUnsure = input.previewOnlyForUnsure !== false;
- const classifier = buildClassifierConfig(input);
- const collected = [];
- const perSource = { roomList: { rooms: [], pages: 0 }, session: { rooms: [], pages: 0 }, messages: { rooms: [], pages: 0, totalMessages: 0 } };
- if (scope === 'self' || scope === 'all') {
- try {
- const result = await scanRoomsFromRoomList(ctx, maxPages);
- perSource.roomList = result;
- collected.push(...result.rooms);
- } catch (err) {
- // 继续其他来源
- }
- }
- if (scope === 'session' || scope === 'all') {
- try {
- const result = await scanRoomsFromSessions(ctx, maxPages);
- perSource.session = result;
- collected.push(...result.rooms);
- } catch (err) {
- // 继续其他来源
- }
- }
- if (scope === 'messages' || (scope === 'all' && scanFromMessages)) {
- try {
- const result = await scanRoomsFromMessages(ctx, maxPages);
- perSource.messages = result;
- collected.push(...result.rooms);
- } catch (err) {
- // 继续其他来源
- }
- }
- const deduped = dedupRooms(collected);
- const roomsNeedingDetails = deduped
- .filter(r => Number(r.memberCount || 0) > 0 && (!Array.isArray(r.members) || !r.members.length))
- .map(r => r.roomId)
- .filter(Boolean);
- if (roomsNeedingDetails.length) {
- try {
- const detailRooms = await enrichRoomsWithDetails(ctx, roomsNeedingDetails, 20);
- const detailMap = new Map(detailRooms.map(r => [r.roomId, r]));
- for (let i = 0; i < deduped.length; i++) {
- const detail = detailMap.get(deduped[i].roomId);
- if (!detail) continue;
- deduped[i] = {
- ...deduped[i],
- ...detail,
- roomName: deduped[i].roomName || detail.roomName,
- source: deduped[i].source,
- sources: [...new Set([...(deduped[i].sources || []), ...(detail.sources || [])])],
- seenAt: deduped[i].seenAt || detail.seenAt
- };
- }
- } catch {
- // 群详情不是同步列表的硬依赖;拿不到成员明细时仍保留群列表结果。
- }
- }
- const filtered = deduped.filter(r => isExternalRoom(r, includeInternalGroups));
- const classifiedRooms = [];
- for (const room of filtered) {
- if (!autoClassify) {
- classifiedRooms.push({
- ...room,
- reviewStatus: 'IMPORTED',
- confidence: 0,
- reason: '自动分类已关闭',
- matchedKeywords: []
- });
- continue;
- }
- let text = room.roomName || '';
- let classification = classifyGroupByName(text, classifier);
- if (previewMessages > 0 && previewOnlyForUnsure && classification.reviewStatus !== 'AUTO_CONFIRMED') {
- try {
- const previews = await fetchPreviewMessages(ctx, room.roomId, previewMessages);
- if (previews.length) {
- text += ' ' + previews.join(' ');
- classification = classifyGroupByName(text, classifier);
- }
- } catch {
- // 忽略消息拉取失败,保留初次分类结果
- }
- }
- classifiedRooms.push({ ...room, ...classification });
- }
- const runDir = createRunDir('groups', 'sync-external-groups');
- const fileName = timestampFileName();
- const filePath = path.join(runDir, fileName);
- const manifest = {
- scope,
- maxPages,
- includeInternalGroups,
- autoClassify,
- previewMessages,
- previewOnlyForUnsure,
- matchMode: classifier.matchMode,
- threshold: classifier.threshold,
- total: classifiedRooms.length,
- sources: countBySource(classifiedRooms),
- files: [fileName]
- };
- fs.writeFileSync(filePath, JSON.stringify(classifiedRooms, null, 2), 'utf8');
- const manifestPath = writeRunManifest(runDir, manifest);
- const latestFile = latestPath('groups', 'rooms-latest.json');
- fs.writeFileSync(latestFile, JSON.stringify(classifiedRooms, null, 2), 'utf8');
- const scanManifestPath = path.join(groupsDir(), 'group-scan-manifest.json');
- fs.writeFileSync(scanManifestPath, JSON.stringify({
- lastRunAt: new Date().toISOString(),
- lastRunDir: path.relative(outputsRoot(), runDir),
- scope,
- total: classifiedRooms.length,
- autoConfirmed: classifiedRooms.filter(r => r.reviewStatus === 'AUTO_CONFIRMED').length,
- suggested: classifiedRooms.filter(r => r.reviewStatus === 'SUGGESTED').length,
- imported: classifiedRooms.filter(r => r.reviewStatus === 'IMPORTED').length
- }, null, 2), 'utf8');
- const discoveredCustomers = recordCustomersFromGroups(classifiedRooms, { source: 'group-sync' });
- return okResult({
- assistantMessage: `外部群同步完成:扫描到 ${classifiedRooms.length} 个群(scope=${scope})。`,
- summary: {
- scanned: classifiedRooms.length,
- selfCount: perSource.roomList.rooms.length,
- sessionCount: perSource.session.rooms.length,
- messageCount: perSource.messages.rooms.length,
- mergedCount: deduped.length,
- externalCount: filtered.length,
- autoConfirmed: classifiedRooms.filter(r => r.reviewStatus === 'AUTO_CONFIRMED').length,
- suggested: classifiedRooms.filter(r => r.reviewStatus === 'SUGGESTED').length,
- imported: classifiedRooms.filter(r => r.reviewStatus === 'IMPORTED').length,
- discoveredCustomers: discoveredCustomers.discovered,
- previewMessages,
- previewOnlyForUnsure,
- pages: perSource.roomList.pages + perSource.session.pages + perSource.messages.pages
- },
- data: {
- rooms: classifiedRooms.slice(0, 20),
- filePath: path.relative(outputsRoot(), filePath),
- manifestPath: path.relative(outputsRoot(), manifestPath)
- },
- files: [filePath, manifestPath]
- });
- });
- const qiweiListExternalGroups = safeResult(async function qiweiListExternalGroups(input = {}) {
- const rooms = readLatestRooms();
- const confirmed = readConfirmedMapping();
- const rejected = readRejectedMapping();
- const classifier = buildClassifierConfig(input);
- let result = rooms.map(room => {
- const mapped = confirmed[room.roomId];
- const rejectedRecord = rejected[room.roomId];
- if (rejectedRecord) {
- return {
- ...room,
- reviewStatus: 'REJECTED',
- confidence: 0,
- reason: rejectedRecord.reason || '经纪人手动拒绝',
- matchedKeywords: [],
- customerId: undefined,
- externalUserId: undefined
- };
- }
- if (mapped) {
- return {
- ...room,
- reviewStatus: 'CONFIRMED',
- confidence: 1.0,
- reason: '经纪人手动确认',
- matchedKeywords: [],
- customerId: mapped.customerId,
- externalUserId: mapped.externalUserId,
- customerName: mapped.customerName
- };
- }
- const classification = classifyGroupByName(room.roomName, classifier);
- return { ...room, ...classification };
- });
- const keyword = String(input.keyword || '').trim();
- if (keyword) {
- result = result.filter(r => (r.roomName || '').includes(keyword) || (r.roomId || '').includes(keyword));
- }
- const statusFilter = String(input.status || '').trim();
- if (statusFilter) {
- result = result.filter(r => r.reviewStatus === statusFilter);
- }
- const sourceFilter = String(input.source || '').trim();
- if (sourceFilter) {
- result = result.filter(r => r.sources && r.sources.includes(sourceFilter));
- }
- if (input.includeRejected !== true) {
- result = result.filter(r => r.reviewStatus !== 'REJECTED');
- }
- return okResult({
- assistantMessage: `共 ${result.length} 个外部群(已同步 ${rooms.length})。`,
- summary: {
- total: result.length,
- imported: result.filter(r => r.reviewStatus === 'IMPORTED').length,
- suggested: result.filter(r => r.reviewStatus === 'SUGGESTED').length,
- autoConfirmed: result.filter(r => r.reviewStatus === 'AUTO_CONFIRMED').length,
- confirmed: result.filter(r => r.reviewStatus === 'CONFIRMED').length,
- rejected: result.filter(r => r.reviewStatus === 'REJECTED').length
- },
- data: { groups: result }
- });
- });
- async function fetchPreviewMessages(ctx, roomId, previewMessages) {
- const texts = [];
- let msgSeq = 0;
- let hasMore = true;
- let pages = 0;
- const maxPages = Math.min(10, Math.ceil(previewMessages / 100));
- while (hasMore && pages < maxPages && texts.length < previewMessages) {
- pages++;
- const data = await gatewayCall(ctx, REQUIRED_METHODS.syncMsg, {
- guid: ctx.guid,
- msgSeq,
- limit: 100
- });
- const msgList = Array.isArray(data && data.syncMsgList) ? data.syncMsgList : [];
- hasMore = Boolean(data.hasMore);
- msgSeq = data.travelSyncKey ?? msgSeq + 1;
- for (const msg of msgList) {
- if (String(msg.fromRoomId || '') !== roomId) continue;
- const content = String(msg.content || msg.msgContent || '').trim();
- if (content) texts.push(content);
- if (texts.length >= previewMessages) break;
- }
- if (!msgList.length) break;
- }
- return texts;
- }
- const qiweiAnalyzeGroupMembers = safeResult(async function qiweiAnalyzeGroupMembers(input = {}) {
- assertMethodsInCatalog({ batchGetRoomDetail: REQUIRED_METHODS.batchGetRoomDetail });
- const ctx = buildContext(input);
- requireGuid(ctx);
- const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String).filter(Boolean) : [];
- if (!roomIds.length) return errorResult('缺少 roomIds');
- const classifier = buildClassifierConfig(input);
- const autoClassify = input.autoClassify !== false;
- const previewMessages = Math.max(0, Math.min(100, Number(input.previewMessages || 0)));
- const updateSnapshot = input.updateSnapshot === true;
- const details = await enrichRoomsWithDetails(ctx, roomIds, 20);
- const classified = [];
- for (const room of details) {
- let text = room.roomName || '';
- if (previewMessages > 0) {
- try {
- const previews = await fetchPreviewMessages(ctx, room.roomId, previewMessages);
- if (previews.length) text += ' ' + previews.join(' ');
- } catch {
- // 忽略消息拉取失败
- }
- }
- if (!autoClassify) {
- classified.push({ ...room, reviewStatus: 'IMPORTED', confidence: 0, reason: '自动分类已关闭', matchedKeywords: [] });
- continue;
- }
- const classification = classifyGroupByName(text, classifier);
- classified.push({ ...room, ...classification });
- }
- if (updateSnapshot) {
- const snapshot = readLatestRooms();
- const snapshotMap = new Map(snapshot.map(r => [r.roomId, r]));
- for (const room of classified) {
- snapshotMap.set(room.roomId, room);
- }
- fs.writeFileSync(latestPath('groups', 'rooms-latest.json'), JSON.stringify(Array.from(snapshotMap.values()), null, 2), 'utf8');
- }
- const discoveredCustomers = recordCustomersFromGroups(classified, { source: 'group-analysis' });
- return okResult({
- assistantMessage: `群成员分析完成:分析 ${classified.length} 个群。`,
- summary: {
- analyzed: classified.length,
- autoConfirmed: classified.filter(d => d.reviewStatus === 'AUTO_CONFIRMED').length,
- suggested: classified.filter(d => d.reviewStatus === 'SUGGESTED').length,
- imported: classified.filter(d => d.reviewStatus === 'IMPORTED').length,
- discoveredCustomers: discoveredCustomers.discovered
- },
- data: { details: classified }
- });
- });
- function updateConfirmedMapping(roomId, fields) {
- const mapping = readConfirmedMapping();
- mapping[roomId] = { ...(mapping[roomId] || {}), ...fields, confirmedAt: new Date().toISOString() };
- writeConfirmedMapping(mapping);
- return mapping[roomId];
- }
- function importedMappingPath() {
- ensureGroupsDir();
- return path.join(groupsDir(), 'imported-mapping.json');
- }
- function readImportedMapping() {
- return safeReadJson(importedMappingPath(), {});
- }
- function writeImportedMapping(mapping) {
- ensureGroupsDir();
- fs.writeFileSync(importedMappingPath(), JSON.stringify(mapping, null, 2), 'utf8');
- }
- function updateConfirmedMapping(roomId, fields) {
- const mapping = readConfirmedMapping();
- mapping[roomId] = { ...(mapping[roomId] || {}), ...fields, confirmedAt: new Date().toISOString() };
- writeConfirmedMapping(mapping);
- return mapping[roomId];
- }
- function updateImportedMapping(roomId, fields) {
- const mapping = readImportedMapping();
- mapping[roomId] = { ...(mapping[roomId] || {}), ...fields, importedAt: new Date().toISOString() };
- writeImportedMapping(mapping);
- return mapping[roomId];
- }
- const qiweiConfirmExternalGroup = safeResult(async function qiweiConfirmExternalGroup(input = {}) {
- const roomId = String(input.roomId || '').trim();
- if (!roomId) return errorResult('缺少 roomId');
- const customerId = String(input.customerId || '').trim() || undefined;
- const externalUserId = String(input.externalUserId || '').trim() || undefined;
- const customerName = String(input.customerName || '').trim() || undefined;
- const rooms = readLatestRooms();
- const room = rooms.find(r => r.roomId === roomId);
- if (!room) return errorResult(`roomId ${roomId} 不在最近一次同步的群列表中,请先调用 qiwei_sync_external_groups 或改用 qiwei_add_external_group`);
- const record = updateConfirmedMapping(roomId, { customerId, externalUserId, customerName, roomName: room.roomName });
- const roomsForDiscovery = rooms.map(item => item.roomId === roomId
- ? {
- ...item,
- reviewStatus: 'CONFIRMED',
- customerId,
- externalUserId,
- customerName,
- roomName: item.roomName || room.roomName
- }
- : item);
- const discoveredCustomers = recordCustomersFromGroups(roomsForDiscovery, { source: 'group-confirm' });
- return okResult({
- assistantMessage: `已确认外部群为客户群:${room.roomName || roomId}。`,
- summary: { roomId, roomName: room.roomName, reviewStatus: 'CONFIRMED', discoveredCustomers: discoveredCustomers.discovered },
- data: record
- });
- });
- const qiweiAddExternalGroup = safeResult(async function qiweiAddExternalGroup(input = {}) {
- const roomId = String(input.roomId || '').trim();
- if (!roomId) return errorResult('缺少 roomId');
- const customerId = String(input.customerId || '').trim() || undefined;
- const externalUserId = String(input.externalUserId || '').trim() || undefined;
- const customerName = String(input.customerName || '').trim() || undefined;
- const roomName = String(input.roomName || '').trim() || undefined;
- const record = updateConfirmedMapping(roomId, { customerId, externalUserId, customerName, roomName });
- return okResult({
- assistantMessage: `已手动添加外部群:${roomName || roomId}。`,
- summary: { roomId, reviewStatus: 'CONFIRMED' },
- data: record
- });
- });
- const qiweiConfigureGroupKeywords = safeResult(async function qiweiConfigureGroupKeywords(input = {}) {
- if (input.reset === true) {
- saveCustomerKeywords(DEFAULT_KEYWORD_CONFIG);
- return okResult({
- assistantMessage: '已重置为客户群关键词默认配置。',
- summary: { ...DEFAULT_KEYWORD_CONFIG, keywordCount: DEFAULT_KEYWORD_CONFIG.keywords.length },
- data: { config: DEFAULT_KEYWORD_CONFIG }
- });
- }
- const current = loadCustomerKeywords();
- const next = {
- matchMode: input.matchMode === 'any' || input.matchMode === 'threshold'
- ? input.matchMode
- : current.matchMode,
- threshold: Math.max(1, Number(input.threshold || current.threshold) || 1),
- highConfidenceTerms: Array.isArray(input.highConfidenceTerms)
- ? input.highConfidenceTerms
- : current.highConfidenceTerms,
- keywords: Array.isArray(input.keywords)
- ? input.keywords
- : current.keywords
- };
- saveCustomerKeywords(next);
- return okResult({
- assistantMessage: `已更新客户群关键词配置:模式 ${next.matchMode},关键词 ${next.keywords.length} 个,高置信度 ${next.highConfidenceTerms.length} 个。`,
- summary: {
- matchMode: next.matchMode,
- threshold: next.threshold,
- keywordCount: next.keywords.length,
- highConfidenceCount: next.highConfidenceTerms.length
- },
- data: { config: next }
- });
- });
- const qiweiRejectExternalGroup = safeResult(async function qiweiRejectExternalGroup(input = {}) {
- const roomId = String(input.roomId || '').trim();
- if (!roomId) return errorResult('缺少 roomId');
- const rooms = readLatestRooms();
- const room = rooms.find(r => r.roomId === roomId);
- if (!room) return errorResult(`roomId ${roomId} 不在最近一次同步的群列表中`);
- const mapping = readRejectedMapping();
- mapping[roomId] = {
- roomId,
- roomName: room.roomName,
- rejectedAt: new Date().toISOString(),
- reason: String(input.reason || '经纪人手动拒绝')
- };
- writeRejectedMapping(mapping);
- return okResult({
- assistantMessage: `已拒绝外部群:${room.roomName || roomId}。`,
- summary: { roomId, roomName: room.roomName, reviewStatus: 'REJECTED' },
- data: mapping[roomId]
- });
- });
- const qiweiSyncGroupMessages = safeResult(async function qiweiSyncGroupMessages(input = {}) {
- assertMethodsInCatalog({ syncMsg: REQUIRED_METHODS.syncMsg });
- const ctx = buildContext(input);
- requireGuid(ctx);
- const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String).filter(Boolean) : null;
- const maxPages = Math.max(1, Math.min(300, Number(input.maxPages || 100)));
- const maxMessagesPerRoom = Math.max(1, Math.min(5000, Number(input.maxMessagesPerRoom || 1000)));
- let targetRoomIds = roomIds;
- if (!targetRoomIds) {
- const mapping = readConfirmedMapping();
- targetRoomIds = Object.keys(mapping);
- if (!targetRoomIds.length) return errorResult('未指定 roomIds 且没有已确认的客户群映射,请先调用 qiwei_confirm_external_group');
- }
- const results = [];
- for (const roomId of targetRoomIds) {
- let hasMore = true;
- let msgSeq = 0;
- let pages = 0;
- let newCount = 0;
- const messages = [];
- while (hasMore && pages < maxPages && newCount < maxMessagesPerRoom) {
- pages++;
- const data = await gatewayCall(ctx, REQUIRED_METHODS.syncMsg, {
- guid: ctx.guid,
- msgSeq,
- limit: 200
- });
- const msgList = Array.isArray(data && data.syncMsgList) ? data.syncMsgList : [];
- hasMore = Boolean(data.hasMore);
- msgSeq = data.travelSyncKey ?? msgSeq + 1;
- for (const msg of msgList) {
- const msgRoomId = String(msg.fromRoomId || '');
- if (msgRoomId !== roomId) continue;
- messages.push({
- msgId: msg.msgUniqueIdentifier || msg.msgServerId || `${roomId}_${msg.seq}`,
- seq: msg.seq,
- senderId: msg.senderId || '',
- senderName: msg.senderName || '',
- receiverId: msg.receiverId || '',
- fromRoomId: msg.fromRoomId || roomId,
- msgType: String(msg.msgType),
- content: extractMessageContent(msg),
- timestamp: msg.timestamp ? new Date(msg.timestamp * 1000).toISOString() : new Date().toISOString(),
- rawData: msg
- });
- newCount++;
- }
- if (!msgList.length) break;
- }
- if (messages.length) {
- ensureMessagesDir();
- let writtenCount = 0;
- for (const message of messages) {
- if (appendWebhookMessage(roomId, message)) writtenCount++;
- }
- // 更新 confirmed-mapping 的 lastMsgAt / lastSyncSeq
- const mapping = readConfirmedMapping();
- if (mapping[roomId]) {
- const maxSeq = Math.max(...messages.map(m => Number(m.seq) || 0));
- const lastMsg = messages[messages.length - 1];
- mapping[roomId].lastMsgAt = lastMsg.timestamp || new Date().toISOString();
- mapping[roomId].lastSyncSeq = Math.max(mapping[roomId].lastSyncSeq || 0, maxSeq);
- writeConfirmedMapping(mapping);
- recordGroupSync(roomId, {
- messageCount: writtenCount,
- lastMsgAt: lastMsg.timestamp || new Date().toISOString(),
- lastSyncSeq: mapping[roomId].lastSyncSeq
- });
- }
- results.push({ roomId, newCount: writtenCount, fileCount: messages.length });
- } else {
- results.push({ roomId, newCount: 0, fileCount: 0 });
- }
- }
- const totalNew = results.reduce((sum, r) => sum + r.newCount, 0);
- return okResult({
- assistantMessage: `群消息同步完成:${targetRoomIds.length} 个群,共 ${totalNew} 条新消息。`,
- summary: { rooms: targetRoomIds.length, totalNew, syncedRooms: results.filter(r => r.newCount > 0).length },
- data: { results },
- files: results.filter(r => r.filePath).map(r => path.join(outputsRoot(), r.filePath))
- });
- });
- module.exports = {
- qiweiSyncExternalGroups,
- qiweiListExternalGroups,
- qiweiAnalyzeGroupMembers,
- qiweiConfirmExternalGroup,
- qiweiAddExternalGroup,
- qiweiConfigureGroupKeywords,
- qiweiRejectExternalGroup,
- qiweiSyncGroupMessages,
- readConfirmedMapping,
- writeConfirmedMapping,
- updateConfirmedMapping,
- readImportedMapping,
- writeImportedMapping,
- updateImportedMapping
- };
|