webhook-processor.js 26 KB

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