shennian 0.2.75 → 0.2.77

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;
@@ -28,6 +30,7 @@ export class WeChatRpaChannelAdapter {
28
30
  conn.stopped = false;
29
31
  conn.config = config;
30
32
  hydratePendingReplyState(conn, config);
33
+ hydrateMessageState(conn, config);
31
34
  this.seedConfiguredConversations(conn, secret);
32
35
  if (conn.timer)
33
36
  return;
@@ -46,16 +49,29 @@ export class WeChatRpaChannelAdapter {
46
49
  clearInterval(conn.timer);
47
50
  this.connections.delete(config.id);
48
51
  }
52
+ async syncNow(config) {
53
+ if (!config.enabled)
54
+ throw new Error('WeChat RPA channel is disabled');
55
+ const secret = this.readSecret(config);
56
+ const conn = this.ensureConnection(config);
57
+ conn.stopped = false;
58
+ conn.config = config;
59
+ hydratePendingReplyState(conn, config);
60
+ hydrateMessageState(conn, config);
61
+ this.seedConfiguredConversations(conn, secret);
62
+ return this.enqueueOperation(conn, () => this.pollOnce(conn, this.readSecret(conn.config)));
63
+ }
49
64
  async send(config, reply) {
50
65
  const secret = this.readSecret(config);
51
66
  if (secret.canReply === false)
52
67
  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');
68
+ if (!isFlowSource(secret.source)) {
69
+ throw new Error('WeChat RPA reply requires source=macos-flow or windows-visual-flow');
55
70
  }
56
71
  const conn = this.ensureConnection(config);
57
72
  conn.config = config;
58
73
  hydratePendingReplyState(conn, config);
74
+ hydrateMessageState(conn, config);
59
75
  this.seedConfiguredConversations(conn, secret);
60
76
  const conversationName = this.resolveConversationName(config, secret, reply.conversationId);
61
77
  if (!conversationName)
@@ -94,6 +110,21 @@ export class WeChatRpaChannelAdapter {
94
110
  conn.lastError = error instanceof Error ? error.message : String(error);
95
111
  throw error;
96
112
  }
113
+ const partial = applyPartialSendProgress(flow, { text, attachmentPath });
114
+ if (partial.status === 'queued') {
115
+ const reason = partial.reason;
116
+ enqueuePendingReply(conn, {
117
+ key: pendingKey,
118
+ conversationId: reply.conversationId,
119
+ conversationName,
120
+ text: partial.text,
121
+ attachmentPath: partial.attachmentPath,
122
+ reason,
123
+ skipText: partial.skipText,
124
+ });
125
+ conn.lastError = reason;
126
+ return { status: 'queued', reason };
127
+ }
97
128
  if (flow.interrupted) {
98
129
  const reason = flow.error || 'WeChat RPA send was interrupted by user activity';
99
130
  noteInterruption(conn, flow, reason);
@@ -101,12 +132,15 @@ export class WeChatRpaChannelAdapter {
101
132
  key: pendingKey,
102
133
  conversationId: reply.conversationId,
103
134
  conversationName,
104
- text,
135
+ text: partial.text,
105
136
  attachmentPath,
106
137
  reason,
138
+ skipText: partial.skipText,
107
139
  });
108
140
  return { status: 'queued', reason };
109
141
  }
142
+ if (!flow.ok)
143
+ throw new Error(flow.error || 'WeChat RPA send failed');
110
144
  recordCloudOcrRuntime(conn, flow);
111
145
  noteSuccessfulRun(conn);
112
146
  conn.lastError = null;
@@ -125,6 +159,9 @@ export class WeChatRpaChannelAdapter {
125
159
  if (!configuredGroups(secret).length)
126
160
  return { ok: false, message: 'WeChat RPA macOS flow requires at least one group' };
127
161
  }
162
+ if (secret.source === 'windows-visual-flow') {
163
+ return windowsVisualFlowHealth(secret, process.platform);
164
+ }
128
165
  const probe = await probeMacWeChat();
129
166
  return probe.ok
130
167
  ? { ok: true, message: probe.wechatRunning ? 'WeChat detected' : 'WeChat is not running' }
@@ -167,6 +204,7 @@ export class WeChatRpaChannelAdapter {
167
204
  pendingReplies: new Map(),
168
205
  completedPendingReplyKeys: new Set(),
169
206
  pendingStatePath: undefined,
207
+ messageStatePath: undefined,
170
208
  operation: Promise.resolve(),
171
209
  runtimeState: 'idle_waiting',
172
210
  consecutiveInterruptions: 0,
@@ -189,16 +227,17 @@ export class WeChatRpaChannelAdapter {
189
227
  }
190
228
  async pollOnce(conn, secret) {
191
229
  if (conn.stopped)
192
- return;
230
+ return [];
231
+ const emitted = [];
193
232
  conn.lastRunAt = new Date().toISOString();
194
233
  try {
195
234
  if (isInterruptionCooldownActive(conn, secret)) {
196
235
  conn.runtimeState = 'cooldown';
197
- return;
236
+ return [];
198
237
  }
199
238
  const pendingInterrupted = await this.drainPendingReplies(conn, secret);
200
239
  if (pendingInterrupted)
201
- return;
240
+ return [];
202
241
  let interrupted = false;
203
242
  conn.runtimeState = 'syncing';
204
243
  const observed = await this.readObservedMessages(conn.config, secret, (flow) => {
@@ -213,9 +252,11 @@ export class WeChatRpaChannelAdapter {
213
252
  const message = normalizeWeChatRpaMessage(item);
214
253
  if (!message || !conn.deduper.accept(message.messageId))
215
254
  continue;
255
+ persistMessageState(conn);
216
256
  conn.conversations.set(message.conversationId, message.conversationName);
217
257
  conn.lastMessageAt = message.receivedAt;
218
- this.onMessage?.({
258
+ const event = {
259
+ type: 'external.message',
219
260
  managerSessionId: conn.config.managerSessionId,
220
261
  channelId: conn.config.id,
221
262
  channelType: 'wechat-rpa',
@@ -227,10 +268,12 @@ export class WeChatRpaChannelAdapter {
227
268
  receivedAt: message.receivedAt,
228
269
  replyTarget: '',
229
270
  rawRef: message.rawRef,
230
- });
271
+ };
272
+ emitted.push(this.onMessage?.(event) ?? event);
231
273
  }
232
274
  if (!interrupted)
233
275
  noteSuccessfulRun(conn);
276
+ return emitted;
234
277
  }
235
278
  catch (error) {
236
279
  conn.lastError = error instanceof Error ? error.message : String(error);
@@ -245,15 +288,33 @@ export class WeChatRpaChannelAdapter {
245
288
  pending.attempts += 1;
246
289
  pending.lastAttemptAt = new Date().toISOString();
247
290
  persistPendingReplyState(conn);
248
- const flow = await runSendFlow(conn.config, secret, pending.conversationName, pending.text, pending.attachmentPath);
291
+ const flow = await runSendFlow(conn.config, secret, pending.conversationName, pending.skipText ? '' : pending.text, pending.attachmentPath);
249
292
  recordCloudOcrRuntime(conn, flow);
293
+ const partial = applyPartialSendProgress(flow, {
294
+ text: pending.skipText ? '' : pending.text,
295
+ attachmentPath: pending.attachmentPath,
296
+ });
297
+ if (partial.status === 'queued') {
298
+ pending.text = partial.text;
299
+ pending.attachmentPath = partial.attachmentPath;
300
+ pending.skipText = partial.skipText;
301
+ pending.lastInterruptedReason = partial.reason;
302
+ conn.lastError = partial.reason;
303
+ persistPendingReplyState(conn);
304
+ return true;
305
+ }
250
306
  if (flow.interrupted) {
251
307
  const reason = flow.error || 'WeChat RPA pending send was interrupted by user activity';
308
+ pending.text = partial.text;
309
+ pending.attachmentPath = partial.attachmentPath;
310
+ pending.skipText = partial.skipText;
252
311
  pending.lastInterruptedReason = reason;
253
312
  noteInterruption(conn, flow, reason);
254
313
  persistPendingReplyState(conn);
255
314
  return true;
256
315
  }
316
+ if (!flow.ok)
317
+ throw new Error(flow.error || 'WeChat RPA pending send failed');
257
318
  conn.pendingReplies.delete(pending.key);
258
319
  conn.completedPendingReplyKeys.add(pending.key);
259
320
  persistPendingReplyState(conn);
@@ -269,6 +330,9 @@ export class WeChatRpaChannelAdapter {
269
330
  if (secret.source === 'macos-flow') {
270
331
  return readMacFlowMessages(config, secret, onFlow);
271
332
  }
333
+ if (secret.source === 'windows-visual-flow') {
334
+ return readWindowsVisualFlowMessages(config, secret, onFlow);
335
+ }
272
336
  const probe = await probeMacWeChat();
273
337
  const message = observedMessageFromProbe(probe);
274
338
  return message ? [message] : [];
@@ -296,6 +360,14 @@ function configuredGroups(secret) {
296
360
  ? secret.groups.map((group) => String(group?.name || '').trim()).filter(Boolean)
297
361
  : [];
298
362
  }
363
+ export function windowsVisualFlowHealth(secret, platform = process.platform) {
364
+ if (platform !== 'win32')
365
+ return { ok: false, message: 'WeChat RPA Windows visual flow requires Windows' };
366
+ if (!configuredGroups({ type: 'wechat-rpa', groups: secret.groups, updatedAt: '' }).length) {
367
+ return { ok: false, message: 'WeChat RPA Windows visual flow requires at least one group' };
368
+ }
369
+ return { ok: true, message: 'WeChat RPA Windows visual flow configured' };
370
+ }
299
371
  async function readMacFlowMessages(config, secret, onFlow) {
300
372
  const result = [];
301
373
  for (const name of configuredGroups(secret)) {
@@ -333,7 +405,56 @@ async function readMacFlowMessages(config, secret, onFlow) {
333
405
  }
334
406
  return result;
335
407
  }
408
+ async function readWindowsVisualFlowMessages(config, secret, onFlow) {
409
+ const result = [];
410
+ for (const name of configuredGroups(secret)) {
411
+ const flow = await runWindowsWeChatRpaVisualFlow({
412
+ groupName: name,
413
+ scriptPath: secret.flowScriptPath,
414
+ workDir: config.workDir,
415
+ recentLimit: clampNumber(secret.recentLimit, DEFAULT_RECENT_LIMIT, 0, 50),
416
+ 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,
421
+ });
422
+ onFlow?.(flow);
423
+ if (flow.interrupted)
424
+ continue;
425
+ 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
+ });
438
+ }
439
+ }
440
+ return result;
441
+ }
336
442
  async function runSendFlow(config, secret, conversationName, text, attachmentPath) {
443
+ if (secret.source === 'windows-visual-flow') {
444
+ return runWindowsWeChatRpaVisualFlow({
445
+ groupName: conversationName,
446
+ replyText: text || undefined,
447
+ attachmentPath,
448
+ scriptPath: secret.flowScriptPath,
449
+ workDir: config.workDir,
450
+ recentLimit: clampNumber(secret.recentLimit, DEFAULT_RECENT_LIMIT, 0, 50),
451
+ 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,
456
+ });
457
+ }
337
458
  return runMacWeChatRpaFlow({
338
459
  groupName: conversationName,
339
460
  replyText: text || undefined,
@@ -351,6 +472,26 @@ async function runSendFlow(config, secret, conversationName, text, attachmentPat
351
472
  cloudOcrChannelId: config.id,
352
473
  });
353
474
  }
475
+ function isFlowSource(source) {
476
+ return source === 'macos-flow' || source === 'windows-visual-flow';
477
+ }
478
+ function applyPartialSendProgress(flow, input) {
479
+ if (input.text && input.attachmentPath && flow.sentReplyObserved && !flow.sentAttachmentObserved) {
480
+ return {
481
+ status: flow.interrupted ? 'continue' : 'queued',
482
+ text: '',
483
+ attachmentPath: input.attachmentPath,
484
+ skipText: true,
485
+ reason: flow.error || 'WeChat RPA sent the text but did not confirm the attachment; queued attachment-only retry',
486
+ };
487
+ }
488
+ return {
489
+ status: 'continue',
490
+ text: input.text,
491
+ attachmentPath: input.attachmentPath,
492
+ reason: flow.error || '',
493
+ };
494
+ }
354
495
  function pendingReplyKey(config, reply) {
355
496
  if (reply.idempotencyKey)
356
497
  return reply.idempotencyKey;
@@ -367,6 +508,7 @@ function enqueuePendingReply(conn, input) {
367
508
  conversationName: input.conversationName,
368
509
  text: input.text,
369
510
  attachmentPath: input.attachmentPath,
511
+ skipText: input.skipText,
370
512
  queuedAt: existing?.queuedAt ?? new Date().toISOString(),
371
513
  attempts: existing?.attempts ?? 0,
372
514
  lastAttemptAt: existing?.lastAttemptAt,
@@ -424,12 +566,45 @@ function isPendingReplyRecord(value) {
424
566
  && typeof record.queuedAt === 'string'
425
567
  && typeof record.attempts === 'number'
426
568
  && Number.isFinite(record.attempts)
427
- && (record.attachmentPath === undefined || typeof record.attachmentPath === 'string');
569
+ && (record.attachmentPath === undefined || typeof record.attachmentPath === 'string')
570
+ && (record.skipText === undefined || typeof record.skipText === 'boolean');
428
571
  }
429
572
  function pendingReplyStatePath(config) {
430
573
  const id = crypto.createHash('sha256').update(config.id).digest('hex').slice(0, 16);
431
574
  return path.join(config.workDir, '.shennian', 'wechat-rpa-pending-replies', `${id}.json`);
432
575
  }
576
+ function hydrateMessageState(conn, config) {
577
+ const filePath = messageStatePath(config);
578
+ if (conn.messageStatePath === filePath)
579
+ return;
580
+ conn.messageStatePath = filePath;
581
+ const store = readMessageSeenStore(filePath);
582
+ conn.deduper = new WeChatRpaDeduper((store.messageIds ?? []).filter((id) => typeof id === 'string' && id.length > 0));
583
+ }
584
+ function persistMessageState(conn) {
585
+ if (!conn.messageStatePath)
586
+ return;
587
+ try {
588
+ fs.mkdirSync(path.dirname(conn.messageStatePath), { recursive: true });
589
+ fs.writeFileSync(conn.messageStatePath, JSON.stringify({ version: 1, messageIds: conn.deduper.snapshot() }, null, 2));
590
+ }
591
+ catch {
592
+ // Best-effort only; in-memory dedupe still protects the current daemon.
593
+ }
594
+ }
595
+ function readMessageSeenStore(filePath) {
596
+ try {
597
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
598
+ return parsed && parsed.version === 1 ? parsed : { version: 1, messageIds: [] };
599
+ }
600
+ catch {
601
+ return { version: 1, messageIds: [] };
602
+ }
603
+ }
604
+ function messageStatePath(config) {
605
+ const id = crypto.createHash('sha256').update(config.id).digest('hex').slice(0, 16);
606
+ return path.join(config.workDir, '.shennian', 'wechat-rpa-seen-messages', `${id}.json`);
607
+ }
433
608
  function noteInterruption(conn, flow, reason) {
434
609
  conn.lastInterruptedAt = new Date().toISOString();
435
610
  conn.lastError = null;
@@ -489,13 +664,10 @@ export async function materializeWeChatRpaOutboundAttachment(workDir, attachment
489
664
  throw new Error(`WeChat RPA local attachment does not exist: ${attachment.localPath}`);
490
665
  return attachment.localPath;
491
666
  }
492
- if (attachment.dataBase64) {
493
- return writeOutboundAttachmentBuffer(workDir, attachment, Buffer.from(attachment.dataBase64, 'base64'));
494
- }
495
667
  if (attachment.url) {
496
668
  return downloadOutboundAttachment(workDir, attachment);
497
669
  }
498
- throw new Error('WeChat RPA attachment requires dataBase64, localPath, or url');
670
+ throw new Error('WeChat RPA attachment requires localPath or url; dataBase64 is not accepted over Manager IPC');
499
671
  }
500
672
  async function downloadOutboundAttachment(workDir, attachment) {
501
673
  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
  });