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 };