shennian 0.2.73 → 0.2.75

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.
@@ -31,6 +31,40 @@ function buildReplyCommandInstructions(mode, sessionHint, workdirHint) {
31
31
  '只发送用户可见的最终内容,不要发送内部推理、工具日志或实现细节。',
32
32
  ].filter(Boolean).join('\n');
33
33
  }
34
+ function externalChannelMessageLabel(channel) {
35
+ if (channel?.type === 'wechat-rpa')
36
+ return '个人微信群';
37
+ if (channel?.type === 'feishu')
38
+ return '飞书群';
39
+ if (channel?.type === 'wecom')
40
+ return '企业微信群';
41
+ return '外部群';
42
+ }
43
+ function attachmentStatusInstructions(channel) {
44
+ if (channel?.type !== 'wechat-rpa') {
45
+ return '外部消息里的附件会作为本地文件或可下载链接进入对话;需要转发时先落到本地文件,再用 send-image/send-video/send-file。';
46
+ }
47
+ return [
48
+ '个人微信附件可能带状态:edge-local 表示附件只在绑定电脑本地可用;metadata-only 表示目前只识别到图片/视频/文件气泡或文件名;pending-download 表示等待本机 RPA 下载。',
49
+ '收到 metadata-only 或 pending-download 附件时,不要假装已经看过文件内容;如果需要内容,先说明需要绑定机器下载/同步,或等待下一轮本机 RPA 本地化后再处理。',
50
+ '转发 edge-local 附件时优先使用随消息进入对话的本地附件路径;不要把仅本机可用的路径当成远端用户也能打开的链接。',
51
+ ].join('\n');
52
+ }
53
+ function weChatRpaRuntimeInstructions(channel) {
54
+ if (channel?.type !== 'wechat-rpa')
55
+ return '';
56
+ const groups = Array.isArray(channel.wechatRpaGroups)
57
+ ? channel.wechatRpaGroups.map((group) => group.name).filter(Boolean)
58
+ : [];
59
+ return [
60
+ '这是运行在绑定电脑上的个人微信 RPA 通道,不是云端托管企业微信账号。',
61
+ groups.length ? `当前监听群:${groups.join('、')}。只把这些群里的消息当成此通道上下文。` : '',
62
+ channel.forceForeground ? '本通道允许在用户空闲时短暂前台化微信。' : '本通道默认不强制抢前台;用户正在操作时可能延迟收发。',
63
+ channel.cloudOcrMode && channel.cloudOcrMode !== 'off'
64
+ ? `截图识别不足时会通过神念服务端调用云端 OCR fallback(模式:${channel.cloudOcrMode}),边缘端不直接持有千问 API key。`
65
+ : '默认只用本机 OCR/视觉结果;不要假设云端多模态已经理解图片内容。',
66
+ ].filter(Boolean).join('\n');
67
+ }
34
68
  export function buildExternalChannelInstructions(channel, workDir, sessionId, mode = 'agent') {
35
69
  if (!channel?.configured && !channel?.connected)
36
70
  return '';
@@ -44,8 +78,10 @@ export function buildExternalChannelInstructions(channel, workDir, sessionId, mo
44
78
  : '如果使用 shell_command,请设置 workdir 为当前对话的项目目录。不要用裸 /bin/zsh -lc 或 command_execution 运行此命令,因为那种环境可能拿不到当前对话的外部通道身份。';
45
79
  const sections = [
46
80
  `当前对话已接入外部消息通道:${channelName}。`,
47
- `外部企业微信群消息会以如下格式进入对话:\n外部企业微信群消息\n<时间> <用户昵称>: <内容>`,
81
+ `外部${externalChannelMessageLabel(channel)}消息会以如下格式进入对话:\n外部${externalChannelMessageLabel(channel)}消息\n<时间> <用户昵称>: <内容>`,
48
82
  EXTERNAL_REQUEST_GUARDRAILS,
83
+ weChatRpaRuntimeInstructions(channel),
84
+ attachmentStatusInstructions(channel),
49
85
  channel.canReply === false
50
86
  ? '当前通道只允许接收消息,不要尝试向外部通道发送回复。'
51
87
  : buildReplyCommandInstructions(mode, sessionHint, workdirHint),
@@ -1,3 +1,5 @@
1
+ import type { ExternalChannelSessionStatus } from '@shennian/wire';
2
+ export type ExternalChannelRuntimeStatus = Pick<ExternalChannelSessionStatus, 'wechatRpaRuntimeState' | 'wechatRpaLastRunAt' | 'wechatRpaLastMessageAt' | 'wechatRpaLastInterruptedAt' | 'wechatRpaPendingReplyCount' | 'wechatRpaLastError' | 'wechatRpaLastCloudOcrAt' | 'wechatRpaLastCloudOcrPurpose' | 'wechatRpaLastCloudOcrRequestId' | 'wechatRpaLastCloudOcrImageHash' | 'wechatRpaLastCloudOcrUsage'>;
1
3
  export type ExternalChannelType = 'wecom' | 'websocket' | 'wechat-rpa';
2
4
  export type ExternalChannelConfig = {
3
5
  id: string;
@@ -28,6 +30,34 @@ export type ExternalChannelView = {
28
30
  tokenConfigured?: boolean;
29
31
  canReply?: boolean;
30
32
  systemPrompt?: string;
33
+ wechatRpaSource?: string;
34
+ wechatRpaGroups?: Array<{
35
+ name: string;
36
+ }>;
37
+ pollIntervalMs?: number;
38
+ recentLimit?: number;
39
+ idleSeconds?: number;
40
+ forceForeground?: boolean;
41
+ noRestore?: boolean;
42
+ downloadAttachments?: boolean;
43
+ downloadAttachmentsDir?: string;
44
+ cloudOcrUrl?: string;
45
+ cloudOcrToken?: string;
46
+ cloudOcrMode?: string;
47
+ wechatRpaRuntimeState?: ExternalChannelSessionStatus['wechatRpaRuntimeState'];
48
+ wechatRpaLastRunAt?: string | null;
49
+ wechatRpaLastMessageAt?: string | null;
50
+ wechatRpaLastInterruptedAt?: string | null;
51
+ wechatRpaLastError?: string | null;
52
+ wechatRpaLastCloudOcrAt?: string | null;
53
+ wechatRpaLastCloudOcrPurpose?: string | null;
54
+ wechatRpaLastCloudOcrRequestId?: string | null;
55
+ wechatRpaLastCloudOcrImageHash?: string | null;
56
+ wechatRpaLastCloudOcrUsage?: {
57
+ inputTokens?: number;
58
+ outputTokens?: number;
59
+ totalTokens?: number;
60
+ } | null;
31
61
  };
32
62
  export type ExternalMessageEvent = {
33
63
  type: 'external.message';
@@ -40,29 +70,51 @@ export type ExternalMessageEvent = {
40
70
  name?: string | null;
41
71
  };
42
72
  text: string;
43
- attachments: Array<{
44
- type: string;
45
- name?: string;
46
- url?: string;
47
- mimeType?: string;
48
- size?: number;
49
- }>;
73
+ attachments: ExternalMessageAttachment[];
50
74
  receivedAt: string;
51
75
  replyTarget: string;
52
76
  rawRef?: string | null;
53
77
  };
78
+ export type ExternalMessageAttachment = {
79
+ type: string;
80
+ name?: string;
81
+ url?: string;
82
+ mimeType?: string;
83
+ size?: number;
84
+ localPath?: string;
85
+ thumbnailPath?: string;
86
+ hash?: string;
87
+ availability?: 'edge-local' | 'server-url' | 'pending-download' | 'metadata-only' | 'unavailable-large';
88
+ machineId?: string;
89
+ expiresAt?: string;
90
+ providerError?: string;
91
+ };
92
+ export type ExternalReplyAttachment = {
93
+ kind: 'image' | 'video' | 'file';
94
+ name: string;
95
+ mimeType: string;
96
+ size: number;
97
+ dataBase64?: string;
98
+ localPath?: string;
99
+ url?: string;
100
+ };
54
101
  export type ExternalReply = {
55
102
  channelId: string;
56
103
  conversationId: string;
57
104
  text: string;
105
+ attachment?: ExternalReplyAttachment;
58
106
  messageId?: string;
59
107
  idempotencyKey?: string;
60
108
  };
109
+ export type ExternalChannelSendResult = void | {
110
+ status: 'sent' | 'queued';
111
+ reason?: string;
112
+ };
61
113
  export interface ExternalChannelAdapter {
62
114
  readonly type: ExternalChannelType;
63
115
  connect(config: ExternalChannelConfig): Promise<void>;
64
116
  disconnect(config: ExternalChannelConfig): Promise<void>;
65
- send(config: ExternalChannelConfig, reply: ExternalReply): Promise<void>;
117
+ send(config: ExternalChannelConfig, reply: ExternalReply): Promise<ExternalChannelSendResult>;
66
118
  health(config: ExternalChannelConfig): Promise<{
67
119
  ok: boolean;
68
120
  message?: string;
@@ -71,4 +123,5 @@ export interface ExternalChannelAdapter {
71
123
  conversationId: string;
72
124
  conversationName?: string;
73
125
  }>;
126
+ runtimeStatus?(config: ExternalChannelConfig): Partial<ExternalChannelRuntimeStatus>;
74
127
  }
@@ -6,6 +6,7 @@ export declare class ChannelRuntime {
6
6
  private configs;
7
7
  private secrets;
8
8
  private adapters;
9
+ private completedReplyKeys;
9
10
  constructor(onExternalMessage: (sessionId: string, event: ExternalMessageEvent) => void, createReplyTarget: (input: {
10
11
  managerSessionId: string;
11
12
  channelId: string;
@@ -21,10 +22,14 @@ export declare class ChannelRuntime {
21
22
  managerSessionId: string;
22
23
  }): Promise<{
23
24
  ok: true;
25
+ pending?: boolean;
24
26
  } | {
25
27
  ok: false;
26
28
  error: string;
27
29
  }>;
30
+ private isReplyCompleted;
31
+ private markReplyCompleted;
32
+ private loadReplyCompletionSet;
28
33
  getDefaultReplyTarget(sessionId: string): Promise<{
29
34
  channelId: string;
30
35
  conversationId: string;
@@ -35,15 +40,8 @@ export declare class ChannelRuntime {
35
40
  getChannelById(channelId: string, opts?: {
36
41
  includeSecret?: boolean;
37
42
  }): ExternalChannelView | null;
38
- getManagerChannelStatus(managerSessionId: string): {
39
- configured: boolean;
40
- connected: boolean;
41
- type?: string;
42
- channelId?: string;
43
- name?: string;
44
- canReply?: boolean;
45
- systemPrompt?: string;
46
- } | null;
43
+ getChannelStatusById(channelId: string): ExternalChannelSessionStatus | null;
44
+ getManagerChannelStatus(managerSessionId: string): ExternalChannelSessionStatus | null;
47
45
  listManagerChannelStatuses(): Array<{
48
46
  managerSessionId: string;
49
47
  status: {
@@ -71,4 +69,38 @@ export declare class ChannelRuntime {
71
69
  canReply?: boolean;
72
70
  systemPrompt?: string;
73
71
  }): Promise<ExternalChannelView>;
72
+ upsertManagerWeChatRpaChannel(input: {
73
+ id: string;
74
+ managerSessionId: string;
75
+ sessionId?: string;
76
+ workDir: string;
77
+ name?: string;
78
+ agentType?: string;
79
+ agentSessionId?: string | null;
80
+ modelId?: string | null;
81
+ enabled: boolean;
82
+ groups: Array<{
83
+ name: string;
84
+ }>;
85
+ canReply?: boolean;
86
+ systemPrompt?: string;
87
+ source?: 'macos-flow' | 'macos-probe' | 'fixture-jsonl';
88
+ pollIntervalMs?: number;
89
+ recentLimit?: number;
90
+ idleSeconds?: number;
91
+ forceForeground?: boolean;
92
+ noRestore?: boolean;
93
+ downloadAttachments?: boolean;
94
+ downloadAttachmentsDir?: string;
95
+ flowScriptPath?: string;
96
+ cloudOcrUrl?: string;
97
+ cloudOcrToken?: string;
98
+ cloudOcrMode?: 'off' | 'fallback' | 'always';
99
+ }): Promise<ExternalChannelView>;
74
100
  }
101
+ export type ExternalReplySendPlanItem = {
102
+ text: string;
103
+ attachment?: ExternalReply['attachment'];
104
+ idempotencyKey?: string;
105
+ };
106
+ export declare function planExternalReplySends(channelType: ExternalChannelConfig['type'], input: Pick<ExternalReply, 'text' | 'attachment' | 'idempotencyKey'>): ExternalReplySendPlanItem[];
@@ -1,17 +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';
6
9
  import { WeChatRpaChannelAdapter } from './wechat-rpa.js';
7
10
  import { ExternalWebSocketChannelAdapter } from './websocket.js';
8
11
  import { splitExternalReplyText } from './reply-split.js';
12
+ import { loadConfig } from '../config/index.js';
13
+ import { SERVERS } from '../region.js';
9
14
  export class ChannelRuntime {
10
15
  onExternalMessage;
11
16
  createReplyTarget;
12
17
  configs = new ChannelConfigRegistry();
13
18
  secrets = new ChannelSecretRegistry();
14
19
  adapters = new Map();
20
+ completedReplyKeys = new Map();
15
21
  constructor(onExternalMessage, createReplyTarget) {
16
22
  this.onExternalMessage = onExternalMessage;
17
23
  this.createReplyTarget = createReplyTarget;
@@ -56,24 +62,55 @@ export class ChannelRuntime {
56
62
  if (!adapter)
57
63
  return { ok: false, error: `Unsupported channel type: ${config.type}` };
58
64
  try {
59
- const parts = splitExternalReplyText(input.text);
60
- if (!parts.length)
61
- return { ok: false, error: 'Reply text is required' };
62
- for (const [index, text] of parts.entries()) {
63
- 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, {
64
74
  ...input,
65
- text,
66
- idempotencyKey: parts.length > 1 && input.idempotencyKey
67
- ? `${input.idempotencyKey}:${index + 1}`
68
- : input.idempotencyKey,
75
+ ...send,
69
76
  });
77
+ if (result?.status === 'queued') {
78
+ pending = true;
79
+ continue;
80
+ }
81
+ if (idempotencyKey)
82
+ this.markReplyCompleted(config, input.conversationId, idempotencyKey);
70
83
  }
71
- return { ok: true };
84
+ return pending ? { ok: true, pending: true } : { ok: true };
72
85
  }
73
86
  catch (err) {
74
87
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
75
88
  }
76
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
+ }
77
114
  async getDefaultReplyTarget(sessionId) {
78
115
  const config = this.configs.list().find((channel) => (channel.sessionId ?? channel.managerSessionId) === sessionId && channel.enabled);
79
116
  if (!config)
@@ -94,6 +131,7 @@ export class ChannelRuntime {
94
131
  if (!config)
95
132
  return null;
96
133
  const secret = this.secrets.get(config.secretRef);
134
+ const adapterStatus = this.adapters.get(config.type)?.runtimeStatus?.(config) ?? {};
97
135
  return {
98
136
  id: config.id,
99
137
  type: config.type,
@@ -110,6 +148,8 @@ export class ChannelRuntime {
110
148
  tokenConfigured: Boolean(secret?.token),
111
149
  canReply: Boolean(secret?.canReply),
112
150
  systemPrompt: typeof secret?.systemPrompt === 'string' ? secret.systemPrompt : '',
151
+ ...wechatRpaViewFields(secret, opts.includeSecret),
152
+ ...adapterStatus,
113
153
  };
114
154
  }
115
155
  getChannelById(channelId, opts = {}) {
@@ -117,6 +157,7 @@ export class ChannelRuntime {
117
157
  if (!config)
118
158
  return null;
119
159
  const secret = this.secrets.get(config.secretRef);
160
+ const adapterStatus = this.adapters.get(config.type)?.runtimeStatus?.(config) ?? {};
120
161
  return {
121
162
  id: config.id,
122
163
  type: config.type,
@@ -133,6 +174,26 @@ export class ChannelRuntime {
133
174
  tokenConfigured: Boolean(secret?.token),
134
175
  canReply: Boolean(secret?.canReply),
135
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,
136
197
  };
137
198
  }
138
199
  getManagerChannelStatus(managerSessionId) {
@@ -140,14 +201,17 @@ export class ChannelRuntime {
140
201
  if (!config)
141
202
  return null;
142
203
  const secret = this.secrets.get(config.secretRef);
204
+ const adapterStatus = this.adapters.get(config.type)?.runtimeStatus?.(config) ?? {};
143
205
  return {
144
206
  configured: true,
145
- connected: Boolean(secret?.token),
207
+ connected: isChannelSecretConfigured(config, secret),
146
208
  type: config.type,
147
209
  channelId: config.id,
148
210
  name: config.name,
149
211
  canReply: Boolean(secret?.canReply),
150
212
  systemPrompt: typeof secret?.systemPrompt === 'string' ? secret.systemPrompt : '',
213
+ ...wechatRpaStatusFields(secret),
214
+ ...adapterStatus,
151
215
  };
152
216
  }
153
217
  listManagerChannelStatuses() {
@@ -164,14 +228,17 @@ export class ChannelRuntime {
164
228
  .filter((channel) => channel.enabled && (channel.sessionId ?? channel.managerSessionId) === managerSessionId)
165
229
  .map((channel) => {
166
230
  const secret = this.secrets.get(channel.secretRef);
231
+ const adapterStatus = this.adapters.get(channel.type)?.runtimeStatus?.(channel) ?? {};
167
232
  return {
168
233
  configured: true,
169
- connected: Boolean(secret?.token),
234
+ connected: isChannelSecretConfigured(channel, secret),
170
235
  type: channel.type,
171
236
  channelId: channel.id,
172
237
  name: channel.name,
173
238
  canReply: Boolean(secret?.canReply),
174
239
  systemPrompt: typeof secret?.systemPrompt === 'string' ? secret.systemPrompt : '',
240
+ ...wechatRpaStatusFields(secret),
241
+ ...adapterStatus,
175
242
  };
176
243
  });
177
244
  }
@@ -227,4 +294,210 @@ export class ChannelRuntime {
227
294
  }
228
295
  return this.getManagerChannel(boundSessionId, input.type, { includeSecret: true });
229
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;
230
503
  }
@@ -6,9 +6,22 @@ type ChannelSecretRecord = {
6
6
  token?: string;
7
7
  canReply?: boolean;
8
8
  systemPrompt?: string;
9
- source?: 'macos-probe' | 'fixture-jsonl';
9
+ source?: 'macos-probe' | 'macos-flow' | 'fixture-jsonl';
10
10
  fixturePath?: string;
11
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';
12
25
  updatedAt: string;
13
26
  };
14
27
  export declare class ChannelSecretRegistry {