shennian 0.2.78 → 0.2.84
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.
- package/dist/scripts/wechat-rpa-win-visual.mjs +1155 -127
- package/dist/scripts/wechat-rpa-win.mjs +227 -1
- package/dist/src/agents/external-channel-instructions.js +1 -3
- package/dist/src/channels/base.d.ts +9 -2
- package/dist/src/channels/runtime.d.ts +2 -1
- package/dist/src/channels/runtime.js +16 -32
- package/dist/src/channels/secret-registry.d.ts +3 -1
- package/dist/src/channels/wechat-rpa/macos-flow.d.ts +6 -5
- package/dist/src/channels/wechat-rpa/macos-flow.js +7 -80
- package/dist/src/channels/wechat-rpa/normalizer.d.ts +5 -1
- package/dist/src/channels/wechat-rpa/normalizer.js +14 -1
- package/dist/src/channels/wechat-rpa/windows-visual-flow.d.ts +4 -0
- package/dist/src/channels/wechat-rpa/windows-visual-flow.js +13 -6
- package/dist/src/channels/wechat-rpa.d.ts +12 -5
- package/dist/src/channels/wechat-rpa.js +362 -71
- package/dist/src/commands/daemon.d.ts +13 -2
- package/dist/src/commands/daemon.js +175 -14
- package/dist/src/commands/manager.d.ts +1 -1
- package/dist/src/commands/manager.js +13 -10
- package/dist/src/index.js +64 -39
- package/dist/src/manager/runtime.js +35 -3
- package/dist/src/native-fusion/opencode-parser.js +2 -0
- package/dist/src/native-fusion/parsers.js +15 -0
- package/dist/src/native-fusion/service.js +3 -23
- package/package.json +3 -3
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
}
|