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.
- package/dist/scripts/wechat-rpa-confirmation.mjs +97 -0
- package/dist/scripts/wechat-rpa-download-candidates.mjs +84 -0
- package/dist/scripts/wechat-rpa-win-visual.mjs +668 -0
- package/dist/scripts/wechat-rpa-win.mjs +119 -0
- package/dist/src/agents/command-spec.js +24 -2
- package/dist/src/agents/external-channel-instructions.js +2 -0
- package/dist/src/channels/base.d.ts +1 -0
- package/dist/src/channels/runtime.d.ts +9 -2
- package/dist/src/channels/runtime.js +56 -6
- package/dist/src/channels/secret-registry.d.ts +1 -1
- package/dist/src/channels/wechat-rpa/macos-flow.d.ts +5 -0
- package/dist/src/channels/wechat-rpa/macos-flow.js +50 -2
- package/dist/src/channels/wechat-rpa/windows-visual-flow.d.ts +36 -0
- package/dist/src/channels/wechat-rpa/windows-visual-flow.js +182 -0
- package/dist/src/channels/wechat-rpa.d.ts +13 -16
- package/dist/src/channels/wechat-rpa.js +149 -14
- package/dist/src/commands/external.js +10 -1
- package/dist/src/commands/manager.js +18 -0
- package/dist/src/manager/runtime.js +23 -6
- package/dist/src/session/handlers/chat.js +12 -7
- package/dist/src/session/queue.js +2 -0
- package/dist/src/session/types.d.ts +1 -0
- package/package.json +10 -9
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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')
|