shennian 0.2.75 → 0.2.76

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.
@@ -0,0 +1,182 @@
1
+ // @arch docs/features/wechat-rpa-channel.md
2
+ // @test src/__tests__/wechat-rpa-normalizer.test.ts
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { execFile } from 'node:child_process';
8
+ import { promisify } from 'node:util';
9
+ const execFileAsync = promisify(execFile);
10
+ export function buildWindowsVisualFlowExec(options) {
11
+ const scriptPath = resolveWindowsVisualScriptPath(options.scriptPath, options.workDir);
12
+ const args = [
13
+ scriptPath,
14
+ '--group',
15
+ options.groupName,
16
+ '--recent-limit',
17
+ String(Number.isFinite(options.recentLimit) ? options.recentLimit : 5),
18
+ ];
19
+ if (options.replyText)
20
+ args.push('--reply-text', options.replyText);
21
+ const attachmentPaths = normalizeAttachmentPaths(options);
22
+ for (const attachmentPath of attachmentPaths)
23
+ args.push('--file', attachmentPath);
24
+ if (options.downloadAttachmentsDir)
25
+ args.push('--download-attachments-dir', options.downloadAttachmentsDir);
26
+ if (options.cloudOcrUrl)
27
+ args.push('--ocr-url', options.cloudOcrUrl);
28
+ if (options.cloudOcrToken)
29
+ args.push('--token', options.cloudOcrToken);
30
+ if (options.cloudOcrChannelId)
31
+ args.push('--channel-id', options.cloudOcrChannelId);
32
+ if (options.helperPath)
33
+ args.push('--helper', options.helperPath);
34
+ if (options.ocrFixturePath)
35
+ args.push('--ocr-fixture', options.ocrFixturePath);
36
+ return {
37
+ command: process.execPath,
38
+ args,
39
+ execOptions: {
40
+ cwd: options.workDir || process.cwd(),
41
+ timeout: options.timeoutMs ?? 120_000,
42
+ maxBuffer: 20 * 1024 * 1024,
43
+ windowsHide: true,
44
+ },
45
+ };
46
+ }
47
+ export async function runWindowsWeChatRpaVisualFlow(options) {
48
+ if (process.platform !== 'win32') {
49
+ throw new Error('WeChat RPA Windows visual flow can only run on Windows');
50
+ }
51
+ const exec = buildWindowsVisualFlowExec(options);
52
+ let stdout = '';
53
+ let stderr = '';
54
+ try {
55
+ const result = await execFileAsync(exec.command, exec.args, exec.execOptions);
56
+ stdout = String(result.stdout);
57
+ stderr = String(result.stderr);
58
+ }
59
+ catch (error) {
60
+ const execError = error;
61
+ stdout = execError.stdout ? String(execError.stdout) : '';
62
+ stderr = execError.stderr ? String(execError.stderr) : '';
63
+ const parsed = parseWindowsVisualStdout(stdout);
64
+ if (parsed)
65
+ return windowsSummaryToFlowResult(options, parsed);
66
+ throw new Error(stderr.trim() || stdout.slice(0, 1_000) || execError.message || 'WeChat RPA Windows visual flow failed');
67
+ }
68
+ const parsed = parseWindowsVisualStdout(stdout);
69
+ if (!parsed)
70
+ throw new Error(stderr.trim() || stdout.slice(0, 1_000) || 'WeChat RPA Windows visual flow returned invalid JSON');
71
+ const flow = windowsSummaryToFlowResult(options, parsed);
72
+ if (!flow.ok)
73
+ throw new Error(flow.error || 'WeChat RPA Windows visual flow failed');
74
+ return flow;
75
+ }
76
+ function parseWindowsVisualStdout(stdout) {
77
+ try {
78
+ return JSON.parse(stdout);
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ export function windowsSummaryToFlowResult(options, summary) {
85
+ const recentMessages = observationsToMessages(options.groupName, summary.recentMessages);
86
+ const sentText = Boolean(options.replyText && summary.sent?.some(item => item.type === 'text'));
87
+ const attachmentPaths = normalizeAttachmentPaths(options);
88
+ const sentAttachment = Boolean(attachmentPaths.length > 0 && summary.sent?.some(item => item.type === 'files'));
89
+ const postSendScreenshotPath = selectPostSendScreenshot(summary.artifacts);
90
+ return {
91
+ ok: Boolean(summary.ok),
92
+ groupName: summary.group || options.groupName,
93
+ screenshotPath: selectReadScreenshot(summary.artifacts),
94
+ recentMessages,
95
+ newMessages: recentMessages,
96
+ sentReply: Boolean(options.replyText),
97
+ sentReplyObserved: sentText,
98
+ sentAttachment: attachmentPaths.length > 0,
99
+ sentAttachmentObserved: sentAttachment,
100
+ ...(postSendScreenshotPath ? { postSendScreenshotPath } : {}),
101
+ ...(summary.error ? { error: summary.error } : {}),
102
+ };
103
+ }
104
+ function normalizeAttachmentPaths(options) {
105
+ const result = [];
106
+ if (options.attachmentPath)
107
+ result.push(options.attachmentPath);
108
+ for (const item of options.attachmentPaths || []) {
109
+ if (item && !result.includes(item))
110
+ result.push(item);
111
+ }
112
+ return result;
113
+ }
114
+ function observationsToMessages(groupName, observations) {
115
+ const messages = [];
116
+ for (const item of Array.isArray(observations) ? observations : []) {
117
+ const attachment = normalizeAttachment(item.attachment);
118
+ if (!shouldIncludeObservation(item, attachment))
119
+ continue;
120
+ const rawText = String(item.text || '').trim();
121
+ const text = attachment && shouldUseAttachmentNameAsMessageText(rawText, attachment) ? attachment.name || rawText : rawText;
122
+ if (!text && !attachment)
123
+ continue;
124
+ messages.push({
125
+ id: `win:${stableId(`${groupName}\n${text}\n${attachment?.name || ''}`)}`,
126
+ text,
127
+ confidence: item.confidence,
128
+ attachments: attachment ? [attachment] : [],
129
+ });
130
+ }
131
+ return messages;
132
+ }
133
+ function shouldUseAttachmentNameAsMessageText(text, attachment) {
134
+ const name = String(attachment.name || '').trim();
135
+ if (!name)
136
+ return false;
137
+ if (/^\[(图片|视频|语音|文件)\]$/u.test(name))
138
+ return true;
139
+ return Boolean(text && !text.includes(name) && (attachment.type === 'image' || attachment.type === 'video' || attachment.type === 'audio'));
140
+ }
141
+ function shouldIncludeObservation(item, attachment) {
142
+ const role = String(item.role || '').toLowerCase();
143
+ if (role === 'title' || role === 'sender' || role === 'timestamp' || role === 'system')
144
+ return false;
145
+ return Boolean(String(item.text || '').trim() || attachment);
146
+ }
147
+ function normalizeAttachment(value) {
148
+ if (!value || typeof value !== 'object')
149
+ return null;
150
+ const type = String(value.type || '').trim();
151
+ if (!type)
152
+ return null;
153
+ return {
154
+ ...value,
155
+ type,
156
+ availability: value.availability || (value.localPath ? 'edge-local' : 'metadata-only'),
157
+ };
158
+ }
159
+ function selectReadScreenshot(artifacts) {
160
+ return artifacts?.find(item => /messages-before-send\.png$/i.test(item)) || artifacts?.find(item => /opened-conversation\.png$/i.test(item));
161
+ }
162
+ function selectPostSendScreenshot(artifacts) {
163
+ return artifacts?.find(item => /after-file-send\.png$/i.test(item)) || artifacts?.find(item => /after-text-send\.png$/i.test(item));
164
+ }
165
+ function resolveWindowsVisualScriptPath(scriptPath, workDir) {
166
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
167
+ const candidates = [
168
+ scriptPath,
169
+ process.env.SHENNIAN_WECHAT_RPA_WIN_VISUAL_SCRIPT,
170
+ path.resolve(moduleDir, '../../../scripts/wechat-rpa-win-visual.mjs'),
171
+ workDir ? path.join(workDir, 'scripts/wechat-rpa-win-visual.mjs') : '',
172
+ path.resolve(process.cwd(), 'scripts/wechat-rpa-win-visual.mjs'),
173
+ ].filter(Boolean);
174
+ const found = candidates.find((candidate) => fs.existsSync(candidate));
175
+ if (!found) {
176
+ throw new Error('WeChat RPA Windows visual script is missing; set SHENNIAN_WECHAT_RPA_WIN_VISUAL_SCRIPT or channel flowScriptPath');
177
+ }
178
+ return path.resolve(found);
179
+ }
180
+ function stableId(value) {
181
+ return crypto.createHash('sha256').update(value).digest('hex').slice(0, 24);
182
+ }
@@ -1,29 +1,18 @@
1
- import type { ExternalChannelAdapter, ExternalChannelConfig, ExternalChannelRuntimeStatus, ExternalMessageAttachment, ExternalReply } from './base.js';
1
+ import type { ExternalChannelAdapter, ExternalChannelConfig, ExternalChannelRuntimeStatus, ExternalMessageEvent, ExternalReply } from './base.js';
2
2
  import { type WeChatRpaObservedMessage } from './wechat-rpa/normalizer.js';
3
- type WeChatRpaEvent = {
3
+ type WeChatRpaEvent = ExternalMessageEvent & {
4
+ type: 'external.message';
4
5
  managerSessionId: string;
5
- channelId: string;
6
- channelType: 'wechat-rpa';
7
- conversationId: string;
8
- messageId: string;
9
- sender: {
10
- id: string;
11
- name?: string | null;
12
- };
13
- text: string;
14
- attachments: ExternalMessageAttachment[];
15
- receivedAt: string;
16
- replyTarget: string;
17
- rawRef?: string | null;
18
6
  };
19
7
  export declare class WeChatRpaChannelAdapter implements ExternalChannelAdapter {
20
8
  private onMessage?;
21
9
  readonly type: "wechat-rpa";
22
10
  private secrets;
23
11
  private connections;
24
- constructor(onMessage?: ((event: WeChatRpaEvent) => void) | undefined);
12
+ constructor(onMessage?: ((event: WeChatRpaEvent) => ExternalMessageEvent | void) | undefined);
25
13
  connect(config: ExternalChannelConfig): Promise<void>;
26
14
  disconnect(config: ExternalChannelConfig): Promise<void>;
15
+ syncNow(config: ExternalChannelConfig): Promise<ExternalMessageEvent[]>;
27
16
  send(config: ExternalChannelConfig, reply: ExternalReply): Promise<{
28
17
  status: 'sent' | 'queued';
29
18
  reason?: string;
@@ -46,6 +35,14 @@ export declare class WeChatRpaChannelAdapter implements ExternalChannelAdapter {
46
35
  private seedConfiguredConversations;
47
36
  private resolveConversationName;
48
37
  }
38
+ export declare function windowsVisualFlowHealth(secret: {
39
+ groups?: Array<{
40
+ name: string;
41
+ }>;
42
+ }, platform?: NodeJS.Platform): {
43
+ ok: boolean;
44
+ message?: string;
45
+ };
49
46
  export declare function materializeWeChatRpaOutboundAttachment(workDir: string, attachment: NonNullable<ExternalReply['attachment']>): Promise<string>;
50
47
  export declare function annotateWeChatRpaInboundAttachments(attachments: WeChatRpaObservedMessage['attachments']): WeChatRpaObservedMessage['attachments'];
51
48
  export {};
@@ -1,11 +1,13 @@
1
1
  // @arch docs/features/wechat-rpa-channel.md
2
2
  // @test src/__tests__/wechat-rpa-normalizer.test.ts
3
+ // @test src/__tests__/wechat-rpa-health.test.ts
3
4
  import crypto from 'node:crypto';
4
5
  import fs from 'node:fs';
5
6
  import path from 'node:path';
6
7
  import { ChannelSecretRegistry } from './secret-registry.js';
7
8
  import { probeMacWeChat, observedMessageFromProbe } from './wechat-rpa/macos.js';
8
9
  import { runMacWeChatRpaFlow } from './wechat-rpa/macos-flow.js';
10
+ import { runWindowsWeChatRpaVisualFlow } from './wechat-rpa/windows-visual-flow.js';
9
11
  import { normalizeWeChatRpaMessage, WeChatRpaDeduper, weChatRpaConversationId, } from './wechat-rpa/normalizer.js';
10
12
  const DEFAULT_POLL_INTERVAL_MS = 5_000;
11
13
  const DEFAULT_RECENT_LIMIT = 5;
@@ -46,12 +48,23 @@ export class WeChatRpaChannelAdapter {
46
48
  clearInterval(conn.timer);
47
49
  this.connections.delete(config.id);
48
50
  }
51
+ async syncNow(config) {
52
+ if (!config.enabled)
53
+ throw new Error('WeChat RPA channel is disabled');
54
+ const secret = this.readSecret(config);
55
+ const conn = this.ensureConnection(config);
56
+ conn.stopped = false;
57
+ conn.config = config;
58
+ hydratePendingReplyState(conn, config);
59
+ this.seedConfiguredConversations(conn, secret);
60
+ return this.enqueueOperation(conn, () => this.pollOnce(conn, this.readSecret(conn.config)));
61
+ }
49
62
  async send(config, reply) {
50
63
  const secret = this.readSecret(config);
51
64
  if (secret.canReply === false)
52
65
  throw new Error('WeChat RPA channel does not allow replies');
53
- if ((secret.source ?? 'macos-probe') !== 'macos-flow') {
54
- throw new Error('WeChat RPA reply requires source=macos-flow');
66
+ if (!isFlowSource(secret.source)) {
67
+ throw new Error('WeChat RPA reply requires source=macos-flow or windows-visual-flow');
55
68
  }
56
69
  const conn = this.ensureConnection(config);
57
70
  conn.config = config;
@@ -94,6 +107,21 @@ export class WeChatRpaChannelAdapter {
94
107
  conn.lastError = error instanceof Error ? error.message : String(error);
95
108
  throw error;
96
109
  }
110
+ const partial = applyPartialSendProgress(flow, { text, attachmentPath });
111
+ if (partial.status === 'queued') {
112
+ const reason = partial.reason;
113
+ enqueuePendingReply(conn, {
114
+ key: pendingKey,
115
+ conversationId: reply.conversationId,
116
+ conversationName,
117
+ text: partial.text,
118
+ attachmentPath: partial.attachmentPath,
119
+ reason,
120
+ skipText: partial.skipText,
121
+ });
122
+ conn.lastError = reason;
123
+ return { status: 'queued', reason };
124
+ }
97
125
  if (flow.interrupted) {
98
126
  const reason = flow.error || 'WeChat RPA send was interrupted by user activity';
99
127
  noteInterruption(conn, flow, reason);
@@ -101,12 +129,15 @@ export class WeChatRpaChannelAdapter {
101
129
  key: pendingKey,
102
130
  conversationId: reply.conversationId,
103
131
  conversationName,
104
- text,
132
+ text: partial.text,
105
133
  attachmentPath,
106
134
  reason,
135
+ skipText: partial.skipText,
107
136
  });
108
137
  return { status: 'queued', reason };
109
138
  }
139
+ if (!flow.ok)
140
+ throw new Error(flow.error || 'WeChat RPA send failed');
110
141
  recordCloudOcrRuntime(conn, flow);
111
142
  noteSuccessfulRun(conn);
112
143
  conn.lastError = null;
@@ -125,6 +156,9 @@ export class WeChatRpaChannelAdapter {
125
156
  if (!configuredGroups(secret).length)
126
157
  return { ok: false, message: 'WeChat RPA macOS flow requires at least one group' };
127
158
  }
159
+ if (secret.source === 'windows-visual-flow') {
160
+ return windowsVisualFlowHealth(secret, process.platform);
161
+ }
128
162
  const probe = await probeMacWeChat();
129
163
  return probe.ok
130
164
  ? { ok: true, message: probe.wechatRunning ? 'WeChat detected' : 'WeChat is not running' }
@@ -189,16 +223,17 @@ export class WeChatRpaChannelAdapter {
189
223
  }
190
224
  async pollOnce(conn, secret) {
191
225
  if (conn.stopped)
192
- return;
226
+ return [];
227
+ const emitted = [];
193
228
  conn.lastRunAt = new Date().toISOString();
194
229
  try {
195
230
  if (isInterruptionCooldownActive(conn, secret)) {
196
231
  conn.runtimeState = 'cooldown';
197
- return;
232
+ return [];
198
233
  }
199
234
  const pendingInterrupted = await this.drainPendingReplies(conn, secret);
200
235
  if (pendingInterrupted)
201
- return;
236
+ return [];
202
237
  let interrupted = false;
203
238
  conn.runtimeState = 'syncing';
204
239
  const observed = await this.readObservedMessages(conn.config, secret, (flow) => {
@@ -215,7 +250,8 @@ export class WeChatRpaChannelAdapter {
215
250
  continue;
216
251
  conn.conversations.set(message.conversationId, message.conversationName);
217
252
  conn.lastMessageAt = message.receivedAt;
218
- this.onMessage?.({
253
+ const event = {
254
+ type: 'external.message',
219
255
  managerSessionId: conn.config.managerSessionId,
220
256
  channelId: conn.config.id,
221
257
  channelType: 'wechat-rpa',
@@ -227,10 +263,12 @@ export class WeChatRpaChannelAdapter {
227
263
  receivedAt: message.receivedAt,
228
264
  replyTarget: '',
229
265
  rawRef: message.rawRef,
230
- });
266
+ };
267
+ emitted.push(this.onMessage?.(event) ?? event);
231
268
  }
232
269
  if (!interrupted)
233
270
  noteSuccessfulRun(conn);
271
+ return emitted;
234
272
  }
235
273
  catch (error) {
236
274
  conn.lastError = error instanceof Error ? error.message : String(error);
@@ -245,15 +283,33 @@ export class WeChatRpaChannelAdapter {
245
283
  pending.attempts += 1;
246
284
  pending.lastAttemptAt = new Date().toISOString();
247
285
  persistPendingReplyState(conn);
248
- const flow = await runSendFlow(conn.config, secret, pending.conversationName, pending.text, pending.attachmentPath);
286
+ const flow = await runSendFlow(conn.config, secret, pending.conversationName, pending.skipText ? '' : pending.text, pending.attachmentPath);
249
287
  recordCloudOcrRuntime(conn, flow);
288
+ const partial = applyPartialSendProgress(flow, {
289
+ text: pending.skipText ? '' : pending.text,
290
+ attachmentPath: pending.attachmentPath,
291
+ });
292
+ if (partial.status === 'queued') {
293
+ pending.text = partial.text;
294
+ pending.attachmentPath = partial.attachmentPath;
295
+ pending.skipText = partial.skipText;
296
+ pending.lastInterruptedReason = partial.reason;
297
+ conn.lastError = partial.reason;
298
+ persistPendingReplyState(conn);
299
+ return true;
300
+ }
250
301
  if (flow.interrupted) {
251
302
  const reason = flow.error || 'WeChat RPA pending send was interrupted by user activity';
303
+ pending.text = partial.text;
304
+ pending.attachmentPath = partial.attachmentPath;
305
+ pending.skipText = partial.skipText;
252
306
  pending.lastInterruptedReason = reason;
253
307
  noteInterruption(conn, flow, reason);
254
308
  persistPendingReplyState(conn);
255
309
  return true;
256
310
  }
311
+ if (!flow.ok)
312
+ throw new Error(flow.error || 'WeChat RPA pending send failed');
257
313
  conn.pendingReplies.delete(pending.key);
258
314
  conn.completedPendingReplyKeys.add(pending.key);
259
315
  persistPendingReplyState(conn);
@@ -269,6 +325,9 @@ export class WeChatRpaChannelAdapter {
269
325
  if (secret.source === 'macos-flow') {
270
326
  return readMacFlowMessages(config, secret, onFlow);
271
327
  }
328
+ if (secret.source === 'windows-visual-flow') {
329
+ return readWindowsVisualFlowMessages(config, secret, onFlow);
330
+ }
272
331
  const probe = await probeMacWeChat();
273
332
  const message = observedMessageFromProbe(probe);
274
333
  return message ? [message] : [];
@@ -296,6 +355,14 @@ function configuredGroups(secret) {
296
355
  ? secret.groups.map((group) => String(group?.name || '').trim()).filter(Boolean)
297
356
  : [];
298
357
  }
358
+ export function windowsVisualFlowHealth(secret, platform = process.platform) {
359
+ if (platform !== 'win32')
360
+ return { ok: false, message: 'WeChat RPA Windows visual flow requires Windows' };
361
+ if (!configuredGroups({ type: 'wechat-rpa', groups: secret.groups, updatedAt: '' }).length) {
362
+ return { ok: false, message: 'WeChat RPA Windows visual flow requires at least one group' };
363
+ }
364
+ return { ok: true, message: 'WeChat RPA Windows visual flow configured' };
365
+ }
299
366
  async function readMacFlowMessages(config, secret, onFlow) {
300
367
  const result = [];
301
368
  for (const name of configuredGroups(secret)) {
@@ -333,7 +400,56 @@ async function readMacFlowMessages(config, secret, onFlow) {
333
400
  }
334
401
  return result;
335
402
  }
403
+ async function readWindowsVisualFlowMessages(config, secret, onFlow) {
404
+ const result = [];
405
+ for (const name of configuredGroups(secret)) {
406
+ const flow = await runWindowsWeChatRpaVisualFlow({
407
+ groupName: name,
408
+ scriptPath: secret.flowScriptPath,
409
+ workDir: config.workDir,
410
+ recentLimit: clampNumber(secret.recentLimit, DEFAULT_RECENT_LIMIT, 0, 50),
411
+ downloadAttachmentsDir: resolveInboundAttachmentDir(config.workDir, secret),
412
+ cloudOcrUrl: typeof secret.cloudOcrUrl === 'string' ? secret.cloudOcrUrl : undefined,
413
+ cloudOcrToken: typeof secret.cloudOcrToken === 'string' ? secret.cloudOcrToken : undefined,
414
+ cloudOcrMode: normalizeCloudOcrMode(secret.cloudOcrMode),
415
+ cloudOcrChannelId: config.id,
416
+ });
417
+ onFlow?.(flow);
418
+ if (flow.interrupted)
419
+ continue;
420
+ for (const message of flow.newMessages ?? []) {
421
+ const text = String(message.text || '').trim();
422
+ const attachments = Array.isArray(message.attachments) ? message.attachments : [];
423
+ if (!text && attachments.length === 0)
424
+ continue;
425
+ result.push({
426
+ conversationName: name,
427
+ senderName: null,
428
+ text,
429
+ attachments: annotateWeChatRpaInboundAttachments(attachments),
430
+ observedAt: new Date().toISOString(),
431
+ rawId: String(message.id || `${name}:${text}`),
432
+ });
433
+ }
434
+ }
435
+ return result;
436
+ }
336
437
  async function runSendFlow(config, secret, conversationName, text, attachmentPath) {
438
+ if (secret.source === 'windows-visual-flow') {
439
+ return runWindowsWeChatRpaVisualFlow({
440
+ groupName: conversationName,
441
+ replyText: text || undefined,
442
+ attachmentPath,
443
+ scriptPath: secret.flowScriptPath,
444
+ workDir: config.workDir,
445
+ recentLimit: clampNumber(secret.recentLimit, DEFAULT_RECENT_LIMIT, 0, 50),
446
+ downloadAttachmentsDir: resolveInboundAttachmentDir(config.workDir, secret),
447
+ cloudOcrUrl: typeof secret.cloudOcrUrl === 'string' ? secret.cloudOcrUrl : undefined,
448
+ cloudOcrToken: typeof secret.cloudOcrToken === 'string' ? secret.cloudOcrToken : undefined,
449
+ cloudOcrMode: normalizeCloudOcrMode(secret.cloudOcrMode),
450
+ cloudOcrChannelId: config.id,
451
+ });
452
+ }
337
453
  return runMacWeChatRpaFlow({
338
454
  groupName: conversationName,
339
455
  replyText: text || undefined,
@@ -351,6 +467,26 @@ async function runSendFlow(config, secret, conversationName, text, attachmentPat
351
467
  cloudOcrChannelId: config.id,
352
468
  });
353
469
  }
470
+ function isFlowSource(source) {
471
+ return source === 'macos-flow' || source === 'windows-visual-flow';
472
+ }
473
+ function applyPartialSendProgress(flow, input) {
474
+ if (input.text && input.attachmentPath && flow.sentReplyObserved && !flow.sentAttachmentObserved) {
475
+ return {
476
+ status: flow.interrupted ? 'continue' : 'queued',
477
+ text: '',
478
+ attachmentPath: input.attachmentPath,
479
+ skipText: true,
480
+ reason: flow.error || 'WeChat RPA sent the text but did not confirm the attachment; queued attachment-only retry',
481
+ };
482
+ }
483
+ return {
484
+ status: 'continue',
485
+ text: input.text,
486
+ attachmentPath: input.attachmentPath,
487
+ reason: flow.error || '',
488
+ };
489
+ }
354
490
  function pendingReplyKey(config, reply) {
355
491
  if (reply.idempotencyKey)
356
492
  return reply.idempotencyKey;
@@ -367,6 +503,7 @@ function enqueuePendingReply(conn, input) {
367
503
  conversationName: input.conversationName,
368
504
  text: input.text,
369
505
  attachmentPath: input.attachmentPath,
506
+ skipText: input.skipText,
370
507
  queuedAt: existing?.queuedAt ?? new Date().toISOString(),
371
508
  attempts: existing?.attempts ?? 0,
372
509
  lastAttemptAt: existing?.lastAttemptAt,
@@ -424,7 +561,8 @@ function isPendingReplyRecord(value) {
424
561
  && typeof record.queuedAt === 'string'
425
562
  && typeof record.attempts === 'number'
426
563
  && Number.isFinite(record.attempts)
427
- && (record.attachmentPath === undefined || typeof record.attachmentPath === 'string');
564
+ && (record.attachmentPath === undefined || typeof record.attachmentPath === 'string')
565
+ && (record.skipText === undefined || typeof record.skipText === 'boolean');
428
566
  }
429
567
  function pendingReplyStatePath(config) {
430
568
  const id = crypto.createHash('sha256').update(config.id).digest('hex').slice(0, 16);
@@ -489,13 +627,10 @@ export async function materializeWeChatRpaOutboundAttachment(workDir, attachment
489
627
  throw new Error(`WeChat RPA local attachment does not exist: ${attachment.localPath}`);
490
628
  return attachment.localPath;
491
629
  }
492
- if (attachment.dataBase64) {
493
- return writeOutboundAttachmentBuffer(workDir, attachment, Buffer.from(attachment.dataBase64, 'base64'));
494
- }
495
630
  if (attachment.url) {
496
631
  return downloadOutboundAttachment(workDir, attachment);
497
632
  }
498
- throw new Error('WeChat RPA attachment requires dataBase64, localPath, or url');
633
+ throw new Error('WeChat RPA attachment requires localPath or url; dataBase64 is not accepted over Manager IPC');
499
634
  }
500
635
  async function downloadOutboundAttachment(workDir, attachment) {
501
636
  if (!attachment.url || !/^https?:\/\//i.test(attachment.url))
@@ -49,6 +49,7 @@ function processExists(pid) {
49
49
  }
50
50
  async function sendExternal(input) {
51
51
  const ctx = requireExternalContext(input.sessionId);
52
+ const replyTarget = input.replyTarget?.trim() || process.env.SHENNIAN_EXTERNAL_REPLY_TARGET || undefined;
52
53
  const response = await fetch(`${ctx.url}/external/reply`, {
53
54
  method: 'POST',
54
55
  headers: {
@@ -61,6 +62,7 @@ async function sendExternal(input) {
61
62
  text: input.text,
62
63
  attachment: input.attachment,
63
64
  idempotencyKey: input.idempotencyKey,
65
+ replyTarget,
64
66
  }),
65
67
  });
66
68
  const data = await response.json().catch(() => ({ ok: false, error: response.statusText }));
@@ -76,9 +78,10 @@ export function registerExternalCommand(program) {
76
78
  .description('Send a message to the external channel bound to this conversation')
77
79
  .requiredOption('--text <text>', 'Message text')
78
80
  .option('--session-id <id>', 'Shennian conversation/session id; defaults to injected current-session env')
81
+ .option('--reply-target <id>', 'Daemon-generated reply target; defaults to SHENNIAN_EXTERNAL_REPLY_TARGET or latest external message')
79
82
  .option('--idempotency-key <key>', 'Idempotency key')
80
83
  .action(async (opts) => {
81
- await sendExternal({ text: opts.text, idempotencyKey: opts.idempotencyKey, sessionId: opts.sessionId });
84
+ await sendExternal({ text: opts.text, replyTarget: opts.replyTarget, idempotencyKey: opts.idempotencyKey, sessionId: opts.sessionId });
82
85
  });
83
86
  external
84
87
  .command('send-image')
@@ -86,11 +89,13 @@ export function registerExternalCommand(program) {
86
89
  .requiredOption('--path <path>', 'Image file path')
87
90
  .option('--caption <text>', 'Optional text to send before the image')
88
91
  .option('--session-id <id>', 'Shennian conversation/session id; defaults to injected current-session env')
92
+ .option('--reply-target <id>', 'Daemon-generated reply target; defaults to SHENNIAN_EXTERNAL_REPLY_TARGET or latest external message')
89
93
  .option('--idempotency-key <key>', 'Idempotency key')
90
94
  .action(async (opts) => {
91
95
  await sendExternal({
92
96
  text: opts.caption,
93
97
  attachment: readExternalAttachment(opts.path, 'image'),
98
+ replyTarget: opts.replyTarget,
94
99
  idempotencyKey: opts.idempotencyKey,
95
100
  sessionId: opts.sessionId,
96
101
  });
@@ -101,11 +106,13 @@ export function registerExternalCommand(program) {
101
106
  .requiredOption('--path <path>', 'Video file path')
102
107
  .option('--caption <text>', 'Optional text to send before the video')
103
108
  .option('--session-id <id>', 'Shennian conversation/session id; defaults to injected current-session env')
109
+ .option('--reply-target <id>', 'Daemon-generated reply target; defaults to SHENNIAN_EXTERNAL_REPLY_TARGET or latest external message')
104
110
  .option('--idempotency-key <key>', 'Idempotency key')
105
111
  .action(async (opts) => {
106
112
  await sendExternal({
107
113
  text: opts.caption,
108
114
  attachment: readExternalAttachment(opts.path, 'video'),
115
+ replyTarget: opts.replyTarget,
109
116
  idempotencyKey: opts.idempotencyKey,
110
117
  sessionId: opts.sessionId,
111
118
  });
@@ -116,11 +123,13 @@ export function registerExternalCommand(program) {
116
123
  .requiredOption('--path <path>', 'File path')
117
124
  .option('--caption <text>', 'Optional text to send before the file')
118
125
  .option('--session-id <id>', 'Shennian conversation/session id; defaults to injected current-session env')
126
+ .option('--reply-target <id>', 'Daemon-generated reply target; defaults to SHENNIAN_EXTERNAL_REPLY_TARGET or latest external message')
119
127
  .option('--idempotency-key <key>', 'Idempotency key')
120
128
  .action(async (opts) => {
121
129
  await sendExternal({
122
130
  text: opts.caption,
123
131
  attachment: readExternalAttachment(opts.path, 'file'),
132
+ replyTarget: opts.replyTarget,
124
133
  idempotencyKey: opts.idempotencyKey,
125
134
  sessionId: opts.sessionId,
126
135
  });
@@ -219,6 +219,7 @@ export function registerManagerCommand(program) {
219
219
  .command('send')
220
220
  .description('Send a message to the Manager-bound external channel')
221
221
  .requiredOption('--text <text>', 'Message text')
222
+ .option('--reply-target <id>', 'Daemon-generated reply target; omitted means latest external message for this Manager')
222
223
  .option('--idempotency-key <key>', 'Idempotency key')
223
224
  .action(sendExternal);
224
225
  external
@@ -226,11 +227,13 @@ export function registerManagerCommand(program) {
226
227
  .description('Send an image file to the Manager-bound external channel')
227
228
  .requiredOption('--path <path>', 'Image file path')
228
229
  .option('--caption <text>', 'Optional text to send before the image')
230
+ .option('--reply-target <id>', 'Daemon-generated reply target; omitted means latest external message for this Manager')
229
231
  .option('--idempotency-key <key>', 'Idempotency key')
230
232
  .action(async (opts) => {
231
233
  await sendExternal({
232
234
  text: opts.caption,
233
235
  attachment: readExternalAttachment(opts.path, 'image'),
236
+ replyTarget: opts.replyTarget,
234
237
  idempotencyKey: opts.idempotencyKey,
235
238
  });
236
239
  });
@@ -239,11 +242,13 @@ export function registerManagerCommand(program) {
239
242
  .description('Send a video file to the Manager-bound external channel')
240
243
  .requiredOption('--path <path>', 'Video file path')
241
244
  .option('--caption <text>', 'Optional text to send before the video')
245
+ .option('--reply-target <id>', 'Daemon-generated reply target; omitted means latest external message for this Manager')
242
246
  .option('--idempotency-key <key>', 'Idempotency key')
243
247
  .action(async (opts) => {
244
248
  await sendExternal({
245
249
  text: opts.caption,
246
250
  attachment: readExternalAttachment(opts.path, 'video'),
251
+ replyTarget: opts.replyTarget,
247
252
  idempotencyKey: opts.idempotencyKey,
248
253
  });
249
254
  });
@@ -252,11 +257,13 @@ export function registerManagerCommand(program) {
252
257
  .description('Send a file to the Manager-bound external channel')
253
258
  .requiredOption('--path <path>', 'File path')
254
259
  .option('--caption <text>', 'Optional text to send before the file')
260
+ .option('--reply-target <id>', 'Daemon-generated reply target; omitted means latest external message for this Manager')
255
261
  .option('--idempotency-key <key>', 'Idempotency key')
256
262
  .action(async (opts) => {
257
263
  await sendExternal({
258
264
  text: opts.caption,
259
265
  attachment: readExternalAttachment(opts.path, 'file'),
266
+ replyTarget: opts.replyTarget,
260
267
  idempotencyKey: opts.idempotencyKey,
261
268
  });
262
269
  });
@@ -328,6 +335,17 @@ export function registerManagerCommand(program) {
328
335
  else
329
336
  printWeChatRpaStatus((result.channel ?? null));
330
337
  });
338
+ wechatRpa
339
+ .command('sync')
340
+ .description('Run one immediate WeChat RPA sync and print the updated runtime status')
341
+ .option('--json', 'Print JSON')
342
+ .action(async (opts) => {
343
+ const result = await ipc('/wechat-rpa/channel/sync', {});
344
+ if (opts.json)
345
+ printJson(result);
346
+ else
347
+ printWeChatRpaStatus((result.channel ?? null));
348
+ });
331
349
  wechatRpa
332
350
  .command('upsert')
333
351
  .description('Create or update a local WeChat RPA channel binding')