const fs = require('fs'); const path = require('path'); const { outputsRoot } = require('./output-paths'); const { readQiweiGuid } = require('./credentials'); const { readCustomerByExternalUserId, readCustomerByPhone, writeCustomer, readBrokerById, readBrokerByWecomUserId, normalizePhone } = require('./customer-broker-store'); const { resolveBrokerIdForGuid, resolveWecomUserIdForGuid } = require('./device-broker-mapping'); const { saveWebhookEventStructured, isDuplicateEvent, markEventProcessed, updateWebhookEventStatus } = require('./webhook-store'); const { parseWebhookEnvelope, ParsedWebhookEvent, SystemMsgType, decodeChangedMemberList } = require('./webhook-types'); const { qiweiAutoCreateGroup, qiweiCheckFriendStatus } = require('../tools/qiwei-customer-ops-run'); const { readImportedMapping, writeImportedMapping, readConfirmedMapping, writeConfirmedMapping, updateConfirmedMapping, updateImportedMapping, appendWebhookMessage, messageExists } = require('../tools/qiwei-group-management-run'); const { enqueuePortraitUpdate } = require('../tools/qiwei-portrait-tags-run'); const { qiweiTranscribeVoice } = require('../tools/qiwei-voice-run'); const { getGroupOperationsStore } = require('./group-operations-store'); const REQUIRED_METHODS = { getWxContactList: '/contact/getWxContactList' }; function webhookConfigPath() { return path.join(outputsRoot(), 'webhook', 'webhook-config.json'); } function readWebhookConfig() { try { if (!fs.existsSync(webhookConfigPath())) return { callbackUrl: null, secret: null, autoCreateGroup: true }; return { callbackUrl: null, secret: null, autoCreateGroup: true, ...JSON.parse(fs.readFileSync(webhookConfigPath(), 'utf8')) }; } catch { return { callbackUrl: null, secret: null, autoCreateGroup: true }; } } function groupsDir() { return path.join(outputsRoot(), 'groups'); } function customersDir() { return path.join(outputsRoot(), 'customers'); } function ensureGroupsDir() { fs.mkdirSync(groupsDir(), { recursive: true }); } function hasActiveGroup(brokerId, customerId) { const mapping = readConfirmedMapping(); for (const entry of Object.values(mapping)) { if (entry.brokerId === brokerId && entry.customerId === customerId && entry.status === 'ACTIVE') { return true; } } return false; } function recordConfirmedGroup({ roomId, roomName, brokerId, customerId, externalUserId, source = 'webhook-auto' }) { const mapping = readConfirmedMapping(); mapping[roomId] = { roomId, roomName: roomName || mapping[roomId]?.roomName, brokerId, customerId, externalUserId, status: 'ACTIVE', reviewStatus: 'AUTO_CONFIRMED', source, confirmedAt: new Date().toISOString(), ...(mapping[roomId] || {}) }; writeConfirmedMapping(mapping); return mapping[roomId]; } function resolveDeviceGuid(input = {}) { if (input.guid) return input.guid; if (input.deviceGuid) return input.deviceGuid; return readQiweiGuid(); } function buildContext(input = {}) { const { buildContext: sharedBuildContext } = require('./shared-gateway'); return sharedBuildContext(input); } function gatewayCall(ctx, method, params) { const { gatewayCall: sharedGatewayCall } = require('./shared-gateway'); return sharedGatewayCall(ctx, method, params); } function assertMethodsInCatalog(methods) { const { assertMethodsInCatalog: sharedAssert } = require('./shared-gateway'); return sharedAssert(methods); } async function findNewlyAddedContact(guid) { if (!guid) return undefined; try { assertMethodsInCatalog({ getWxContactList: REQUIRED_METHODS.getWxContactList }); const ctx = buildContext({ guid }); const data = await gatewayCall(ctx, REQUIRED_METHODS.getWxContactList, { guid, currentSeq: 0, limit: 50, bizType: 1 }); const list = Array.isArray(data && data.contactList) ? data.contactList : []; for (const contact of list) { const userId = String(contact.userId || contact.externalUserId || ''); if (!userId) continue; const customer = readCustomerByExternalUserId(userId); if (customer) { console.log(`[Webhook] 在联系人列表中找到匹配客户: ${userId}`); return userId; } } console.log(`[Webhook] 设备 ${guid} 的联系人列表中没有匹配到客户`); return undefined; } catch (err) { console.warn(`[Webhook] 设备 ${guid} 获取联系人列表失败:`, err.message); return undefined; } } async function findNewlyAddedContactAcrossDevices() { // 目标项目简化为:尝试从当前 guid 获取联系人列表 const guid = readQiweiGuid(); if (!guid) { console.warn('[Webhook] 缺少 guid,无法跨设备查找联系人'); return undefined; } return findNewlyAddedContact(guid); } function buildGroupName(customer, broker) { const template = customer.groupNameTemplate || '{name} 专属服务群'; const phone = normalizePhone(customer.phone); const phoneSuffix = phone.length >= 4 ? phone.slice(-4) : ''; const customerLabel = customer.name || (phoneSuffix ? `客户${phoneSuffix}` : '客户'); return template .replace(/{name}/g, customerLabel) .replace(/{phone}/g, phone) .replace(/{brokerName}/g, broker.name || ''); } async function triggerAutoCreateGroup(event, eventFilePath = null) { const guid = resolveDeviceGuid(event); let externalUserId = event.externalUserId; if (!externalUserId && event.msgType === SystemMsgType.CONTACT_EXTERNAL_CHANGE) { externalUserId = await findNewlyAddedContact(guid); if (!externalUserId) { externalUserId = await findNewlyAddedContactAcrossDevices(); } } if (!externalUserId) { if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', '无法提取 externalUserId'); return { success: false, ignored: true, reason: '无法提取 externalUserId' }; } let customer = readCustomerByExternalUserId(externalUserId); if (!customer && event.contactNickname) { console.log(`[Webhook] 未通过 externalUserId 匹配客户,尝试备注/昵称: ${event.contactNickname}`); } if (!customer) { if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', `externalUserId=${externalUserId} 未匹配到客户档案`); return { success: false, ignored: true, reason: '未匹配到客户档案' }; } const customerId = customer.customerId; let brokerId = customer.brokerId; if (!brokerId) { brokerId = resolveBrokerIdForGuid(guid); if (!brokerId) { if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', `客户 ${customerId} 无归属经纪人`); return { success: false, ignored: true, reason: '客户无归属经纪人' }; } customer.brokerId = brokerId; } const broker = readBrokerById(brokerId); if (!broker) { if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', `经纪人 ${brokerId} 不存在`); return { success: false, ignored: true, reason: '经纪人不存在' }; } if (!guid) { if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', '没有可用的企微设备'); return { success: false, ignored: true, reason: '没有可用的企微设备' }; } let shouldSaveCustomer = false; if (!customer.externalUserId) { customer.externalUserId = externalUserId; shouldSaveCustomer = true; } if (customer.friendRequestStatus !== 'ACCEPTED') { customer.friendRequestStatus = 'ACCEPTED'; shouldSaveCustomer = true; } if (shouldSaveCustomer) { writeCustomer(customer); } const checkResult = await qiweiCheckFriendStatus({ guid, customers: customer.phone ? [{ phone: customer.phone, name: customer.name }] : [], externalUserIds: customer.phone ? [] : [externalUserId] }); const confirmed = checkResult.status === 'ok' && checkResult.data && checkResult.data.details && checkResult.data.details.some(d => d.statusText === 'already_friend'); if (!confirmed) { if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', '二次确认:客户尚未成为好友'); return { success: false, ignored: true, reason: '客户尚未成为好友' }; } if (hasActiveGroup(brokerId, customerId)) { if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', '客户已有活跃服务群,跳过建群'); return { success: false, ignored: true, reason: '已有活跃服务群' }; } const supportMemberIds = Array.isArray(customer.supportBrokerId) ? customer.supportBrokerId : customer.supportBrokerId ? [customer.supportBrokerId] : []; const resolvedSupportIds = []; for (const supportId of supportMemberIds) { if (!supportId || supportId === brokerId) continue; const supportBroker = readBrokerById(supportId); if (supportBroker && supportBroker.wecomUserId) { resolvedSupportIds.push(supportBroker.wecomUserId); } } const groupName = buildGroupName(customer, broker); const createResult = await qiweiAutoCreateGroup({ guid, memberList: [externalUserId], supportMemberIds: resolvedSupportIds, groupName, isOuterRoom: 1 }); if (createResult.status !== 'ok' || !createResult.data || !createResult.data.roomId) { const reason = createResult.assistantMessage || '建群接口调用失败'; if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'ERROR', reason); return { success: false, error: reason }; } const roomId = createResult.data.roomId; const roomName = createResult.data.groupName || groupName; recordConfirmedGroup({ roomId, roomName, brokerId, customerId, externalUserId, source: 'webhook-auto' }); if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'AUTO_GROUP_CREATED', `自动建群成功: roomId=${roomId}, groupName=${roomName}`); console.log(`[Webhook] 自动建群成功: brokerId=${brokerId}, customerId=${customerId}, roomId=${roomId}`); return { success: true, roomId, roomName }; } async function identifyServiceGroupMembers(event, raw, changedMemberListRaw) { let brokerId = null; // 1. 优先 senderId 匹配 broker.wecomUserId const senderId = raw.senderId ? String(raw.senderId) : ''; if (senderId) { const broker = readBrokerByWecomUserId(senderId); if (broker) { brokerId = broker.brokerId; console.log(`[Webhook] GROUP_CREATED: 通过 senderId=${senderId} 识别经纪人: ${brokerId}`); } } // 2. 回退 event.guid -> device -> brokerId if (!brokerId && event.guid) { brokerId = resolveBrokerIdForGuid(event.guid); if (brokerId) { console.log(`[Webhook] GROUP_CREATED: 通过 guid=${event.guid} 回退识别经纪人: ${brokerId}`); } } // 3. 再回退群成员中匹配 broker.wecomUserId let memberIds = decodeChangedMemberList(changedMemberListRaw); if (!brokerId && memberIds.length) { for (const memberId of memberIds) { const broker = readBrokerByWecomUserId(memberId); if (broker) { brokerId = broker.brokerId; console.log(`[Webhook] GROUP_CREATED: 通过群成员 ${memberId} 识别经纪人: ${brokerId}`); break; } } } // 客户识别:排除经纪人后匹配 customer.externalUserId let customerId = null; let customerExternalUserId = null; const brokerWecomUserIds = new Set(); if (senderId) brokerWecomUserIds.add(senderId); if (brokerId) { const broker = readBrokerById(brokerId); if (broker && broker.wecomUserId) brokerWecomUserIds.add(broker.wecomUserId); } const potentialCustomers = memberIds.filter(id => !brokerWecomUserIds.has(id)); for (const memberId of potentialCustomers) { const customer = readCustomerByExternalUserId(memberId); if (customer) { customerId = customer.customerId; customerExternalUserId = memberId; console.log(`[Webhook] GROUP_CREATED: 通过成员 ${memberId} 识别客户: ${customerId}`); break; } } return { brokerId, customerId, customerExternalUserId }; } async function handleGroupCreateWebhook(event, eventFilePath = null) { const raw = event.raw || {}; const roomId = String(raw.fromRoomId || ''); if (!roomId || roomId === '0') { if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', '缺少 fromRoomId'); return { success: false, ignored: true, reason: '缺少 fromRoomId' }; } const changedMemberListRaw = raw.changedMemberList; const { brokerId, customerId, customerExternalUserId } = await identifyServiceGroupMembers(event, raw, changedMemberListRaw); const memberIds = decodeChangedMemberList(changedMemberListRaw); if (brokerId && customerId) { updateConfirmedMapping(roomId, { roomId, brokerId, customerId, externalUserId: customerExternalUserId, status: 'ACTIVE', reviewStatus: 'AUTO_CONFIRMED', memberCount: memberIds.length, seenAt: new Date().toISOString() }); if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'PROCESSED', `自动捕获群创建: roomId=${roomId}, brokerId=${brokerId}, customerId=${customerId}`); console.log(`[Webhook] GROUP_CREATED: 新群 ${roomId} 已录入 (brokerId=${brokerId}, customerId=${customerId})`); return { success: true, brokerId, customerId }; } if (brokerId && !customerId) { updateImportedMapping(roomId, { roomId, brokerId, customerId: 'orphan', status: 'IMPORTED', reviewStatus: 'IMPORTED', memberCount: memberIds.length, seenAt: new Date().toISOString() }); if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'PROCESSED', `群 ${roomId} 已创建 orphan 记录 (brokerId=${brokerId})`); console.log(`[Webhook] GROUP_CREATED: 群 ${roomId} 已创建 orphan 记录(经纪人=${brokerId})`); return { success: true, orphan: true, brokerId }; } updateImportedMapping(roomId, { roomId, brokerId: null, customerId: customerId || 'orphan', externalUserId: customerExternalUserId, status: 'IMPORTED', reviewStatus: 'IMPORTED', memberCount: memberIds.length, seenAt: new Date().toISOString() }); const debugInfo = `senderId=${raw.senderId || 'N/A'}, guid=${event.guid || 'N/A'}`; if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'PROCESSED', `无法识别经纪人 (${debugInfo}),已导入待补充`); console.log(`[Webhook] GROUP_CREATED: 群 ${roomId} 无法识别经纪人,已导入 (${debugInfo})`); return { success: true, imported: true }; } function resolveSenderType(senderId, msgType, customerId, customerExternalUserId, brokerWecomUserId) { if (!senderId) return 'UNKNOWN'; if (customerExternalUserId && senderId === customerExternalUserId) return 'CUSTOMER'; if (brokerWecomUserId && senderId === brokerWecomUserId) return 'BROKER'; // 通过 customer 档案进一步判断 if (customerId) { const customer = readCustomerById(customerId); if (customer) { if (customer.externalUserId && senderId === customer.externalUserId) return 'CUSTOMER'; if (customer.supportBrokerId) { const supportIds = Array.isArray(customer.supportBrokerId) ? customer.supportBrokerId : [customer.supportBrokerId]; for (const supportId of supportIds) { const supportBroker = readBrokerById(supportId); if (supportBroker && supportBroker.wecomUserId === senderId) return 'SUPPORT'; } } } } const broker = readBrokerByWecomUserId(senderId); if (broker) return 'BROKER'; return 'UNKNOWN'; } function ensureRelatedContact(customerId, externalUserId, name) { if (!customerId || !externalUserId) return null; const filePath = path.join(customersDir(), `${customerId}-related-${externalUserId}.json`); if (fs.existsSync(filePath)) return filePath; const record = { customerId, externalUserId, name: name || '', createdAt: new Date().toISOString() }; fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8'); return filePath; } function extractMessageContent(raw) { if (!raw) return ''; if (typeof raw.content === 'string' && raw.content) return raw.content; if (typeof raw.msgContent === 'string' && raw.msgContent) return raw.msgContent; if (raw.msgData) { if (typeof raw.msgData.content === 'string') return raw.msgData.content; if (typeof raw.msgData.text === 'string') return raw.msgData.text; } return ''; } async function processVoiceMessage(msgUniqueId, msgType, msgData) { const isVoice = msgType === 16 || msgType === 34 || String(msgType) === '16' || String(msgType) === '34'; if (!isVoice) return {}; const voiceUrl = msgData && (msgData.voiceUrl || msgData.url || msgData.cdnUrl); const base64Audio = msgData && (msgData.base64 || msgData.base64Data); if (!voiceUrl && !base64Audio) return {}; try { const result = await qiweiTranscribeVoice({ voiceUrl, base64Audio, transcribe: true }); if (result.status === 'ok' && result.data) { return { voiceTranscript: result.data.transcript || result.data.text || '', voiceLocalPath: result.data.filePath || result.data.localPath || '' }; } } catch (err) { console.warn(`[Webhook] 语音转写失败 ${msgUniqueId}:`, err.message); } return {}; } function isKnownRoom(roomId) { const confirmed = readConfirmedMapping(); if (confirmed[roomId]) return true; const imported = readImportedMapping(); if (imported[roomId]) return true; return false; } async function storeGroupMessageFromWebhook(event, eventFilePath = null) { const raw = event.raw || {}; const roomId = String(raw.fromRoomId || ''); if (!roomId || roomId === '0') { if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', '非群消息,跳过'); return { success: false, ignored: true, reason: '非群消息' }; } const msgUniqueId = String(raw.msgUniqueIdentifier || ''); if (!msgUniqueId) { if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', '缺少 msgUniqueIdentifier'); return { success: false, ignored: true, reason: '缺少 msgUniqueIdentifier' }; } const msgType = Number(raw.msgType) || 0; const SKIP_MSG_TYPES = new Set([2001, 2005]); if (SKIP_MSG_TYPES.has(msgType)) { if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', `msgType=${msgType} 为通知类消息,跳过`); return { success: false, ignored: true, reason: '通知类消息' }; } if (!isKnownRoom(roomId)) { if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', `roomId=${roomId} 不在群映射中,跳过`); return { success: false, ignored: true, reason: '不在群映射中' }; } if (messageExists(roomId, msgUniqueId)) { if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', `消息 ${msgUniqueId} 已存在`); return { success: false, ignored: true, reason: '消息已存在' }; } const content = extractMessageContent(raw); const timestamp = Number(raw.timestamp) || 0; const senderId = String(raw.senderId || ''); // 解析群对应的客户/经纪人 const confirmed = readConfirmedMapping(); const imported = readImportedMapping(); const groupMapping = confirmed[roomId] || imported[roomId] || {}; const customerId = groupMapping.customerId; const customerExternalUserId = groupMapping.externalUserId; const brokerId = groupMapping.brokerId; const broker = brokerId ? readBrokerById(brokerId) : null; const brokerWecomUserId = broker ? broker.wecomUserId : null; const senderType = resolveSenderType(senderId, msgType, customerId, customerExternalUserId, brokerWecomUserId); if (senderType === 'UNKNOWN' && senderId) { ensureRelatedContact(customerId, senderId, String(raw.senderName || '')); } const voiceMeta = await processVoiceMessage(msgUniqueId, msgType, raw.msgData); const message = { msgId: msgUniqueId, seq: raw.seq || 0, senderId, senderName: String(raw.senderName || ''), senderType, msgType: String(msgType), content, timestamp: timestamp ? new Date(timestamp * 1000).toISOString() : new Date().toISOString(), isRevoked: raw.isRevoked ? true : false, ...voiceMeta, rawData: raw }; appendWebhookMessage(roomId, message); // 兼容写入账号隔离的社群运营事实表;失败不回滚已经落盘的旧消息。 const operationAccountKey = resolveDeviceGuid(event); if (operationAccountKey) { try { const operationStore = getGroupOperationsStore(); operationStore.upsertGroup(operationAccountKey, { roomId, roomName: groupMapping.roomName || '', ownerId: brokerId || '', groupType: groupMapping.groupType || 'customer' }, 'webhook'); operationStore.ingestMessages(operationAccountKey, roomId, [{ ...message, messageId: msgUniqueId, senderRole: senderType === 'CUSTOMER' ? 'customer' : ['BROKER', 'SUPPORT'].includes(senderType) ? 'staff' : 'unknown', sentAt: message.timestamp }]); } catch (error) { console.warn(`[Webhook] 社群运营事实表写入失败: ${error.message}`); } } else { console.warn('[Webhook] 事件缺少设备 guid,已保留旧消息但跳过社群运营账号隔离入库'); } // 更新 mapping 的 lastMsgAt / lastSyncSeq if (confirmed[roomId]) { confirmed[roomId].lastMsgAt = message.timestamp; confirmed[roomId].lastSyncSeq = Math.max(confirmed[roomId].lastSyncSeq || 0, Number(raw.seq) || 0); writeConfirmedMapping(confirmed); } else if (imported[roomId]) { imported[roomId].lastMsgAt = message.timestamp; imported[roomId].lastSyncSeq = Math.max(imported[roomId].lastSyncSeq || 0, Number(raw.seq) || 0); writeImportedMapping(imported); } // 触发客户画像更新 if (customerExternalUserId && senderType === 'CUSTOMER') { enqueuePortraitUpdate(customerExternalUserId, 'WEBHOOK'); } if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'PROCESSED', `群消息已入库: ${content.substring(0, 80)}`); console.log(`[Webhook] 群消息已入库: roomId=${roomId}, seq=${raw.seq}`); return { success: true }; } async function processWebhookEvents(envelope) { const events = parseWebhookEnvelope(envelope); if (!events.length) { console.log('[Webhook] 回调中无有效事件'); return { processed: 0, ignored: 0, errors: 0 }; } const result = { processed: 0, ignored: 0, errors: 0 }; for (const event of events) { if (isDuplicateEvent(event.eventId)) { console.log(`[Webhook] 重复事件已跳过: ${event.eventId}`); result.ignored++; continue; } const eventFilePath = saveWebhookEventStructured(event, envelope.source || 'callback', envelope.__rawBody); markEventProcessed(event.eventId); try { const config = readWebhookConfig(); const autoCreateGroupEnabled = config.autoCreateGroup !== false; if (event.parsedType === ParsedWebhookEvent.NEW_MESSAGE) { const msgResult = await storeGroupMessageFromWebhook(event, eventFilePath); if (msgResult.success) result.processed++; else if (msgResult.ignored) result.ignored++; else result.errors++; continue; } if (event.parsedType === ParsedWebhookEvent.GROUP_CREATED) { const groupResult = await handleGroupCreateWebhook(event, eventFilePath); if (groupResult.success) result.processed++; else if (groupResult.ignored) result.ignored++; else result.errors++; continue; } if (autoCreateGroupEnabled && ( event.parsedType === ParsedWebhookEvent.CONTACT_ADDED_OR_CHANGED || event.parsedType === ParsedWebhookEvent.FRIEND_REQUEST_RECEIVED )) { const autoResult = await triggerAutoCreateGroup(event, eventFilePath); if (autoResult.success) result.processed++; else if (autoResult.ignored) result.ignored++; else result.errors++; continue; } updateWebhookEventStatus(eventFilePath, 'IGNORED', `事件类型 ${event.parsedType} 不需要自动处理`); result.ignored++; } catch (err) { console.error('[Webhook] 事件处理异常:', err && err.message ? err.message : err); updateWebhookEventStatus(eventFilePath, 'ERROR', String(err && err.message ? err.message : err)); result.errors++; } } return result; } module.exports = { processWebhookEvents, triggerAutoCreateGroup, findNewlyAddedContact, findNewlyAddedContactAcrossDevices, resolveDeviceGuid, handleGroupCreateWebhook, identifyServiceGroupMembers, storeGroupMessageFromWebhook, resolveSenderType, ensureRelatedContact, readConfirmedMapping, writeConfirmedMapping, recordConfirmedGroup, hasActiveGroup };