message-polling-worker.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. #!/usr/bin/env node
  2. /**
  3. * Message polling fallback worker.
  4. *
  5. * This is a demo-safe alternative when webhook callback configuration cannot
  6. * be changed. It pulls messages through /msg/syncMsg and writes them into the
  7. * same outputs/messages tree used by webhook processing.
  8. */
  9. const fs = require('fs');
  10. const path = require('path');
  11. const { outputsRoot } = require('../mcp/src/core/output-paths');
  12. const { buildContext, gatewayCall } = require('../mcp/src/core/shared-gateway');
  13. const { readQiweiGuid } = require('../mcp/src/core/credentials');
  14. const { recordGroupSync } = require('../mcp/src/core/dashboard-state');
  15. const INTERVAL_MS = Math.max(3000, Number(process.env.MESSAGE_POLL_INTERVAL_MS || 5000));
  16. const PAGE_LIMIT = Math.max(20, Math.min(500, Number(process.env.MESSAGE_POLL_PAGE_LIMIT || 200)));
  17. const MAX_PAGES_PER_TICK = Math.max(1, Math.min(20, Number(process.env.MESSAGE_POLL_MAX_PAGES || 5)));
  18. const IMPORT_HISTORY = process.argv.includes('--import-history') || process.env.MESSAGE_POLL_IMPORT_HISTORY === 'true';
  19. const RESET = process.argv.includes('--reset');
  20. const ONCE = process.argv.includes('--once');
  21. const LATEST_SEQ_PROBE = 999999999;
  22. const REQUEST_RETRIES = Math.max(0, Math.min(5, Number(process.env.MESSAGE_POLL_REQUEST_RETRIES || 3)));
  23. let gbkEncodeMap = null;
  24. function messagesDir() {
  25. return path.join(outputsRoot(), 'messages');
  26. }
  27. function statePath() {
  28. return path.join(messagesDir(), 'polling-state.json');
  29. }
  30. function ensureDir(dir) {
  31. fs.mkdirSync(dir, { recursive: true });
  32. }
  33. function safeReadJson(filePath, fallback = null) {
  34. try {
  35. if (!fs.existsSync(filePath)) return fallback;
  36. return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
  37. } catch {
  38. return fallback;
  39. }
  40. }
  41. function writeJson(filePath, data) {
  42. ensureDir(path.dirname(filePath));
  43. fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
  44. }
  45. function sleep(ms) {
  46. return new Promise(resolve => setTimeout(resolve, ms));
  47. }
  48. function readState() {
  49. if (RESET) return {};
  50. return safeReadJson(statePath(), {}) || {};
  51. }
  52. function writeState(state) {
  53. writeJson(statePath(), { ...state, updatedAt: new Date().toISOString() });
  54. }
  55. function sanitizeFilePart(value, fallback = 'message') {
  56. const safe = String(value || '').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 90);
  57. return safe || fallback;
  58. }
  59. function conversationIdForMessage(msg) {
  60. const roomId = String(msg.fromRoomId || '');
  61. if (roomId && roomId !== '0') return roomId;
  62. const senderId = String(msg.senderId || '');
  63. const receiverId = String(msg.receiverId || '');
  64. const otherId = senderId && senderId !== readQiweiGuid() ? senderId : receiverId || senderId;
  65. return `private-${sanitizeFilePart(otherId || 'unknown')}`;
  66. }
  67. function getGbkEncodeMap() {
  68. if (gbkEncodeMap) return gbkEncodeMap;
  69. gbkEncodeMap = new Map();
  70. const decoder = new TextDecoder('gbk');
  71. for (let first = 0x81; first <= 0xfe; first++) {
  72. for (let second = 0x40; second <= 0xfe; second++) {
  73. if (second === 0x7f) continue;
  74. const bytes = Buffer.from([first, second]);
  75. const char = decoder.decode(bytes);
  76. if (!char || char === '\uFFFD' || char.length !== 1) continue;
  77. if (!gbkEncodeMap.has(char)) gbkEncodeMap.set(char, bytes);
  78. }
  79. }
  80. return gbkEncodeMap;
  81. }
  82. function repairUtf8DecodedAsGbk(value) {
  83. const text = String(value || '');
  84. if (!/[\u4e00-\u9fa5]/.test(text)) return text;
  85. const map = getGbkEncodeMap();
  86. const chunks = [];
  87. for (const char of text) {
  88. const code = char.codePointAt(0);
  89. if (code <= 0x7f) {
  90. chunks.push(Buffer.from([code]));
  91. continue;
  92. }
  93. const bytes = map.get(char);
  94. if (!bytes) return text;
  95. chunks.push(bytes);
  96. }
  97. const repaired = Buffer.concat(chunks).toString('utf8');
  98. if (repaired.includes('\uFFFD')) return text;
  99. return /[\u4e00-\u9fa5]/.test(repaired) ? repaired : text;
  100. }
  101. function cleanExtractedText(value) {
  102. const cleaned = String(value || '').replace(/[\u0000-\u001f\u007f]/g, '').trim();
  103. return repairUtf8DecodedAsGbk(cleaned);
  104. }
  105. function extractUtf8TextFromBase64(value) {
  106. if (!value) return '';
  107. let buffer;
  108. try {
  109. buffer = Buffer.from(String(value), 'base64');
  110. } catch {
  111. return '';
  112. }
  113. const candidates = [];
  114. for (let i = 0; i < buffer.length - 1; i++) {
  115. const len = buffer[i];
  116. if (!len || len > 240 || i + 1 + len > buffer.length) continue;
  117. const text = cleanExtractedText(buffer.slice(i + 1, i + 1 + len).toString('utf8'));
  118. if (!text || text.includes('\uFFFD')) continue;
  119. if (/[\u4e00-\u9fa5]/.test(text)) candidates.push(text);
  120. }
  121. return candidates.sort((a, b) => b.length - a.length)[0] || '';
  122. }
  123. function extractContent(msg) {
  124. const rawText = extractUtf8TextFromBase64(msg.base64RawData || msg.msgData?.extras?.base64RawData);
  125. if (rawText) return rawText;
  126. if (typeof msg.content === 'string' && msg.content) return cleanExtractedText(msg.content);
  127. if (typeof msg.msgContent === 'string' && msg.msgContent) return cleanExtractedText(msg.msgContent);
  128. if (msg.msgData) {
  129. if (typeof msg.msgData.content === 'string') return cleanExtractedText(msg.msgData.content);
  130. if (Array.isArray(msg.msgData.moreDetail)) {
  131. return cleanExtractedText(msg.msgData.moreDetail.map(item => item && item.text).filter(Boolean).join(''));
  132. }
  133. if (typeof msg.msgData.notifyTitle === 'string') return cleanExtractedText(msg.msgData.notifyTitle);
  134. }
  135. return '';
  136. }
  137. function normalizeMessage(msg, conversationId) {
  138. const timestamp = Number(msg.timestamp) || 0;
  139. return {
  140. msgId: String(msg.msgUniqueIdentifier || msg.msgServerId || `${conversationId}_${msg.seq || Date.now()}`),
  141. seq: Number(msg.seq) || 0,
  142. senderId: String(msg.senderId || ''),
  143. senderName: String(msg.senderName || ''),
  144. receiverId: String(msg.receiverId || ''),
  145. msgType: String(msg.msgType || ''),
  146. content: extractContent(msg),
  147. timestamp: timestamp ? new Date(timestamp * 1000).toISOString() : new Date().toISOString(),
  148. isRevoked: Boolean(msg.isRevoked),
  149. source: 'polling',
  150. rawData: msg
  151. };
  152. }
  153. function messageFilePath(conversationId, message) {
  154. const dir = path.join(messagesDir(), sanitizeFilePart(conversationId, 'conversation'));
  155. ensureDir(dir);
  156. const seq = Number(message.seq) || 0;
  157. return path.join(dir, `${seq}-${sanitizeFilePart(message.msgId)}.json`);
  158. }
  159. function saveMessage(msg) {
  160. const conversationId = conversationIdForMessage(msg);
  161. const message = normalizeMessage(msg, conversationId);
  162. const filePath = messageFilePath(conversationId, message);
  163. if (fs.existsSync(filePath)) return { written: false, conversationId, filePath, message };
  164. writeJson(filePath, message);
  165. return { written: true, conversationId, filePath, message };
  166. }
  167. async function syncPage(ctx, msgSeq) {
  168. let lastError = null;
  169. for (let attempt = 0; attempt <= REQUEST_RETRIES; attempt++) {
  170. try {
  171. const data = await gatewayCall(ctx, '/msg/syncMsg', {
  172. guid: ctx.guid,
  173. msgSeq,
  174. limit: PAGE_LIMIT
  175. });
  176. return {
  177. messages: Array.isArray(data && data.syncMsgList) ? data.syncMsgList : [],
  178. hasMore: Boolean(data && data.hasMore),
  179. nextSeq: data && data.travelSyncKey !== undefined ? Number(data.travelSyncKey) : msgSeq
  180. };
  181. } catch (err) {
  182. lastError = err;
  183. if (attempt >= REQUEST_RETRIES) break;
  184. await sleep(500 * (attempt + 1));
  185. }
  186. }
  187. throw lastError;
  188. }
  189. async function readLatestSessionSeq(ctx) {
  190. const data = await gatewayCall(ctx, '/session/getSessionPage', {
  191. guid: ctx.guid,
  192. page: 1,
  193. limit: 20
  194. });
  195. const seq = Number(data && data.currentSeq);
  196. return Number.isFinite(seq) && seq > 0 ? seq : 0;
  197. }
  198. async function seedCursor(ctx) {
  199. let msgSeq = 0;
  200. try {
  201. msgSeq = await readLatestSessionSeq(ctx);
  202. } catch {
  203. const page = await syncPage(ctx, LATEST_SEQ_PROBE);
  204. msgSeq = page.nextSeq || LATEST_SEQ_PROBE;
  205. }
  206. writeState({ msgSeq, seededAt: new Date().toISOString(), imported: false });
  207. console.log(`[MessagePolling] Seeded cursor at ${msgSeq}. New messages after this point will be imported.`);
  208. return { msgSeq };
  209. }
  210. async function runOnce(ctx, state) {
  211. let msgSeq = Number(state.msgSeq || 0);
  212. let pages = 0;
  213. let fetched = 0;
  214. let written = 0;
  215. const touched = new Map();
  216. while (pages < MAX_PAGES_PER_TICK) {
  217. pages++;
  218. const page = await syncPage(ctx, msgSeq);
  219. fetched += page.messages.length;
  220. msgSeq = page.nextSeq || msgSeq + 1;
  221. for (const msg of page.messages) {
  222. const saved = saveMessage(msg);
  223. if (!saved.written) continue;
  224. written++;
  225. const current = touched.get(saved.conversationId) || { messageCount: 0, lastMsgAt: null, lastSyncSeq: 0 };
  226. current.messageCount++;
  227. current.lastMsgAt = saved.message.timestamp;
  228. current.lastSyncSeq = Math.max(current.lastSyncSeq || 0, Number(saved.message.seq) || 0);
  229. touched.set(saved.conversationId, current);
  230. }
  231. if (!page.hasMore || !page.messages.length) break;
  232. }
  233. for (const [conversationId, info] of touched.entries()) {
  234. recordGroupSync(conversationId, info);
  235. }
  236. const nextState = {
  237. ...state,
  238. msgSeq,
  239. lastFetched: fetched,
  240. lastWritten: written,
  241. lastRunAt: new Date().toISOString()
  242. };
  243. writeState(nextState);
  244. return nextState;
  245. }
  246. async function main() {
  247. const guid = String(process.argv.find(arg => arg.startsWith('--guid='))?.slice('--guid='.length) || readQiweiGuid() || '').trim();
  248. if (!guid) {
  249. console.error('[MessagePolling] Missing guid. Log in first or pass --guid=<device-guid>.');
  250. process.exit(1);
  251. }
  252. const ctx = buildContext({ guid });
  253. console.log(`[MessagePolling] Started. guid=${guid}, interval=${INTERVAL_MS}ms`);
  254. let state = readState();
  255. if (!state.msgSeq && !IMPORT_HISTORY) {
  256. state = await seedCursor(ctx);
  257. }
  258. while (true) {
  259. try {
  260. state = await runOnce(ctx, state);
  261. console.log(`[MessagePolling] fetched=${state.lastFetched}, written=${state.lastWritten}, nextSeq=${state.msgSeq}`);
  262. } catch (err) {
  263. console.error('[MessagePolling] Poll failed:', err.message);
  264. }
  265. if (ONCE) break;
  266. await new Promise(resolve => setTimeout(resolve, INTERVAL_MS));
  267. }
  268. }
  269. main().catch(err => {
  270. console.error('[MessagePolling] Fatal:', err);
  271. process.exit(1);
  272. });