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.
- package/dist/scripts/wechat-rpa-confirmation.mjs +97 -0
- package/dist/scripts/wechat-rpa-download-candidates.mjs +105 -0
- package/dist/scripts/wechat-rpa-win-visual.mjs +707 -0
- package/dist/scripts/wechat-rpa-win.mjs +126 -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/normalizer.d.ts +3 -0
- package/dist/src/channels/wechat-rpa/normalizer.js +16 -1
- 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 +186 -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/dist/src/upgrade/engine.d.ts +1 -0
- package/dist/src/upgrade/engine.js +10 -1
- package/package.json +5 -5
|
@@ -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;
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
});
|