shennian 0.2.72 → 0.2.74
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/src/agents/command-spec.js +19 -12
- package/dist/src/agents/external-channel-instructions.d.ts +3 -1
- package/dist/src/agents/external-channel-instructions.js +73 -15
- package/dist/src/channels/base.d.ts +62 -9
- package/dist/src/channels/runtime.d.ts +43 -10
- package/dist/src/channels/runtime.js +300 -14
- package/dist/src/channels/secret-registry.d.ts +17 -1
- package/dist/src/channels/websocket.d.ts +3 -0
- package/dist/src/channels/websocket.js +39 -2
- package/dist/src/channels/wechat-rpa/macos-flow.d.ts +77 -0
- package/dist/src/channels/wechat-rpa/macos-flow.js +254 -0
- package/dist/src/channels/wechat-rpa/macos.d.ts +11 -0
- package/dist/src/channels/wechat-rpa/macos.js +63 -0
- package/dist/src/channels/wechat-rpa/normalizer.d.ts +42 -0
- package/dist/src/channels/wechat-rpa/normalizer.js +99 -0
- package/dist/src/channels/wechat-rpa.d.ts +51 -0
- package/dist/src/channels/wechat-rpa.js +587 -0
- package/dist/src/channels/wecom.d.ts +3 -0
- package/dist/src/channels/wecom.js +43 -1
- package/dist/src/commands/external-attachments.d.ts +1 -1
- package/dist/src/commands/external-attachments.js +2 -3
- package/dist/src/commands/external.js +19 -1
- package/dist/src/commands/manager.js +109 -0
- package/dist/src/manager/prompt.d.ts +1 -1
- package/dist/src/manager/prompt.js +1 -11
- package/dist/src/manager/runtime.d.ts +2 -10
- package/dist/src/manager/runtime.js +197 -33
- package/dist/src/native-fusion/service.js +7 -0
- package/dist/src/session/archive-zip.d.ts +10 -0
- package/dist/src/session/archive-zip.js +220 -0
- package/dist/src/session/handlers/agent-config.js +85 -6
- package/dist/src/session/handlers/chat.js +58 -2
- package/dist/src/session/handlers/fs.d.ts +1 -0
- package/dist/src/session/handlers/fs.js +57 -1
- package/dist/src/session/manager.js +4 -1
- package/package.json +10 -9
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// @arch docs/features/wechat-rpa-channel.md
|
|
2
|
+
// @test src/__tests__/wechat-rpa-normalizer.test.ts
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
export async function runMacWeChatRpaFlow(options) {
|
|
10
|
+
if (process.platform !== 'darwin') {
|
|
11
|
+
throw new Error('WeChat RPA macOS flow can only run on macOS');
|
|
12
|
+
}
|
|
13
|
+
const scriptPath = resolveFlowScriptPath(options.scriptPath, options.workDir);
|
|
14
|
+
const args = [
|
|
15
|
+
scriptPath,
|
|
16
|
+
'--group',
|
|
17
|
+
options.groupName,
|
|
18
|
+
'--idle-seconds',
|
|
19
|
+
String(Number.isFinite(options.idleSeconds) ? options.idleSeconds : 15),
|
|
20
|
+
];
|
|
21
|
+
if (options.forceForeground)
|
|
22
|
+
args.push('--force');
|
|
23
|
+
if (options.noRestore)
|
|
24
|
+
args.push('--no-restore');
|
|
25
|
+
if (options.replyText)
|
|
26
|
+
args.push('--reply-text', options.replyText);
|
|
27
|
+
if (options.attachmentPath)
|
|
28
|
+
args.push('--attachment-path', options.attachmentPath);
|
|
29
|
+
if (options.downloadAttachmentsDir)
|
|
30
|
+
args.push('--download-attachments-dir', options.downloadAttachmentsDir);
|
|
31
|
+
if (Number.isFinite(options.recentLimit) && Number(options.recentLimit) > 0) {
|
|
32
|
+
args.push('--recent-limit', String(options.recentLimit));
|
|
33
|
+
}
|
|
34
|
+
let stdout = '';
|
|
35
|
+
let stderr = '';
|
|
36
|
+
try {
|
|
37
|
+
const result = await execFileAsync(process.execPath, args, {
|
|
38
|
+
cwd: options.workDir || process.cwd(),
|
|
39
|
+
timeout: options.timeoutMs ?? 120_000,
|
|
40
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
41
|
+
});
|
|
42
|
+
stdout = result.stdout;
|
|
43
|
+
stderr = result.stderr;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
const execError = error;
|
|
47
|
+
stdout = execError.stdout || '';
|
|
48
|
+
stderr = execError.stderr || '';
|
|
49
|
+
const parsed = parseFlowStdout(stdout);
|
|
50
|
+
if (parsed?.interrupted)
|
|
51
|
+
return parsed;
|
|
52
|
+
throw new Error(stderr.trim() || stdout.slice(0, 1_000) || execError.message || 'WeChat RPA flow failed');
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(stdout);
|
|
56
|
+
if (parsed.interrupted)
|
|
57
|
+
return parsed;
|
|
58
|
+
if (!parsed.ok)
|
|
59
|
+
throw new Error(parsed.error || 'WeChat RPA flow failed');
|
|
60
|
+
return await maybeEnrichWithCloudOcr(parsed, options);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
const detail = stderr.trim() || stdout.slice(0, 1_000);
|
|
64
|
+
throw new Error(detail || (error instanceof Error ? error.message : String(error)));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function parseFlowStdout(stdout) {
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(stdout);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function maybeEnrichWithCloudOcr(result, options) {
|
|
76
|
+
const mode = options.cloudOcrMode ?? 'off';
|
|
77
|
+
const request = selectCloudOcrRequest(result, mode);
|
|
78
|
+
if (!request || !options.cloudOcrUrl || !options.cloudOcrToken)
|
|
79
|
+
return result;
|
|
80
|
+
const imageBase64 = fs.readFileSync(request.screenshotPath).toString('base64');
|
|
81
|
+
const response = await fetch(options.cloudOcrUrl, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: {
|
|
84
|
+
authorization: `Bearer ${options.cloudOcrToken}`,
|
|
85
|
+
'content-type': 'application/json',
|
|
86
|
+
},
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
imageBase64,
|
|
89
|
+
mimeType: 'image/png',
|
|
90
|
+
conversationName: options.groupName,
|
|
91
|
+
purpose: request.purpose,
|
|
92
|
+
channelId: options.cloudOcrChannelId,
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
if (!response.ok)
|
|
96
|
+
return result;
|
|
97
|
+
const payload = await response.json().catch(() => null);
|
|
98
|
+
const observations = Array.isArray(payload?.observations) ? payload.observations : [];
|
|
99
|
+
if (!observations.length)
|
|
100
|
+
return result;
|
|
101
|
+
const cloudMessages = messagesFromCloudOcrObservations(options.groupName, observations);
|
|
102
|
+
const recentLimit = Math.max(1, options.recentLimit || 5);
|
|
103
|
+
return {
|
|
104
|
+
...result,
|
|
105
|
+
cloudOcrPurpose: request.purpose,
|
|
106
|
+
cloudOcrObservations: observations,
|
|
107
|
+
...(payload?.requestId ? { cloudOcrRequestId: payload.requestId } : {}),
|
|
108
|
+
...(payload?.imageHash ? { cloudOcrImageHash: payload.imageHash } : {}),
|
|
109
|
+
...(payload?.usage ? { cloudOcrUsage: payload.usage } : {}),
|
|
110
|
+
newMessages: mergeCloudMessages(result.newMessages ?? [], cloudMessages),
|
|
111
|
+
recentMessages: mergeCloudMessages(result.recentMessages ?? [], cloudMessages).slice(-recentLimit),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
export function selectCloudOcrRequest(result, mode) {
|
|
115
|
+
if (!shouldUseCloudOcr(result, mode))
|
|
116
|
+
return null;
|
|
117
|
+
if ((result.sentReply || result.sentAttachment) && result.postSendScreenshotPath) {
|
|
118
|
+
return { screenshotPath: result.postSendScreenshotPath, purpose: 'send-confirmation' };
|
|
119
|
+
}
|
|
120
|
+
if (!result.screenshotPath)
|
|
121
|
+
return null;
|
|
122
|
+
const messages = [...(result.newMessages ?? []), ...(result.recentMessages ?? [])];
|
|
123
|
+
const hasUnresolvedAttachment = messages.some((message) => (message.attachments ?? []).some((attachment) => attachment.availability === 'metadata-only'
|
|
124
|
+
|| attachment.availability === 'pending-download'
|
|
125
|
+
|| Boolean(attachment.providerError)));
|
|
126
|
+
return {
|
|
127
|
+
screenshotPath: result.screenshotPath,
|
|
128
|
+
purpose: hasUnresolvedAttachment ? 'attachment-localization' : 'message-read',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
export function shouldUseCloudOcr(result, mode) {
|
|
132
|
+
if (mode === 'off')
|
|
133
|
+
return false;
|
|
134
|
+
if (mode === 'always')
|
|
135
|
+
return true;
|
|
136
|
+
const messages = [...(result.newMessages ?? []), ...(result.recentMessages ?? [])];
|
|
137
|
+
if (!(result.newMessages?.length))
|
|
138
|
+
return true;
|
|
139
|
+
if (messages.some((message) => Number.isFinite(message.confidence) && Number(message.confidence) < 0.65))
|
|
140
|
+
return true;
|
|
141
|
+
return messages.some((message) => (message.attachments ?? []).some((attachment) => attachment.availability === 'metadata-only'
|
|
142
|
+
|| attachment.availability === 'pending-download'
|
|
143
|
+
|| Boolean(attachment.providerError)));
|
|
144
|
+
}
|
|
145
|
+
export function mergeCloudMessages(localMessages, cloudMessages) {
|
|
146
|
+
const merged = [...localMessages];
|
|
147
|
+
for (const cloud of cloudMessages) {
|
|
148
|
+
const signature = messageSignature(cloud);
|
|
149
|
+
const index = merged.findIndex((message) => messageSignature(message) === signature);
|
|
150
|
+
if (index < 0) {
|
|
151
|
+
merged.push(cloud);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const existingAttachments = merged[index]?.attachments ?? [];
|
|
155
|
+
const cloudAttachments = cloud.attachments ?? [];
|
|
156
|
+
if (!existingAttachments.length && cloudAttachments.length) {
|
|
157
|
+
const existing = merged[index] ?? {};
|
|
158
|
+
merged[index] = { ...existing, attachments: cloudAttachments };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return merged;
|
|
162
|
+
}
|
|
163
|
+
export function messagesFromCloudOcrObservations(groupName, observations) {
|
|
164
|
+
const messages = [];
|
|
165
|
+
for (const item of observations) {
|
|
166
|
+
const text = String(item.text || '').trim();
|
|
167
|
+
if (!text)
|
|
168
|
+
continue;
|
|
169
|
+
const role = String(item.role || 'unknown');
|
|
170
|
+
if (!['message', 'attachment', 'unknown'].includes(role))
|
|
171
|
+
continue;
|
|
172
|
+
const attachment = normalizeCloudAttachment(item.attachment) ?? (role === 'attachment' ? attachmentFromText(text) : null);
|
|
173
|
+
messages.push({
|
|
174
|
+
id: `cloud:${stableId(`${groupName}\n${role}\n${text}`)}`,
|
|
175
|
+
text,
|
|
176
|
+
confidence: item.confidence,
|
|
177
|
+
attachments: attachment ? [attachment] : [],
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return messages;
|
|
181
|
+
}
|
|
182
|
+
function messageSignature(message) {
|
|
183
|
+
const text = String(message.text || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
184
|
+
const attachments = (message.attachments ?? [])
|
|
185
|
+
.map((attachment) => `${attachment.type}:${attachment.name || ''}:${attachment.mimeType || ''}`)
|
|
186
|
+
.sort()
|
|
187
|
+
.join('|');
|
|
188
|
+
return text || attachments || String(message.id || '');
|
|
189
|
+
}
|
|
190
|
+
function normalizeCloudAttachment(value) {
|
|
191
|
+
if (!value || typeof value !== 'object')
|
|
192
|
+
return null;
|
|
193
|
+
const record = value;
|
|
194
|
+
const type = String(record.type || 'file').trim() || 'file';
|
|
195
|
+
const name = String(record.name || '').replace(/\s+/g, ' ').trim();
|
|
196
|
+
const mimeType = String(record.mimeType || '').replace(/\s+/g, ' ').trim();
|
|
197
|
+
const size = Number(record.size);
|
|
198
|
+
return {
|
|
199
|
+
type: ['image', 'video', 'audio', 'file'].includes(type) ? type : 'file',
|
|
200
|
+
...(name ? { name } : {}),
|
|
201
|
+
...(mimeType ? { mimeType } : {}),
|
|
202
|
+
...(Number.isFinite(size) && size > 0 ? { size } : {}),
|
|
203
|
+
availability: 'metadata-only',
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function attachmentFromText(text) {
|
|
207
|
+
const matches = Array.from(text.matchAll(/[\p{L}\p{N}_().[\]-]+\.(png|jpe?g|gif|webp|heic|mp4|mov|avi|mkv|pdf|docx?|xlsx?|pptx?|txt|md|csv|zip|rar)/giu));
|
|
208
|
+
const filename = matches.at(-1)?.[0]?.trim();
|
|
209
|
+
if (!filename)
|
|
210
|
+
return null;
|
|
211
|
+
const ext = path.extname(filename).toLowerCase();
|
|
212
|
+
return { type: attachmentTypeFromExt(ext), name: filename, mimeType: mimeTypeFromExt(ext), availability: 'metadata-only' };
|
|
213
|
+
}
|
|
214
|
+
function attachmentTypeFromExt(ext) {
|
|
215
|
+
if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.heic'].includes(ext))
|
|
216
|
+
return 'image';
|
|
217
|
+
if (['.mp4', '.mov', '.avi', '.mkv'].includes(ext))
|
|
218
|
+
return 'video';
|
|
219
|
+
return 'file';
|
|
220
|
+
}
|
|
221
|
+
function mimeTypeFromExt(ext) {
|
|
222
|
+
const map = {
|
|
223
|
+
'.png': 'image/png',
|
|
224
|
+
'.jpg': 'image/jpeg',
|
|
225
|
+
'.jpeg': 'image/jpeg',
|
|
226
|
+
'.gif': 'image/gif',
|
|
227
|
+
'.webp': 'image/webp',
|
|
228
|
+
'.heic': 'image/heic',
|
|
229
|
+
'.mp4': 'video/mp4',
|
|
230
|
+
'.mov': 'video/quicktime',
|
|
231
|
+
'.pdf': 'application/pdf',
|
|
232
|
+
'.txt': 'text/plain',
|
|
233
|
+
'.md': 'text/markdown',
|
|
234
|
+
'.csv': 'text/csv',
|
|
235
|
+
'.zip': 'application/zip',
|
|
236
|
+
};
|
|
237
|
+
return map[ext] || 'application/octet-stream';
|
|
238
|
+
}
|
|
239
|
+
function stableId(value) {
|
|
240
|
+
return crypto.createHash('sha256').update(value).digest('hex').slice(0, 24);
|
|
241
|
+
}
|
|
242
|
+
function resolveFlowScriptPath(scriptPath, workDir) {
|
|
243
|
+
const candidates = [
|
|
244
|
+
scriptPath,
|
|
245
|
+
process.env.SHENNIAN_WECHAT_RPA_FLOW_SCRIPT,
|
|
246
|
+
workDir ? path.join(workDir, 'scripts/wechat-rpa-flow.mjs') : '',
|
|
247
|
+
path.resolve(process.cwd(), 'scripts/wechat-rpa-flow.mjs'),
|
|
248
|
+
].filter(Boolean);
|
|
249
|
+
const found = candidates.find((candidate) => fs.existsSync(candidate));
|
|
250
|
+
if (!found) {
|
|
251
|
+
throw new Error('WeChat RPA flow script is missing; set SHENNIAN_WECHAT_RPA_FLOW_SCRIPT or channel flowScriptPath');
|
|
252
|
+
}
|
|
253
|
+
return path.resolve(found);
|
|
254
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { WeChatRpaObservedMessage } from './normalizer.js';
|
|
2
|
+
export type WeChatRpaProbeResult = {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
platform: NodeJS.Platform;
|
|
5
|
+
wechatRunning: boolean;
|
|
6
|
+
frontmost: boolean;
|
|
7
|
+
windowTitle?: string;
|
|
8
|
+
message?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function probeMacWeChat(): Promise<WeChatRpaProbeResult>;
|
|
11
|
+
export declare function observedMessageFromProbe(result: WeChatRpaProbeResult): WeChatRpaObservedMessage | null;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// @arch docs/features/wechat-rpa-channel.md
|
|
2
|
+
// @test src/__tests__/wechat-rpa-normalizer.test.ts
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
const WECHAT_PROBE_SCRIPT = `
|
|
7
|
+
tell application "System Events"
|
|
8
|
+
set isRunning to exists process "WeChat"
|
|
9
|
+
if isRunning is false then
|
|
10
|
+
return "not_running"
|
|
11
|
+
end if
|
|
12
|
+
tell process "WeChat"
|
|
13
|
+
set isFront to frontmost
|
|
14
|
+
set titleText to ""
|
|
15
|
+
if (count of windows) > 0 then
|
|
16
|
+
try
|
|
17
|
+
set titleText to name of window 1
|
|
18
|
+
end try
|
|
19
|
+
end if
|
|
20
|
+
return (isFront as text) & "\n" & titleText
|
|
21
|
+
end tell
|
|
22
|
+
end tell
|
|
23
|
+
`;
|
|
24
|
+
export async function probeMacWeChat() {
|
|
25
|
+
if (process.platform !== 'darwin') {
|
|
26
|
+
return { ok: false, platform: process.platform, wechatRunning: false, frontmost: false, message: 'macOS only' };
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const { stdout } = await execFileAsync('osascript', ['-e', WECHAT_PROBE_SCRIPT], { timeout: 5_000 });
|
|
30
|
+
const output = stdout.trim();
|
|
31
|
+
if (output === 'not_running') {
|
|
32
|
+
return { ok: true, platform: process.platform, wechatRunning: false, frontmost: false };
|
|
33
|
+
}
|
|
34
|
+
const [frontmostText, ...titleParts] = output.split('\n');
|
|
35
|
+
return {
|
|
36
|
+
ok: true,
|
|
37
|
+
platform: process.platform,
|
|
38
|
+
wechatRunning: true,
|
|
39
|
+
frontmost: frontmostText === 'true',
|
|
40
|
+
windowTitle: titleParts.join('\n').trim() || undefined,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
platform: process.platform,
|
|
47
|
+
wechatRunning: false,
|
|
48
|
+
frontmost: false,
|
|
49
|
+
message: err instanceof Error ? err.message : String(err),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function observedMessageFromProbe(result) {
|
|
54
|
+
if (!result.ok || !result.wechatRunning || !result.windowTitle)
|
|
55
|
+
return null;
|
|
56
|
+
return {
|
|
57
|
+
conversationName: result.windowTitle,
|
|
58
|
+
senderName: 'WeChat RPA',
|
|
59
|
+
text: `[微信窗口可见性探测] WeChat ${result.frontmost ? '位于前台' : '未位于前台'},当前窗口:${result.windowTitle}`,
|
|
60
|
+
observedAt: new Date().toISOString(),
|
|
61
|
+
rawId: `probe:${result.windowTitle}:${result.frontmost}`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type WeChatRpaObservedMessage = {
|
|
2
|
+
conversationName: string;
|
|
3
|
+
senderName?: string | null;
|
|
4
|
+
text: string;
|
|
5
|
+
attachments?: WeChatRpaObservedAttachment[];
|
|
6
|
+
observedAt?: string | null;
|
|
7
|
+
rawId?: string | null;
|
|
8
|
+
};
|
|
9
|
+
export type WeChatRpaObservedAttachment = {
|
|
10
|
+
type: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
url?: string;
|
|
13
|
+
mimeType?: string;
|
|
14
|
+
size?: number;
|
|
15
|
+
localPath?: string;
|
|
16
|
+
thumbnailPath?: string;
|
|
17
|
+
hash?: string;
|
|
18
|
+
availability?: 'edge-local' | 'server-url' | 'pending-download' | 'metadata-only' | 'unavailable-large';
|
|
19
|
+
machineId?: string;
|
|
20
|
+
expiresAt?: string;
|
|
21
|
+
providerError?: string;
|
|
22
|
+
};
|
|
23
|
+
export type WeChatRpaNormalizedMessage = {
|
|
24
|
+
conversationId: string;
|
|
25
|
+
conversationName: string;
|
|
26
|
+
messageId: string;
|
|
27
|
+
sender: {
|
|
28
|
+
id: string;
|
|
29
|
+
name?: string | null;
|
|
30
|
+
};
|
|
31
|
+
text: string;
|
|
32
|
+
attachments: WeChatRpaObservedAttachment[];
|
|
33
|
+
receivedAt: string;
|
|
34
|
+
rawRef: string | null;
|
|
35
|
+
};
|
|
36
|
+
export declare class WeChatRpaDeduper {
|
|
37
|
+
private seen;
|
|
38
|
+
private queue;
|
|
39
|
+
accept(messageId: string): boolean;
|
|
40
|
+
}
|
|
41
|
+
export declare function normalizeWeChatRpaMessage(input: WeChatRpaObservedMessage): WeChatRpaNormalizedMessage | null;
|
|
42
|
+
export declare function weChatRpaConversationId(conversationName: string): string;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// @arch docs/features/wechat-rpa-channel.md
|
|
2
|
+
// @test src/__tests__/wechat-rpa-normalizer.test.ts
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
const MAX_DEDUP_KEYS = 500;
|
|
5
|
+
export class WeChatRpaDeduper {
|
|
6
|
+
seen = new Set();
|
|
7
|
+
queue = [];
|
|
8
|
+
accept(messageId) {
|
|
9
|
+
if (this.seen.has(messageId))
|
|
10
|
+
return false;
|
|
11
|
+
this.seen.add(messageId);
|
|
12
|
+
this.queue.push(messageId);
|
|
13
|
+
while (this.queue.length > MAX_DEDUP_KEYS) {
|
|
14
|
+
const old = this.queue.shift();
|
|
15
|
+
if (old)
|
|
16
|
+
this.seen.delete(old);
|
|
17
|
+
}
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function normalizeWeChatRpaMessage(input) {
|
|
22
|
+
const conversationName = cleanText(input.conversationName);
|
|
23
|
+
const text = cleanText(input.text);
|
|
24
|
+
const attachments = normalizeAttachments(input.attachments);
|
|
25
|
+
if (!conversationName || (!text && attachments.length === 0))
|
|
26
|
+
return null;
|
|
27
|
+
const senderName = cleanText(input.senderName || '') || null;
|
|
28
|
+
const receivedAt = normalizeIso(input.observedAt);
|
|
29
|
+
const conversationId = weChatRpaConversationId(conversationName);
|
|
30
|
+
const senderId = stableId('wechat-sender', senderName || 'unknown');
|
|
31
|
+
const rawRef = cleanText(input.rawId || '') || null;
|
|
32
|
+
const messageId = rawRef
|
|
33
|
+
? stableId('wechat-message', `${conversationName}\n${rawRef}`)
|
|
34
|
+
: stableId('wechat-message', `${conversationName}\n${senderName || ''}\n${text}\n${attachments.map((item) => item.name || item.url || item.type).join('\n')}\n${receivedAt}`);
|
|
35
|
+
return {
|
|
36
|
+
conversationId,
|
|
37
|
+
conversationName,
|
|
38
|
+
messageId,
|
|
39
|
+
sender: { id: senderId, name: senderName },
|
|
40
|
+
text,
|
|
41
|
+
attachments,
|
|
42
|
+
receivedAt,
|
|
43
|
+
rawRef,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function weChatRpaConversationId(conversationName) {
|
|
47
|
+
return stableId('wechat-conversation', cleanText(conversationName));
|
|
48
|
+
}
|
|
49
|
+
function cleanText(value) {
|
|
50
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
51
|
+
}
|
|
52
|
+
function normalizeAttachments(value) {
|
|
53
|
+
if (!Array.isArray(value))
|
|
54
|
+
return [];
|
|
55
|
+
return value
|
|
56
|
+
.map((item) => {
|
|
57
|
+
const url = item?.url ? cleanText(item.url) : '';
|
|
58
|
+
const localPath = item?.localPath ? cleanText(item.localPath) : '';
|
|
59
|
+
const availability = normalizeAvailability(item?.availability, { url, localPath });
|
|
60
|
+
return {
|
|
61
|
+
type: cleanText(String(item?.type || 'file')) || 'file',
|
|
62
|
+
...(item?.name ? { name: cleanText(item.name) } : {}),
|
|
63
|
+
...(url ? { url } : {}),
|
|
64
|
+
...(item?.mimeType ? { mimeType: cleanText(item.mimeType) } : {}),
|
|
65
|
+
...(Number.isFinite(item?.size) && Number(item.size) > 0 ? { size: Number(item.size) } : {}),
|
|
66
|
+
...(localPath ? { localPath } : {}),
|
|
67
|
+
...(item?.thumbnailPath ? { thumbnailPath: cleanText(item.thumbnailPath) } : {}),
|
|
68
|
+
...(item?.hash ? { hash: cleanText(item.hash) } : {}),
|
|
69
|
+
...(availability ? { availability } : {}),
|
|
70
|
+
...(item?.machineId ? { machineId: cleanText(item.machineId) } : {}),
|
|
71
|
+
...(item?.expiresAt ? { expiresAt: cleanText(item.expiresAt) } : {}),
|
|
72
|
+
...(item?.providerError ? { providerError: cleanText(item.providerError) } : {}),
|
|
73
|
+
};
|
|
74
|
+
})
|
|
75
|
+
.filter((item) => item.type);
|
|
76
|
+
}
|
|
77
|
+
function normalizeAvailability(value, sources) {
|
|
78
|
+
if (value === 'edge-local'
|
|
79
|
+
|| value === 'server-url'
|
|
80
|
+
|| value === 'pending-download'
|
|
81
|
+
|| value === 'metadata-only'
|
|
82
|
+
|| value === 'unavailable-large') {
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
if (sources.localPath)
|
|
86
|
+
return 'edge-local';
|
|
87
|
+
if (sources.url)
|
|
88
|
+
return 'server-url';
|
|
89
|
+
return 'metadata-only';
|
|
90
|
+
}
|
|
91
|
+
function normalizeIso(value) {
|
|
92
|
+
if (!value)
|
|
93
|
+
return new Date().toISOString();
|
|
94
|
+
const date = new Date(value);
|
|
95
|
+
return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString();
|
|
96
|
+
}
|
|
97
|
+
function stableId(prefix, value) {
|
|
98
|
+
return `${prefix}:${createHash('sha256').update(value).digest('hex').slice(0, 24)}`;
|
|
99
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ExternalChannelAdapter, ExternalChannelConfig, ExternalChannelRuntimeStatus, ExternalMessageAttachment, ExternalReply } from './base.js';
|
|
2
|
+
import { type WeChatRpaObservedMessage } from './wechat-rpa/normalizer.js';
|
|
3
|
+
type WeChatRpaEvent = {
|
|
4
|
+
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
|
+
};
|
|
19
|
+
export declare class WeChatRpaChannelAdapter implements ExternalChannelAdapter {
|
|
20
|
+
private onMessage?;
|
|
21
|
+
readonly type: "wechat-rpa";
|
|
22
|
+
private secrets;
|
|
23
|
+
private connections;
|
|
24
|
+
constructor(onMessage?: ((event: WeChatRpaEvent) => void) | undefined);
|
|
25
|
+
connect(config: ExternalChannelConfig): Promise<void>;
|
|
26
|
+
disconnect(config: ExternalChannelConfig): Promise<void>;
|
|
27
|
+
send(config: ExternalChannelConfig, reply: ExternalReply): Promise<{
|
|
28
|
+
status: 'sent' | 'queued';
|
|
29
|
+
reason?: string;
|
|
30
|
+
}>;
|
|
31
|
+
health(config: ExternalChannelConfig): Promise<{
|
|
32
|
+
ok: boolean;
|
|
33
|
+
message?: string;
|
|
34
|
+
}>;
|
|
35
|
+
defaultConversation(config: ExternalChannelConfig): Promise<{
|
|
36
|
+
conversationId: string;
|
|
37
|
+
conversationName?: string;
|
|
38
|
+
}>;
|
|
39
|
+
runtimeStatus(config: ExternalChannelConfig): Partial<ExternalChannelRuntimeStatus>;
|
|
40
|
+
private ensureConnection;
|
|
41
|
+
private enqueueOperation;
|
|
42
|
+
private readSecret;
|
|
43
|
+
private pollOnce;
|
|
44
|
+
private drainPendingReplies;
|
|
45
|
+
private readObservedMessages;
|
|
46
|
+
private seedConfiguredConversations;
|
|
47
|
+
private resolveConversationName;
|
|
48
|
+
}
|
|
49
|
+
export declare function materializeWeChatRpaOutboundAttachment(workDir: string, attachment: NonNullable<ExternalReply['attachment']>): Promise<string>;
|
|
50
|
+
export declare function annotateWeChatRpaInboundAttachments(attachments: WeChatRpaObservedMessage['attachments']): WeChatRpaObservedMessage['attachments'];
|
|
51
|
+
export {};
|