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
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
// @arch docs/features/manager-agent.md
|
|
2
2
|
// @test src/__tests__/manager-runtime.test.ts
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
3
6
|
import { ChannelConfigRegistry } from './registry.js';
|
|
4
7
|
import { ChannelSecretRegistry } from './secret-registry.js';
|
|
5
8
|
import { WeComChannelAdapter } from './wecom.js';
|
|
9
|
+
import { WeChatRpaChannelAdapter } from './wechat-rpa.js';
|
|
6
10
|
import { ExternalWebSocketChannelAdapter } from './websocket.js';
|
|
7
11
|
import { splitExternalReplyText } from './reply-split.js';
|
|
12
|
+
import { loadConfig } from '../config/index.js';
|
|
13
|
+
import { SERVERS } from '../region.js';
|
|
8
14
|
export class ChannelRuntime {
|
|
9
15
|
onExternalMessage;
|
|
10
16
|
createReplyTarget;
|
|
11
17
|
configs = new ChannelConfigRegistry();
|
|
12
18
|
secrets = new ChannelSecretRegistry();
|
|
13
19
|
adapters = new Map();
|
|
20
|
+
completedReplyKeys = new Map();
|
|
14
21
|
constructor(onExternalMessage, createReplyTarget) {
|
|
15
22
|
this.onExternalMessage = onExternalMessage;
|
|
16
23
|
this.createReplyTarget = createReplyTarget;
|
|
@@ -18,6 +25,8 @@ export class ChannelRuntime {
|
|
|
18
25
|
this.adapters.set(wecom.type, wecom);
|
|
19
26
|
const websocket = new ExternalWebSocketChannelAdapter((event) => this.ingest({ type: 'external.message', ...event }));
|
|
20
27
|
this.adapters.set(websocket.type, websocket);
|
|
28
|
+
const wechatRpa = new WeChatRpaChannelAdapter((event) => this.ingest({ type: 'external.message', ...event }));
|
|
29
|
+
this.adapters.set(wechatRpa.type, wechatRpa);
|
|
21
30
|
}
|
|
22
31
|
async start() {
|
|
23
32
|
for (const config of this.configs.list().filter((channel) => channel.enabled)) {
|
|
@@ -53,24 +62,55 @@ export class ChannelRuntime {
|
|
|
53
62
|
if (!adapter)
|
|
54
63
|
return { ok: false, error: `Unsupported channel type: ${config.type}` };
|
|
55
64
|
try {
|
|
56
|
-
const
|
|
57
|
-
if (!
|
|
58
|
-
return { ok: false, error: 'Reply text is required' };
|
|
59
|
-
|
|
60
|
-
|
|
65
|
+
const sends = planExternalReplySends(config.type, input);
|
|
66
|
+
if (!sends.length)
|
|
67
|
+
return { ok: false, error: 'Reply text or attachment is required' };
|
|
68
|
+
let pending = false;
|
|
69
|
+
for (const send of sends) {
|
|
70
|
+
const idempotencyKey = send.idempotencyKey;
|
|
71
|
+
if (idempotencyKey && this.isReplyCompleted(config, input.conversationId, idempotencyKey))
|
|
72
|
+
continue;
|
|
73
|
+
const result = await adapter.send(config, {
|
|
61
74
|
...input,
|
|
62
|
-
|
|
63
|
-
idempotencyKey: parts.length > 1 && input.idempotencyKey
|
|
64
|
-
? `${input.idempotencyKey}:${index + 1}`
|
|
65
|
-
: input.idempotencyKey,
|
|
75
|
+
...send,
|
|
66
76
|
});
|
|
77
|
+
if (result?.status === 'queued') {
|
|
78
|
+
pending = true;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (idempotencyKey)
|
|
82
|
+
this.markReplyCompleted(config, input.conversationId, idempotencyKey);
|
|
67
83
|
}
|
|
68
|
-
return { ok: true };
|
|
84
|
+
return pending ? { ok: true, pending: true } : { ok: true };
|
|
69
85
|
}
|
|
70
86
|
catch (err) {
|
|
71
87
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
72
88
|
}
|
|
73
89
|
}
|
|
90
|
+
isReplyCompleted(config, conversationId, idempotencyKey) {
|
|
91
|
+
const set = this.loadReplyCompletionSet(config);
|
|
92
|
+
return set.has(replyCompletionKey(config.id, conversationId, idempotencyKey));
|
|
93
|
+
}
|
|
94
|
+
markReplyCompleted(config, conversationId, idempotencyKey) {
|
|
95
|
+
const set = this.loadReplyCompletionSet(config);
|
|
96
|
+
set.add(replyCompletionKey(config.id, conversationId, idempotencyKey));
|
|
97
|
+
try {
|
|
98
|
+
persistReplyCompletionSet(config.workDir, set);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Some tests and diagnostic channels use virtual workDirs. In-memory idempotency
|
|
102
|
+
// still protects the current daemon; persistence resumes when workDir is writable.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
loadReplyCompletionSet(config) {
|
|
106
|
+
const cacheKey = path.resolve(config.workDir);
|
|
107
|
+
const cached = this.completedReplyKeys.get(cacheKey);
|
|
108
|
+
if (cached)
|
|
109
|
+
return cached;
|
|
110
|
+
const set = readReplyCompletionSet(config.workDir);
|
|
111
|
+
this.completedReplyKeys.set(cacheKey, set);
|
|
112
|
+
return set;
|
|
113
|
+
}
|
|
74
114
|
async getDefaultReplyTarget(sessionId) {
|
|
75
115
|
const config = this.configs.list().find((channel) => (channel.sessionId ?? channel.managerSessionId) === sessionId && channel.enabled);
|
|
76
116
|
if (!config)
|
|
@@ -91,6 +131,7 @@ export class ChannelRuntime {
|
|
|
91
131
|
if (!config)
|
|
92
132
|
return null;
|
|
93
133
|
const secret = this.secrets.get(config.secretRef);
|
|
134
|
+
const adapterStatus = this.adapters.get(config.type)?.runtimeStatus?.(config) ?? {};
|
|
94
135
|
return {
|
|
95
136
|
id: config.id,
|
|
96
137
|
type: config.type,
|
|
@@ -107,6 +148,8 @@ export class ChannelRuntime {
|
|
|
107
148
|
tokenConfigured: Boolean(secret?.token),
|
|
108
149
|
canReply: Boolean(secret?.canReply),
|
|
109
150
|
systemPrompt: typeof secret?.systemPrompt === 'string' ? secret.systemPrompt : '',
|
|
151
|
+
...wechatRpaViewFields(secret, opts.includeSecret),
|
|
152
|
+
...adapterStatus,
|
|
110
153
|
};
|
|
111
154
|
}
|
|
112
155
|
getChannelById(channelId, opts = {}) {
|
|
@@ -114,6 +157,7 @@ export class ChannelRuntime {
|
|
|
114
157
|
if (!config)
|
|
115
158
|
return null;
|
|
116
159
|
const secret = this.secrets.get(config.secretRef);
|
|
160
|
+
const adapterStatus = this.adapters.get(config.type)?.runtimeStatus?.(config) ?? {};
|
|
117
161
|
return {
|
|
118
162
|
id: config.id,
|
|
119
163
|
type: config.type,
|
|
@@ -130,6 +174,26 @@ export class ChannelRuntime {
|
|
|
130
174
|
tokenConfigured: Boolean(secret?.token),
|
|
131
175
|
canReply: Boolean(secret?.canReply),
|
|
132
176
|
systemPrompt: typeof secret?.systemPrompt === 'string' ? secret.systemPrompt : '',
|
|
177
|
+
...wechatRpaViewFields(secret, opts.includeSecret),
|
|
178
|
+
...adapterStatus,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
getChannelStatusById(channelId) {
|
|
182
|
+
const config = this.configs.get(channelId);
|
|
183
|
+
if (!config)
|
|
184
|
+
return null;
|
|
185
|
+
const secret = this.secrets.get(config.secretRef);
|
|
186
|
+
const adapterStatus = this.adapters.get(config.type)?.runtimeStatus?.(config) ?? {};
|
|
187
|
+
return {
|
|
188
|
+
configured: true,
|
|
189
|
+
connected: isChannelSecretConfigured(config, secret),
|
|
190
|
+
type: config.type,
|
|
191
|
+
channelId: config.id,
|
|
192
|
+
name: config.name,
|
|
193
|
+
canReply: Boolean(secret?.canReply),
|
|
194
|
+
systemPrompt: typeof secret?.systemPrompt === 'string' ? secret.systemPrompt : '',
|
|
195
|
+
...wechatRpaStatusFields(secret),
|
|
196
|
+
...adapterStatus,
|
|
133
197
|
};
|
|
134
198
|
}
|
|
135
199
|
getManagerChannelStatus(managerSessionId) {
|
|
@@ -137,14 +201,17 @@ export class ChannelRuntime {
|
|
|
137
201
|
if (!config)
|
|
138
202
|
return null;
|
|
139
203
|
const secret = this.secrets.get(config.secretRef);
|
|
204
|
+
const adapterStatus = this.adapters.get(config.type)?.runtimeStatus?.(config) ?? {};
|
|
140
205
|
return {
|
|
141
206
|
configured: true,
|
|
142
|
-
connected:
|
|
207
|
+
connected: isChannelSecretConfigured(config, secret),
|
|
143
208
|
type: config.type,
|
|
144
209
|
channelId: config.id,
|
|
145
210
|
name: config.name,
|
|
146
211
|
canReply: Boolean(secret?.canReply),
|
|
147
212
|
systemPrompt: typeof secret?.systemPrompt === 'string' ? secret.systemPrompt : '',
|
|
213
|
+
...wechatRpaStatusFields(secret),
|
|
214
|
+
...adapterStatus,
|
|
148
215
|
};
|
|
149
216
|
}
|
|
150
217
|
listManagerChannelStatuses() {
|
|
@@ -156,11 +223,24 @@ export class ChannelRuntime {
|
|
|
156
223
|
}))
|
|
157
224
|
.filter((entry) => Boolean(entry.status));
|
|
158
225
|
}
|
|
159
|
-
|
|
226
|
+
listManagerExternalChannels(managerSessionId) {
|
|
160
227
|
return this.configs.list()
|
|
161
228
|
.filter((channel) => channel.enabled && (channel.sessionId ?? channel.managerSessionId) === managerSessionId)
|
|
162
|
-
.map((channel) =>
|
|
163
|
-
|
|
229
|
+
.map((channel) => {
|
|
230
|
+
const secret = this.secrets.get(channel.secretRef);
|
|
231
|
+
const adapterStatus = this.adapters.get(channel.type)?.runtimeStatus?.(channel) ?? {};
|
|
232
|
+
return {
|
|
233
|
+
configured: true,
|
|
234
|
+
connected: isChannelSecretConfigured(channel, secret),
|
|
235
|
+
type: channel.type,
|
|
236
|
+
channelId: channel.id,
|
|
237
|
+
name: channel.name,
|
|
238
|
+
canReply: Boolean(secret?.canReply),
|
|
239
|
+
systemPrompt: typeof secret?.systemPrompt === 'string' ? secret.systemPrompt : '',
|
|
240
|
+
...wechatRpaStatusFields(secret),
|
|
241
|
+
...adapterStatus,
|
|
242
|
+
};
|
|
243
|
+
});
|
|
164
244
|
}
|
|
165
245
|
async upsertManagerChannel(input) {
|
|
166
246
|
const previous = this.configs.get(input.id);
|
|
@@ -214,4 +294,210 @@ export class ChannelRuntime {
|
|
|
214
294
|
}
|
|
215
295
|
return this.getManagerChannel(boundSessionId, input.type, { includeSecret: true });
|
|
216
296
|
}
|
|
297
|
+
async upsertManagerWeChatRpaChannel(input) {
|
|
298
|
+
const previous = this.configs.get(input.id);
|
|
299
|
+
const allConfigs = this.configs.list();
|
|
300
|
+
const boundSessionId = input.sessionId || input.managerSessionId;
|
|
301
|
+
const groups = normalizeWeChatRpaGroups(input.groups);
|
|
302
|
+
if (input.enabled && !groups.length)
|
|
303
|
+
throw new Error('WeChat RPA 至少需要配置一个群');
|
|
304
|
+
const nextConfig = {
|
|
305
|
+
id: input.id,
|
|
306
|
+
type: 'wechat-rpa',
|
|
307
|
+
name: input.name?.trim() || previous?.name || '本机微信 RPA',
|
|
308
|
+
sessionId: boundSessionId,
|
|
309
|
+
managerSessionId: boundSessionId,
|
|
310
|
+
workDir: input.workDir,
|
|
311
|
+
agentType: input.agentType || previous?.agentType,
|
|
312
|
+
agentSessionId: input.agentSessionId ?? previous?.agentSessionId ?? null,
|
|
313
|
+
modelId: input.modelId ?? previous?.modelId ?? null,
|
|
314
|
+
enabled: input.enabled,
|
|
315
|
+
secretRef: previous?.secretRef || `channel:${input.id}`,
|
|
316
|
+
};
|
|
317
|
+
const priorSecret = this.secrets.get(nextConfig.secretRef);
|
|
318
|
+
const source = input.source || (priorSecret?.source === 'macos-probe' || priorSecret?.source === 'fixture-jsonl' || priorSecret?.source === 'macos-flow' ? priorSecret.source : 'macos-flow');
|
|
319
|
+
const configs = allConfigs
|
|
320
|
+
.filter((channel) => channel.id !== nextConfig.id)
|
|
321
|
+
.map((channel) => (channel.sessionId ?? channel.managerSessionId) === boundSessionId && channel.type === 'wechat-rpa'
|
|
322
|
+
? { ...channel, enabled: false }
|
|
323
|
+
: channel);
|
|
324
|
+
configs.push(nextConfig);
|
|
325
|
+
this.configs.replaceAll(configs);
|
|
326
|
+
const cloudOcrMode = normalizeCloudOcrMode(input.cloudOcrMode ?? priorSecret?.cloudOcrMode);
|
|
327
|
+
const cloudOcrDefaults = cloudOcrMode === 'off' ? {} : defaultWeChatRpaCloudOcrConfig();
|
|
328
|
+
this.secrets.upsert(nextConfig.secretRef, {
|
|
329
|
+
type: 'wechat-rpa',
|
|
330
|
+
source,
|
|
331
|
+
groups,
|
|
332
|
+
pollIntervalMs: clampOptionalNumber(input.pollIntervalMs, priorSecret?.pollIntervalMs),
|
|
333
|
+
recentLimit: clampOptionalNumber(input.recentLimit, priorSecret?.recentLimit),
|
|
334
|
+
idleSeconds: clampOptionalNumber(input.idleSeconds, priorSecret?.idleSeconds),
|
|
335
|
+
forceForeground: input.forceForeground ?? Boolean(priorSecret?.forceForeground),
|
|
336
|
+
noRestore: input.noRestore ?? (priorSecret?.noRestore === undefined ? true : Boolean(priorSecret.noRestore)),
|
|
337
|
+
downloadAttachments: input.downloadAttachments ?? (priorSecret?.downloadAttachments === undefined ? true : Boolean(priorSecret.downloadAttachments)),
|
|
338
|
+
downloadAttachmentsDir: input.downloadAttachmentsDir?.trim() || stringOrUndefined(priorSecret?.downloadAttachmentsDir),
|
|
339
|
+
flowScriptPath: input.flowScriptPath?.trim() || stringOrUndefined(priorSecret?.flowScriptPath),
|
|
340
|
+
cloudOcrUrl: input.cloudOcrUrl?.trim() || stringOrUndefined(priorSecret?.cloudOcrUrl) || cloudOcrDefaults.cloudOcrUrl,
|
|
341
|
+
cloudOcrToken: input.cloudOcrToken?.trim() || stringOrUndefined(priorSecret?.cloudOcrToken) || cloudOcrDefaults.cloudOcrToken,
|
|
342
|
+
cloudOcrMode,
|
|
343
|
+
canReply: input.canReply ?? priorSecret?.canReply ?? false,
|
|
344
|
+
systemPrompt: input.systemPrompt ?? (typeof priorSecret?.systemPrompt === 'string' ? priorSecret.systemPrompt : ''),
|
|
345
|
+
});
|
|
346
|
+
const adapter = this.adapters.get(nextConfig.type);
|
|
347
|
+
for (const config of allConfigs) {
|
|
348
|
+
if ((config.sessionId ?? config.managerSessionId) === boundSessionId && config.type === 'wechat-rpa' && config.enabled) {
|
|
349
|
+
await this.adapters.get(config.type)?.disconnect(config).catch(() => { });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (nextConfig.enabled) {
|
|
353
|
+
void adapter?.connect(nextConfig).catch(() => { });
|
|
354
|
+
}
|
|
355
|
+
return this.getManagerChannel(boundSessionId, 'wechat-rpa', { includeSecret: true });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function isChannelSecretConfigured(config, secret) {
|
|
359
|
+
if (!secret || secret.type !== config.type)
|
|
360
|
+
return false;
|
|
361
|
+
if (config.type === 'wechat-rpa')
|
|
362
|
+
return true;
|
|
363
|
+
if (config.type === 'websocket')
|
|
364
|
+
return Boolean(secret.wsUrl && secret.token);
|
|
365
|
+
if (config.type === 'wecom')
|
|
366
|
+
return Boolean(secret.token || secret.botId || secret.secret);
|
|
367
|
+
return Boolean(secret.token);
|
|
368
|
+
}
|
|
369
|
+
function wechatRpaViewFields(secret, includeSecret) {
|
|
370
|
+
if (!secret || secret.type !== 'wechat-rpa')
|
|
371
|
+
return {};
|
|
372
|
+
return {
|
|
373
|
+
wechatRpaSource: typeof secret.source === 'string' ? secret.source : '',
|
|
374
|
+
wechatRpaGroups: normalizeWeChatRpaGroups(Array.isArray(secret.groups) ? secret.groups : []),
|
|
375
|
+
pollIntervalMs: Number.isFinite(secret.pollIntervalMs) ? Number(secret.pollIntervalMs) : undefined,
|
|
376
|
+
recentLimit: Number.isFinite(secret.recentLimit) ? Number(secret.recentLimit) : undefined,
|
|
377
|
+
idleSeconds: Number.isFinite(secret.idleSeconds) ? Number(secret.idleSeconds) : undefined,
|
|
378
|
+
forceForeground: Boolean(secret.forceForeground),
|
|
379
|
+
noRestore: secret.noRestore === undefined ? undefined : Boolean(secret.noRestore),
|
|
380
|
+
downloadAttachments: secret.downloadAttachments === undefined ? true : Boolean(secret.downloadAttachments),
|
|
381
|
+
downloadAttachmentsDir: typeof secret.downloadAttachmentsDir === 'string' ? secret.downloadAttachmentsDir : '',
|
|
382
|
+
cloudOcrUrl: typeof secret.cloudOcrUrl === 'string' ? secret.cloudOcrUrl : '',
|
|
383
|
+
cloudOcrToken: includeSecret && typeof secret.cloudOcrToken === 'string' ? secret.cloudOcrToken : '',
|
|
384
|
+
cloudOcrMode: typeof secret.cloudOcrMode === 'string' ? secret.cloudOcrMode : 'off',
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
function wechatRpaStatusFields(secret) {
|
|
388
|
+
if (!secret || secret.type !== 'wechat-rpa')
|
|
389
|
+
return {};
|
|
390
|
+
return {
|
|
391
|
+
wechatRpaSource: typeof secret.source === 'string' ? secret.source : null,
|
|
392
|
+
wechatRpaGroups: normalizeWeChatRpaGroups(Array.isArray(secret.groups) ? secret.groups : []),
|
|
393
|
+
pollIntervalMs: Number.isFinite(secret.pollIntervalMs) ? Number(secret.pollIntervalMs) : null,
|
|
394
|
+
recentLimit: Number.isFinite(secret.recentLimit) ? Number(secret.recentLimit) : null,
|
|
395
|
+
idleSeconds: Number.isFinite(secret.idleSeconds) ? Number(secret.idleSeconds) : null,
|
|
396
|
+
forceForeground: Boolean(secret.forceForeground),
|
|
397
|
+
noRestore: secret.noRestore === undefined ? null : Boolean(secret.noRestore),
|
|
398
|
+
downloadAttachments: secret.downloadAttachments === undefined ? true : Boolean(secret.downloadAttachments),
|
|
399
|
+
downloadAttachmentsDir: typeof secret.downloadAttachmentsDir === 'string' ? secret.downloadAttachmentsDir : null,
|
|
400
|
+
cloudOcrUrl: typeof secret.cloudOcrUrl === 'string' ? secret.cloudOcrUrl : null,
|
|
401
|
+
cloudOcrMode: typeof secret.cloudOcrMode === 'string' ? secret.cloudOcrMode : 'off',
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
function normalizeWeChatRpaGroups(groups) {
|
|
405
|
+
const seen = new Set();
|
|
406
|
+
const result = [];
|
|
407
|
+
for (const group of groups) {
|
|
408
|
+
const name = String(group?.name || '').replace(/\s+/g, ' ').trim();
|
|
409
|
+
if (!name || seen.has(name))
|
|
410
|
+
continue;
|
|
411
|
+
seen.add(name);
|
|
412
|
+
result.push({ name });
|
|
413
|
+
}
|
|
414
|
+
return result;
|
|
415
|
+
}
|
|
416
|
+
function normalizeCloudOcrMode(value) {
|
|
417
|
+
return value === 'fallback' || value === 'always' ? value : 'off';
|
|
418
|
+
}
|
|
419
|
+
function defaultWeChatRpaCloudOcrConfig() {
|
|
420
|
+
const config = loadConfig();
|
|
421
|
+
const serverUrl = (config.serverUrl || SERVERS.cn.url).replace(/\/+$/, '');
|
|
422
|
+
const token = config.machineToken?.trim() || config.accessToken?.trim();
|
|
423
|
+
return {
|
|
424
|
+
cloudOcrUrl: `${serverUrl}/integrations/wechat-rpa/ocr`,
|
|
425
|
+
...(token ? { cloudOcrToken: token } : {}),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
export function planExternalReplySends(channelType, input) {
|
|
429
|
+
const parts = splitExternalReplyText(input.text);
|
|
430
|
+
if (!parts.length && !input.attachment)
|
|
431
|
+
return [];
|
|
432
|
+
if (channelType === 'wechat-rpa' && input.attachment && parts.length <= 1) {
|
|
433
|
+
return [{
|
|
434
|
+
text: parts[0] ?? '',
|
|
435
|
+
attachment: input.attachment,
|
|
436
|
+
idempotencyKey: input.idempotencyKey,
|
|
437
|
+
}];
|
|
438
|
+
}
|
|
439
|
+
const sends = parts.map((text, index) => ({
|
|
440
|
+
text,
|
|
441
|
+
attachment: undefined,
|
|
442
|
+
idempotencyKey: parts.length > 1 && input.idempotencyKey
|
|
443
|
+
? `${input.idempotencyKey}:${index + 1}`
|
|
444
|
+
: input.idempotencyKey,
|
|
445
|
+
}));
|
|
446
|
+
if (input.attachment) {
|
|
447
|
+
sends.push({
|
|
448
|
+
text: '',
|
|
449
|
+
attachment: input.attachment,
|
|
450
|
+
idempotencyKey: parts.length && input.idempotencyKey
|
|
451
|
+
? `${input.idempotencyKey}:attachment`
|
|
452
|
+
: input.idempotencyKey,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
return sends;
|
|
456
|
+
}
|
|
457
|
+
function replyCompletionKey(channelId, conversationId, idempotencyKey) {
|
|
458
|
+
return crypto.createHash('sha256')
|
|
459
|
+
.update(`${channelId}\n${conversationId}\n${idempotencyKey}`)
|
|
460
|
+
.digest('hex')
|
|
461
|
+
.slice(0, 32);
|
|
462
|
+
}
|
|
463
|
+
function replyCompletionFile(workDir) {
|
|
464
|
+
return path.join(workDir, '.shennian', 'external-reply-idempotency.json');
|
|
465
|
+
}
|
|
466
|
+
function readReplyCompletionSet(workDir) {
|
|
467
|
+
try {
|
|
468
|
+
const parsed = JSON.parse(fs.readFileSync(replyCompletionFile(workDir), 'utf8'));
|
|
469
|
+
const rows = Array.isArray(parsed.completed) ? parsed.completed : [];
|
|
470
|
+
return new Set(rows
|
|
471
|
+
.map((row) => {
|
|
472
|
+
if (typeof row === 'string')
|
|
473
|
+
return row;
|
|
474
|
+
if (row && typeof row === 'object' && typeof row.key === 'string') {
|
|
475
|
+
return row.key;
|
|
476
|
+
}
|
|
477
|
+
return '';
|
|
478
|
+
})
|
|
479
|
+
.filter(Boolean));
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
return new Set();
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
function persistReplyCompletionSet(workDir, set) {
|
|
486
|
+
const filePath = replyCompletionFile(workDir);
|
|
487
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
488
|
+
const keys = Array.from(set).slice(-500);
|
|
489
|
+
fs.writeFileSync(filePath, JSON.stringify({
|
|
490
|
+
updatedAt: new Date().toISOString(),
|
|
491
|
+
completed: keys.map((key) => ({ key })),
|
|
492
|
+
}, null, 2));
|
|
493
|
+
set.clear();
|
|
494
|
+
for (const key of keys)
|
|
495
|
+
set.add(key);
|
|
496
|
+
}
|
|
497
|
+
function clampOptionalNumber(value, fallback) {
|
|
498
|
+
const number = Number(value ?? fallback);
|
|
499
|
+
return Number.isFinite(number) && number >= 0 ? number : undefined;
|
|
500
|
+
}
|
|
501
|
+
function stringOrUndefined(value) {
|
|
502
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
217
503
|
}
|
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
type ChannelSecretRecord = {
|
|
2
|
-
type: 'wecom' | 'websocket';
|
|
2
|
+
type: 'wecom' | 'websocket' | 'wechat-rpa';
|
|
3
3
|
botId?: string;
|
|
4
4
|
secret?: string;
|
|
5
5
|
wsUrl?: string;
|
|
6
6
|
token?: string;
|
|
7
7
|
canReply?: boolean;
|
|
8
8
|
systemPrompt?: string;
|
|
9
|
+
source?: 'macos-probe' | 'macos-flow' | 'fixture-jsonl';
|
|
10
|
+
fixturePath?: string;
|
|
11
|
+
pollIntervalMs?: number;
|
|
12
|
+
groups?: Array<{
|
|
13
|
+
name: string;
|
|
14
|
+
}>;
|
|
15
|
+
forceForeground?: boolean;
|
|
16
|
+
noRestore?: boolean;
|
|
17
|
+
downloadAttachments?: boolean;
|
|
18
|
+
downloadAttachmentsDir?: string;
|
|
19
|
+
idleSeconds?: number;
|
|
20
|
+
recentLimit?: number;
|
|
21
|
+
flowScriptPath?: string;
|
|
22
|
+
cloudOcrUrl?: string;
|
|
23
|
+
cloudOcrToken?: string;
|
|
24
|
+
cloudOcrMode?: 'off' | 'fallback' | 'always';
|
|
9
25
|
updatedAt: string;
|
|
10
26
|
};
|
|
11
27
|
export declare class ChannelSecretRegistry {
|
|
@@ -44,6 +44,9 @@ export declare class ExternalWebSocketChannelAdapter implements ExternalChannelA
|
|
|
44
44
|
private sendAwaitAck;
|
|
45
45
|
private handlePayload;
|
|
46
46
|
private buildVisibleText;
|
|
47
|
+
private batchMessageKey;
|
|
48
|
+
private hasSeenBatchMessage;
|
|
49
|
+
private rememberBatchMessage;
|
|
47
50
|
private normalizeAttachments;
|
|
48
51
|
private formatAttachment;
|
|
49
52
|
private formatMessageTime;
|
|
@@ -120,6 +120,8 @@ export class ExternalWebSocketChannelAdapter {
|
|
|
120
120
|
pingTimer: null,
|
|
121
121
|
dedup: new Set(),
|
|
122
122
|
dedupQueue: [],
|
|
123
|
+
seenBatchMessageKeys: new Set(),
|
|
124
|
+
seenBatchMessageQueue: [],
|
|
123
125
|
pendingAcks: new Map(),
|
|
124
126
|
};
|
|
125
127
|
this.connections.set(config.id, conn);
|
|
@@ -232,8 +234,10 @@ export class ExternalWebSocketChannelAdapter {
|
|
|
232
234
|
if (!messageId || this.isDuplicate(conn, messageId))
|
|
233
235
|
return;
|
|
234
236
|
const attachments = this.normalizeAttachments(payload);
|
|
235
|
-
const text = this.buildVisibleText(payload, attachments);
|
|
237
|
+
const text = this.buildVisibleText(conn, payload, attachments);
|
|
236
238
|
const conversationId = String(payload.conversationId || '').trim();
|
|
239
|
+
if (Array.isArray(payload.messages) && !text)
|
|
240
|
+
return;
|
|
237
241
|
if ((!text && attachments.length === 0) || !conversationId)
|
|
238
242
|
return;
|
|
239
243
|
this.onMessage?.({
|
|
@@ -252,7 +256,7 @@ export class ExternalWebSocketChannelAdapter {
|
|
|
252
256
|
replyTarget: '',
|
|
253
257
|
});
|
|
254
258
|
}
|
|
255
|
-
buildVisibleText(payload, attachments) {
|
|
259
|
+
buildVisibleText(conn, payload, attachments) {
|
|
256
260
|
const text = String(payload.text || '').trim();
|
|
257
261
|
if (text)
|
|
258
262
|
return text;
|
|
@@ -262,6 +266,9 @@ export class ExternalWebSocketChannelAdapter {
|
|
|
262
266
|
if (!item || typeof item !== 'object')
|
|
263
267
|
return '';
|
|
264
268
|
const message = item;
|
|
269
|
+
const messageKey = this.batchMessageKey(message, payload);
|
|
270
|
+
if (messageKey && this.hasSeenBatchMessage(conn, messageKey))
|
|
271
|
+
return '';
|
|
265
272
|
const isGroupMessage = String(message.conversationType || payload.conversationType || '') === 'group';
|
|
266
273
|
const sender = String(message.senderName || (isGroupMessage ? '群友' : message.senderExternalId || message.senderId || 'unknown'));
|
|
267
274
|
const time = this.formatMessageTime(message.timestampIso || message.timestamp);
|
|
@@ -269,6 +276,8 @@ export class ExternalWebSocketChannelAdapter {
|
|
|
269
276
|
const messageAttachments = this.normalizeAttachments(message);
|
|
270
277
|
const attachmentText = messageAttachments.map((attachment) => this.formatAttachment(attachment)).join(' ');
|
|
271
278
|
const content = [messageText, attachmentText].filter(Boolean).join(' ').trim() || `[${String(message.contentType || 'message')}]`;
|
|
279
|
+
if (messageKey)
|
|
280
|
+
this.rememberBatchMessage(conn, messageKey);
|
|
272
281
|
return `${index + 1}. ${time} ${sender}: ${content}`;
|
|
273
282
|
})
|
|
274
283
|
.filter(Boolean)
|
|
@@ -277,6 +286,34 @@ export class ExternalWebSocketChannelAdapter {
|
|
|
277
286
|
}
|
|
278
287
|
return attachments.map((attachment) => this.formatAttachment(attachment)).join('\n');
|
|
279
288
|
}
|
|
289
|
+
batchMessageKey(message, payload) {
|
|
290
|
+
const explicitId = String(message.messageId || message.msgid || message.id || message.rawId || '').trim();
|
|
291
|
+
if (explicitId)
|
|
292
|
+
return `id:${explicitId}`;
|
|
293
|
+
const sender = String(message.senderExternalId || message.senderId || message.senderName || '').trim();
|
|
294
|
+
const timestamp = String(message.timestampIso || message.timestamp || message.receivedAt || '').trim();
|
|
295
|
+
const text = String(message.text || '').trim();
|
|
296
|
+
const contentType = String(message.contentType || '').trim();
|
|
297
|
+
const conversationId = String(message.conversationId || payload.conversationId || '').trim();
|
|
298
|
+
const attachmentKey = this.normalizeAttachments(message)
|
|
299
|
+
.map((attachment) => [attachment.type, attachment.name || '', attachment.url || '', attachment.size ?? ''].join(':'))
|
|
300
|
+
.join('|');
|
|
301
|
+
return `content:${conversationId}\n${sender}\n${timestamp}\n${contentType}\n${text}\n${attachmentKey}`;
|
|
302
|
+
}
|
|
303
|
+
hasSeenBatchMessage(conn, key) {
|
|
304
|
+
return conn.seenBatchMessageKeys.has(key);
|
|
305
|
+
}
|
|
306
|
+
rememberBatchMessage(conn, key) {
|
|
307
|
+
if (conn.seenBatchMessageKeys.has(key))
|
|
308
|
+
return;
|
|
309
|
+
conn.seenBatchMessageKeys.add(key);
|
|
310
|
+
conn.seenBatchMessageQueue.push(key);
|
|
311
|
+
while (conn.seenBatchMessageQueue.length > 2_000) {
|
|
312
|
+
const removed = conn.seenBatchMessageQueue.shift();
|
|
313
|
+
if (removed)
|
|
314
|
+
conn.seenBatchMessageKeys.delete(removed);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
280
317
|
normalizeAttachments(payload) {
|
|
281
318
|
const direct = Array.isArray(payload.attachments) ? payload.attachments : [];
|
|
282
319
|
const nested = Array.isArray(payload.messages)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export type MacWeChatRpaFlowOptions = {
|
|
2
|
+
groupName: string;
|
|
3
|
+
replyText?: string;
|
|
4
|
+
attachmentPath?: string;
|
|
5
|
+
scriptPath?: string;
|
|
6
|
+
workDir?: string;
|
|
7
|
+
forceForeground?: boolean;
|
|
8
|
+
noRestore?: boolean;
|
|
9
|
+
idleSeconds?: number;
|
|
10
|
+
recentLimit?: number;
|
|
11
|
+
downloadAttachmentsDir?: string;
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
cloudOcrUrl?: string;
|
|
14
|
+
cloudOcrToken?: string;
|
|
15
|
+
cloudOcrMode?: 'off' | 'fallback' | 'always';
|
|
16
|
+
cloudOcrChannelId?: string;
|
|
17
|
+
};
|
|
18
|
+
export type MacWeChatRpaFlowResult = {
|
|
19
|
+
ok: boolean;
|
|
20
|
+
groupName: string;
|
|
21
|
+
interrupted?: boolean;
|
|
22
|
+
reason?: string;
|
|
23
|
+
screenshotPath?: string;
|
|
24
|
+
recentMessages?: MacWeChatRpaFlowMessage[];
|
|
25
|
+
newMessages?: MacWeChatRpaFlowMessage[];
|
|
26
|
+
sentReply?: boolean;
|
|
27
|
+
sentReplyObserved?: boolean;
|
|
28
|
+
sentAttachment?: boolean;
|
|
29
|
+
sentAttachmentObserved?: boolean;
|
|
30
|
+
postSendScreenshotPath?: string;
|
|
31
|
+
cloudOcrPurpose?: MacWeChatRpaCloudOcrPurpose;
|
|
32
|
+
cloudOcrObservations?: MacWeChatRpaCloudOcrObservation[];
|
|
33
|
+
cloudOcrRequestId?: string;
|
|
34
|
+
cloudOcrImageHash?: string;
|
|
35
|
+
cloudOcrUsage?: MacWeChatRpaCloudOcrUsage;
|
|
36
|
+
error?: string;
|
|
37
|
+
};
|
|
38
|
+
export type MacWeChatRpaCloudOcrPurpose = 'message-read' | 'attachment-localization' | 'send-confirmation';
|
|
39
|
+
export type MacWeChatRpaFlowMessage = {
|
|
40
|
+
id?: string;
|
|
41
|
+
text?: string;
|
|
42
|
+
confidence?: number;
|
|
43
|
+
attachments?: MacWeChatRpaAttachment[];
|
|
44
|
+
};
|
|
45
|
+
export type MacWeChatRpaAttachment = {
|
|
46
|
+
type: string;
|
|
47
|
+
name?: string;
|
|
48
|
+
mimeType?: string;
|
|
49
|
+
size?: number;
|
|
50
|
+
url?: string;
|
|
51
|
+
localPath?: string;
|
|
52
|
+
thumbnailPath?: string;
|
|
53
|
+
hash?: string;
|
|
54
|
+
availability?: 'edge-local' | 'server-url' | 'pending-download' | 'metadata-only' | 'unavailable-large';
|
|
55
|
+
machineId?: string;
|
|
56
|
+
expiresAt?: string;
|
|
57
|
+
providerError?: string;
|
|
58
|
+
};
|
|
59
|
+
export type MacWeChatRpaCloudOcrObservation = {
|
|
60
|
+
text: string;
|
|
61
|
+
confidence?: number;
|
|
62
|
+
role?: string;
|
|
63
|
+
attachment?: MacWeChatRpaAttachment;
|
|
64
|
+
};
|
|
65
|
+
export type MacWeChatRpaCloudOcrUsage = {
|
|
66
|
+
inputTokens?: number;
|
|
67
|
+
outputTokens?: number;
|
|
68
|
+
totalTokens?: number;
|
|
69
|
+
};
|
|
70
|
+
export declare function runMacWeChatRpaFlow(options: MacWeChatRpaFlowOptions): Promise<MacWeChatRpaFlowResult>;
|
|
71
|
+
export declare function selectCloudOcrRequest(result: Pick<MacWeChatRpaFlowResult, 'newMessages' | 'recentMessages' | 'screenshotPath' | 'postSendScreenshotPath' | 'sentReply' | 'sentAttachment'>, mode: 'off' | 'fallback' | 'always'): {
|
|
72
|
+
screenshotPath: string;
|
|
73
|
+
purpose: MacWeChatRpaCloudOcrPurpose;
|
|
74
|
+
} | null;
|
|
75
|
+
export declare function shouldUseCloudOcr(result: Pick<MacWeChatRpaFlowResult, 'newMessages' | 'recentMessages'>, mode: 'off' | 'fallback' | 'always'): boolean;
|
|
76
|
+
export declare function mergeCloudMessages(localMessages: MacWeChatRpaFlowMessage[], cloudMessages: MacWeChatRpaFlowMessage[]): MacWeChatRpaFlowMessage[];
|
|
77
|
+
export declare function messagesFromCloudOcrObservations(groupName: string, observations: MacWeChatRpaCloudOcrObservation[]): MacWeChatRpaFlowMessage[];
|