shennian 0.2.73 → 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/external-channel-instructions.js +37 -1
- package/dist/src/channels/base.d.ts +61 -8
- package/dist/src/channels/runtime.d.ts +41 -9
- package/dist/src/channels/runtime.js +285 -12
- package/dist/src/channels/secret-registry.d.ts +14 -1
- 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/normalizer.d.ts +17 -0
- package/dist/src/channels/wechat-rpa/normalizer.js +47 -3
- package/dist/src/channels/wechat-rpa.d.ts +35 -18
- package/dist/src/channels/wechat-rpa.js +488 -24
- 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/runtime.d.ts +2 -10
- package/dist/src/manager/runtime.js +194 -32
- package/dist/src/native-fusion/service.js +7 -0
- package/dist/src/session/handlers/chat.js +31 -1
- package/package.json +10 -9
|
@@ -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
|
-
|
|
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:
|
|
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<
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
60
|
-
if (!
|
|
61
|
-
return { ok: false, error: 'Reply text is required' };
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 {
|