webhook-processor.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
  1. const fs = require('fs');
  2. const path = require('path');
  3. const { outputsRoot } = require('./output-paths');
  4. const { readQiweiGuid } = require('./credentials');
  5. const {
  6. readCustomerByExternalUserId,
  7. readCustomerByPhone,
  8. writeCustomer,
  9. readBrokerById,
  10. readBrokerByWecomUserId,
  11. normalizePhone
  12. } = require('./customer-broker-store');
  13. const {
  14. resolveBrokerIdForGuid,
  15. resolveWecomUserIdForGuid
  16. } = require('./device-broker-mapping');
  17. const {
  18. saveWebhookEventStructured,
  19. isDuplicateEvent,
  20. markEventProcessed,
  21. updateWebhookEventStatus
  22. } = require('./webhook-store');
  23. const { parseWebhookEnvelope, ParsedWebhookEvent, SystemMsgType, decodeChangedMemberList } = require('./webhook-types');
  24. const {
  25. qiweiAutoCreateGroup,
  26. qiweiCheckFriendStatus
  27. } = require('../tools/qiwei-customer-ops-run');
  28. const {
  29. readImportedMapping,
  30. writeImportedMapping,
  31. readConfirmedMapping,
  32. writeConfirmedMapping,
  33. updateConfirmedMapping,
  34. updateImportedMapping,
  35. appendWebhookMessage,
  36. messageExists
  37. } = require('../tools/qiwei-group-management-run');
  38. const { enqueuePortraitUpdate } = require('../tools/qiwei-portrait-tags-run');
  39. const { qiweiTranscribeVoice } = require('../tools/qiwei-voice-run');
  40. const { getGroupOperationsStore } = require('./group-operations-store');
  41. const REQUIRED_METHODS = {
  42. getWxContactList: '/contact/getWxContactList'
  43. };
  44. function webhookConfigPath() {
  45. return path.join(outputsRoot(), 'webhook', 'webhook-config.json');
  46. }
  47. function readWebhookConfig() {
  48. try {
  49. if (!fs.existsSync(webhookConfigPath())) return { callbackUrl: null, secret: null, autoCreateGroup: true };
  50. return { callbackUrl: null, secret: null, autoCreateGroup: true, ...JSON.parse(fs.readFileSync(webhookConfigPath(), 'utf8')) };
  51. } catch {
  52. return { callbackUrl: null, secret: null, autoCreateGroup: true };
  53. }
  54. }
  55. function groupsDir() {
  56. return path.join(outputsRoot(), 'groups');
  57. }
  58. function customersDir() {
  59. return path.join(outputsRoot(), 'customers');
  60. }
  61. function ensureGroupsDir() {
  62. fs.mkdirSync(groupsDir(), { recursive: true });
  63. }
  64. function hasActiveGroup(brokerId, customerId) {
  65. const mapping = readConfirmedMapping();
  66. for (const entry of Object.values(mapping)) {
  67. if (entry.brokerId === brokerId && entry.customerId === customerId && entry.status === 'ACTIVE') {
  68. return true;
  69. }
  70. }
  71. return false;
  72. }
  73. function recordConfirmedGroup({ roomId, roomName, brokerId, customerId, externalUserId, source = 'webhook-auto' }) {
  74. const mapping = readConfirmedMapping();
  75. mapping[roomId] = {
  76. roomId,
  77. roomName: roomName || mapping[roomId]?.roomName,
  78. brokerId,
  79. customerId,
  80. externalUserId,
  81. status: 'ACTIVE',
  82. reviewStatus: 'AUTO_CONFIRMED',
  83. source,
  84. confirmedAt: new Date().toISOString(),
  85. ...(mapping[roomId] || {})
  86. };
  87. writeConfirmedMapping(mapping);
  88. return mapping[roomId];
  89. }
  90. function resolveDeviceGuid(input = {}) {
  91. if (input.guid) return input.guid;
  92. if (input.deviceGuid) return input.deviceGuid;
  93. return readQiweiGuid();
  94. }
  95. function buildContext(input = {}) {
  96. const { buildContext: sharedBuildContext } = require('./shared-gateway');
  97. return sharedBuildContext(input);
  98. }
  99. function gatewayCall(ctx, method, params) {
  100. const { gatewayCall: sharedGatewayCall } = require('./shared-gateway');
  101. return sharedGatewayCall(ctx, method, params);
  102. }
  103. function assertMethodsInCatalog(methods) {
  104. const { assertMethodsInCatalog: sharedAssert } = require('./shared-gateway');
  105. return sharedAssert(methods);
  106. }
  107. async function findNewlyAddedContact(guid) {
  108. if (!guid) return undefined;
  109. try {
  110. assertMethodsInCatalog({ getWxContactList: REQUIRED_METHODS.getWxContactList });
  111. const ctx = buildContext({ guid });
  112. const data = await gatewayCall(ctx, REQUIRED_METHODS.getWxContactList, {
  113. guid,
  114. currentSeq: 0,
  115. limit: 50,
  116. bizType: 1
  117. });
  118. const list = Array.isArray(data && data.contactList) ? data.contactList : [];
  119. for (const contact of list) {
  120. const userId = String(contact.userId || contact.externalUserId || '');
  121. if (!userId) continue;
  122. const customer = readCustomerByExternalUserId(userId);
  123. if (customer) {
  124. console.log(`[Webhook] 在联系人列表中找到匹配客户: ${userId}`);
  125. return userId;
  126. }
  127. }
  128. console.log(`[Webhook] 设备 ${guid} 的联系人列表中没有匹配到客户`);
  129. return undefined;
  130. } catch (err) {
  131. console.warn(`[Webhook] 设备 ${guid} 获取联系人列表失败:`, err.message);
  132. return undefined;
  133. }
  134. }
  135. async function findNewlyAddedContactAcrossDevices() {
  136. // 目标项目简化为:尝试从当前 guid 获取联系人列表
  137. const guid = readQiweiGuid();
  138. if (!guid) {
  139. console.warn('[Webhook] 缺少 guid,无法跨设备查找联系人');
  140. return undefined;
  141. }
  142. return findNewlyAddedContact(guid);
  143. }
  144. function buildGroupName(customer, broker) {
  145. const template = customer.groupNameTemplate || '{name} 专属服务群';
  146. const phone = normalizePhone(customer.phone);
  147. const phoneSuffix = phone.length >= 4 ? phone.slice(-4) : '';
  148. const customerLabel = customer.name || (phoneSuffix ? `客户${phoneSuffix}` : '客户');
  149. return template
  150. .replace(/{name}/g, customerLabel)
  151. .replace(/{phone}/g, phone)
  152. .replace(/{brokerName}/g, broker.name || '');
  153. }
  154. async function triggerAutoCreateGroup(event, eventFilePath = null) {
  155. const guid = resolveDeviceGuid(event);
  156. let externalUserId = event.externalUserId;
  157. if (!externalUserId && event.msgType === SystemMsgType.CONTACT_EXTERNAL_CHANGE) {
  158. externalUserId = await findNewlyAddedContact(guid);
  159. if (!externalUserId) {
  160. externalUserId = await findNewlyAddedContactAcrossDevices();
  161. }
  162. }
  163. if (!externalUserId) {
  164. if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', '无法提取 externalUserId');
  165. return { success: false, ignored: true, reason: '无法提取 externalUserId' };
  166. }
  167. let customer = readCustomerByExternalUserId(externalUserId);
  168. if (!customer && event.contactNickname) {
  169. console.log(`[Webhook] 未通过 externalUserId 匹配客户,尝试备注/昵称: ${event.contactNickname}`);
  170. }
  171. if (!customer) {
  172. if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', `externalUserId=${externalUserId} 未匹配到客户档案`);
  173. return { success: false, ignored: true, reason: '未匹配到客户档案' };
  174. }
  175. const customerId = customer.customerId;
  176. let brokerId = customer.brokerId;
  177. if (!brokerId) {
  178. brokerId = resolveBrokerIdForGuid(guid);
  179. if (!brokerId) {
  180. if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', `客户 ${customerId} 无归属经纪人`);
  181. return { success: false, ignored: true, reason: '客户无归属经纪人' };
  182. }
  183. customer.brokerId = brokerId;
  184. }
  185. const broker = readBrokerById(brokerId);
  186. if (!broker) {
  187. if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', `经纪人 ${brokerId} 不存在`);
  188. return { success: false, ignored: true, reason: '经纪人不存在' };
  189. }
  190. if (!guid) {
  191. if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', '没有可用的企微设备');
  192. return { success: false, ignored: true, reason: '没有可用的企微设备' };
  193. }
  194. let shouldSaveCustomer = false;
  195. if (!customer.externalUserId) {
  196. customer.externalUserId = externalUserId;
  197. shouldSaveCustomer = true;
  198. }
  199. if (customer.friendRequestStatus !== 'ACCEPTED') {
  200. customer.friendRequestStatus = 'ACCEPTED';
  201. shouldSaveCustomer = true;
  202. }
  203. if (shouldSaveCustomer) {
  204. writeCustomer(customer);
  205. }
  206. const checkResult = await qiweiCheckFriendStatus({
  207. guid,
  208. customers: customer.phone ? [{ phone: customer.phone, name: customer.name }] : [],
  209. externalUserIds: customer.phone ? [] : [externalUserId]
  210. });
  211. const confirmed = checkResult.status === 'ok' && checkResult.data && checkResult.data.details && checkResult.data.details.some(d => d.statusText === 'already_friend');
  212. if (!confirmed) {
  213. if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', '二次确认:客户尚未成为好友');
  214. return { success: false, ignored: true, reason: '客户尚未成为好友' };
  215. }
  216. if (hasActiveGroup(brokerId, customerId)) {
  217. if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', '客户已有活跃服务群,跳过建群');
  218. return { success: false, ignored: true, reason: '已有活跃服务群' };
  219. }
  220. const supportMemberIds = Array.isArray(customer.supportBrokerId)
  221. ? customer.supportBrokerId
  222. : customer.supportBrokerId ? [customer.supportBrokerId] : [];
  223. const resolvedSupportIds = [];
  224. for (const supportId of supportMemberIds) {
  225. if (!supportId || supportId === brokerId) continue;
  226. const supportBroker = readBrokerById(supportId);
  227. if (supportBroker && supportBroker.wecomUserId) {
  228. resolvedSupportIds.push(supportBroker.wecomUserId);
  229. }
  230. }
  231. const groupName = buildGroupName(customer, broker);
  232. const createResult = await qiweiAutoCreateGroup({
  233. guid,
  234. memberList: [externalUserId],
  235. supportMemberIds: resolvedSupportIds,
  236. groupName,
  237. isOuterRoom: 1
  238. });
  239. if (createResult.status !== 'ok' || !createResult.data || !createResult.data.roomId) {
  240. const reason = createResult.assistantMessage || '建群接口调用失败';
  241. if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'ERROR', reason);
  242. return { success: false, error: reason };
  243. }
  244. const roomId = createResult.data.roomId;
  245. const roomName = createResult.data.groupName || groupName;
  246. recordConfirmedGroup({
  247. roomId,
  248. roomName,
  249. brokerId,
  250. customerId,
  251. externalUserId,
  252. source: 'webhook-auto'
  253. });
  254. if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'AUTO_GROUP_CREATED', `自动建群成功: roomId=${roomId}, groupName=${roomName}`);
  255. console.log(`[Webhook] 自动建群成功: brokerId=${brokerId}, customerId=${customerId}, roomId=${roomId}`);
  256. return { success: true, roomId, roomName };
  257. }
  258. async function identifyServiceGroupMembers(event, raw, changedMemberListRaw) {
  259. let brokerId = null;
  260. // 1. 优先 senderId 匹配 broker.wecomUserId
  261. const senderId = raw.senderId ? String(raw.senderId) : '';
  262. if (senderId) {
  263. const broker = readBrokerByWecomUserId(senderId);
  264. if (broker) {
  265. brokerId = broker.brokerId;
  266. console.log(`[Webhook] GROUP_CREATED: 通过 senderId=${senderId} 识别经纪人: ${brokerId}`);
  267. }
  268. }
  269. // 2. 回退 event.guid -> device -> brokerId
  270. if (!brokerId && event.guid) {
  271. brokerId = resolveBrokerIdForGuid(event.guid);
  272. if (brokerId) {
  273. console.log(`[Webhook] GROUP_CREATED: 通过 guid=${event.guid} 回退识别经纪人: ${brokerId}`);
  274. }
  275. }
  276. // 3. 再回退群成员中匹配 broker.wecomUserId
  277. let memberIds = decodeChangedMemberList(changedMemberListRaw);
  278. if (!brokerId && memberIds.length) {
  279. for (const memberId of memberIds) {
  280. const broker = readBrokerByWecomUserId(memberId);
  281. if (broker) {
  282. brokerId = broker.brokerId;
  283. console.log(`[Webhook] GROUP_CREATED: 通过群成员 ${memberId} 识别经纪人: ${brokerId}`);
  284. break;
  285. }
  286. }
  287. }
  288. // 客户识别:排除经纪人后匹配 customer.externalUserId
  289. let customerId = null;
  290. let customerExternalUserId = null;
  291. const brokerWecomUserIds = new Set();
  292. if (senderId) brokerWecomUserIds.add(senderId);
  293. if (brokerId) {
  294. const broker = readBrokerById(brokerId);
  295. if (broker && broker.wecomUserId) brokerWecomUserIds.add(broker.wecomUserId);
  296. }
  297. const potentialCustomers = memberIds.filter(id => !brokerWecomUserIds.has(id));
  298. for (const memberId of potentialCustomers) {
  299. const customer = readCustomerByExternalUserId(memberId);
  300. if (customer) {
  301. customerId = customer.customerId;
  302. customerExternalUserId = memberId;
  303. console.log(`[Webhook] GROUP_CREATED: 通过成员 ${memberId} 识别客户: ${customerId}`);
  304. break;
  305. }
  306. }
  307. return { brokerId, customerId, customerExternalUserId };
  308. }
  309. async function handleGroupCreateWebhook(event, eventFilePath = null) {
  310. const raw = event.raw || {};
  311. const roomId = String(raw.fromRoomId || '');
  312. if (!roomId || roomId === '0') {
  313. if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', '缺少 fromRoomId');
  314. return { success: false, ignored: true, reason: '缺少 fromRoomId' };
  315. }
  316. const changedMemberListRaw = raw.changedMemberList;
  317. const { brokerId, customerId, customerExternalUserId } = await identifyServiceGroupMembers(event, raw, changedMemberListRaw);
  318. const memberIds = decodeChangedMemberList(changedMemberListRaw);
  319. if (brokerId && customerId) {
  320. updateConfirmedMapping(roomId, {
  321. roomId,
  322. brokerId,
  323. customerId,
  324. externalUserId: customerExternalUserId,
  325. status: 'ACTIVE',
  326. reviewStatus: 'AUTO_CONFIRMED',
  327. memberCount: memberIds.length,
  328. seenAt: new Date().toISOString()
  329. });
  330. if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'PROCESSED', `自动捕获群创建: roomId=${roomId}, brokerId=${brokerId}, customerId=${customerId}`);
  331. console.log(`[Webhook] GROUP_CREATED: 新群 ${roomId} 已录入 (brokerId=${brokerId}, customerId=${customerId})`);
  332. return { success: true, brokerId, customerId };
  333. }
  334. if (brokerId && !customerId) {
  335. updateImportedMapping(roomId, {
  336. roomId,
  337. brokerId,
  338. customerId: 'orphan',
  339. status: 'IMPORTED',
  340. reviewStatus: 'IMPORTED',
  341. memberCount: memberIds.length,
  342. seenAt: new Date().toISOString()
  343. });
  344. if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'PROCESSED', `群 ${roomId} 已创建 orphan 记录 (brokerId=${brokerId})`);
  345. console.log(`[Webhook] GROUP_CREATED: 群 ${roomId} 已创建 orphan 记录(经纪人=${brokerId})`);
  346. return { success: true, orphan: true, brokerId };
  347. }
  348. updateImportedMapping(roomId, {
  349. roomId,
  350. brokerId: null,
  351. customerId: customerId || 'orphan',
  352. externalUserId: customerExternalUserId,
  353. status: 'IMPORTED',
  354. reviewStatus: 'IMPORTED',
  355. memberCount: memberIds.length,
  356. seenAt: new Date().toISOString()
  357. });
  358. const debugInfo = `senderId=${raw.senderId || 'N/A'}, guid=${event.guid || 'N/A'}`;
  359. if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'PROCESSED', `无法识别经纪人 (${debugInfo}),已导入待补充`);
  360. console.log(`[Webhook] GROUP_CREATED: 群 ${roomId} 无法识别经纪人,已导入 (${debugInfo})`);
  361. return { success: true, imported: true };
  362. }
  363. function resolveSenderType(senderId, msgType, customerId, customerExternalUserId, brokerWecomUserId) {
  364. if (!senderId) return 'UNKNOWN';
  365. if (customerExternalUserId && senderId === customerExternalUserId) return 'CUSTOMER';
  366. if (brokerWecomUserId && senderId === brokerWecomUserId) return 'BROKER';
  367. // 通过 customer 档案进一步判断
  368. if (customerId) {
  369. const customer = readCustomerById(customerId);
  370. if (customer) {
  371. if (customer.externalUserId && senderId === customer.externalUserId) return 'CUSTOMER';
  372. if (customer.supportBrokerId) {
  373. const supportIds = Array.isArray(customer.supportBrokerId) ? customer.supportBrokerId : [customer.supportBrokerId];
  374. for (const supportId of supportIds) {
  375. const supportBroker = readBrokerById(supportId);
  376. if (supportBroker && supportBroker.wecomUserId === senderId) return 'SUPPORT';
  377. }
  378. }
  379. }
  380. }
  381. const broker = readBrokerByWecomUserId(senderId);
  382. if (broker) return 'BROKER';
  383. return 'UNKNOWN';
  384. }
  385. function ensureRelatedContact(customerId, externalUserId, name) {
  386. if (!customerId || !externalUserId) return null;
  387. const filePath = path.join(customersDir(), `${customerId}-related-${externalUserId}.json`);
  388. if (fs.existsSync(filePath)) return filePath;
  389. const record = {
  390. customerId,
  391. externalUserId,
  392. name: name || '',
  393. createdAt: new Date().toISOString()
  394. };
  395. fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8');
  396. return filePath;
  397. }
  398. function extractMessageContent(raw) {
  399. if (!raw) return '';
  400. if (typeof raw.content === 'string' && raw.content) return raw.content;
  401. if (typeof raw.msgContent === 'string' && raw.msgContent) return raw.msgContent;
  402. if (raw.msgData) {
  403. if (typeof raw.msgData.content === 'string') return raw.msgData.content;
  404. if (typeof raw.msgData.text === 'string') return raw.msgData.text;
  405. }
  406. return '';
  407. }
  408. async function processVoiceMessage(msgUniqueId, msgType, msgData) {
  409. const isVoice = msgType === 16 || msgType === 34 || String(msgType) === '16' || String(msgType) === '34';
  410. if (!isVoice) return {};
  411. const voiceUrl = msgData && (msgData.voiceUrl || msgData.url || msgData.cdnUrl);
  412. const base64Audio = msgData && (msgData.base64 || msgData.base64Data);
  413. if (!voiceUrl && !base64Audio) return {};
  414. try {
  415. const result = await qiweiTranscribeVoice({
  416. voiceUrl,
  417. base64Audio,
  418. transcribe: true
  419. });
  420. if (result.status === 'ok' && result.data) {
  421. return {
  422. voiceTranscript: result.data.transcript || result.data.text || '',
  423. voiceLocalPath: result.data.filePath || result.data.localPath || ''
  424. };
  425. }
  426. } catch (err) {
  427. console.warn(`[Webhook] 语音转写失败 ${msgUniqueId}:`, err.message);
  428. }
  429. return {};
  430. }
  431. function isKnownRoom(roomId) {
  432. const confirmed = readConfirmedMapping();
  433. if (confirmed[roomId]) return true;
  434. const imported = readImportedMapping();
  435. if (imported[roomId]) return true;
  436. return false;
  437. }
  438. async function storeGroupMessageFromWebhook(event, eventFilePath = null) {
  439. const raw = event.raw || {};
  440. const roomId = String(raw.fromRoomId || '');
  441. if (!roomId || roomId === '0') {
  442. if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', '非群消息,跳过');
  443. return { success: false, ignored: true, reason: '非群消息' };
  444. }
  445. const msgUniqueId = String(raw.msgUniqueIdentifier || '');
  446. if (!msgUniqueId) {
  447. if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', '缺少 msgUniqueIdentifier');
  448. return { success: false, ignored: true, reason: '缺少 msgUniqueIdentifier' };
  449. }
  450. const msgType = Number(raw.msgType) || 0;
  451. const SKIP_MSG_TYPES = new Set([2001, 2005]);
  452. if (SKIP_MSG_TYPES.has(msgType)) {
  453. if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', `msgType=${msgType} 为通知类消息,跳过`);
  454. return { success: false, ignored: true, reason: '通知类消息' };
  455. }
  456. if (!isKnownRoom(roomId)) {
  457. if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', `roomId=${roomId} 不在群映射中,跳过`);
  458. return { success: false, ignored: true, reason: '不在群映射中' };
  459. }
  460. if (messageExists(roomId, msgUniqueId)) {
  461. if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', `消息 ${msgUniqueId} 已存在`);
  462. return { success: false, ignored: true, reason: '消息已存在' };
  463. }
  464. const content = extractMessageContent(raw);
  465. const timestamp = Number(raw.timestamp) || 0;
  466. const senderId = String(raw.senderId || '');
  467. // 解析群对应的客户/经纪人
  468. const confirmed = readConfirmedMapping();
  469. const imported = readImportedMapping();
  470. const groupMapping = confirmed[roomId] || imported[roomId] || {};
  471. const customerId = groupMapping.customerId;
  472. const customerExternalUserId = groupMapping.externalUserId;
  473. const brokerId = groupMapping.brokerId;
  474. const broker = brokerId ? readBrokerById(brokerId) : null;
  475. const brokerWecomUserId = broker ? broker.wecomUserId : null;
  476. const senderType = resolveSenderType(senderId, msgType, customerId, customerExternalUserId, brokerWecomUserId);
  477. if (senderType === 'UNKNOWN' && senderId) {
  478. ensureRelatedContact(customerId, senderId, String(raw.senderName || ''));
  479. }
  480. const voiceMeta = await processVoiceMessage(msgUniqueId, msgType, raw.msgData);
  481. const message = {
  482. msgId: msgUniqueId,
  483. seq: raw.seq || 0,
  484. senderId,
  485. senderName: String(raw.senderName || ''),
  486. senderType,
  487. msgType: String(msgType),
  488. content,
  489. timestamp: timestamp ? new Date(timestamp * 1000).toISOString() : new Date().toISOString(),
  490. isRevoked: raw.isRevoked ? true : false,
  491. ...voiceMeta,
  492. rawData: raw
  493. };
  494. appendWebhookMessage(roomId, message);
  495. // 兼容写入账号隔离的社群运营事实表;失败不回滚已经落盘的旧消息。
  496. const operationAccountKey = resolveDeviceGuid(event);
  497. if (operationAccountKey) {
  498. try {
  499. const operationStore = getGroupOperationsStore();
  500. operationStore.upsertGroup(operationAccountKey, {
  501. roomId,
  502. roomName: groupMapping.roomName || '',
  503. ownerId: brokerId || '',
  504. groupType: groupMapping.groupType || 'customer'
  505. }, 'webhook');
  506. operationStore.ingestMessages(operationAccountKey, roomId, [{
  507. ...message,
  508. messageId: msgUniqueId,
  509. senderRole: senderType === 'CUSTOMER' ? 'customer' : ['BROKER', 'SUPPORT'].includes(senderType) ? 'staff' : 'unknown',
  510. sentAt: message.timestamp
  511. }]);
  512. } catch (error) {
  513. console.warn(`[Webhook] 社群运营事实表写入失败: ${error.message}`);
  514. }
  515. } else {
  516. console.warn('[Webhook] 事件缺少设备 guid,已保留旧消息但跳过社群运营账号隔离入库');
  517. }
  518. // 更新 mapping 的 lastMsgAt / lastSyncSeq
  519. if (confirmed[roomId]) {
  520. confirmed[roomId].lastMsgAt = message.timestamp;
  521. confirmed[roomId].lastSyncSeq = Math.max(confirmed[roomId].lastSyncSeq || 0, Number(raw.seq) || 0);
  522. writeConfirmedMapping(confirmed);
  523. } else if (imported[roomId]) {
  524. imported[roomId].lastMsgAt = message.timestamp;
  525. imported[roomId].lastSyncSeq = Math.max(imported[roomId].lastSyncSeq || 0, Number(raw.seq) || 0);
  526. writeImportedMapping(imported);
  527. }
  528. // 触发客户画像更新
  529. if (customerExternalUserId && senderType === 'CUSTOMER') {
  530. enqueuePortraitUpdate(customerExternalUserId, 'WEBHOOK');
  531. }
  532. if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'PROCESSED', `群消息已入库: ${content.substring(0, 80)}`);
  533. console.log(`[Webhook] 群消息已入库: roomId=${roomId}, seq=${raw.seq}`);
  534. return { success: true };
  535. }
  536. async function processWebhookEvents(envelope) {
  537. const events = parseWebhookEnvelope(envelope);
  538. if (!events.length) {
  539. console.log('[Webhook] 回调中无有效事件');
  540. return { processed: 0, ignored: 0, errors: 0 };
  541. }
  542. const result = { processed: 0, ignored: 0, errors: 0 };
  543. for (const event of events) {
  544. if (isDuplicateEvent(event.eventId)) {
  545. console.log(`[Webhook] 重复事件已跳过: ${event.eventId}`);
  546. result.ignored++;
  547. continue;
  548. }
  549. const eventFilePath = saveWebhookEventStructured(event, envelope.source || 'callback', envelope.__rawBody);
  550. markEventProcessed(event.eventId);
  551. try {
  552. const config = readWebhookConfig();
  553. const autoCreateGroupEnabled = config.autoCreateGroup !== false;
  554. if (event.parsedType === ParsedWebhookEvent.NEW_MESSAGE) {
  555. const msgResult = await storeGroupMessageFromWebhook(event, eventFilePath);
  556. if (msgResult.success) result.processed++;
  557. else if (msgResult.ignored) result.ignored++;
  558. else result.errors++;
  559. continue;
  560. }
  561. if (event.parsedType === ParsedWebhookEvent.GROUP_CREATED) {
  562. const groupResult = await handleGroupCreateWebhook(event, eventFilePath);
  563. if (groupResult.success) result.processed++;
  564. else if (groupResult.ignored) result.ignored++;
  565. else result.errors++;
  566. continue;
  567. }
  568. if (autoCreateGroupEnabled && (
  569. event.parsedType === ParsedWebhookEvent.CONTACT_ADDED_OR_CHANGED ||
  570. event.parsedType === ParsedWebhookEvent.FRIEND_REQUEST_RECEIVED
  571. )) {
  572. const autoResult = await triggerAutoCreateGroup(event, eventFilePath);
  573. if (autoResult.success) result.processed++;
  574. else if (autoResult.ignored) result.ignored++;
  575. else result.errors++;
  576. continue;
  577. }
  578. updateWebhookEventStatus(eventFilePath, 'IGNORED', `事件类型 ${event.parsedType} 不需要自动处理`);
  579. result.ignored++;
  580. } catch (err) {
  581. console.error('[Webhook] 事件处理异常:', err && err.message ? err.message : err);
  582. updateWebhookEventStatus(eventFilePath, 'ERROR', String(err && err.message ? err.message : err));
  583. result.errors++;
  584. }
  585. }
  586. return result;
  587. }
  588. module.exports = {
  589. processWebhookEvents,
  590. triggerAutoCreateGroup,
  591. findNewlyAddedContact,
  592. findNewlyAddedContactAcrossDevices,
  593. resolveDeviceGuid,
  594. handleGroupCreateWebhook,
  595. identifyServiceGroupMembers,
  596. storeGroupMessageFromWebhook,
  597. resolveSenderType,
  598. ensureRelatedContact,
  599. readConfirmedMapping,
  600. writeConfirmedMapping,
  601. recordConfirmedGroup,
  602. hasActiveGroup
  603. };