shennian 0.2.77 → 0.2.83

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.
@@ -4,6 +4,7 @@
4
4
  import crypto from 'node:crypto';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
8
  import { ChannelSecretRegistry } from './secret-registry.js';
8
9
  import { probeMacWeChat, observedMessageFromProbe } from './wechat-rpa/macos.js';
9
10
  import { runMacWeChatRpaFlow } from './wechat-rpa/macos-flow.js';
@@ -11,9 +12,14 @@ import { runWindowsWeChatRpaVisualFlow } from './wechat-rpa/windows-visual-flow.
11
12
  import { normalizeWeChatRpaMessage, WeChatRpaDeduper, weChatRpaConversationId, } from './wechat-rpa/normalizer.js';
12
13
  const DEFAULT_POLL_INTERVAL_MS = 5_000;
13
14
  const DEFAULT_RECENT_LIMIT = 5;
15
+ const LAB_SCROLL_READ_RECENT_LIMIT = 8;
14
16
  const MAX_OUTBOUND_ATTACHMENT_BYTES = Number(process.env.SHENNIAN_WECHAT_RPA_OUTBOUND_ATTACHMENT_MAX_BYTES || 20 * 1024 * 1024);
15
17
  const INTERRUPTION_COOLDOWN_THRESHOLD = Number(process.env.SHENNIAN_WECHAT_RPA_INTERRUPTION_COOLDOWN_THRESHOLD || 3);
16
18
  const INTERRUPTION_COOLDOWN_MS = Number(process.env.SHENNIAN_WECHAT_RPA_INTERRUPTION_COOLDOWN_MS || 5 * 60 * 1000);
19
+ const MAX_RECENT_TASK_SUMMARIES = 10;
20
+ export function selectWeChatRpaLabReadKind(recentLimit) {
21
+ return clampNumber(recentLimit, DEFAULT_RECENT_LIMIT, 1, 50) >= LAB_SCROLL_READ_RECENT_LIMIT ? 'scroll-read' : 'read-latest';
22
+ }
17
23
  export class WeChatRpaChannelAdapter {
18
24
  onMessage;
19
25
  type = 'wechat-rpa';
@@ -66,7 +72,7 @@ export class WeChatRpaChannelAdapter {
66
72
  if (secret.canReply === false)
67
73
  throw new Error('WeChat RPA channel does not allow replies');
68
74
  if (!isFlowSource(secret.source)) {
69
- throw new Error('WeChat RPA reply requires source=macos-flow or windows-visual-flow');
75
+ throw new Error('WeChat RPA reply requires source=macos-flow, windows-visual-flow, or wechat-rpa-lab');
70
76
  }
71
77
  const conn = this.ensureConnection(config);
72
78
  conn.config = config;
@@ -79,6 +85,9 @@ export class WeChatRpaChannelAdapter {
79
85
  const pendingKey = pendingReplyKey(config, reply);
80
86
  if (conn.completedPendingReplyKeys.has(pendingKey))
81
87
  return { status: 'sent' };
88
+ const manualReview = conn.manualReviewReplies.get(pendingKey);
89
+ if (manualReview)
90
+ return { status: 'manual-review', reason: manualReview.reason };
82
91
  const attachmentPath = reply.attachment
83
92
  ? await materializeWeChatRpaOutboundAttachment(config.workDir, reply.attachment)
84
93
  : undefined;
@@ -103,13 +112,19 @@ export class WeChatRpaChannelAdapter {
103
112
  conn.runtimeState = 'syncing';
104
113
  flow = await this.enqueueOperation(conn, () => {
105
114
  conn.lastRunAt = new Date().toISOString();
106
- return runSendFlow(config, secret, conversationName, text, attachmentPath);
115
+ return runSendFlow(config, secret, conversationName, text, attachmentPath, pendingKey);
107
116
  });
108
117
  }
109
118
  catch (error) {
110
119
  conn.lastError = error instanceof Error ? error.message : String(error);
120
+ addTaskSummary(conn, {
121
+ status: classifyWeChatRpaFailure(conn.lastError),
122
+ runId: conn.lastRunId ?? null,
123
+ summary: conn.lastError,
124
+ });
111
125
  throw error;
112
126
  }
127
+ recordTraceRuntime(conn, flow);
113
128
  const partial = applyPartialSendProgress(flow, { text, attachmentPath });
114
129
  if (partial.status === 'queued') {
115
130
  const reason = partial.reason;
@@ -139,9 +154,17 @@ export class WeChatRpaChannelAdapter {
139
154
  });
140
155
  return { status: 'queued', reason };
141
156
  }
142
- if (!flow.ok)
143
- throw new Error(flow.error || 'WeChat RPA send failed');
157
+ if (!flow.ok) {
158
+ const reason = flow.error || 'WeChat RPA send validator failed; manual review required before retry';
159
+ noteManualReview(conn, pendingKey, reason);
160
+ return { status: 'manual-review', reason };
161
+ }
144
162
  recordCloudOcrRuntime(conn, flow);
163
+ addTaskSummary(conn, {
164
+ status: 'sent',
165
+ runId: conn.lastRunId ?? null,
166
+ summary: flow.rpaTraceSummary || `sent ${conversationName}`,
167
+ });
145
168
  noteSuccessfulRun(conn);
146
169
  conn.lastError = null;
147
170
  return { status: 'sent' };
@@ -159,6 +182,19 @@ export class WeChatRpaChannelAdapter {
159
182
  if (!configuredGroups(secret).length)
160
183
  return { ok: false, message: 'WeChat RPA macOS flow requires at least one group' };
161
184
  }
185
+ if (secret.source === 'wechat-rpa-lab') {
186
+ if (process.platform !== 'darwin')
187
+ return { ok: false, message: 'WeChat RPA Lab source requires macOS for live runs' };
188
+ if (!configuredGroups(secret).length)
189
+ return { ok: false, message: 'WeChat RPA Lab source requires at least one group' };
190
+ try {
191
+ resolveWeChatRpaLabIndex(config.workDir);
192
+ }
193
+ catch (error) {
194
+ return { ok: false, message: error instanceof Error ? error.message : String(error) };
195
+ }
196
+ return { ok: true, message: 'WeChat RPA Lab source configured' };
197
+ }
162
198
  if (secret.source === 'windows-visual-flow') {
163
199
  return windowsVisualFlowHealth(secret, process.platform);
164
200
  }
@@ -185,6 +221,10 @@ export class WeChatRpaChannelAdapter {
185
221
  wechatRpaLastInterruptedAt: conn.lastInterruptedAt ?? null,
186
222
  wechatRpaPendingReplyCount: conn.pendingReplies.size,
187
223
  wechatRpaLastError: conn.lastError ?? null,
224
+ wechatRpaLastRunId: conn.lastRunId ?? null,
225
+ wechatRpaLastTracePath: conn.lastTracePath ?? null,
226
+ wechatRpaLastTraceSummary: conn.lastTraceSummary ?? null,
227
+ wechatRpaRecentTaskSummaries: conn.recentTaskSummaries,
188
228
  wechatRpaLastCloudOcrAt: conn.lastCloudOcrAt ?? null,
189
229
  wechatRpaLastCloudOcrPurpose: conn.lastCloudOcrPurpose ?? null,
190
230
  wechatRpaLastCloudOcrRequestId: conn.lastCloudOcrRequestId ?? null,
@@ -202,10 +242,12 @@ export class WeChatRpaChannelAdapter {
202
242
  stopped: false,
203
243
  conversations: new Map(),
204
244
  pendingReplies: new Map(),
245
+ manualReviewReplies: new Map(),
205
246
  completedPendingReplyKeys: new Set(),
206
247
  pendingStatePath: undefined,
207
248
  messageStatePath: undefined,
208
249
  operation: Promise.resolve(),
250
+ recentTaskSummaries: [],
209
251
  runtimeState: 'idle_waiting',
210
252
  consecutiveInterruptions: 0,
211
253
  };
@@ -241,6 +283,7 @@ export class WeChatRpaChannelAdapter {
241
283
  let interrupted = false;
242
284
  conn.runtimeState = 'syncing';
243
285
  const observed = await this.readObservedMessages(conn.config, secret, (flow) => {
286
+ recordTraceRuntime(conn, flow);
244
287
  recordCloudOcrRuntime(conn, flow);
245
288
  if (!flow.interrupted)
246
289
  return;
@@ -249,7 +292,7 @@ export class WeChatRpaChannelAdapter {
249
292
  });
250
293
  conn.lastError = null;
251
294
  for (const item of observed) {
252
- const message = normalizeWeChatRpaMessage(item);
295
+ const message = normalizeWeChatRpaMessage(item, { selfNicknames: wechatRpaSelfNicknames(secret) });
253
296
  if (!message || !conn.deduper.accept(message.messageId))
254
297
  continue;
255
298
  persistMessageState(conn);
@@ -261,15 +304,22 @@ export class WeChatRpaChannelAdapter {
261
304
  channelId: conn.config.id,
262
305
  channelType: 'wechat-rpa',
263
306
  conversationId: message.conversationId,
307
+ conversationName: message.conversationName,
264
308
  messageId: message.messageId,
265
309
  sender: message.sender,
266
310
  text: message.text,
267
311
  attachments: message.attachments,
268
312
  receivedAt: message.receivedAt,
313
+ isMentioned: message.isMentioned,
269
314
  replyTarget: '',
270
315
  rawRef: message.rawRef,
271
316
  };
272
317
  emitted.push(this.onMessage?.(event) ?? event);
318
+ addTaskSummary(conn, {
319
+ status: 'received',
320
+ runId: conn.lastRunId ?? null,
321
+ summary: `received ${message.conversationName}${message.sender.name ? ` from ${message.sender.name}` : ''}: ${clipSummary(message.text || message.attachments[0]?.name || 'attachment')}`,
322
+ });
273
323
  }
274
324
  if (!interrupted)
275
325
  noteSuccessfulRun(conn);
@@ -277,6 +327,11 @@ export class WeChatRpaChannelAdapter {
277
327
  }
278
328
  catch (error) {
279
329
  conn.lastError = error instanceof Error ? error.message : String(error);
330
+ addTaskSummary(conn, {
331
+ status: classifyWeChatRpaFailure(conn.lastError),
332
+ runId: conn.lastRunId ?? null,
333
+ summary: conn.lastError,
334
+ });
280
335
  throw error;
281
336
  }
282
337
  }
@@ -288,7 +343,8 @@ export class WeChatRpaChannelAdapter {
288
343
  pending.attempts += 1;
289
344
  pending.lastAttemptAt = new Date().toISOString();
290
345
  persistPendingReplyState(conn);
291
- const flow = await runSendFlow(conn.config, secret, pending.conversationName, pending.skipText ? '' : pending.text, pending.attachmentPath);
346
+ const flow = await runSendFlow(conn.config, secret, pending.conversationName, pending.skipText ? '' : pending.text, pending.attachmentPath, pending.key);
347
+ recordTraceRuntime(conn, flow);
292
348
  recordCloudOcrRuntime(conn, flow);
293
349
  const partial = applyPartialSendProgress(flow, {
294
350
  text: pending.skipText ? '' : pending.text,
@@ -313,12 +369,22 @@ export class WeChatRpaChannelAdapter {
313
369
  persistPendingReplyState(conn);
314
370
  return true;
315
371
  }
316
- if (!flow.ok)
317
- throw new Error(flow.error || 'WeChat RPA pending send failed');
372
+ if (!flow.ok) {
373
+ const reason = flow.error || 'WeChat RPA pending send validator failed; manual review required before retry';
374
+ conn.pendingReplies.delete(pending.key);
375
+ noteManualReview(conn, pending.key, reason);
376
+ persistPendingReplyState(conn);
377
+ return true;
378
+ }
318
379
  conn.pendingReplies.delete(pending.key);
319
380
  conn.completedPendingReplyKeys.add(pending.key);
320
381
  persistPendingReplyState(conn);
321
382
  conn.lastError = null;
383
+ addTaskSummary(conn, {
384
+ status: 'sent',
385
+ runId: conn.lastRunId ?? null,
386
+ summary: flow.rpaTraceSummary || `sent ${pending.conversationName}`,
387
+ });
322
388
  noteSuccessfulRun(conn);
323
389
  }
324
390
  return false;
@@ -330,6 +396,9 @@ export class WeChatRpaChannelAdapter {
330
396
  if (secret.source === 'macos-flow') {
331
397
  return readMacFlowMessages(config, secret, onFlow);
332
398
  }
399
+ if (secret.source === 'wechat-rpa-lab') {
400
+ return readLabMessages(config, secret, onFlow);
401
+ }
333
402
  if (secret.source === 'windows-visual-flow') {
334
403
  return readWindowsVisualFlowMessages(config, secret, onFlow);
335
404
  }
@@ -360,6 +429,20 @@ function configuredGroups(secret) {
360
429
  ? secret.groups.map((group) => String(group?.name || '').trim()).filter(Boolean)
361
430
  : [];
362
431
  }
432
+ function wechatRpaSelfNicknames(secret) {
433
+ return String(secret.selfNickname || '')
434
+ .split(/[,\n,、]/)
435
+ .map((item) => item.trim())
436
+ .filter(Boolean);
437
+ }
438
+ function isWeChatRpaTextMentioned(text, aliases) {
439
+ if (!aliases.length)
440
+ return false;
441
+ return aliases.some((alias) => {
442
+ const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
443
+ return new RegExp(`@\\s*${escaped}(?=$|\\s|[,。!?,.!?::;;、)])`).test(text);
444
+ });
445
+ }
363
446
  export function windowsVisualFlowHealth(secret, platform = process.platform) {
364
447
  if (platform !== 'win32')
365
448
  return { ok: false, message: 'WeChat RPA Windows visual flow requires Windows' };
@@ -368,6 +451,173 @@ export function windowsVisualFlowHealth(secret, platform = process.platform) {
368
451
  }
369
452
  return { ok: true, message: 'WeChat RPA Windows visual flow configured' };
370
453
  }
454
+ async function readLabMessages(config, secret, onFlow) {
455
+ const api = await importWeChatRpaLabApi(config.workDir);
456
+ const result = [];
457
+ for (const name of configuredGroups(secret)) {
458
+ const flow = await runLabReadFlow(api, secret, name);
459
+ onFlow?.(flow);
460
+ if (!flow.ok || flow.interrupted)
461
+ continue;
462
+ for (const message of flow.newMessages ?? []) {
463
+ const observed = flowMessageToObservedMessage(name, message, secret);
464
+ if (observed)
465
+ result.push(observed);
466
+ }
467
+ }
468
+ return result;
469
+ }
470
+ async function runLabReadFlow(api, secret, groupName) {
471
+ const kind = selectWeChatRpaLabReadKind(secret.recentLimit);
472
+ const task = {
473
+ kind,
474
+ requestId: `wechat-rpa:${groupName}:read:${Date.now()}`,
475
+ targetGroup: groupName,
476
+ policy: secret.forceForeground ? 'work' : 'polite',
477
+ limit: clampNumber(secret.recentLimit, DEFAULT_RECENT_LIMIT, 1, 50),
478
+ readMode: secret.readMode === 'hybrid-vlm' ? 'hybrid-vlm' : 'local-ocr',
479
+ };
480
+ const result = await api.runWechatRpaReadLatest(task);
481
+ const structuredMessages = Array.isArray(result.data?.structuredMessages) ? result.data.structuredMessages : [];
482
+ const latestMessages = result.validation?.deterministic?.latestMessages ?? [];
483
+ const flowMessages = structuredMessages.length
484
+ ? structuredMessages.map((message, index) => structuredMessageToFlowMessage(message, result.runId || 'lab-read', index))
485
+ : latestMessages.map((text, index) => ({
486
+ id: `${result.runId || 'lab-read'}:${index}`,
487
+ text,
488
+ confidence: 0.8,
489
+ }));
490
+ return {
491
+ ok: Boolean(result.ok),
492
+ groupName,
493
+ interrupted: result.status === 'interrupted',
494
+ reason: result.status,
495
+ rpaRunId: result.runId,
496
+ rpaTraceSummary: `${kind} ${result.status || (result.ok ? 'success' : 'failed')} ${groupName}`,
497
+ recentMessages: flowMessages,
498
+ newMessages: flowMessages,
499
+ screenshotPath: result.tracePath,
500
+ cloudOcrUsage: result.data?.hybrid?.usage,
501
+ error: labResultError(result),
502
+ };
503
+ }
504
+ function structuredMessageToFlowMessage(message, runId, index) {
505
+ return {
506
+ id: `${runId}:${message.index ?? index}`,
507
+ text: String(message.text || message.card?.title || '').trim(),
508
+ confidence: typeof message.confidence === 'number' ? message.confidence : 0.75,
509
+ senderName: typeof message.senderName === 'string' ? message.senderName : null,
510
+ attachments: structuredAttachments(message),
511
+ };
512
+ }
513
+ function structuredAttachments(message) {
514
+ const kind = String(message.kind || '');
515
+ if (!['image', 'file', 'video-file', 'video-card', 'link-card', 'official-account-card', 'mini-program-card'].includes(kind))
516
+ return [];
517
+ const localPath = typeof message.localPath === 'string' ? message.localPath : undefined;
518
+ const attachmentType = kind === 'video-file' || kind === 'video-card' ? 'video' : kind === 'link-card' || kind === 'official-account-card' || kind === 'mini-program-card' ? 'file' : kind;
519
+ return [{
520
+ type: attachmentType,
521
+ localPath,
522
+ availability: localPath ? 'edge-local' : 'metadata-only',
523
+ }];
524
+ }
525
+ async function runLabSendFlow(config, secret, conversationName, text, attachmentPath, dedupeKey) {
526
+ const api = await importWeChatRpaLabApi(config.workDir);
527
+ const tasks = planWeChatRpaLabSendTasks({
528
+ groupName: conversationName,
529
+ text,
530
+ attachmentPath,
531
+ dedupeKey,
532
+ requestId: dedupeKey,
533
+ policy: 'work',
534
+ });
535
+ const results = [];
536
+ for (const task of tasks) {
537
+ const result = await api.runWechatRpaTask(task);
538
+ results.push(result);
539
+ if (!result.ok)
540
+ break;
541
+ }
542
+ const textResult = results.find((result) => result.kind === 'send-text');
543
+ const attachmentResult = results.find((result) => result.kind === 'send-image' || result.kind === 'send-video' || result.kind === 'send-file');
544
+ const ok = tasks.length > 0 && results.length === tasks.length && results.every((result) => result.ok);
545
+ const interrupted = results.some((result) => result.status === 'interrupted');
546
+ return {
547
+ ok,
548
+ groupName: conversationName,
549
+ interrupted,
550
+ reason: results.at(-1)?.status,
551
+ rpaRunId: results.at(-1)?.runId,
552
+ rpaTraceSummary: `send ${ok ? 'success' : results.at(-1)?.status || 'failed'} ${conversationName} (${results.length}/${tasks.length})`,
553
+ sentReply: Boolean(text),
554
+ sentReplyObserved: text ? Boolean(textResult?.ok) : false,
555
+ sentAttachment: Boolean(attachmentPath),
556
+ sentAttachmentObserved: attachmentPath ? Boolean(attachmentResult?.ok) : false,
557
+ postSendScreenshotPath: results.at(-1)?.tracePath,
558
+ error: ok ? undefined : labResultError(results.at(-1)),
559
+ };
560
+ }
561
+ export function planWeChatRpaLabSendTasks(input) {
562
+ const tasks = [];
563
+ const text = String(input.text || '').trim();
564
+ const policy = input.policy || 'work';
565
+ if (text) {
566
+ tasks.push({
567
+ kind: 'send-text',
568
+ requestId: input.requestId ? `${input.requestId}:text` : undefined,
569
+ targetGroup: input.groupName,
570
+ policy,
571
+ text,
572
+ ...(input.dedupeKey ? { dedupeKey: `${input.dedupeKey}:text` } : {}),
573
+ });
574
+ }
575
+ if (input.attachmentPath) {
576
+ const kind = weChatRpaLabTaskKindForAttachment(input.attachmentPath);
577
+ tasks.push({
578
+ kind,
579
+ requestId: input.requestId ? `${input.requestId}:attachment` : undefined,
580
+ targetGroup: input.groupName,
581
+ policy,
582
+ filePath: input.attachmentPath,
583
+ ...(input.dedupeKey ? { dedupeKey: `${input.dedupeKey}:attachment` } : {}),
584
+ });
585
+ }
586
+ return tasks;
587
+ }
588
+ export function weChatRpaLabTaskKindForAttachment(filePath) {
589
+ const ext = path.extname(filePath).toLowerCase();
590
+ if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.heic'].includes(ext))
591
+ return 'send-image';
592
+ if (['.mp4', '.mov', '.avi', '.mkv'].includes(ext))
593
+ return 'send-video';
594
+ return 'send-file';
595
+ }
596
+ async function importWeChatRpaLabApi(workDir) {
597
+ const modulePath = resolveWeChatRpaLabIndex(workDir);
598
+ return await import(pathToFileURL(modulePath).href);
599
+ }
600
+ function resolveWeChatRpaLabIndex(workDir) {
601
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
602
+ const candidates = [
603
+ process.env.SHENNIAN_WECHAT_RPA_LAB_INDEX,
604
+ path.resolve(moduleDir, '../../../../scripts/wechat-rpa-lab/index.mjs'),
605
+ workDir ? path.join(workDir, 'scripts/wechat-rpa-lab/index.mjs') : '',
606
+ path.resolve(process.cwd(), 'scripts/wechat-rpa-lab/index.mjs'),
607
+ ].filter(Boolean);
608
+ const found = candidates.find((candidate) => fs.existsSync(candidate));
609
+ if (!found) {
610
+ throw new Error('WeChat RPA Lab API is missing; set SHENNIAN_WECHAT_RPA_LAB_INDEX or run from the repository root');
611
+ }
612
+ return path.resolve(found);
613
+ }
614
+ function labResultError(result) {
615
+ if (!result)
616
+ return 'WeChat RPA Lab did not return a result';
617
+ if (typeof result.error === 'string')
618
+ return result.error;
619
+ return result.error?.message || (result.ok ? undefined : `WeChat RPA Lab task ${result.kind || '<unknown>'} ${result.status || 'failed'}`);
620
+ }
371
621
  async function readMacFlowMessages(config, secret, onFlow) {
372
622
  const result = [];
373
623
  for (const name of configuredGroups(secret)) {
@@ -380,27 +630,15 @@ async function readMacFlowMessages(config, secret, onFlow) {
380
630
  idleSeconds: clampNumber(secret.idleSeconds, 15, 0, 3600),
381
631
  recentLimit: clampNumber(secret.recentLimit, DEFAULT_RECENT_LIMIT, 0, 50),
382
632
  downloadAttachmentsDir: resolveInboundAttachmentDir(config.workDir, secret),
383
- cloudOcrUrl: typeof secret.cloudOcrUrl === 'string' ? secret.cloudOcrUrl : undefined,
384
- cloudOcrToken: typeof secret.cloudOcrToken === 'string' ? secret.cloudOcrToken : undefined,
385
- cloudOcrMode: normalizeCloudOcrMode(secret.cloudOcrMode),
386
- cloudOcrChannelId: config.id,
633
+ cloudOcrMode: 'off',
387
634
  });
388
635
  onFlow?.(flow);
389
636
  if (flow.interrupted)
390
637
  continue;
391
638
  for (const message of flow.newMessages ?? []) {
392
- const text = String(message.text || '').trim();
393
- const attachments = Array.isArray(message.attachments) ? message.attachments : [];
394
- if (!text && attachments.length === 0)
395
- continue;
396
- result.push({
397
- conversationName: name,
398
- senderName: null,
399
- text,
400
- attachments: annotateWeChatRpaInboundAttachments(attachments),
401
- observedAt: new Date().toISOString(),
402
- rawId: String(message.id || `${name}:${text}`),
403
- });
639
+ const observed = flowMessageToObservedMessage(name, message, secret);
640
+ if (observed)
641
+ result.push(observed);
404
642
  }
405
643
  }
406
644
  return result;
@@ -414,32 +652,37 @@ async function readWindowsVisualFlowMessages(config, secret, onFlow) {
414
652
  workDir: config.workDir,
415
653
  recentLimit: clampNumber(secret.recentLimit, DEFAULT_RECENT_LIMIT, 0, 50),
416
654
  downloadAttachmentsDir: resolveInboundAttachmentDir(config.workDir, secret),
417
- cloudOcrUrl: typeof secret.cloudOcrUrl === 'string' ? secret.cloudOcrUrl : undefined,
418
- cloudOcrToken: typeof secret.cloudOcrToken === 'string' ? secret.cloudOcrToken : undefined,
419
- cloudOcrMode: normalizeCloudOcrMode(secret.cloudOcrMode),
420
- cloudOcrChannelId: config.id,
655
+ cloudOcrMode: 'off',
421
656
  });
422
657
  onFlow?.(flow);
423
658
  if (flow.interrupted)
424
659
  continue;
425
660
  for (const message of flow.newMessages ?? []) {
426
- const text = String(message.text || '').trim();
427
- const attachments = Array.isArray(message.attachments) ? message.attachments : [];
428
- if (!text && attachments.length === 0)
429
- continue;
430
- result.push({
431
- conversationName: name,
432
- senderName: null,
433
- text,
434
- attachments: annotateWeChatRpaInboundAttachments(attachments),
435
- observedAt: new Date().toISOString(),
436
- rawId: String(message.id || `${name}:${text}`),
437
- });
661
+ const observed = flowMessageToObservedMessage(name, message, secret);
662
+ if (observed)
663
+ result.push(observed);
438
664
  }
439
665
  }
440
666
  return result;
441
667
  }
442
- async function runSendFlow(config, secret, conversationName, text, attachmentPath) {
668
+ function flowMessageToObservedMessage(conversationName, message, secret) {
669
+ const text = String(message.text || '').trim();
670
+ const attachments = Array.isArray(message.attachments) ? message.attachments : [];
671
+ if (!text && attachments.length === 0)
672
+ return null;
673
+ const senderName = typeof message.senderName === 'string' ? message.senderName.trim() : '';
674
+ const observedAt = String(message.observedAt || message.timestampIso || '').trim() || new Date().toISOString();
675
+ return {
676
+ conversationName,
677
+ senderName: senderName || null,
678
+ text,
679
+ attachments: annotateWeChatRpaInboundAttachments(attachments),
680
+ observedAt,
681
+ isMentioned: message.isMentioned === true || isWeChatRpaTextMentioned(text, wechatRpaSelfNicknames(secret)),
682
+ rawId: String(message.id || `${conversationName}:${senderName}:${text}:${observedAt}`),
683
+ };
684
+ }
685
+ async function runSendFlow(config, secret, conversationName, text, attachmentPath, dedupeKey) {
443
686
  if (secret.source === 'windows-visual-flow') {
444
687
  return runWindowsWeChatRpaVisualFlow({
445
688
  groupName: conversationName,
@@ -449,12 +692,12 @@ async function runSendFlow(config, secret, conversationName, text, attachmentPat
449
692
  workDir: config.workDir,
450
693
  recentLimit: clampNumber(secret.recentLimit, DEFAULT_RECENT_LIMIT, 0, 50),
451
694
  downloadAttachmentsDir: resolveInboundAttachmentDir(config.workDir, secret),
452
- cloudOcrUrl: typeof secret.cloudOcrUrl === 'string' ? secret.cloudOcrUrl : undefined,
453
- cloudOcrToken: typeof secret.cloudOcrToken === 'string' ? secret.cloudOcrToken : undefined,
454
- cloudOcrMode: normalizeCloudOcrMode(secret.cloudOcrMode),
455
- cloudOcrChannelId: config.id,
695
+ cloudOcrMode: 'off',
456
696
  });
457
697
  }
698
+ if (secret.source === 'wechat-rpa-lab') {
699
+ return runLabSendFlow(config, secret, conversationName, text, attachmentPath, dedupeKey);
700
+ }
458
701
  return runMacWeChatRpaFlow({
459
702
  groupName: conversationName,
460
703
  replyText: text || undefined,
@@ -466,14 +709,11 @@ async function runSendFlow(config, secret, conversationName, text, attachmentPat
466
709
  idleSeconds: clampNumber(secret.idleSeconds, 15, 0, 3600),
467
710
  recentLimit: clampNumber(secret.recentLimit, DEFAULT_RECENT_LIMIT, 0, 50),
468
711
  downloadAttachmentsDir: resolveInboundAttachmentDir(config.workDir, secret),
469
- cloudOcrUrl: typeof secret.cloudOcrUrl === 'string' ? secret.cloudOcrUrl : undefined,
470
- cloudOcrToken: typeof secret.cloudOcrToken === 'string' ? secret.cloudOcrToken : undefined,
471
- cloudOcrMode: normalizeCloudOcrMode(secret.cloudOcrMode),
472
- cloudOcrChannelId: config.id,
712
+ cloudOcrMode: 'off',
473
713
  });
474
714
  }
475
715
  function isFlowSource(source) {
476
- return source === 'macos-flow' || source === 'windows-visual-flow';
716
+ return source === 'macos-flow' || source === 'windows-visual-flow' || source === 'wechat-rpa-lab';
477
717
  }
478
718
  function applyPartialSendProgress(flow, input) {
479
719
  if (input.text && input.attachmentPath && flow.sentReplyObserved && !flow.sentAttachmentObserved) {
@@ -516,6 +756,20 @@ function enqueuePendingReply(conn, input) {
516
756
  });
517
757
  persistPendingReplyState(conn);
518
758
  }
759
+ function noteManualReview(conn, key, reason) {
760
+ conn.manualReviewReplies.set(key, {
761
+ key,
762
+ reason,
763
+ createdAt: conn.manualReviewReplies.get(key)?.createdAt ?? new Date().toISOString(),
764
+ });
765
+ conn.lastError = reason;
766
+ addTaskSummary(conn, {
767
+ status: 'failed',
768
+ runId: conn.lastRunId ?? null,
769
+ summary: `manual review required: ${reason}`,
770
+ });
771
+ persistPendingReplyState(conn);
772
+ }
519
773
  function hydratePendingReplyState(conn, config) {
520
774
  const filePath = pendingReplyStatePath(config);
521
775
  if (conn.pendingStatePath === filePath)
@@ -528,6 +782,12 @@ function hydratePendingReplyState(conn, config) {
528
782
  if (!conn.pendingReplies.has(pending.key))
529
783
  conn.pendingReplies.set(pending.key, pending);
530
784
  }
785
+ for (const item of store.manualReview ?? []) {
786
+ if (!isManualReviewRecord(item))
787
+ continue;
788
+ if (!conn.manualReviewReplies.has(item.key))
789
+ conn.manualReviewReplies.set(item.key, item);
790
+ }
531
791
  for (const key of store.completedKeys ?? []) {
532
792
  if (typeof key === 'string' && key)
533
793
  conn.completedPendingReplyKeys.add(key);
@@ -538,9 +798,10 @@ function persistPendingReplyState(conn) {
538
798
  return;
539
799
  const completedKeys = Array.from(conn.completedPendingReplyKeys).slice(-500);
540
800
  const pending = Array.from(conn.pendingReplies.values()).slice(0, 500);
801
+ const manualReview = Array.from(conn.manualReviewReplies.values()).slice(-500);
541
802
  try {
542
803
  fs.mkdirSync(path.dirname(conn.pendingStatePath), { recursive: true });
543
- fs.writeFileSync(conn.pendingStatePath, JSON.stringify({ version: 1, pending, completedKeys }, null, 2));
804
+ fs.writeFileSync(conn.pendingStatePath, JSON.stringify({ version: 1, pending, manualReview, completedKeys }, null, 2));
544
805
  }
545
806
  catch {
546
807
  // Runtime state is best-effort; the in-memory queue still protects the current daemon.
@@ -569,6 +830,14 @@ function isPendingReplyRecord(value) {
569
830
  && (record.attachmentPath === undefined || typeof record.attachmentPath === 'string')
570
831
  && (record.skipText === undefined || typeof record.skipText === 'boolean');
571
832
  }
833
+ function isManualReviewRecord(value) {
834
+ if (!value || typeof value !== 'object')
835
+ return false;
836
+ const record = value;
837
+ return typeof record.key === 'string'
838
+ && typeof record.reason === 'string'
839
+ && typeof record.createdAt === 'string';
840
+ }
572
841
  function pendingReplyStatePath(config) {
573
842
  const id = crypto.createHash('sha256').update(config.id).digest('hex').slice(0, 16);
574
843
  return path.join(config.workDir, '.shennian', 'wechat-rpa-pending-replies', `${id}.json`);
@@ -615,6 +884,11 @@ function noteInterruption(conn, flow, reason) {
615
884
  conn.runtimeState = 'cooldown';
616
885
  }
617
886
  recordCloudOcrRuntime(conn, flow);
887
+ addTaskSummary(conn, {
888
+ status: 'interrupted',
889
+ runId: flow.rpaRunId || conn.lastRunId || null,
890
+ summary: reason,
891
+ });
618
892
  void reason;
619
893
  }
620
894
  function noteSuccessfulRun(conn) {
@@ -622,6 +896,38 @@ function noteSuccessfulRun(conn) {
622
896
  conn.interruptionCooldownUntil = undefined;
623
897
  conn.runtimeState = 'idle_waiting';
624
898
  }
899
+ function recordTraceRuntime(conn, flow) {
900
+ const tracePath = flow.postSendScreenshotPath || flow.screenshotPath;
901
+ if (flow.rpaRunId)
902
+ conn.lastRunId = flow.rpaRunId;
903
+ if (tracePath)
904
+ conn.lastTracePath = tracePath;
905
+ conn.lastTraceSummary = flow.rpaTraceSummary || [
906
+ flow.groupName ? `group=${flow.groupName}` : '',
907
+ flow.ok === false ? 'failed' : flow.interrupted ? 'interrupted' : 'ok',
908
+ flow.reason ? `reason=${flow.reason}` : '',
909
+ ].filter(Boolean).join(' ');
910
+ }
911
+ function addTaskSummary(conn, summary) {
912
+ const runId = summary.runId || conn.lastRunId || `local:${Date.now().toString(36)}`;
913
+ conn.lastRunId = runId;
914
+ conn.recentTaskSummaries.unshift({
915
+ at: new Date().toISOString(),
916
+ status: summary.status,
917
+ runId,
918
+ summary: clipSummary(summary.summary),
919
+ });
920
+ conn.recentTaskSummaries = conn.recentTaskSummaries.slice(0, MAX_RECENT_TASK_SUMMARIES);
921
+ }
922
+ function classifyWeChatRpaFailure(message) {
923
+ const value = String(message || '').toLowerCase();
924
+ return /permission|accessibility|screen recording|automation|window|foreground|target group|refusing|requires|安全|权限|窗口|目标群/.test(value)
925
+ ? 'blocked'
926
+ : 'failed';
927
+ }
928
+ function clipSummary(value) {
929
+ return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 180);
930
+ }
625
931
  function isInterruptionCooldownActive(conn, secret) {
626
932
  if (secret.forceForeground)
627
933
  return false;
@@ -735,25 +1041,10 @@ function clampNumber(value, fallback, min, max) {
735
1041
  return fallback;
736
1042
  return Math.min(max, Math.max(min, number));
737
1043
  }
738
- function normalizeCloudOcrMode(value) {
739
- return value === 'fallback' || value === 'always' ? value : 'off';
740
- }
741
1044
  function recordCloudOcrRuntime(conn, flow) {
742
- if (!flow.cloudOcrPurpose && !flow.cloudOcrRequestId && !flow.cloudOcrUsage)
743
- return;
744
- conn.lastCloudOcrAt = new Date().toISOString();
745
- conn.lastCloudOcrPurpose = flow.cloudOcrPurpose;
746
- conn.lastCloudOcrRequestId = flow.cloudOcrRequestId;
747
- conn.lastCloudOcrImageHash = flow.cloudOcrImageHash;
748
- conn.lastCloudOcrUsage = normalizeCloudOcrUsage(flow.cloudOcrUsage);
749
- }
750
- function normalizeCloudOcrUsage(value) {
751
- if (!value)
752
- return undefined;
753
- const usage = {
754
- ...(Number.isFinite(value.inputTokens) ? { inputTokens: Number(value.inputTokens) } : {}),
755
- ...(Number.isFinite(value.outputTokens) ? { outputTokens: Number(value.outputTokens) } : {}),
756
- ...(Number.isFinite(value.totalTokens) ? { totalTokens: Number(value.totalTokens) } : {}),
757
- };
758
- return Object.keys(usage).length ? usage : undefined;
1045
+ void conn;
1046
+ void flow;
1047
+ // WeChat RPA production routing is local OCR only. Legacy cloud OCR payload
1048
+ // fields may still appear in old fixtures, but the channel must not publish
1049
+ // them as runtime status or invite a server OCR dependency back in.
759
1050
  }