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.
Files changed (36) hide show
  1. package/dist/src/agents/command-spec.js +19 -12
  2. package/dist/src/agents/external-channel-instructions.d.ts +3 -1
  3. package/dist/src/agents/external-channel-instructions.js +73 -15
  4. package/dist/src/channels/base.d.ts +62 -9
  5. package/dist/src/channels/runtime.d.ts +43 -10
  6. package/dist/src/channels/runtime.js +300 -14
  7. package/dist/src/channels/secret-registry.d.ts +17 -1
  8. package/dist/src/channels/websocket.d.ts +3 -0
  9. package/dist/src/channels/websocket.js +39 -2
  10. package/dist/src/channels/wechat-rpa/macos-flow.d.ts +77 -0
  11. package/dist/src/channels/wechat-rpa/macos-flow.js +254 -0
  12. package/dist/src/channels/wechat-rpa/macos.d.ts +11 -0
  13. package/dist/src/channels/wechat-rpa/macos.js +63 -0
  14. package/dist/src/channels/wechat-rpa/normalizer.d.ts +42 -0
  15. package/dist/src/channels/wechat-rpa/normalizer.js +99 -0
  16. package/dist/src/channels/wechat-rpa.d.ts +51 -0
  17. package/dist/src/channels/wechat-rpa.js +587 -0
  18. package/dist/src/channels/wecom.d.ts +3 -0
  19. package/dist/src/channels/wecom.js +43 -1
  20. package/dist/src/commands/external-attachments.d.ts +1 -1
  21. package/dist/src/commands/external-attachments.js +2 -3
  22. package/dist/src/commands/external.js +19 -1
  23. package/dist/src/commands/manager.js +109 -0
  24. package/dist/src/manager/prompt.d.ts +1 -1
  25. package/dist/src/manager/prompt.js +1 -11
  26. package/dist/src/manager/runtime.d.ts +2 -10
  27. package/dist/src/manager/runtime.js +197 -33
  28. package/dist/src/native-fusion/service.js +7 -0
  29. package/dist/src/session/archive-zip.d.ts +10 -0
  30. package/dist/src/session/archive-zip.js +220 -0
  31. package/dist/src/session/handlers/agent-config.js +85 -6
  32. package/dist/src/session/handlers/chat.js +58 -2
  33. package/dist/src/session/handlers/fs.d.ts +1 -0
  34. package/dist/src/session/handlers/fs.js +57 -1
  35. package/dist/src/session/manager.js +4 -1
  36. 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 parts = splitExternalReplyText(input.text);
57
- if (!parts.length)
58
- return { ok: false, error: 'Reply text is required' };
59
- for (const [index, text] of parts.entries()) {
60
- await adapter.send(config, {
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
- text,
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: Boolean(secret?.token),
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
- listManagerChannelSystemPrompts(managerSessionId) {
226
+ listManagerExternalChannels(managerSessionId) {
160
227
  return this.configs.list()
161
228
  .filter((channel) => channel.enabled && (channel.sessionId ?? channel.managerSessionId) === managerSessionId)
162
- .map((channel) => this.secrets.get(channel.secretRef)?.systemPrompt)
163
- .filter((prompt) => typeof prompt === 'string' && prompt.trim().length > 0);
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[];