1688-cli 0.1.24 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,219 @@
1
+ import { writeFileSync } from 'node:fs';
1
2
  import { dispatch } from '../session/dispatch.js';
2
- import { emit, info } from '../io/output.js';
3
+ import { emit, info, isJson } from '../io/output.js';
3
4
  import { CliError } from '../io/errors.js';
4
5
  import { readState } from '../session/state.js';
5
6
  const IM_BASE = 'https://air.1688.com/app/ocms-fusion-components-1688/def_cbu_web_im/index.html';
7
+ function formatBeijingTime(ms) {
8
+ // 1688 is a Chinese platform — format in Asia/Shanghai (UTC+8).
9
+ const d = new Date(ms + 8 * 3600_000);
10
+ const pad = (n) => String(n).padStart(2, '0');
11
+ return (`${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ` +
12
+ `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`);
13
+ }
14
+ function parseOneWsMessage(m, myLoginId) {
15
+ const msg = m.message;
16
+ if (!msg)
17
+ return null;
18
+ if (m.recallFeature?.code)
19
+ return null; // recalled — skip
20
+ const ext = msg.extension ?? {};
21
+ const content = msg.content ?? {};
22
+ const senderNick = ext.sender_nick ?? '';
23
+ const isMine = senderNick === `cnalichn${myLoginId}`;
24
+ let sender = ext.senderNickName ?? senderNick.replace(/^cnalichn/, '');
25
+ const colon = sender.indexOf(':');
26
+ if (colon > -1)
27
+ sender = sender.slice(0, colon);
28
+ const createAt = Number(msg.createAt) || 0;
29
+ const time = createAt ? formatBeijingTime(createAt) : null;
30
+ const read = Number(m.readStatus ?? 0) === 2;
31
+ let kind = 'text';
32
+ let contentText = '';
33
+ let card;
34
+ const ct = content.contentType;
35
+ if (ct === 1) {
36
+ contentText = String(content.text?.content ?? '');
37
+ const offerMatch = contentText.match(/https?:\/\/detail\.1688\.com\/offer\/(\d+)\.html/i);
38
+ if (offerMatch) {
39
+ kind = 'offerCard';
40
+ card = { title: null, price: null, image: null, url: contentText };
41
+ }
42
+ else if (/order[Ii]d=\d+/.test(contentText) &&
43
+ /1688\.com|alibaba\.com/i.test(contentText)) {
44
+ kind = 'orderCard';
45
+ card = { title: null, price: null, image: null, url: contentText };
46
+ }
47
+ }
48
+ else if (ct === 101) {
49
+ // Custom template — subtype determined by bizuniqueID prefix.
50
+ contentText = String(content.custom?.summary ?? content.custom?.title ?? '');
51
+ const biz = ext.bizuniqueID ?? '';
52
+ if (biz.startsWith('cbu_offer_reply'))
53
+ kind = 'autoReply';
54
+ else if (biz.startsWith('cbu_im_msg_assessment'))
55
+ kind = 'assessment';
56
+ else
57
+ kind = 'other';
58
+ }
59
+ else {
60
+ contentText = `[contentType=${ct}]`;
61
+ kind = 'other';
62
+ }
63
+ const messageId = msg.messageId !== undefined ? String(msg.messageId) : undefined;
64
+ return {
65
+ sender: sender.trim(),
66
+ time,
67
+ isMine,
68
+ content: contentText,
69
+ read,
70
+ kind,
71
+ ...(card ? { card } : {}),
72
+ ...(messageId ? { messageId } : {}),
73
+ _sortMs: createAt,
74
+ };
75
+ }
76
+ function parseFromWsFrames(frames, myLoginId) {
77
+ const midToMethod = new Map();
78
+ const ourCids = new Set();
79
+ for (const f of frames) {
80
+ if (f.type === 'sent' && f.mid && f.method) {
81
+ midToMethod.set(f.mid, f.method);
82
+ }
83
+ if (f.type === 'sent' &&
84
+ f.method === '/r/MessageManager/listUserMessages') {
85
+ try {
86
+ const body = JSON.parse(f.payload).body;
87
+ if (Array.isArray(body) && typeof body[0] === 'string') {
88
+ ourCids.add(body[0]);
89
+ }
90
+ }
91
+ catch {
92
+ /* ignore */
93
+ }
94
+ }
95
+ }
96
+ const seen = new Set();
97
+ const out = [];
98
+ for (const f of frames) {
99
+ if (f.type !== 'recv')
100
+ continue;
101
+ const reqMethod = f.mid ? midToMethod.get(f.mid) : undefined;
102
+ if (reqMethod !== '/r/MessageManager/listUserMessages')
103
+ continue;
104
+ let body;
105
+ try {
106
+ body = JSON.parse(f.payload).body;
107
+ }
108
+ catch {
109
+ continue;
110
+ }
111
+ const models = body?.userMessageModels;
112
+ if (!Array.isArray(models))
113
+ continue;
114
+ for (const m of models) {
115
+ if (ourCids.size > 0 &&
116
+ m.message?.cid &&
117
+ !ourCids.has(m.message.cid)) {
118
+ continue;
119
+ }
120
+ const msgId = String(m.message?.messageId ?? '');
121
+ if (msgId && seen.has(msgId))
122
+ continue;
123
+ if (msgId)
124
+ seen.add(msgId);
125
+ const parsed = parseOneWsMessage(m, myLoginId);
126
+ if (parsed)
127
+ out.push(parsed);
128
+ }
129
+ }
130
+ out.sort((a, b) => a._sortMs - b._sortMs);
131
+ return out.map(({ _sortMs: _ms, ...rest }) => rest);
132
+ }
133
+ async function waitForWsMessages(frames, timeoutMs) {
134
+ const deadline = Date.now() + timeoutMs;
135
+ while (Date.now() < deadline) {
136
+ const midToMethod = new Map();
137
+ for (const f of frames) {
138
+ if (f.type === 'sent' && f.mid && f.method) {
139
+ midToMethod.set(f.mid, f.method);
140
+ }
141
+ }
142
+ const hit = frames.some((f) => f.type === 'recv' &&
143
+ f.mid &&
144
+ midToMethod.get(f.mid) === '/r/MessageManager/listUserMessages');
145
+ if (hit)
146
+ return true;
147
+ await new Promise((r) => setTimeout(r, 250));
148
+ }
149
+ return false;
150
+ }
6
151
  export async function execute(ctx, args) {
7
152
  const page = await ctx.newPage();
153
+ // ALWAYS collect WebSocket frames (LWP protocol). Used as the primary data
154
+ // source for messages — DOM extraction is only the fallback.
155
+ const wsFrames = [];
156
+ page.on('websocket', (ws) => {
157
+ const record = (type, payloadRaw) => {
158
+ const payload = typeof payloadRaw === 'string' ? payloadRaw : payloadRaw.toString();
159
+ let method = '';
160
+ let mid = null;
161
+ try {
162
+ const j = JSON.parse(payload);
163
+ method = j?.lwp ?? '';
164
+ mid = j?.headers?.mid ?? null;
165
+ }
166
+ catch {
167
+ /* not JSON — skip */
168
+ }
169
+ wsFrames.push({
170
+ type,
171
+ method,
172
+ mid,
173
+ payloadLen: payload.length,
174
+ payload,
175
+ });
176
+ };
177
+ ws.on('framesent', (frame) => record('sent', frame.payload));
178
+ ws.on('framereceived', (frame) => record('recv', frame.payload));
179
+ });
180
+ if (process.env.BB1688_PROBE === '1') {
181
+ // Probe: dump frames to file for offline analysis.
182
+ page.on('close', () => {
183
+ const midToMethod = new Map();
184
+ for (const f of wsFrames) {
185
+ if (f.type === 'sent' && f.mid && f.method) {
186
+ midToMethod.set(f.mid, f.method);
187
+ }
188
+ }
189
+ const enriched = wsFrames.map((f) => ({
190
+ ...f,
191
+ reqMethod: f.type === 'recv' && f.mid ? midToMethod.get(f.mid) ?? null : null,
192
+ }));
193
+ const interesting = enriched.filter((f) => {
194
+ if (f.type === 'sent') {
195
+ return /Message|Conversation|SingleChat/i.test(f.method);
196
+ }
197
+ if (f.reqMethod &&
198
+ /Message|Conversation|SingleChat/i.test(f.reqMethod)) {
199
+ return true;
200
+ }
201
+ return /msgId|messageId|cardType|offerId|orderId|conversationCode|userConvs|listMessage/i.test(f.payload);
202
+ });
203
+ // Write full frames to file (truncation-free)
204
+ try {
205
+ writeFileSync('/tmp/1688-ws-frames.json', JSON.stringify(enriched, null, 2));
206
+ writeFileSync('/tmp/1688-ws-interesting.json', JSON.stringify(interesting, null, 2));
207
+ }
208
+ catch (e) {
209
+ process.stderr.write(`[ws-frames] write failed: ${String(e)}\n`);
210
+ }
211
+ process.stderr.write(`[ws-frames] total=${wsFrames.length} interesting=${interesting.length}\n` +
212
+ `methods seen: ${[...new Set(wsFrames.map((f) => f.method).filter(Boolean))].join(', ')}\n` +
213
+ `full dump → /tmp/1688-ws-frames.json (${enriched.length} frames)\n` +
214
+ `interesting dump → /tmp/1688-ws-interesting.json (${interesting.length} frames)\n`);
215
+ });
216
+ }
8
217
  try {
9
218
  let url;
10
219
  if (args.sellerLoginId && (args.orderId || args.offerId)) {
@@ -74,14 +283,150 @@ export async function execute(ctx, args) {
74
283
  if (!activated) {
75
284
  throw new CliError(26, 'CONVERSATION_NOT_SELECTED', `Conversation did not activate for ${matched}.`);
76
285
  }
77
- await new Promise((r) => setTimeout(r, 2500)); // let messages render
78
- // Extract messages from .message-item elements
79
- const frame = page
286
+ // Wait for IM page to fire `/r/MessageManager/listUserMessages` and the
287
+ // server's recv frame to arrive. Falls through after timeout so we can
288
+ // still fall back to DOM extraction.
289
+ const gotWsMessages = await waitForWsMessages(wsFrames, 8000);
290
+ if (gotWsMessages) {
291
+ // Give a tiny grace window in case a second page-load batch is in flight.
292
+ await new Promise((r) => setTimeout(r, 800));
293
+ }
294
+ else {
295
+ // No WS response captured — wait a bit more for DOM render.
296
+ await new Promise((r) => setTimeout(r, 2500));
297
+ }
298
+ // Try the WebSocket path first — server-truth data (messageId, createAt,
299
+ // readStatus, URLs in plain text). If empty, fall back to DOM scraping.
300
+ const wsMessages = parseFromWsFrames(wsFrames, args.myLoginId);
301
+ const imFrameDoc = page
80
302
  .frames()
81
303
  .find((f) => /def_cbu_web_im_core/.test(f.url()));
304
+ if (wsMessages.length > 0) {
305
+ // WS data is clean but lacks visual card metadata (title/price/image).
306
+ // The IM client hydrates URL messages into cards in DOM — pull that
307
+ // enrichment so the human view shows product names instead of just IDs.
308
+ if (imFrameDoc && wsMessages.some((m) => m.kind === 'offerCard')) {
309
+ try {
310
+ const domCards = await imFrameDoc.evaluate(() => {
311
+ const out = [];
312
+ for (const item of Array.from(document.querySelectorAll('.message-item'))) {
313
+ const card = item.querySelector('.text-od-wrap, .od-wrap');
314
+ if (!card)
315
+ continue;
316
+ const timeEl = item.querySelector('.time');
317
+ const time = timeEl?.textContent?.trim() ?? null;
318
+ const titleEl = card.querySelector('.odName, .od-name, [class*="odName"]');
319
+ const priceEl = card.querySelector('.odPrice, .od-price, [class*="odPrice"]');
320
+ const imgEl = card.querySelector('img');
321
+ out.push({
322
+ time,
323
+ title: titleEl?.textContent?.trim().slice(0, 200) ?? null,
324
+ price: priceEl?.textContent
325
+ ?.replace(/\s+/g, '')
326
+ .replace('¥', '¥')
327
+ .trim() ?? null,
328
+ image: imgEl?.getAttribute('src') ?? null,
329
+ });
330
+ }
331
+ return out;
332
+ });
333
+ // Pair by exact time match (YYYY-MM-DD HH:MM:SS). If duplicates
334
+ // (e.g. user re-sent same URL within the same second), consume
335
+ // each DOM card only once via shift().
336
+ const byTime = new Map();
337
+ for (const dc of domCards) {
338
+ if (!dc.time)
339
+ continue;
340
+ const list = byTime.get(dc.time) ?? [];
341
+ list.push(dc);
342
+ byTime.set(dc.time, list);
343
+ }
344
+ for (const msg of wsMessages) {
345
+ if (msg.kind !== 'offerCard' || !msg.card || !msg.time)
346
+ continue;
347
+ const bucket = byTime.get(msg.time);
348
+ const dc = bucket?.shift();
349
+ if (!dc)
350
+ continue;
351
+ msg.card.title = dc.title;
352
+ msg.card.price = dc.price;
353
+ msg.card.image = dc.image;
354
+ }
355
+ }
356
+ catch {
357
+ /* enrichment is best-effort */
358
+ }
359
+ }
360
+ return {
361
+ conversation: matched ?? '?',
362
+ total: wsMessages.length,
363
+ messages: wsMessages.slice(-Math.max(1, args.limit)),
364
+ };
365
+ }
366
+ // Fallback: scrape from .message-item DOM nodes.
367
+ const frame = imFrameDoc;
82
368
  if (!frame) {
83
369
  throw new CliError(22, 'CHAT_NOT_LOADED', 'IM iframe not available.');
84
370
  }
371
+ // Probe mode: wait longer for response frames.
372
+ if (process.env.BB1688_PROBE === '1') {
373
+ await new Promise((r) => setTimeout(r, 15000));
374
+ }
375
+ // Probe: dump (1) suspected card item HTML, (2) all mtop calls fired
376
+ // during the IM load, (3) any WebSocket connections + global state.
377
+ if (typeof globalThis !== 'undefined' && typeof process !== 'undefined' && process.env.BB1688_PROBE === '1') {
378
+ // mtop trap was set up via addInitScript above? No — let's do it now
379
+ // post-hoc by reading page state if available.
380
+ const wsAndState = await page
381
+ .evaluate(() => {
382
+ const out = {
383
+ globalKeys: [],
384
+ imGlobals: {},
385
+ wsCount: 0,
386
+ };
387
+ // Top-level window keys that look IM/AMP/wangwang-related
388
+ const filter = /amp|wangwang|ww|chat|im|conversation|message/i;
389
+ for (const k of Object.keys(window)) {
390
+ if (filter.test(k)) {
391
+ out.globalKeys.push(k);
392
+ try {
393
+ const v = window[k];
394
+ if (v && typeof v === 'object') {
395
+ out.imGlobals[k] =
396
+ 'obj{' +
397
+ Object.keys(v).slice(0, 8).join(',') +
398
+ '}';
399
+ }
400
+ else if (typeof v === 'string' || typeof v === 'number') {
401
+ out.imGlobals[k] = String(v).slice(0, 60);
402
+ }
403
+ }
404
+ catch {
405
+ /* ignore */
406
+ }
407
+ }
408
+ }
409
+ return out;
410
+ })
411
+ .catch(() => ({
412
+ globalKeys: [],
413
+ imGlobals: {},
414
+ wsCount: 0,
415
+ }));
416
+ process.stderr.write(`[im-state-probe]\n` + JSON.stringify(wsAndState, null, 2) + '\n');
417
+ }
418
+ if (typeof globalThis !== 'undefined' && typeof process !== 'undefined' && process.env.BB1688_PROBE === '1') {
419
+ const probe = await frame.evaluate(() => {
420
+ const items = Array.from(document.querySelectorAll('.message-item'));
421
+ return items.slice(-6).map((item) => {
422
+ const text = (item.innerText ?? '').replace(/\s+/g, ' ').trim().slice(0, 80);
423
+ const html = item.outerHTML.slice(0, 800);
424
+ const classes = item.className?.toString?.() ?? '';
425
+ return { text, classes, html };
426
+ });
427
+ });
428
+ process.stderr.write(`[card-probe]\n` + JSON.stringify(probe, null, 2) + '\n');
429
+ }
85
430
  const raw = await frame.evaluate(() => {
86
431
  const items = Array.from(document.querySelectorAll('.message-item'));
87
432
  return items.map((item) => {
@@ -102,12 +447,69 @@ export async function execute(ctx, args) {
102
447
  const senderShort = colonIdx > -1 ? sender.slice(0, colonIdx) : sender;
103
448
  const read = /已读\s*$/.test(content);
104
449
  const cleanContent = content.replace(/\s*已读\s*$/, '').trim();
450
+ // Detect card-like content. 1688 IM auto-renders detail / order URLs
451
+ // into a card with image + title + price + link.
452
+ let kind = 'text';
453
+ let card;
454
+ // 1688 IM auto-renders offer-detail URLs and order URLs into custom
455
+ // card components. They have NO `<a href>` — detection is by class:
456
+ // - Offer card: div.text-od-wrap > img.headPic + div.infoWrap
457
+ // (.odName + .odPrice inside)
458
+ // - Order card: similar wrapper with order-specific classes
459
+ // - Auto-reply / template: div.im-template-msg
460
+ const offerCardEl = item.querySelector('.text-od-wrap, .od-wrap, .offer-card-wrap');
461
+ const orderCardEl = item.querySelector('.text-od-order-wrap, .order-card-wrap, [class*="OrderCard"]');
462
+ if (offerCardEl) {
463
+ kind = 'offerCard';
464
+ const titleEl = offerCardEl.querySelector('.odName, .od-name, [class*="odName"]');
465
+ const priceEl = offerCardEl.querySelector('.odPrice, .od-price, [class*="odPrice"]');
466
+ const cardImg = offerCardEl.querySelector('img');
467
+ card = {
468
+ title: titleEl?.textContent?.trim().slice(0, 200) ?? null,
469
+ price: priceEl?.textContent?.replace(/\s+/g, '').replace('¥', '¥').trim() ?? null,
470
+ image: cardImg?.getAttribute('src') ?? null,
471
+ // Offer cards don't expose URL in DOM — reconstruct from title
472
+ // hash isn't reliable. Leave null; agent can use cart-list or
473
+ // search to map title back to offerId if needed.
474
+ url: null,
475
+ };
476
+ }
477
+ else if (orderCardEl) {
478
+ kind = 'orderCard';
479
+ const cardImg = orderCardEl.querySelector('img');
480
+ card = {
481
+ title: cleanContent.slice(0, 200),
482
+ price: null,
483
+ image: cardImg?.getAttribute('src') ?? null,
484
+ url: null,
485
+ };
486
+ }
487
+ else if (item.querySelector('.im-template-msg')) {
488
+ // Smart-bot auto-reply / rich template message (the "智能客户专员"
489
+ // welcome reply, promotional banners, etc.).
490
+ kind = 'other';
491
+ }
492
+ else {
493
+ const contentImg = item.querySelector('.content img');
494
+ if (contentImg && cleanContent.length < 20) {
495
+ // Bare image in chat (no card wrapping, no card text).
496
+ kind = 'image';
497
+ card = {
498
+ title: null,
499
+ price: null,
500
+ image: contentImg.getAttribute('src') ?? null,
501
+ url: null,
502
+ };
503
+ }
504
+ }
105
505
  return {
106
506
  sender: senderShort.trim(),
107
507
  time,
108
508
  isMine,
109
509
  content: cleanContent,
110
510
  read,
511
+ kind,
512
+ card,
111
513
  };
112
514
  });
113
515
  });
@@ -168,25 +570,117 @@ export async function run(opts) {
168
570
  limit,
169
571
  };
170
572
  }
171
- let data = await dispatch('seller-messages', args, { headed: opts.headed, profile: opts.profile });
172
- // Apply --since filter
173
- if (sinceMs > 0) {
174
- data = {
175
- ...data,
176
- messages: data.messages.filter((m) => {
573
+ const fetchOnce = async () => {
574
+ const result = await dispatch('seller-messages', args, { headed: opts.headed, profile: opts.profile });
575
+ if (sinceMs > 0) {
576
+ const filtered = result.messages.filter((m) => {
177
577
  if (!m.time)
178
578
  return false;
179
579
  const t = Date.parse(m.time.replace(' ', 'T') + '+08:00');
180
580
  return Number.isFinite(t) && t > sinceMs;
181
- }),
182
- total: data.messages.length,
183
- };
581
+ });
582
+ return { ...result, messages: filtered, total: filtered.length };
583
+ }
584
+ return result;
585
+ };
586
+ // Watch mode: prime dedup set with current history, then poll and emit
587
+ // only newly-arrived messages. Ctrl+C to exit.
588
+ if (opts.watch) {
589
+ const intervalSec = Math.max(10, parseInt(opts.interval ?? '30', 10));
590
+ const seen = new Set();
591
+ info(`Watch mode: polling every ${intervalSec}s (Ctrl+C to stop)...`);
592
+ let baseline;
593
+ try {
594
+ baseline = await fetchOnce();
595
+ }
596
+ catch (e) {
597
+ throw new CliError(31, 'WATCH_BASELINE_FAILED', `Initial fetch failed: ${e.message}`);
598
+ }
599
+ for (const m of baseline.messages)
600
+ seen.add(messageKey(m));
601
+ info(`Baseline: ${baseline.conversation} — ${baseline.messages.length} messages in history`);
602
+ // Poll loop
603
+ // eslint-disable-next-line no-constant-condition
604
+ while (true) {
605
+ await new Promise((r) => setTimeout(r, intervalSec * 1000));
606
+ let next;
607
+ try {
608
+ next = await fetchOnce();
609
+ }
610
+ catch (e) {
611
+ process.stderr.write(`[watch] poll failed: ${e.message} — will retry next tick\n`);
612
+ continue;
613
+ }
614
+ const newMsgs = next.messages.filter((m) => !seen.has(messageKey(m)));
615
+ for (const m of newMsgs)
616
+ seen.add(messageKey(m));
617
+ if (newMsgs.length === 0)
618
+ continue;
619
+ if (isJson()) {
620
+ for (const m of newMsgs) {
621
+ process.stdout.write(JSON.stringify({ conversation: next.conversation, message: m }) +
622
+ '\n');
623
+ }
624
+ }
625
+ else {
626
+ for (const m of newMsgs) {
627
+ process.stdout.write(formatOneMessage(m) + '\n');
628
+ }
629
+ }
630
+ }
184
631
  }
632
+ const data = await fetchOnce();
185
633
  emit({
186
634
  human: () => printMessages(data),
187
635
  data,
188
636
  });
189
637
  }
638
+ function formatOneMessage(m) {
639
+ const who = m.isMine ? '我' : m.sender || '对方';
640
+ const tail = m.read && m.isMine ? ' [已读]' : '';
641
+ const prefix = ` [${m.time ?? '?'}] ${who}:`;
642
+ if (m.kind === 'offerCard' && m.card) {
643
+ const offerMatch = m.card.url?.match(/detail\.1688\.com\/offer\/(\d+)\.html/i);
644
+ const offerId = offerMatch?.[1];
645
+ const title = m.card.title;
646
+ const price = m.card.price;
647
+ let body;
648
+ if (title) {
649
+ const priceStr = price ? ` ${price}` : '';
650
+ const idStr = offerId ? ` #${offerId}` : '';
651
+ body = `[商品] ${title}${priceStr}${idStr}`;
652
+ }
653
+ else if (offerId) {
654
+ body = `[商品 ${offerId}]`;
655
+ }
656
+ else {
657
+ body = `[商品卡] ${m.card.url ?? m.content}`;
658
+ }
659
+ return `${prefix} ${body}${tail}`;
660
+ }
661
+ if (m.kind === 'orderCard' && m.card) {
662
+ const orderMatch = m.card.url?.match(/order[Ii]d=(\d+)/);
663
+ const idTag = orderMatch ? `[订单 ${orderMatch[1]}]` : '[订单卡]';
664
+ const extra = m.card.title ? ` ${m.card.title}` : '';
665
+ return `${prefix} ${idTag}${extra}${tail}`;
666
+ }
667
+ if (m.kind === 'image' && m.card?.image) {
668
+ return `${prefix} [图片] ${m.card.image}${tail}`;
669
+ }
670
+ if (m.kind === 'autoReply' ||
671
+ m.kind === 'assessment' ||
672
+ m.kind === 'other') {
673
+ const tag = m.kind === 'autoReply'
674
+ ? '[自动回复]'
675
+ : m.kind === 'assessment'
676
+ ? '[客服评价]'
677
+ : '[模板]';
678
+ const compact = m.content.replace(/\s+/g, ' ').trim();
679
+ return `${prefix} ${tag} ${compact}${tail}`;
680
+ }
681
+ const compact = m.content.replace(/\s+/g, ' ').trim();
682
+ return `${prefix} ${compact}${tail}`;
683
+ }
190
684
  function printMessages(r) {
191
685
  process.stdout.write(`Conversation: ${r.conversation}\n`);
192
686
  if (r.messages.length === 0) {
@@ -194,9 +688,14 @@ function printMessages(r) {
194
688
  return;
195
689
  }
196
690
  for (const m of r.messages) {
197
- const who = m.isMine ? '我' : m.sender || '对方';
198
- const tail = m.read && m.isMine ? ' [已读]' : '';
199
- process.stdout.write(` [${m.time ?? '?'}] ${who}: ${m.content}${tail}\n`);
691
+ process.stdout.write(formatOneMessage(m) + '\n');
200
692
  }
201
693
  }
694
+ /** Stable dedup key for watch mode. Prefers messageId (WS path); falls back
695
+ * to a (time + sender + content-prefix) tuple for the rare DOM-fallback case. */
696
+ function messageKey(m) {
697
+ if (m.messageId)
698
+ return m.messageId;
699
+ return `${m.time ?? '?'}|${m.sender}|${m.content.slice(0, 100)}`;
700
+ }
202
701
  //# sourceMappingURL=seller-messages.js.map