@rezti/dsh-rez-wechat 0.1.0 → 0.1.2

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 CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  由 `@rezti/dsh-rez-suite` 一次装入。不要单独 `dsh plugin add`。
4
4
 
5
- 通道接入复用现成 MCP,不自写微信长连接。
6
-
7
5
  | 能力 | 做法 |
8
6
  |---|---|
9
- | 发到群 | `wecom-mcp` + `REZ_WECHAT_WEBHOOK` |
10
- | 收消息 / 模板到人 | **未接入**。下一阶段选现成 wecomcli / 企业微信应用 MCP |
7
+ | 个人微信(QClaw / ClawBot) | 打开 dsh Web → Rez 面板 → **微信** → 扫码。之后直接在微信里说话。 |
8
+ | 企业微信发到群 | `wecom-mcp`(`mcp__wechat__*`)+ `REZ_WECHAT_WEBHOOK` |
9
+
10
+ 个人微信复用现成 `dsh-wechat-bridge`(腾讯官方 iLink),不自写 WebSocket,也不用员工再跑 `npx weixin-mcp login`。
11
11
 
12
- 「微信」在雷泽场景里按企业微信理解。个人微信不在范围内。
12
+ 这是 ClawBot 客服会话,不是用个人微信号管好友/群。
@@ -0,0 +1,32 @@
1
+ /**
2
+ * QClaw-style WeChat channel: reuse dsh-wechat-bridge (official iLink / ClawBot).
3
+ * Scan QR in the Rez panel, then talk in WeChat. Do not expose MCP tools.
4
+ */
5
+ export type WeixinPhase = 'idle' | 'login' | 'need_verify' | 'listening' | 'error';
6
+ export interface WeixinStatus {
7
+ loggedIn: boolean;
8
+ listening: boolean;
9
+ phase: WeixinPhase;
10
+ qrContent?: string;
11
+ qrDataUrl?: string;
12
+ hint: string;
13
+ }
14
+ export declare class WeixinChannel {
15
+ private loginChild;
16
+ private runChild;
17
+ private phase;
18
+ private qrContent;
19
+ private qrDataUrl;
20
+ private hint;
21
+ private logBuf;
22
+ status(): WeixinStatus;
23
+ startIfLoggedIn(): void;
24
+ startLogin(): Promise<WeixinStatus>;
25
+ sendVerify(code: string): WeixinStatus;
26
+ logout(): Promise<WeixinStatus>;
27
+ dispose(): void;
28
+ private startRun;
29
+ private stopLogin;
30
+ private stopRun;
31
+ }
32
+ export declare const weixinDataDir: string;
package/lib/channel.js ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * QClaw-style WeChat channel: reuse dsh-wechat-bridge (official iLink / ClawBot).
3
+ * Scan QR in the Rez panel, then talk in WeChat. Do not expose MCP tools.
4
+ */
5
+ import { spawn } from 'node:child_process';
6
+ import { existsSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ const DATA_DIR = join(homedir(), '.dsh', 'dsh-rez-weixin');
10
+ const AUTH_FILE = join(DATA_DIR, 'weixin-auth.json');
11
+ function extractQrContent(chunk) {
12
+ const labeled = chunk.match(/二维码链接[^\n]*\n\s*(\S+)/);
13
+ if (labeled?.[1] !== undefined && labeled[1].length > 8)
14
+ return labeled[1];
15
+ const probe = chunk.match(/📱 链接:\s*(\S+)/);
16
+ if (probe?.[1] !== undefined)
17
+ return probe[1];
18
+ const https = chunk.match(/https?:\/\/[^\s]+/);
19
+ return https?.[0];
20
+ }
21
+ async function toDataUrl(content) {
22
+ try {
23
+ const qrcode = await import('qrcode');
24
+ const fn = qrcode.toDataURL ?? qrcode.default?.toDataURL;
25
+ if (typeof fn !== 'function')
26
+ return undefined;
27
+ return await fn(content, { margin: 1, width: 240 });
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
33
+ export class WeixinChannel {
34
+ loginChild;
35
+ runChild;
36
+ phase = 'idle';
37
+ qrContent;
38
+ qrDataUrl;
39
+ hint = '打开 Rez 面板的「微信」页扫码,然后直接在微信里说话。';
40
+ logBuf = '';
41
+ status() {
42
+ const loggedIn = existsSync(AUTH_FILE);
43
+ const status = {
44
+ loggedIn,
45
+ listening: this.runChild !== undefined && this.runChild.exitCode === null,
46
+ phase: this.phase,
47
+ hint: this.hint,
48
+ };
49
+ if (this.qrContent !== undefined)
50
+ status.qrContent = this.qrContent;
51
+ if (this.qrDataUrl !== undefined)
52
+ status.qrDataUrl = this.qrDataUrl;
53
+ return status;
54
+ }
55
+ startIfLoggedIn() {
56
+ if (existsSync(AUTH_FILE))
57
+ this.startRun();
58
+ }
59
+ async startLogin() {
60
+ this.stopLogin();
61
+ this.phase = 'login';
62
+ this.qrContent = undefined;
63
+ this.qrDataUrl = undefined;
64
+ this.logBuf = '';
65
+ this.hint = '正在向微信申请二维码…';
66
+ const child = spawn('npx', ['-y', 'dsh-wechat-bridge', 'login', '--data-dir', DATA_DIR], {
67
+ env: process.env,
68
+ stdio: ['pipe', 'pipe', 'pipe'],
69
+ });
70
+ this.loginChild = child;
71
+ const onChunk = (buf) => {
72
+ const text = buf.toString('utf8');
73
+ this.logBuf += text;
74
+ if (this.logBuf.includes('输入手机微信显示的数字') || this.logBuf.includes('请重新输入手机上显示的数字')) {
75
+ this.phase = 'need_verify';
76
+ this.hint = '手机上出现了配对数字,填到下面确认。';
77
+ }
78
+ if (this.logBuf.includes('已扫码'))
79
+ this.hint = '已扫码,请在手机上确认。';
80
+ const qr = extractQrContent(text) ?? extractQrContent(this.logBuf);
81
+ if (qr !== undefined && qr !== this.qrContent) {
82
+ this.qrContent = qr;
83
+ this.hint = '用微信扫一扫这个二维码(或在手机微信打开链接)。';
84
+ void toDataUrl(qr).then(url => {
85
+ if (url !== undefined && this.qrContent === qr)
86
+ this.qrDataUrl = url;
87
+ });
88
+ }
89
+ };
90
+ child.stdout?.on('data', onChunk);
91
+ child.stderr?.on('data', onChunk);
92
+ child.on('exit', code => {
93
+ if (this.loginChild === child)
94
+ this.loginChild = undefined;
95
+ if (code === 0 && existsSync(AUTH_FILE)) {
96
+ this.phase = 'listening';
97
+ this.hint = '已登录。直接在微信里给 ClawBot 发消息即可,不必再跑命令。';
98
+ this.startRun();
99
+ return;
100
+ }
101
+ if (this.phase === 'login' || this.phase === 'need_verify') {
102
+ this.phase = existsSync(AUTH_FILE) ? 'idle' : 'error';
103
+ this.hint = code === 0 ? '登录结束。' : '登录未完成,请再扫一次。';
104
+ }
105
+ });
106
+ return this.status();
107
+ }
108
+ sendVerify(code) {
109
+ const trimmed = code.trim();
110
+ if (trimmed.length === 0)
111
+ return this.status();
112
+ this.loginChild?.stdin?.write(trimmed + '\n');
113
+ this.phase = 'login';
114
+ this.hint = '已提交配对码,等待手机确认…';
115
+ return this.status();
116
+ }
117
+ async logout() {
118
+ this.stopRun();
119
+ this.stopLogin();
120
+ await new Promise(resolve => {
121
+ const child = spawn('npx', ['-y', 'dsh-wechat-bridge', 'logout', '--data-dir', DATA_DIR], {
122
+ env: process.env,
123
+ stdio: 'ignore',
124
+ });
125
+ child.on('exit', () => resolve());
126
+ child.on('error', () => resolve());
127
+ });
128
+ this.phase = 'idle';
129
+ this.qrContent = undefined;
130
+ this.qrDataUrl = undefined;
131
+ this.hint = '已退出。再点「扫码登录」即可。';
132
+ return this.status();
133
+ }
134
+ dispose() {
135
+ this.stopLogin();
136
+ this.stopRun();
137
+ }
138
+ startRun() {
139
+ if (this.runChild !== undefined && this.runChild.exitCode === null)
140
+ return;
141
+ this.phase = 'listening';
142
+ this.hint = '微信通道已在后台听着。在微信里发消息就会进本机 dsh。';
143
+ const child = spawn('npx', ['-y', 'dsh-wechat-bridge', 'run', '--data-dir', DATA_DIR], {
144
+ env: process.env,
145
+ stdio: ['ignore', 'pipe', 'pipe'],
146
+ });
147
+ this.runChild = child;
148
+ child.on('exit', code => {
149
+ if (this.runChild === child)
150
+ this.runChild = undefined;
151
+ if (this.phase === 'listening') {
152
+ this.phase = existsSync(AUTH_FILE) ? 'idle' : 'error';
153
+ this.hint = code === 0 ? '微信监听已停。' : '微信监听退出,可再点扫码或等自动重连。';
154
+ }
155
+ });
156
+ }
157
+ stopLogin() {
158
+ if (this.loginChild === undefined)
159
+ return;
160
+ this.loginChild.kill('SIGTERM');
161
+ this.loginChild = undefined;
162
+ }
163
+ stopRun() {
164
+ if (this.runChild === undefined)
165
+ return;
166
+ this.runChild.kill('SIGTERM');
167
+ this.runChild = undefined;
168
+ }
169
+ }
170
+ export const weixinDataDir = DATA_DIR;
package/lib/index.js CHANGED
@@ -1,15 +1,115 @@
1
+ import { WeixinChannel } from './channel.js';
1
2
  export const name = 'rez-wechat';
2
3
  export const inject = ['systemPrompt'];
3
4
  const GUIDANCE = [
4
- '企业微信发送已通过官方 dsh-mcp-client + wecom-mcp 接入(mcp__wechat__*)。',
5
- '密钥是 REZ_WECHAT_WEBHOOK(群机器人 webhook URL)。',
6
- '对外发消息必须先征得用户批准。',
7
- '接收消息 / 应用模板通知不走本包:复用现成 wecomcli 或应用 API MCP,不要自写 WebSocket。',
5
+ '企业微信群发送走官方 dsh-mcp-client + wecom-mcp(mcp__wechat__*),密钥 REZ_WECHAT_WEBHOOK。',
6
+ '个人微信是 QClaw 同款 ClawBot 通道:在 Rez 面板「微信」页扫码一次,然后直接在微信里说话,dsh 会自动回。不要再跑 npx weixin-mcp login,也不要调用 mcp__weixin__*。',
7
+ '这是 ClawBot 客服会话,不是用个人微信号管好友/群。对外发消息必须先征得用户批准。',
8
8
  ].join('');
9
+ function isLoopbackRequest(request) {
10
+ const address = request.socket.remoteAddress;
11
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1')
12
+ return false;
13
+ const host = request.headers.host;
14
+ if (typeof host !== 'string')
15
+ return false;
16
+ try {
17
+ const hostUrl = new URL('http://' + host);
18
+ return hostUrl.hostname === '127.0.0.1' || hostUrl.hostname === 'localhost' || hostUrl.hostname === '[::1]';
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ function writeJson(res, status, body) {
25
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' });
26
+ res.end(JSON.stringify(body));
27
+ }
28
+ async function readJsonBody(req) {
29
+ const chunks = [];
30
+ let size = 0;
31
+ for await (const chunk of req) {
32
+ size += chunk.length;
33
+ if (size > 16 * 1024)
34
+ return undefined;
35
+ chunks.push(chunk);
36
+ }
37
+ try {
38
+ const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
39
+ return typeof parsed === 'object' && parsed !== null ? parsed : undefined;
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ function weixinRoutes(channel) {
46
+ const path = '/api/dsh-rez-suite/weixin';
47
+ const guard = (req, res, method) => {
48
+ if (!isLoopbackRequest(req)) {
49
+ writeJson(res, 403, { error: 'forbidden: loopback-only' });
50
+ return false;
51
+ }
52
+ if (req.method !== method) {
53
+ writeJson(res, 405, { error: 'method not allowed' });
54
+ return false;
55
+ }
56
+ return true;
57
+ };
58
+ return [
59
+ {
60
+ kind: 'exact',
61
+ path,
62
+ handler: async (req, res) => {
63
+ if (req.method === 'GET') {
64
+ if (!guard(req, res, 'GET'))
65
+ return;
66
+ writeJson(res, 200, channel.status());
67
+ return;
68
+ }
69
+ if (req.method === 'POST') {
70
+ if (!guard(req, res, 'POST'))
71
+ return;
72
+ writeJson(res, 200, await channel.startLogin());
73
+ return;
74
+ }
75
+ if (req.method === 'DELETE') {
76
+ if (!guard(req, res, 'DELETE'))
77
+ return;
78
+ writeJson(res, 200, await channel.logout());
79
+ return;
80
+ }
81
+ writeJson(res, 405, { error: 'method not allowed' });
82
+ },
83
+ },
84
+ {
85
+ kind: 'exact',
86
+ path: path + '/verify',
87
+ handler: async (req, res) => {
88
+ if (!guard(req, res, 'POST'))
89
+ return;
90
+ const body = await readJsonBody(req);
91
+ const code = typeof body?.code === 'string' ? body.code : '';
92
+ writeJson(res, 200, channel.sendVerify(code));
93
+ },
94
+ },
95
+ ];
96
+ }
9
97
  export function apply(ctx) {
98
+ const channel = new WeixinChannel();
99
+ channel.startIfLoggedIn();
10
100
  ctx.effect(() => ctx.systemPrompt.section({
11
101
  name: 'plugin:dsh-rez-wechat',
12
102
  order: 162,
13
103
  text: GUIDANCE,
14
104
  }), 'dsh-rez-wechat: prompt');
105
+ const bag = ctx;
106
+ if (bag.webServer !== undefined) {
107
+ const routes = weixinRoutes(channel);
108
+ ctx.effect(() => {
109
+ const disposers = routes.map(route => bag.webServer.register(route));
110
+ return () => { for (const dispose of disposers)
111
+ dispose(); };
112
+ }, 'dsh-rez-wechat: weixin routes');
113
+ }
114
+ ctx.effect(() => () => { channel.dispose(); }, 'dsh-rez-wechat: weixin channel');
15
115
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rezti/dsh-rez-wechat",
3
- "version": "0.1.0",
4
- "description": "ReZ-TI WeCom bridge for DeepSeek Harness. Send path uses wecom-mcp; inbound is not a custom WebSocket client.",
3
+ "version": "0.1.2",
4
+ "description": "ReZ-TI WeChat/WeCom bridges. Personal WeChat is QClaw/ClawBot scan-and-chat via dsh-wechat-bridge; WeCom group send uses wecom-mcp.",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -20,10 +20,15 @@
20
20
  "README.md"
21
21
  ],
22
22
  "license": "Apache-2.0",
23
+ "dependencies": {
24
+ "qrcode": "^1.5.4"
25
+ },
23
26
  "devDependencies": {
24
27
  "@deepseek-ai/cordis": "^4.0.1",
28
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
25
29
  "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
26
30
  "@types/node": "^22.20.0",
31
+ "@types/qrcode": "^1.5.5",
27
32
  "typescript": "~5.7.2"
28
33
  },
29
34
  "publishConfig": {