@rezti/dsh-rez-wechat 0.1.14 → 0.1.15
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/README.md +11 -3
- package/lib/index.js +62 -16
- package/lib/web-shim.d.ts +4 -0
- package/lib/web-shim.js +21 -2
- package/lib/wecom-channel.d.ts +68 -0
- package/lib/wecom-channel.js +255 -0
- package/lib/wecom-store.d.ts +26 -0
- package/lib/wecom-store.js +98 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
# @rezti/dsh-rez-wechat
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
企业微信走官方 `@wecom/aibot-node-sdk`(智能机器人 BotID + Secret 长连接);个人微信走 QClaw 同款 `dsh-wechat-bridge`(腾讯 iLink)。都不自写长连接。
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
dsh plugin --profile web add @rezti/dsh-rez-suite
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
以后升级只记:`dsh plugin --profile web
|
|
9
|
+
以后升级只记:`dsh plugin --profile web add @rezti/dsh-rez-suite@latest`。不要再单独 add 本包。
|
|
10
10
|
|
|
11
11
|
| 能力 | 做法 |
|
|
12
12
|
|---|---|
|
|
13
|
-
|
|
|
13
|
+
| 企业微信智能机器人 | 官方 `@wecom/aibot-node-sdk`。设置页给每个 Harness 房间绑一个 BotID + Secret。同事在企微跟哪个 bot 说话,消息进网页左侧那个房间。密钥 `~/.dsh/wecom-channels.json`(mode 600),不进 compose。 |
|
|
14
|
+
| 旧群机器人 Webhook(兼容) | 一个 bot 都没绑、且仍配置了 `REZ_WECHAT_WEBHOOK` 时,才拉起 `wecom-mcp` |
|
|
14
15
|
| 个人微信(QClaw / ClawBot) | dsh Web 设置面板扫码,之后直接在微信里说话。消息进左侧 **微信** 文件夹的同一会话,模型跟网页默认。 |
|
|
15
16
|
|
|
16
17
|
个人微信桥接到正在跑的 `dsh web`(不是 `dsh --profile headless`):
|
|
@@ -23,6 +24,13 @@ dsh plugin --profile web add @rezti/dsh-rez-suite
|
|
|
23
24
|
- 思考过程不会发到微信;多段正文会保留换行
|
|
24
25
|
- 通道断了点「重新连接」,已登录不必再扫码
|
|
25
26
|
|
|
27
|
+
企微智能机器人(Accio 同款按房间绑定):
|
|
28
|
+
|
|
29
|
+
- 可绑房间:搞钱、产品、运营、人事助理、财务助理、法务助理、设计、实验室(不要绑「微信」个人通道)
|
|
30
|
+
- 会话标题是房间名;网页必须开着
|
|
31
|
+
- 收文本 / 混排文字、流式回复、进入会话欢迎语;图片/文件/语音请走网页端或 Nextcloud
|
|
32
|
+
- BotID / Secret 在企业微信管理后台 → 智能机器人([开发前必读](https://developer.work.weixin.qq.com/document/path/101805) / [长连接](https://developer.work.weixin.qq.com/document/path/101463))
|
|
33
|
+
|
|
26
34
|
扫码 UI 在 suite 面板;通道本身在本包。不要对同一 profile 既 add suite 又 add wechat,会重复注册。
|
|
27
35
|
|
|
28
36
|
这是 ClawBot 客服会话,不是用个人微信号管好友/群,也不是客户层 WhatsApp/邮件通道。
|
package/lib/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { startMcpWhenSecret } from '@rezti/dsh-rez-sso';
|
|
2
2
|
import { WeixinChannel } from './channel.js';
|
|
3
|
+
import { WecomChannel, shouldStartWecomWebhook } from './wecom-channel.js';
|
|
3
4
|
import { revealSessionInRegistry } from './unarchive.js';
|
|
5
|
+
import { WECOM_BINDABLE_ROOMS } from './wecom-store.js';
|
|
4
6
|
export const name = 'rez-wechat';
|
|
5
7
|
export const inject = ['systemPrompt', 'webServer', 'rezSso'];
|
|
6
8
|
const GUIDANCE = [
|
|
7
|
-
'
|
|
8
|
-
'个人微信是 QClaw 同款 ClawBot 通道:在 Rez 面板「微信」页扫码一次,然后直接在微信里说话。消息进入网页左侧「微信」文件夹并延续同一会话,模型跟网页默认(在网页里选了 Kimi 就会走 Kimi)。网页端归档了也能从微信自动恢复到侧栏(空白会话网页会藏起来,所以新开的会话会先打一句招呼再出现)。网页端出现选择题时会编成纯文本发到微信,回复数字或选项原文即可;思考过程不会转发。发「重启对话」会新开一条可见的网页会话并把旧的归档。通道断了点「重新连接」(已登录不必再扫码)。不要再跑 npx weixin-mcp login,也不要调用 mcp__weixin__
|
|
9
|
+
'企业微信智能机器人走官方 @wecom/aibot-node-sdk(BotID + Secret 长连接)。设置页按 Harness 房间各绑一个 bot:同事在企微跟哪个机器人说话,消息就进网页左侧那个房间(人事/财务/法务/设计/实验室/老板三室)。密钥写在 ~/.dsh/wecom-channels.json,不要写进 compose。没有绑定任何 bot 且仍配置了 REZ_WECHAT_WEBHOOK 时,才兼容旧的 wecom-mcp 群发。不要自写 WebSocket,也不要引导去填群 Webhook。',
|
|
10
|
+
'个人微信是 QClaw 同款 ClawBot 通道:在 Rez 面板「微信」页扫码一次,然后直接在微信里说话。消息进入网页左侧「微信」文件夹并延续同一会话,模型跟网页默认(在网页里选了 Kimi 就会走 Kimi)。网页端归档了也能从微信自动恢复到侧栏(空白会话网页会藏起来,所以新开的会话会先打一句招呼再出现)。网页端出现选择题时会编成纯文本发到微信,回复数字或选项原文即可;思考过程不会转发。发「重启对话」会新开一条可见的网页会话并把旧的归档。通道断了点「重新连接」(已登录不必再扫码)。不要再跑 npx weixin-mcp login,也不要调用 mcp__weixin__*。网页端必须开着。工商查询请切到法务助理,避免串房间。',
|
|
9
11
|
'这是 ClawBot 客服会话,不是用个人微信号管好友/群。对外发消息必须先征得用户批准。',
|
|
10
12
|
].join('');
|
|
11
13
|
function isLoopbackRequest(request) {
|
|
@@ -118,6 +120,43 @@ function weixinRoutes(channel, ctx) {
|
|
|
118
120
|
},
|
|
119
121
|
];
|
|
120
122
|
}
|
|
123
|
+
function wecomRoutes(channel) {
|
|
124
|
+
const path = '/api/dsh-rez-suite/wecom/channels';
|
|
125
|
+
const guard = (req, res, method) => {
|
|
126
|
+
if (!isLoopbackRequest(req)) {
|
|
127
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
if (req.method !== method) {
|
|
131
|
+
writeJson(res, 405, { error: 'method not allowed' });
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
135
|
+
};
|
|
136
|
+
return [
|
|
137
|
+
{
|
|
138
|
+
kind: 'exact',
|
|
139
|
+
path,
|
|
140
|
+
handler: async (req, res) => {
|
|
141
|
+
if (req.method === 'GET') {
|
|
142
|
+
if (!guard(req, res, 'GET'))
|
|
143
|
+
return;
|
|
144
|
+
writeJson(res, 200, { ...channel.publicState(), rooms: [...WECOM_BINDABLE_ROOMS] });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (req.method === 'PUT') {
|
|
148
|
+
if (!guard(req, res, 'PUT'))
|
|
149
|
+
return;
|
|
150
|
+
const body = await readJsonBody(req);
|
|
151
|
+
const incoming = body?.bots ?? body;
|
|
152
|
+
writeJson(res, 200, await channel.applyBots(incoming));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
writeJson(res, 405, { error: 'method not allowed' });
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
}
|
|
121
160
|
function getWorkspaceRegistry(ctx) {
|
|
122
161
|
try {
|
|
123
162
|
return ctx.get('workspaceRegistry');
|
|
@@ -129,28 +168,35 @@ function getWorkspaceRegistry(ctx) {
|
|
|
129
168
|
export async function apply(ctx) {
|
|
130
169
|
const channel = new WeixinChannel();
|
|
131
170
|
channel.startIfLoggedIn();
|
|
171
|
+
const wecom = new WecomChannel();
|
|
172
|
+
wecom.start();
|
|
132
173
|
ctx.effect(() => ctx.systemPrompt.section({
|
|
133
174
|
name: 'plugin:dsh-rez-wechat',
|
|
134
175
|
order: 162,
|
|
135
176
|
text: GUIDANCE,
|
|
136
177
|
}), 'dsh-rez-wechat: prompt');
|
|
137
|
-
const routes = weixinRoutes(channel, ctx);
|
|
178
|
+
const routes = [...weixinRoutes(channel, ctx), ...wecomRoutes(wecom)];
|
|
138
179
|
ctx.effect(() => {
|
|
139
180
|
const disposers = routes.map(route => ctx.webServer.register(route));
|
|
140
181
|
return () => { for (const dispose of disposers)
|
|
141
182
|
dispose(); };
|
|
142
183
|
}, 'dsh-rez-wechat: weixin routes');
|
|
143
|
-
ctx.effect(() => () => {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
184
|
+
ctx.effect(() => () => {
|
|
185
|
+
channel.dispose();
|
|
186
|
+
wecom.dispose();
|
|
187
|
+
}, 'dsh-rez-wechat: channels');
|
|
188
|
+
if (shouldStartWecomWebhook()) {
|
|
189
|
+
await startMcpWhenSecret(ctx, {
|
|
190
|
+
system: 'wechat',
|
|
191
|
+
label: '企业微信',
|
|
192
|
+
config: (secret) => ({
|
|
193
|
+
serverName: 'wechat',
|
|
194
|
+
transport: 'stdio',
|
|
195
|
+
command: 'npx',
|
|
196
|
+
args: ['-y', 'wecom-mcp'],
|
|
197
|
+
env: { WECOM_WEBHOOK_URL: secret },
|
|
198
|
+
failOnStartupError: false,
|
|
199
|
+
}),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
156
202
|
}
|
package/lib/web-shim.d.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
export declare const WECHAT_WORKSPACE_NAME = "\u5FAE\u4FE1";
|
|
11
11
|
export declare const DEFAULT_WEB_URL = "http://127.0.0.1:3080";
|
|
12
|
+
/** Seeded only when the inbox folder has no AGENTS.md yet. */
|
|
13
|
+
export declare const WECHAT_INBOX_AGENTS: string;
|
|
12
14
|
export interface ModelRef {
|
|
13
15
|
provider?: string;
|
|
14
16
|
model: string;
|
|
@@ -53,6 +55,8 @@ export declare function turnIsBlocked(events: unknown[]): boolean;
|
|
|
53
55
|
export declare function extractLatestUserText(task: string): string;
|
|
54
56
|
export declare function dshHomeDir(env?: NodeJS.ProcessEnv, home?: string): string;
|
|
55
57
|
export declare function wechatWorkspaceDir(env?: NodeJS.ProcessEnv, home?: string): string;
|
|
58
|
+
/** Write a short inbox AGENTS.md once; never overwrite a living file. Skip non-微信 rooms (企微按房间绑定). */
|
|
59
|
+
export declare function ensureWechatInboxAgents(cwd: string): void;
|
|
56
60
|
export declare function sessionStorePath(env?: NodeJS.ProcessEnv, home?: string): string;
|
|
57
61
|
export declare function webBaseUrl(env?: NodeJS.ProcessEnv): string;
|
|
58
62
|
export declare function loadSessionStore(path: string): SessionStore;
|
package/lib/web-shim.js
CHANGED
|
@@ -8,11 +8,18 @@
|
|
|
8
8
|
* (Kimi if that is what the user selected).
|
|
9
9
|
*/
|
|
10
10
|
import { randomUUID } from 'node:crypto';
|
|
11
|
-
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
13
|
import { basename, dirname, join } from 'node:path';
|
|
14
14
|
export const WECHAT_WORKSPACE_NAME = '微信';
|
|
15
15
|
export const DEFAULT_WEB_URL = 'http://127.0.0.1:3080';
|
|
16
|
+
/** Seeded only when the inbox folder has no AGENTS.md yet. */
|
|
17
|
+
export const WECHAT_INBOX_AGENTS = [
|
|
18
|
+
'# 微信',
|
|
19
|
+
'',
|
|
20
|
+
'收件箱。工商查询请切到法务助理,避免串房间。',
|
|
21
|
+
'',
|
|
22
|
+
].join('\n');
|
|
16
23
|
export const ASK_USER_QUESTION_TOOL = 'ask_user_question';
|
|
17
24
|
export function parseHeadlessArgv(argv) {
|
|
18
25
|
const args = argv[0]?.endsWith('node') || argv[0]?.includes('node.exe') ? argv.slice(1) : argv;
|
|
@@ -118,6 +125,16 @@ export function wechatWorkspaceDir(env = process.env, home = homedir()) {
|
|
|
118
125
|
return env.REZ_WECHAT_WORKSPACE;
|
|
119
126
|
return join(dshHomeDir(env, home), 'workspaces', WECHAT_WORKSPACE_NAME);
|
|
120
127
|
}
|
|
128
|
+
/** Write a short inbox AGENTS.md once; never overwrite a living file. Skip non-微信 rooms (企微按房间绑定). */
|
|
129
|
+
export function ensureWechatInboxAgents(cwd) {
|
|
130
|
+
mkdirSync(cwd, { recursive: true });
|
|
131
|
+
if (basename(cwd) !== WECHAT_WORKSPACE_NAME)
|
|
132
|
+
return;
|
|
133
|
+
const file = join(cwd, 'AGENTS.md');
|
|
134
|
+
if (existsSync(file))
|
|
135
|
+
return;
|
|
136
|
+
writeFileSync(file, WECHAT_INBOX_AGENTS);
|
|
137
|
+
}
|
|
121
138
|
export function sessionStorePath(env = process.env, home = homedir()) {
|
|
122
139
|
if (env.REZ_WECHAT_SESSION_STORE !== undefined && env.REZ_WECHAT_SESSION_STORE !== '')
|
|
123
140
|
return env.REZ_WECHAT_SESSION_STORE;
|
|
@@ -728,6 +745,7 @@ export async function postRpc(baseUrl, method, params, timeoutMs = RPC_TIMEOUT_M
|
|
|
728
745
|
}
|
|
729
746
|
async function ensureWorkspace(rpc, cwd) {
|
|
730
747
|
mkdirSync(cwd, { recursive: true });
|
|
748
|
+
ensureWechatInboxAgents(cwd);
|
|
731
749
|
try {
|
|
732
750
|
const created = await rpc('workspace.create', { path: cwd });
|
|
733
751
|
const id = extractWorkspaceId(created) ?? findWorkspaceId(created, cwd);
|
|
@@ -832,7 +850,8 @@ export async function ensureSessionVisible(rpc, sessionId, env = process.env, wo
|
|
|
832
850
|
await tryRpc(rpc, 'workspace.attachSession', { workspaceId, sessionId });
|
|
833
851
|
await tryRpc(rpc, 'workspace.insertSessionBefore', { workspaceId, sessionId });
|
|
834
852
|
}
|
|
835
|
-
|
|
853
|
+
const title = basename(wechatWorkspaceDir(env)) || WECHAT_WORKSPACE_NAME;
|
|
854
|
+
await tryRpc(rpc, 'session.rename', { sessionId, title });
|
|
836
855
|
}
|
|
837
856
|
async function archiveSession(rpc, sessionId) {
|
|
838
857
|
await tryRpc(rpc, 'workspace.archiveSession', { sessionId });
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accio-style WeCom AI bots: one official WSClient per Harness room.
|
|
3
|
+
* Uses @wecom/aibot-node-sdk (botId + secret). Do not hand-roll the WS.
|
|
4
|
+
*/
|
|
5
|
+
import { type WecomBotPublic, type WecomBotRecord } from './wecom-store.js';
|
|
6
|
+
export declare const WECOM_NON_TEXT_HINT = "\u8BF7\u53D1\u6587\u5B57\u3002\u56FE\u7247\u3001\u6587\u4EF6\u548C\u8BED\u97F3\u8BF7\u8D70\u7F51\u9875\u7AEF\u6216 Nextcloud\u3002";
|
|
7
|
+
export type WecomBindingPhase = 'idle' | 'connecting' | 'authenticated' | 'error';
|
|
8
|
+
export interface WecomBindingStatus {
|
|
9
|
+
room: string;
|
|
10
|
+
botId: string;
|
|
11
|
+
phase: WecomBindingPhase;
|
|
12
|
+
hint: string;
|
|
13
|
+
}
|
|
14
|
+
export interface WecomChannelsPublic {
|
|
15
|
+
rooms: readonly string[];
|
|
16
|
+
bots: WecomBotPublic[];
|
|
17
|
+
status: WecomBindingStatus[];
|
|
18
|
+
}
|
|
19
|
+
export interface WecomWsClient {
|
|
20
|
+
connect: () => unknown;
|
|
21
|
+
disconnect: () => void;
|
|
22
|
+
on: (event: string, handler: (...args: unknown[]) => void) => unknown;
|
|
23
|
+
replyStream: (frame: unknown, streamId: string, content: string, finish?: boolean) => Promise<unknown>;
|
|
24
|
+
replyWelcome: (frame: unknown, body: {
|
|
25
|
+
msgtype: string;
|
|
26
|
+
text: {
|
|
27
|
+
content: string;
|
|
28
|
+
};
|
|
29
|
+
}) => Promise<unknown>;
|
|
30
|
+
}
|
|
31
|
+
export type WecomClientFactory = (opts: {
|
|
32
|
+
botId: string;
|
|
33
|
+
secret: string;
|
|
34
|
+
}) => WecomWsClient | Promise<WecomWsClient>;
|
|
35
|
+
export type WecomRunTurn = (room: string, text: string) => Promise<string>;
|
|
36
|
+
export declare function extractWecomText(frame: unknown): string;
|
|
37
|
+
export declare function wecomMsgid(frame: unknown): string;
|
|
38
|
+
export declare function createOfficialWecomClient(opts: {
|
|
39
|
+
botId: string;
|
|
40
|
+
secret: string;
|
|
41
|
+
}): Promise<WecomWsClient>;
|
|
42
|
+
export declare function shouldStartWecomWebhook(bots?: WecomBotRecord[]): boolean;
|
|
43
|
+
export declare class WecomChannel {
|
|
44
|
+
private readonly home;
|
|
45
|
+
private readonly factory;
|
|
46
|
+
private readonly runTurn;
|
|
47
|
+
private readonly bindings;
|
|
48
|
+
private generation;
|
|
49
|
+
constructor(opts?: {
|
|
50
|
+
home?: string;
|
|
51
|
+
factory?: WecomClientFactory;
|
|
52
|
+
runTurn?: WecomRunTurn;
|
|
53
|
+
});
|
|
54
|
+
start(): void;
|
|
55
|
+
status(): WecomBindingStatus[];
|
|
56
|
+
publicState(): WecomChannelsPublic;
|
|
57
|
+
applyBots(incoming: unknown): Promise<WecomChannelsPublic>;
|
|
58
|
+
dispose(): void;
|
|
59
|
+
/** Test seam: wait until inbound handlers finish. */
|
|
60
|
+
flush(): Promise<void>;
|
|
61
|
+
reconnect(): Promise<void>;
|
|
62
|
+
private dropBindings;
|
|
63
|
+
private bind;
|
|
64
|
+
private enqueue;
|
|
65
|
+
private rememberMsgid;
|
|
66
|
+
private handleText;
|
|
67
|
+
private handleNonText;
|
|
68
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accio-style WeCom AI bots: one official WSClient per Harness room.
|
|
3
|
+
* Uses @wecom/aibot-node-sdk (botId + secret). Do not hand-roll the WS.
|
|
4
|
+
*/
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { dshHome } from '@rezti/dsh-rez-sso';
|
|
7
|
+
import { runHeadlessViaWeb } from './web-shim.js';
|
|
8
|
+
import { loadWecomChannels, publicWecomBots, saveWecomChannels, applyWecomBotPatch, WECOM_BINDABLE_ROOMS, } from './wecom-store.js';
|
|
9
|
+
export const WECOM_NON_TEXT_HINT = '请发文字。图片、文件和语音请走网页端或 Nextcloud。';
|
|
10
|
+
const MAX_SEEN_MSGIDS = 200;
|
|
11
|
+
export function extractWecomText(frame) {
|
|
12
|
+
if (typeof frame !== 'object' || frame === null)
|
|
13
|
+
return '';
|
|
14
|
+
const body = frame.body;
|
|
15
|
+
if (typeof body !== 'object' || body === null)
|
|
16
|
+
return '';
|
|
17
|
+
const rec = body;
|
|
18
|
+
const fromText = textContent(rec.text);
|
|
19
|
+
if (fromText.length > 0)
|
|
20
|
+
return fromText;
|
|
21
|
+
if (typeof rec.content === 'string' && rec.content.trim().length > 0)
|
|
22
|
+
return rec.content.trim();
|
|
23
|
+
const mixed = rec.mixed;
|
|
24
|
+
if (typeof mixed === 'object' && mixed !== null) {
|
|
25
|
+
const items = mixed.items;
|
|
26
|
+
if (Array.isArray(items)) {
|
|
27
|
+
const parts = [];
|
|
28
|
+
for (const item of items) {
|
|
29
|
+
if (typeof item !== 'object' || item === null)
|
|
30
|
+
continue;
|
|
31
|
+
const row = item;
|
|
32
|
+
const nested = textContent(row.text) || (typeof row.content === 'string' ? row.content.trim() : '');
|
|
33
|
+
if (nested.length > 0)
|
|
34
|
+
parts.push(nested);
|
|
35
|
+
}
|
|
36
|
+
return parts.join('\n').trim();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
export function wecomMsgid(frame) {
|
|
42
|
+
if (typeof frame !== 'object' || frame === null)
|
|
43
|
+
return '';
|
|
44
|
+
const body = frame.body;
|
|
45
|
+
if (typeof body !== 'object' || body === null)
|
|
46
|
+
return '';
|
|
47
|
+
const id = body.msgid;
|
|
48
|
+
return typeof id === 'string' ? id : '';
|
|
49
|
+
}
|
|
50
|
+
function textContent(value) {
|
|
51
|
+
if (typeof value === 'string')
|
|
52
|
+
return value.trim();
|
|
53
|
+
if (typeof value === 'object' && value !== null) {
|
|
54
|
+
const content = value.content;
|
|
55
|
+
if (typeof content === 'string')
|
|
56
|
+
return content.trim();
|
|
57
|
+
}
|
|
58
|
+
return '';
|
|
59
|
+
}
|
|
60
|
+
function newStreamId() {
|
|
61
|
+
return `stream-${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
|
|
62
|
+
}
|
|
63
|
+
const quietLogger = {
|
|
64
|
+
debug() { },
|
|
65
|
+
info() { },
|
|
66
|
+
warn(...args) { console.warn('[wecom]', ...args); },
|
|
67
|
+
error(...args) { console.error('[wecom]', ...args); },
|
|
68
|
+
};
|
|
69
|
+
export async function createOfficialWecomClient(opts) {
|
|
70
|
+
const mod = await import('@wecom/aibot-node-sdk');
|
|
71
|
+
const holder = mod.default ?? mod;
|
|
72
|
+
const WSClient = holder.WSClient;
|
|
73
|
+
if (typeof WSClient !== 'function') {
|
|
74
|
+
throw new Error('官方 @wecom/aibot-node-sdk 没有 WSClient');
|
|
75
|
+
}
|
|
76
|
+
return new WSClient({
|
|
77
|
+
botId: opts.botId,
|
|
78
|
+
secret: opts.secret,
|
|
79
|
+
maxReconnectAttempts: -1,
|
|
80
|
+
logger: quietLogger,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
async function defaultRunTurn(room, text) {
|
|
84
|
+
const home = dshHome();
|
|
85
|
+
return runHeadlessViaWeb({
|
|
86
|
+
task: text,
|
|
87
|
+
cwd: room,
|
|
88
|
+
env: {
|
|
89
|
+
...process.env,
|
|
90
|
+
REZ_WECHAT_WORKSPACE: join(home, 'workspaces', room),
|
|
91
|
+
REZ_WECHAT_SESSION_STORE: join(home, 'dsh-rez-wecom', room, 'web-sessions.json'),
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
export function shouldStartWecomWebhook(bots = loadWecomChannels().bots) {
|
|
96
|
+
return bots.length === 0;
|
|
97
|
+
}
|
|
98
|
+
export class WecomChannel {
|
|
99
|
+
home;
|
|
100
|
+
factory;
|
|
101
|
+
runTurn;
|
|
102
|
+
bindings = new Map();
|
|
103
|
+
generation = 0;
|
|
104
|
+
constructor(opts) {
|
|
105
|
+
this.home = opts?.home ?? dshHome();
|
|
106
|
+
this.factory = opts?.factory ?? createOfficialWecomClient;
|
|
107
|
+
this.runTurn = opts?.runTurn ?? defaultRunTurn;
|
|
108
|
+
}
|
|
109
|
+
start() {
|
|
110
|
+
void this.reconnect();
|
|
111
|
+
}
|
|
112
|
+
status() {
|
|
113
|
+
return [...this.bindings.values()].map((row) => ({
|
|
114
|
+
room: row.bot.room,
|
|
115
|
+
botId: row.bot.botId,
|
|
116
|
+
phase: row.phase,
|
|
117
|
+
hint: row.hint,
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
publicState() {
|
|
121
|
+
const file = loadWecomChannels(this.home);
|
|
122
|
+
return {
|
|
123
|
+
rooms: WECOM_BINDABLE_ROOMS,
|
|
124
|
+
bots: publicWecomBots(file),
|
|
125
|
+
status: this.status(),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
async applyBots(incoming) {
|
|
129
|
+
const next = applyWecomBotPatch(loadWecomChannels(this.home), incoming);
|
|
130
|
+
saveWecomChannels(next, this.home);
|
|
131
|
+
await this.reconnect();
|
|
132
|
+
return this.publicState();
|
|
133
|
+
}
|
|
134
|
+
dispose() {
|
|
135
|
+
this.generation += 1;
|
|
136
|
+
this.dropBindings();
|
|
137
|
+
}
|
|
138
|
+
/** Test seam: wait until inbound handlers finish. */
|
|
139
|
+
async flush() {
|
|
140
|
+
await Promise.all([...this.bindings.values()].map((row) => row.queue));
|
|
141
|
+
}
|
|
142
|
+
async reconnect() {
|
|
143
|
+
const gen = ++this.generation;
|
|
144
|
+
this.dropBindings();
|
|
145
|
+
const bots = loadWecomChannels(this.home).bots;
|
|
146
|
+
for (const bot of bots) {
|
|
147
|
+
if (gen !== this.generation)
|
|
148
|
+
return;
|
|
149
|
+
await this.bind(bot);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
dropBindings() {
|
|
153
|
+
for (const row of this.bindings.values()) {
|
|
154
|
+
try {
|
|
155
|
+
row.client.disconnect();
|
|
156
|
+
}
|
|
157
|
+
catch { /* already closed */ }
|
|
158
|
+
}
|
|
159
|
+
this.bindings.clear();
|
|
160
|
+
}
|
|
161
|
+
async bind(bot) {
|
|
162
|
+
const row = {
|
|
163
|
+
bot,
|
|
164
|
+
client: undefined,
|
|
165
|
+
phase: 'connecting',
|
|
166
|
+
hint: `正在连接「${bot.room}」企业微信机器人…`,
|
|
167
|
+
seen: [],
|
|
168
|
+
queue: Promise.resolve(),
|
|
169
|
+
};
|
|
170
|
+
this.bindings.set(bot.room, row);
|
|
171
|
+
try {
|
|
172
|
+
const client = await this.factory({ botId: bot.botId, secret: bot.secret });
|
|
173
|
+
row.client = client;
|
|
174
|
+
client.on('authenticated', () => {
|
|
175
|
+
row.phase = 'authenticated';
|
|
176
|
+
row.hint = `「${bot.room}」已接入。同事在企微跟这个机器人说话,会进网页左侧该房间。`;
|
|
177
|
+
});
|
|
178
|
+
client.on('disconnected', (reason) => {
|
|
179
|
+
if (row.phase === 'authenticated')
|
|
180
|
+
row.phase = 'connecting';
|
|
181
|
+
row.hint = `「${bot.room}」断开(${String(reason ?? '')}),正在重连…`;
|
|
182
|
+
});
|
|
183
|
+
client.on('error', (error) => {
|
|
184
|
+
row.phase = 'error';
|
|
185
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
186
|
+
row.hint = `「${bot.room}」连接失败:${why}`;
|
|
187
|
+
});
|
|
188
|
+
client.on('message.text', (frame) => { this.enqueue(row, () => this.handleText(row, frame)); });
|
|
189
|
+
client.on('message.mixed', (frame) => { this.enqueue(row, () => this.handleText(row, frame)); });
|
|
190
|
+
client.on('message.image', (frame) => { this.enqueue(row, () => this.handleNonText(row, frame)); });
|
|
191
|
+
client.on('message.file', (frame) => { this.enqueue(row, () => this.handleNonText(row, frame)); });
|
|
192
|
+
client.on('message.voice', (frame) => { this.enqueue(row, () => this.handleNonText(row, frame)); });
|
|
193
|
+
client.on('message.video', (frame) => { this.enqueue(row, () => this.handleNonText(row, frame)); });
|
|
194
|
+
client.on('event.enter_chat', (frame) => {
|
|
195
|
+
this.enqueue(row, async () => {
|
|
196
|
+
await client.replyWelcome(frame, {
|
|
197
|
+
msgtype: 'text',
|
|
198
|
+
text: { content: `您好,我是「${bot.room}」。直接发文字即可。` },
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
client.connect();
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
row.phase = 'error';
|
|
206
|
+
row.hint = `「${bot.room}」无法启动:${error instanceof Error ? error.message : String(error)}`;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
enqueue(row, work) {
|
|
210
|
+
row.queue = row.queue.then(work, work).catch((error) => {
|
|
211
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
212
|
+
console.error(`[wecom] ${row.bot.room}: ${why}`);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
rememberMsgid(row, frame) {
|
|
216
|
+
const id = wecomMsgid(frame);
|
|
217
|
+
if (id.length === 0)
|
|
218
|
+
return false;
|
|
219
|
+
if (row.seen.includes(id))
|
|
220
|
+
return true;
|
|
221
|
+
row.seen.push(id);
|
|
222
|
+
if (row.seen.length > MAX_SEEN_MSGIDS)
|
|
223
|
+
row.seen.splice(0, row.seen.length - MAX_SEEN_MSGIDS);
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
async handleText(row, frame) {
|
|
227
|
+
if (this.rememberMsgid(row, frame))
|
|
228
|
+
return;
|
|
229
|
+
const text = extractWecomText(frame);
|
|
230
|
+
const streamId = newStreamId();
|
|
231
|
+
if (text.length === 0) {
|
|
232
|
+
await row.client.replyStream(frame, streamId, WECOM_NON_TEXT_HINT, true);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
await row.client.replyStream(frame, streamId, '正在处理…', false);
|
|
237
|
+
const reply = await this.runTurn(row.bot.room, text);
|
|
238
|
+
await row.client.replyStream(frame, streamId, reply.length > 0 ? reply : '(空回复)', true);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
242
|
+
await row.client.replyStream(frame, streamId, `处理失败:${why}`, true);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async handleNonText(row, frame) {
|
|
246
|
+
const text = extractWecomText(frame);
|
|
247
|
+
if (text.length > 0) {
|
|
248
|
+
await this.handleText(row, frame);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (this.rememberMsgid(row, frame))
|
|
252
|
+
return;
|
|
253
|
+
await row.client.replyStream(frame, newStreamId(), WECOM_NON_TEXT_HINT, true);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accio-style WeCom AI bot bindings: one botId+secret per Harness room.
|
|
3
|
+
* Secrets stay in ~/.dsh/wecom-channels.json (mode 600), not compose env.
|
|
4
|
+
*/
|
|
5
|
+
export declare const WECOM_BINDABLE_ROOMS: readonly ["搞钱", "产品", "运营", "人事助理", "财务助理", "法务助理", "设计", "实验室"];
|
|
6
|
+
export type WecomBindableRoom = (typeof WECOM_BINDABLE_ROOMS)[number];
|
|
7
|
+
export interface WecomBotRecord {
|
|
8
|
+
room: string;
|
|
9
|
+
botId: string;
|
|
10
|
+
secret: string;
|
|
11
|
+
}
|
|
12
|
+
export interface WecomBotPublic {
|
|
13
|
+
room: string;
|
|
14
|
+
botId: string;
|
|
15
|
+
secretConfigured: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface WecomChannelFile {
|
|
18
|
+
bots: WecomBotRecord[];
|
|
19
|
+
}
|
|
20
|
+
export declare const WECOM_SECRET_MASK = "********";
|
|
21
|
+
export declare function wecomChannelsPath(home?: string): string;
|
|
22
|
+
export declare function isBindableRoom(room: string): boolean;
|
|
23
|
+
export declare function loadWecomChannels(home?: string): WecomChannelFile;
|
|
24
|
+
export declare function publicWecomBots(file: WecomChannelFile): WecomBotPublic[];
|
|
25
|
+
export declare function applyWecomBotPatch(current: WecomChannelFile, incoming: unknown): WecomChannelFile;
|
|
26
|
+
export declare function saveWecomChannels(file: WecomChannelFile, home?: string): void;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accio-style WeCom AI bot bindings: one botId+secret per Harness room.
|
|
3
|
+
* Secrets stay in ~/.dsh/wecom-channels.json (mode 600), not compose env.
|
|
4
|
+
*/
|
|
5
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { dshHome } from '@rezti/dsh-rez-sso';
|
|
8
|
+
export const WECOM_BINDABLE_ROOMS = [
|
|
9
|
+
'搞钱',
|
|
10
|
+
'产品',
|
|
11
|
+
'运营',
|
|
12
|
+
'人事助理',
|
|
13
|
+
'财务助理',
|
|
14
|
+
'法务助理',
|
|
15
|
+
'设计',
|
|
16
|
+
'实验室',
|
|
17
|
+
];
|
|
18
|
+
export const WECOM_SECRET_MASK = '********';
|
|
19
|
+
export function wecomChannelsPath(home = dshHome()) {
|
|
20
|
+
return join(home, 'wecom-channels.json');
|
|
21
|
+
}
|
|
22
|
+
export function isBindableRoom(room) {
|
|
23
|
+
return WECOM_BINDABLE_ROOMS.includes(room);
|
|
24
|
+
}
|
|
25
|
+
export function loadWecomChannels(home = dshHome()) {
|
|
26
|
+
const file = wecomChannelsPath(home);
|
|
27
|
+
if (!existsSync(file))
|
|
28
|
+
return { bots: [] };
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
31
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
32
|
+
return { bots: [] };
|
|
33
|
+
const bots = parsed.bots;
|
|
34
|
+
if (!Array.isArray(bots))
|
|
35
|
+
return { bots: [] };
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const row of bots) {
|
|
38
|
+
if (typeof row !== 'object' || row === null)
|
|
39
|
+
continue;
|
|
40
|
+
const rec = row;
|
|
41
|
+
const room = typeof rec.room === 'string' ? rec.room.trim() : '';
|
|
42
|
+
const botId = typeof rec.botId === 'string' ? rec.botId.trim() : '';
|
|
43
|
+
const secret = typeof rec.secret === 'string' ? rec.secret.trim() : '';
|
|
44
|
+
if (room.length === 0 || botId.length === 0 || secret.length === 0)
|
|
45
|
+
continue;
|
|
46
|
+
if (!isBindableRoom(room))
|
|
47
|
+
continue;
|
|
48
|
+
out.push({ room, botId, secret });
|
|
49
|
+
}
|
|
50
|
+
return { bots: out };
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return { bots: [] };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function publicWecomBots(file) {
|
|
57
|
+
return file.bots.map((bot) => ({
|
|
58
|
+
room: bot.room,
|
|
59
|
+
botId: bot.botId,
|
|
60
|
+
secretConfigured: bot.secret.length > 0,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
export function applyWecomBotPatch(current, incoming) {
|
|
64
|
+
if (!Array.isArray(incoming))
|
|
65
|
+
return current;
|
|
66
|
+
const next = [];
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
for (const row of incoming) {
|
|
69
|
+
if (typeof row !== 'object' || row === null)
|
|
70
|
+
continue;
|
|
71
|
+
const rec = row;
|
|
72
|
+
const room = typeof rec.room === 'string' ? rec.room.trim() : '';
|
|
73
|
+
const botId = typeof rec.botId === 'string' ? rec.botId.trim() : '';
|
|
74
|
+
if (room.length === 0 || botId.length === 0 || !isBindableRoom(room))
|
|
75
|
+
continue;
|
|
76
|
+
if (seen.has(room))
|
|
77
|
+
continue;
|
|
78
|
+
seen.add(room);
|
|
79
|
+
const secretRaw = typeof rec.secret === 'string' ? rec.secret.trim() : '';
|
|
80
|
+
const previous = current.bots.find((bot) => bot.room === room);
|
|
81
|
+
const secret = secretRaw.length > 0 && secretRaw !== WECOM_SECRET_MASK
|
|
82
|
+
? secretRaw
|
|
83
|
+
: (previous?.secret ?? '');
|
|
84
|
+
if (secret.length === 0)
|
|
85
|
+
continue;
|
|
86
|
+
next.push({ room, botId, secret });
|
|
87
|
+
}
|
|
88
|
+
return { bots: next };
|
|
89
|
+
}
|
|
90
|
+
export function saveWecomChannels(file, home = dshHome()) {
|
|
91
|
+
const path = wecomChannelsPath(home);
|
|
92
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
93
|
+
writeFileSync(path, JSON.stringify({ bots: file.bots }, null, 2) + '\n', { mode: 0o600 });
|
|
94
|
+
try {
|
|
95
|
+
chmodSync(path, 0o600);
|
|
96
|
+
}
|
|
97
|
+
catch { /* Windows 无 POSIX mode */ }
|
|
98
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rezti/dsh-rez-wechat",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "ReZ-TI WeChat/WeCom bridges. Personal WeChat is QClaw/ClawBot scan-and-chat via dsh-wechat-bridge; WeCom
|
|
3
|
+
"version": "0.1.15",
|
|
4
|
+
"description": "ReZ-TI WeChat/WeCom bridges. Personal WeChat is QClaw/ClawBot scan-and-chat via dsh-wechat-bridge; WeCom AI bots use the official @wecom/aibot-node-sdk (BotID + Secret) bound per Harness room.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
|
@@ -27,8 +27,9 @@
|
|
|
27
27
|
],
|
|
28
28
|
"license": "Apache-2.0",
|
|
29
29
|
"dependencies": {
|
|
30
|
+
"@wecom/aibot-node-sdk": "^1.0.7",
|
|
30
31
|
"qrcode": "^1.5.4",
|
|
31
|
-
"@rezti/dsh-rez-sso": "0.1.
|
|
32
|
+
"@rezti/dsh-rez-sso": "0.1.14"
|
|
32
33
|
},
|
|
33
34
|
"devDependencies": {
|
|
34
35
|
"@deepseek-ai/cordis": "^4.0.1",
|