@rezti/dsh-rez-wechat 0.1.15 → 0.1.17
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 +6 -4
- package/lib/index.js +1 -1
- package/lib/web-shim.d.ts +9 -2
- package/lib/web-shim.js +26 -13
- package/lib/wecom-channel.d.ts +1 -1
- package/lib/wecom-channel.js +12 -5
- package/lib/wecom-store.d.ts +1 -1
- package/lib/wecom-store.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
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 update @rezti/dsh-rez-suite`。不要再单独 add 本包。
|
|
10
10
|
|
|
11
11
|
| 能力 | 做法 |
|
|
12
12
|
|---|---|
|
|
13
|
-
| 企业微信智能机器人 | 官方 `@wecom/aibot-node-sdk`。设置页给每个 Harness 房间绑一个 BotID + Secret。同事在企微跟哪个 bot
|
|
13
|
+
| 企业微信智能机器人 | 官方 `@wecom/aibot-node-sdk`。设置页给每个 Harness 房间绑一个 BotID + Secret。同事在企微跟哪个 bot 说话,消息进网页左侧那个房间的**同一条会话**。密钥 `~/.dsh/wecom-channels.json`(mode 600),不进 compose。 |
|
|
14
|
+
| 企业微信办公能力 | 官方 [`wecom-cli`](https://github.com/WecomTeam/wecom-cli) >=1.1(通讯录/在线文档/表格/待办/日程/会议/邮件/微盘等)。**不是** MCP。`rez_status.wecomCli.modules` 是品类目录。房间里种了官方 `wecomcli-*` 技能。CLI 报未授权才说该模块未开通。不要用旧的「超过 10 人只能用文档」说法。 |
|
|
14
15
|
| 旧群机器人 Webhook(兼容) | 一个 bot 都没绑、且仍配置了 `REZ_WECHAT_WEBHOOK` 时,才拉起 `wecom-mcp` |
|
|
15
16
|
| 个人微信(QClaw / ClawBot) | dsh Web 设置面板扫码,之后直接在微信里说话。消息进左侧 **微信** 文件夹的同一会话,模型跟网页默认。 |
|
|
16
17
|
|
|
@@ -26,9 +27,10 @@ dsh plugin --profile web add @rezti/dsh-rez-suite
|
|
|
26
27
|
|
|
27
28
|
企微智能机器人(Accio 同款按房间绑定):
|
|
28
29
|
|
|
29
|
-
-
|
|
30
|
+
- 可绑房间:搞钱、产品、运营、人事助理、财务助理、法务助理、设计、发布、电商、实验室(不要绑「微信」个人通道)
|
|
31
|
+
- 同一房间固定一条网页会话;只有「重启对话」才新开并把旧的归档
|
|
30
32
|
- 会话标题是房间名;网页必须开着
|
|
31
|
-
- 收文本 /
|
|
33
|
+
- 收文本 / 混排文字 / 单聊语音(官方已转写成 `voice.content`,当普通文字进房间)、流式回复、进入会话欢迎语;图片和文件请走网页端或 Nextcloud。个人微信语音不转写。群聊语音官方不保证有转写。
|
|
32
34
|
- BotID / Secret 在企业微信管理后台 → 智能机器人([开发前必读](https://developer.work.weixin.qq.com/document/path/101805) / [长连接](https://developer.work.weixin.qq.com/document/path/101463))
|
|
33
35
|
|
|
34
36
|
扫码 UI 在 suite 面板;通道本身在本包。不要对同一 profile 既 add suite 又 add wechat,会重复注册。
|
package/lib/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { WECOM_BINDABLE_ROOMS } from './wecom-store.js';
|
|
|
6
6
|
export const name = 'rez-wechat';
|
|
7
7
|
export const inject = ['systemPrompt', 'webServer', 'rezSso'];
|
|
8
8
|
const GUIDANCE = [
|
|
9
|
-
'
|
|
9
|
+
'企业微信有两层,不要混:① 房间消息通道 = 官方 @wecom/aibot-node-sdk(设置页 BotID + Secret 长连接),同事在企微跟哪个 bot 说话就进哪个 Harness 房间(人事/财务/法务/设计/发布/电商/实验室/老板三室),并固定同一条网页会话;只有同事说「重启对话」才新开并把旧的归档。② 办公能力 = 官方 wecom-cli >=1.1(通讯录/在线文档/在线表格/智能表格/智能文档/待办/日程/会议/邮件/微盘/消息),本企业已开通,看 rez_status.wecomCli.modules,按房间 wecomcli-* 技能执行。不要因为状态页没有企微 MCP 就说没有企微能力,也不要说「超过 10 人只能用文档」。CLI 报未授权才说该模块未开通。密钥写在 ~/.dsh/wecom-channels.json,不要写进 compose。没有绑定任何 bot 且仍配置了 REZ_WECHAT_WEBHOOK 时,才兼容旧的 wecom-mcp 群发,不要引导去填群 Webhook。不要自写 WebSocket/HTTP 客户端。',
|
|
10
10
|
'个人微信是 QClaw 同款 ClawBot 通道:在 Rez 面板「微信」页扫码一次,然后直接在微信里说话。消息进入网页左侧「微信」文件夹并延续同一会话,模型跟网页默认(在网页里选了 Kimi 就会走 Kimi)。网页端归档了也能从微信自动恢复到侧栏(空白会话网页会藏起来,所以新开的会话会先打一句招呼再出现)。网页端出现选择题时会编成纯文本发到微信,回复数字或选项原文即可;思考过程不会转发。发「重启对话」会新开一条可见的网页会话并把旧的归档。通道断了点「重新连接」(已登录不必再扫码)。不要再跑 npx weixin-mcp login,也不要调用 mcp__weixin__*。网页端必须开着。工商查询请切到法务助理,避免串房间。',
|
|
11
11
|
'这是 ClawBot 客服会话,不是用个人微信号管好友/群。对外发消息必须先征得用户批准。',
|
|
12
12
|
].join('');
|
package/lib/web-shim.d.ts
CHANGED
|
@@ -38,12 +38,19 @@ export declare function parseHeadlessArgv(argv: string[]): {
|
|
|
38
38
|
} | {
|
|
39
39
|
error: string;
|
|
40
40
|
};
|
|
41
|
+
/**
|
|
42
|
+
* Personal WeChat follow-ups wrap history in 桥接上下文. Use this only to
|
|
43
|
+
* slice the latest user line. Do not mint a new web session from it — WeCom
|
|
44
|
+
* turns are always plain text and must reuse the room's stored session.
|
|
45
|
+
*/
|
|
41
46
|
export declare function isFreshBridgeTurn(task: string): boolean;
|
|
42
47
|
/** WeChat /new is handled by dsh-wechat-bridge. Chinese "重启对话" is a normal message and must be detected here. */
|
|
43
48
|
export declare function isRestartCommand(text: string): boolean;
|
|
44
|
-
export declare
|
|
49
|
+
export declare function restartAck(folder?: string): string;
|
|
50
|
+
export declare function stuckAck(folder?: string): string;
|
|
51
|
+
export declare const RESTART_ACK: string;
|
|
45
52
|
export declare const RESTART_SEED = "\u65B0\u4F1A\u8BDD\u5DF2\u5F00\u59CB\u3002\u8BF7\u7528\u4E00\u53E5\u4E2D\u6587\u6253\u62DB\u547C\uFF0C\u4E0D\u8981\u89E3\u91CA\u5185\u90E8\u6B65\u9AA4\u3002";
|
|
46
|
-
export declare const STUCK_ACK
|
|
53
|
+
export declare const STUCK_ACK: string;
|
|
47
54
|
export declare const RPC_TIMEOUT_MS = 15000;
|
|
48
55
|
export declare const QUESTION_HINT = "\u56DE\u590D\u6570\u5B57\u6216\u9009\u9879\u539F\u6587\u5373\u53EF\u3002";
|
|
49
56
|
export declare function stripThinkTags(text: string): string;
|
package/lib/web-shim.js
CHANGED
|
@@ -32,6 +32,11 @@ export function parseHeadlessArgv(argv) {
|
|
|
32
32
|
return { error: 'missing headless task' };
|
|
33
33
|
return { task };
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Personal WeChat follow-ups wrap history in 桥接上下文. Use this only to
|
|
37
|
+
* slice the latest user line. Do not mint a new web session from it — WeCom
|
|
38
|
+
* turns are always plain text and must reuse the room's stored session.
|
|
39
|
+
*/
|
|
35
40
|
export function isFreshBridgeTurn(task) {
|
|
36
41
|
return !task.includes('【桥接上下文】');
|
|
37
42
|
}
|
|
@@ -44,9 +49,15 @@ export function isRestartCommand(text) {
|
|
|
44
49
|
return true;
|
|
45
50
|
return /^(重启对话|重新开始|新对话|开启新对话|重置对话)[。.!!]?$/.test(t);
|
|
46
51
|
}
|
|
47
|
-
export
|
|
52
|
+
export function restartAck(folder = WECHAT_WORKSPACE_NAME) {
|
|
53
|
+
return `已新开网页会话(左侧「${folder}」文件夹)。之后直接说话即可。再发「重启对话」会再开一条;旧会话会归档,网页侧栏更干净。`;
|
|
54
|
+
}
|
|
55
|
+
export function stuckAck(folder = WECHAT_WORKSPACE_NAME) {
|
|
56
|
+
return `网页端这条会话卡住了(在等批准,或模型把内部推理发出来了)。请看网页左侧「${folder}」文件夹,或发「重启对话」。`;
|
|
57
|
+
}
|
|
58
|
+
export const RESTART_ACK = restartAck();
|
|
48
59
|
export const RESTART_SEED = '新会话已开始。请用一句中文打招呼,不要解释内部步骤。';
|
|
49
|
-
export const STUCK_ACK =
|
|
60
|
+
export const STUCK_ACK = stuckAck();
|
|
50
61
|
export const RPC_TIMEOUT_MS = 15_000;
|
|
51
62
|
export const QUESTION_HINT = '回复数字或选项原文即可。';
|
|
52
63
|
const SKIP_CONTENT_TYPES = new Set(['reasoning', 'thinking', 'tool-call', 'tool-result', 'image']);
|
|
@@ -905,7 +916,7 @@ function composeWechatReply(events, ignorePending) {
|
|
|
905
916
|
}
|
|
906
917
|
return { text: visible, pendingQuestions };
|
|
907
918
|
}
|
|
908
|
-
async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs, ignorePending) {
|
|
919
|
+
async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs, ignorePending, stuckText = STUCK_ACK) {
|
|
909
920
|
const deadline = Date.now() + timeoutMs;
|
|
910
921
|
let last = { text: '', pendingQuestions: [] };
|
|
911
922
|
let idleOnce = false;
|
|
@@ -923,7 +934,7 @@ async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs, ignorePen
|
|
|
923
934
|
return composed;
|
|
924
935
|
}
|
|
925
936
|
if (turnIsBlocked(events))
|
|
926
|
-
return { text:
|
|
937
|
+
return { text: stuckText, pendingQuestions: [] };
|
|
927
938
|
if (composed.text !== '')
|
|
928
939
|
last = composed;
|
|
929
940
|
const idle = turnIsIdle(events);
|
|
@@ -937,7 +948,7 @@ async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs, ignorePen
|
|
|
937
948
|
}
|
|
938
949
|
if (last.text !== '' && last.text !== beforeText)
|
|
939
950
|
return last;
|
|
940
|
-
return { text:
|
|
951
|
+
return { text: stuckText, pendingQuestions: [] };
|
|
941
952
|
}
|
|
942
953
|
export async function runHeadlessViaWeb(opts) {
|
|
943
954
|
const env = opts.env ?? process.env;
|
|
@@ -951,20 +962,22 @@ export async function runHeadlessViaWeb(opts) {
|
|
|
951
962
|
const rpc = opts.rpc ?? ((method, params) => postRpc(webBaseUrl(env), method, params));
|
|
952
963
|
const timeoutMs = opts.timeoutMs ?? Number(env.DSH_BRIDGE_TIMEOUT_MS ?? 10 * 60 * 1000);
|
|
953
964
|
const workspaceId = await ensureWorkspace(rpc, workspace);
|
|
965
|
+
const folder = basename(workspace) || WECHAT_WORKSPACE_NAME;
|
|
954
966
|
const restart = isRestartCommand(text);
|
|
955
|
-
const fresh = isFreshBridgeTurn(opts.task) || restart;
|
|
956
967
|
const store = loadSessionStore(storeFile);
|
|
957
968
|
const storedPending = restart ? undefined : store.sessions[key]?.pendingQuestions;
|
|
958
969
|
let sessionId = store.sessions[key]?.sessionId;
|
|
959
970
|
const previousId = sessionId;
|
|
960
|
-
|
|
971
|
+
// WeCom sends plain text (no 桥接上下文). Reuse the stored session unless
|
|
972
|
+
// the user asked to restart or the old session is already gone.
|
|
973
|
+
if (restart) {
|
|
974
|
+
sessionId = undefined;
|
|
975
|
+
}
|
|
976
|
+
else if (sessionId !== undefined) {
|
|
961
977
|
const alive = await sessionAlive(rpc, sessionId);
|
|
962
978
|
if (!alive)
|
|
963
979
|
sessionId = undefined;
|
|
964
980
|
}
|
|
965
|
-
else {
|
|
966
|
-
sessionId = undefined;
|
|
967
|
-
}
|
|
968
981
|
if (sessionId === undefined) {
|
|
969
982
|
sessionId = await createSession(rpc, workspace, workspaceId);
|
|
970
983
|
await maybeSelectModel(rpc, sessionId, env);
|
|
@@ -1019,14 +1032,14 @@ export async function runHeadlessViaWeb(opts) {
|
|
|
1019
1032
|
content: [{ type: 'text', text: promptText }],
|
|
1020
1033
|
});
|
|
1021
1034
|
}
|
|
1022
|
-
const reply = await waitForAssistant(rpc, sessionId, before, timeoutMs, questions);
|
|
1035
|
+
const reply = await waitForAssistant(rpc, sessionId, before, timeoutMs, questions, stuckAck(folder));
|
|
1023
1036
|
store.sessions[key] = {
|
|
1024
1037
|
sessionId,
|
|
1025
1038
|
updatedAt: Date.now(),
|
|
1026
1039
|
...(reply.pendingQuestions.length > 0 ? { pendingQuestions: reply.pendingQuestions } : {}),
|
|
1027
1040
|
};
|
|
1028
1041
|
saveSessionStore(storeFile, store);
|
|
1029
|
-
if (restart && (reply.text ===
|
|
1030
|
-
return
|
|
1042
|
+
if (restart && (reply.text === stuckAck(folder) || reply.text === ''))
|
|
1043
|
+
return restartAck(folder);
|
|
1031
1044
|
return reply.text;
|
|
1032
1045
|
}
|
package/lib/wecom-channel.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Uses @wecom/aibot-node-sdk (botId + secret). Do not hand-roll the WS.
|
|
4
4
|
*/
|
|
5
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\
|
|
6
|
+
export declare const WECOM_NON_TEXT_HINT = "\u8BF7\u53D1\u6587\u5B57\u3002\u56FE\u7247\u548C\u6587\u4EF6\u8BF7\u8D70\u7F51\u9875\u7AEF\u6216 Nextcloud\u3002";
|
|
7
7
|
export type WecomBindingPhase = 'idle' | 'connecting' | 'authenticated' | 'error';
|
|
8
8
|
export interface WecomBindingStatus {
|
|
9
9
|
room: string;
|
package/lib/wecom-channel.js
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from 'node:path';
|
|
|
6
6
|
import { dshHome } from '@rezti/dsh-rez-sso';
|
|
7
7
|
import { runHeadlessViaWeb } from './web-shim.js';
|
|
8
8
|
import { loadWecomChannels, publicWecomBots, saveWecomChannels, applyWecomBotPatch, WECOM_BINDABLE_ROOMS, } from './wecom-store.js';
|
|
9
|
-
export const WECOM_NON_TEXT_HINT = '
|
|
9
|
+
export const WECOM_NON_TEXT_HINT = '请发文字。图片和文件请走网页端或 Nextcloud。';
|
|
10
10
|
const MAX_SEEN_MSGIDS = 200;
|
|
11
11
|
export function extractWecomText(frame) {
|
|
12
12
|
if (typeof frame !== 'object' || frame === null)
|
|
@@ -18,6 +18,10 @@ export function extractWecomText(frame) {
|
|
|
18
18
|
const fromText = textContent(rec.text);
|
|
19
19
|
if (fromText.length > 0)
|
|
20
20
|
return fromText;
|
|
21
|
+
// Official AI-bot voice is already ASR text (single chat). See work.weixin path/100719.
|
|
22
|
+
const fromVoice = textContent(rec.voice);
|
|
23
|
+
if (fromVoice.length > 0)
|
|
24
|
+
return fromVoice;
|
|
21
25
|
if (typeof rec.content === 'string' && rec.content.trim().length > 0)
|
|
22
26
|
return rec.content.trim();
|
|
23
27
|
const mixed = rec.mixed;
|
|
@@ -29,7 +33,8 @@ export function extractWecomText(frame) {
|
|
|
29
33
|
if (typeof item !== 'object' || item === null)
|
|
30
34
|
continue;
|
|
31
35
|
const row = item;
|
|
32
|
-
const nested = textContent(row.text) || (
|
|
36
|
+
const nested = textContent(row.text) || textContent(row.voice)
|
|
37
|
+
|| (typeof row.content === 'string' ? row.content.trim() : '');
|
|
33
38
|
if (nested.length > 0)
|
|
34
39
|
parts.push(nested);
|
|
35
40
|
}
|
|
@@ -82,6 +87,8 @@ export async function createOfficialWecomClient(opts) {
|
|
|
82
87
|
}
|
|
83
88
|
async function defaultRunTurn(room, text) {
|
|
84
89
|
const home = dshHome();
|
|
90
|
+
// Pass the user text as-is. Session stickiness is per-room in web-sessions.json;
|
|
91
|
+
// do not wrap WeCom turns in 桥接上下文 just to reuse a dialog.
|
|
85
92
|
return runHeadlessViaWeb({
|
|
86
93
|
task: text,
|
|
87
94
|
cwd: room,
|
|
@@ -173,7 +180,7 @@ export class WecomChannel {
|
|
|
173
180
|
row.client = client;
|
|
174
181
|
client.on('authenticated', () => {
|
|
175
182
|
row.phase = 'authenticated';
|
|
176
|
-
row.hint = `「${bot.room}
|
|
183
|
+
row.hint = `「${bot.room}」已接入。同事在企微跟这个机器人说话,会进网页左侧该房间的同一条会话。`;
|
|
177
184
|
});
|
|
178
185
|
client.on('disconnected', (reason) => {
|
|
179
186
|
if (row.phase === 'authenticated')
|
|
@@ -189,13 +196,13 @@ export class WecomChannel {
|
|
|
189
196
|
client.on('message.mixed', (frame) => { this.enqueue(row, () => this.handleText(row, frame)); });
|
|
190
197
|
client.on('message.image', (frame) => { this.enqueue(row, () => this.handleNonText(row, frame)); });
|
|
191
198
|
client.on('message.file', (frame) => { this.enqueue(row, () => this.handleNonText(row, frame)); });
|
|
192
|
-
client.on('message.voice', (frame) => { this.enqueue(row, () => this.
|
|
199
|
+
client.on('message.voice', (frame) => { this.enqueue(row, () => this.handleText(row, frame)); });
|
|
193
200
|
client.on('message.video', (frame) => { this.enqueue(row, () => this.handleNonText(row, frame)); });
|
|
194
201
|
client.on('event.enter_chat', (frame) => {
|
|
195
202
|
this.enqueue(row, async () => {
|
|
196
203
|
await client.replyWelcome(frame, {
|
|
197
204
|
msgtype: 'text',
|
|
198
|
-
text: { content: `您好,我是「${bot.room}
|
|
205
|
+
text: { content: `您好,我是「${bot.room}」。直接发文字或语音即可。` },
|
|
199
206
|
});
|
|
200
207
|
});
|
|
201
208
|
});
|
package/lib/wecom-store.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Accio-style WeCom AI bot bindings: one botId+secret per Harness room.
|
|
3
3
|
* Secrets stay in ~/.dsh/wecom-channels.json (mode 600), not compose env.
|
|
4
4
|
*/
|
|
5
|
-
export declare const WECOM_BINDABLE_ROOMS: readonly ["搞钱", "产品", "运营", "人事助理", "财务助理", "法务助理", "设计", "实验室"];
|
|
5
|
+
export declare const WECOM_BINDABLE_ROOMS: readonly ["搞钱", "产品", "运营", "人事助理", "财务助理", "法务助理", "设计", "发布", "电商", "实验室"];
|
|
6
6
|
export type WecomBindableRoom = (typeof WECOM_BINDABLE_ROOMS)[number];
|
|
7
7
|
export interface WecomBotRecord {
|
|
8
8
|
room: string;
|
package/lib/wecom-store.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rezti/dsh-rez-wechat",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
4
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": {
|