dingtalk-ask-mcp-server 0.1.0
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/LICENSE +21 -0
- package/README.md +173 -0
- package/dist/agentLabel.js +37 -0
- package/dist/awayState.js +90 -0
- package/dist/awayToggle.js +30 -0
- package/dist/cliArgs.js +71 -0
- package/dist/daemon/endpoint.js +170 -0
- package/dist/daemon/lock.js +90 -0
- package/dist/daemon.js +160 -0
- package/dist/daemonCli.js +51 -0
- package/dist/daemonClient.js +207 -0
- package/dist/decision/coordinator.js +36 -0
- package/dist/decision/registry.js +166 -0
- package/dist/decision/resultText.js +15 -0
- package/dist/decision/routing.js +102 -0
- package/dist/decision/types.js +8 -0
- package/dist/dingtalk/cardPayload.js +76 -0
- package/dist/dingtalk/channel.js +68 -0
- package/dist/dingtalk/client.js +253 -0
- package/dist/dingtalk/config.js +79 -0
- package/dist/dingtalk/replyMatcher.js +69 -0
- package/dist/index.js +190 -0
- package/dist/installCli.js +207 -0
- package/dist/installConfig.js +80 -0
- package/dist/installHook.js +14 -0
- package/dist/installHookCore.js +97 -0
- package/dist/permissionDecision.js +82 -0
- package/dist/permissionHook.js +136 -0
- package/dist/permissionHookCore.js +79 -0
- package/package.json +70 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 姬煜
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# dingtalk-ask-mcp-server
|
|
2
|
+
|
|
3
|
+
把 code agent(**Claude Code / Codex**)运行中「需要人拍板的决策」和「命令审批」推送到你的**钉钉**,阻塞等你在手机/钉钉回复,再把答复注回 agent。**离开电脑也能远程拍板,一套服务两个宿主通吃。**
|
|
4
|
+
|
|
5
|
+
- **`ask_human` 工具** —— agent 遇到决策/确认时调用,推钉钉、阻塞等回复、答复注回模型。
|
|
6
|
+
- **命令审批路由(Claude Code)** —— 把原生命令审批弹窗推钉钉,手机上批/拒(离开模式开启时)。
|
|
7
|
+
- **离开模式** —— 统一总闸:在电脑前只走终端,离开时才推钉钉,不打扰。
|
|
8
|
+
- **多 Agent** —— 多个窗口共用一条钉钉连接,问题带 `#序号 [项目名]` 前缀并行推送,回 `1 允许、2 拒绝` 精准路由。
|
|
9
|
+
|
|
10
|
+
## 工作原理
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
Claude Code / Codex ──(MCP: ask_human)──┐
|
|
14
|
+
命令审批钩子(仅 CC) ──(HTTP /ask)────────┤
|
|
15
|
+
▼
|
|
16
|
+
dingtalk-daemon(唯一共享守护进程,自动拉起)
|
|
17
|
+
▼ 主动发送 / Stream 收
|
|
18
|
+
钉钉单聊(你本人)
|
|
19
|
+
```
|
|
20
|
+
守护进程独占唯一一条钉钉 Stream 连接(同应用多连接会被负载均衡),所有窗口/宿主经它收发,自动拉起、闲置 30 分钟自动退出。
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 一、前置:配一个钉钉机器人(一次性,必需)
|
|
25
|
+
|
|
26
|
+
工具需要一个钉钉**企业内部应用(H5)**上的机器人,用 Stream 模式收发消息:
|
|
27
|
+
|
|
28
|
+
1. 登录 [钉钉开放平台](https://open-dev.dingtalk.com) → **创建应用 → 企业内部应用**。
|
|
29
|
+
2. 应用里 **添加应用能力 → 机器人**;**消息接收模式**选 **Stream 模式**。
|
|
30
|
+
3. 权限里勾上 **「企业内机器人发送消息权限」**,并**发布**应用。
|
|
31
|
+
4. 记下四项:
|
|
32
|
+
- `AppKey` → 作 `--client-id`
|
|
33
|
+
- `AppSecret` → 作 `--client-secret`
|
|
34
|
+
- 机器人 `robotCode`(通常等于 AppKey)→ 作 `--robot-code`
|
|
35
|
+
- **你本人的 userId** → 作 `--user-id`(只有这个 userId 发的消息会被采纳)
|
|
36
|
+
|
|
37
|
+
> 注意:**独立的「群自定义机器人」不支持 Stream**,必须走上面的企业内部应用 + 机器人能力。
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 二、安装
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm i -g dingtalk-ask-mcp-server
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
装完得到三个全局命令:`dingtalk-ask`(一键接入/管理)、`dingtalk-ask-mcp-server`(服务本体)、`dingtalk-ask-install-hook`(单独装钩子)。
|
|
48
|
+
|
|
49
|
+
> 源码方式:克隆后在包目录 `npm install && npm run build && npm link`,同样得到全局命令。
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## 三、接入(改哪些东西)
|
|
54
|
+
|
|
55
|
+
### 方式 A:一键接入(推荐)
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
dingtalk-ask install --client-id <AppKey> --client-secret <AppSecret> \
|
|
59
|
+
--robot-code <robotCode> --user-id <你的userId> \
|
|
60
|
+
[--timeout 1440000] [--host cc|codex|both]
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
它会自动改这些(合并保留既有内容、幂等):
|
|
64
|
+
|
|
65
|
+
| 宿主 | 改动的文件 | 写入内容 |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| **Claude Code** | `<当前目录>/.mcp.json` | `mcpServers.dingtalk-ask`(服务 + 凭证 env) |
|
|
68
|
+
| | `<当前目录>/.claude/settings.json`(`--global` 则 `~/.claude/settings.json`) | `PermissionRequest` 钩子 + `env.MCP_TOOL_TIMEOUT` |
|
|
69
|
+
| **Codex** | `~/.codex/config.toml` | `[mcp_servers.dingtalk-ask]`(含 `tool_timeout_sec=1500` + 凭证,先备份 `.bak`) |
|
|
70
|
+
|
|
71
|
+
默认 **CC 必装,检测到 `~/.codex` 时 Codex 一并装**。装完**重启 Claude Code / 重开 Codex 会话**生效。
|
|
72
|
+
|
|
73
|
+
### 方式 B:手动接入(想自己控制时)
|
|
74
|
+
|
|
75
|
+
<details>
|
|
76
|
+
<summary><b>Claude Code</b> —— 两处:MCP + 钩子</summary>
|
|
77
|
+
|
|
78
|
+
1. `.mcp.json`(项目根,本地不入库):
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"mcpServers": {
|
|
82
|
+
"dingtalk-ask": {
|
|
83
|
+
"command": "dingtalk-ask-mcp-server",
|
|
84
|
+
"args": ["--client-id","<AppKey>","--client-secret","<AppSecret>",
|
|
85
|
+
"--robot-code","<robotCode>","--user-id","<userId>"]
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
(也可用 `"command":"node","args":["<绝对路径>/dist/index.js"]` + `"env":{...}` 写法。)
|
|
91
|
+
2. **命令审批钩子** `.claude/settings.json`:
|
|
92
|
+
```json
|
|
93
|
+
{ "hooks": { "PermissionRequest": [ { "matcher": "Bash",
|
|
94
|
+
"hooks": [ { "type": "command", "command": "dingtalk-ask-install-hook", "timeout": 60000 } ] } ] } }
|
|
95
|
+
```
|
|
96
|
+
实际用 `node "<绝对路径>/dist/permissionHook.js"`;或直接跑 `dingtalk-ask-install-hook` 自动写入。
|
|
97
|
+
3. `settings.local.json` 或 `settings.json` 的 `env` 设 **`MCP_TOOL_TIMEOUT`**(如 `1500000`),否则阻塞式 `ask_human` 会被宿主提前掐断(默认 60s)。
|
|
98
|
+
</details>
|
|
99
|
+
|
|
100
|
+
<details>
|
|
101
|
+
<summary><b>Codex</b> —— 只需 MCP(命令审批不走钩子)</summary>
|
|
102
|
+
|
|
103
|
+
`~/.codex/config.toml`:
|
|
104
|
+
```toml
|
|
105
|
+
[mcp_servers.dingtalk-ask]
|
|
106
|
+
command = "dingtalk-ask-mcp-server"
|
|
107
|
+
args = ["--client-id","<AppKey>","--client-secret","<AppSecret>","--robot-code","<robotCode>","--user-id","<userId>"]
|
|
108
|
+
startup_timeout_sec = 30
|
|
109
|
+
tool_timeout_sec = 1500 # 关键!默认仅 60s,ask_human 阻塞等回复会被砍断
|
|
110
|
+
```
|
|
111
|
+
Codex 若 `approval_policy="never"` 本就不弹命令审批,故只提供 `ask_human` / `set_away_mode`,与其它 Codex 钩子零冲突。
|
|
112
|
+
</details>
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## 四、使用
|
|
117
|
+
|
|
118
|
+
### 离开模式(核心开关)
|
|
119
|
+
- **默认关**:在电脑前,决策/审批只走终端,不推钉钉。
|
|
120
|
+
- 离开电脑前开启,四种方式任选(同一个全局开关):
|
|
121
|
+
- 钉钉里发 **`/away`**(开)、**`/back`**(关) —— 最方便,手机上就能切;
|
|
122
|
+
- 对 agent 说「我出去一下 / 我回来了」,它会调 `set_away_mode` 工具;
|
|
123
|
+
- 命令行 `dingtalk-ask away on|off|status`。
|
|
124
|
+
- 用 `/` 前缀命令(非汉字)是为了不和你的自由回复误撞。
|
|
125
|
+
|
|
126
|
+
### 决策与审批
|
|
127
|
+
- **`ask_human`**:agent 自己决定何时调用(遇到取舍/确认)。开了离开模式就推钉钉、阻塞等你回复;没开则让 agent 在终端问你。
|
|
128
|
+
- **命令审批(CC)**:开了离开模式,需要批准的命令会推钉钉,回「允许 / 拒绝」;超时/看不懂一律**回落终端**,绝不误放行。
|
|
129
|
+
|
|
130
|
+
### 多 Agent / 一条消息答多个
|
|
131
|
+
多个窗口并发提问时,钉钉里每条带 `#序号 [项目名]`:
|
|
132
|
+
- 只有一个待答 → 直接回 `允许`(免序号);
|
|
133
|
+
- 多个待答 → 回 `1 允许`、`2 拒绝`;**也可一条消息答多个**:`1 允许、2 拒绝`(空格/顿号/换行分隔均可)。
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## 五、命令参考
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
dingtalk-ask install … # 一键接入(见上)
|
|
141
|
+
dingtalk-ask daemon status|stop # 查看/停止共享守护进程
|
|
142
|
+
dingtalk-ask away on|off|status # 本地切换离开模式
|
|
143
|
+
dingtalk-ask-mcp-server --client-id … … # 服务本体(一般由宿主拉起,极少手动跑)
|
|
144
|
+
dingtalk-ask-install-hook [--global|<path>] # 只装 CC 命令审批钩子
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
守护进程自动拉起、闲置自动退出;`DINGTALK_DAEMON_IDLE_MS` 可调(`0` 禁用)。诊断:守护进程日志在系统临时目录 `dingtalk-daemon.log`;设 `DINGTALK_DEBUG=1` 打印钉钉每帧。
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## 六、配置项
|
|
152
|
+
|
|
153
|
+
| 项 | 传法 | 说明 |
|
|
154
|
+
|---|---|---|
|
|
155
|
+
| `DINGTALK_CLIENT_ID` / `--client-id` | env 或 CLI 参数 | AppKey(必填) |
|
|
156
|
+
| `DINGTALK_CLIENT_SECRET` / `--client-secret` | 同上 | AppSecret(必填) |
|
|
157
|
+
| `DINGTALK_ROBOT_CODE` / `--robot-code` | 同上 | robotCode(必填) |
|
|
158
|
+
| `DINGTALK_USER_ID` / `--user-id` | 同上 | 你本人 userId(必填) |
|
|
159
|
+
| `DINGTALK_ASK_TIMEOUT_MS` / `--timeout` | 同上 | 内部等待上限,默认 25 分钟 |
|
|
160
|
+
| `DINGTALK_AGENT_LABEL` / `--agent-label` | 同上 | 钉钉里显示的来源标签,缺省用工作目录名 |
|
|
161
|
+
|
|
162
|
+
CLI 参数优先于环境变量。⚠️ 命令行参数在进程列表(`ps`/tasklist)可见,介意暴露者用 env 段。**凭证只写入宿主本地配置(均不入库),不会上传到任何地方。**
|
|
163
|
+
|
|
164
|
+
## 开发
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
npm run check # build + 全量单测
|
|
168
|
+
npm run verify:multiagent # 真机联调:并发推两条到手机验证编号路由(需 env 有凭证)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent 标签解析(纯函数,可测):多 Agent 场景下,给每条推到钉钉的问题标注来源。
|
|
3
|
+
*
|
|
4
|
+
* 优先级:DINGTALK_AGENT_LABEL(显式配置)> cwd 的最后一段目录名 > 'agent'(兜底)。
|
|
5
|
+
* 自己实现 basename 而非用 path.basename——需同时兼容 POSIX '/' 与 Windows '\\' 分隔符,
|
|
6
|
+
* 且在跨平台单测里行为稳定(path.basename 在 POSIX 上不拆反斜杠)。
|
|
7
|
+
*/
|
|
8
|
+
/** cwd 取不到有效目录名时的兜底标签。 */
|
|
9
|
+
const FALLBACK_LABEL = 'agent';
|
|
10
|
+
/**
|
|
11
|
+
* 从路径取最后一段(兼容 / 与 \\,忽略尾部分隔符)。
|
|
12
|
+
*
|
|
13
|
+
* @param dir 目录路径(可能缺省)。
|
|
14
|
+
* @returns 最后一段目录名;无有效段时返回空串。
|
|
15
|
+
*/
|
|
16
|
+
function lastSegment(dir) {
|
|
17
|
+
if (dir === undefined) {
|
|
18
|
+
return '';
|
|
19
|
+
}
|
|
20
|
+
const segments = dir.split(/[/\\]+/).filter((s) => s !== '');
|
|
21
|
+
return segments.length > 0 ? segments[segments.length - 1] : '';
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 解析 Agent 标签。
|
|
25
|
+
*
|
|
26
|
+
* @param env 环境变量对象(读 DINGTALK_AGENT_LABEL)。
|
|
27
|
+
* @param cwd 当前工作目录(MCP server 用 process.cwd(),钩子用 stdin 的 cwd)。
|
|
28
|
+
* @returns 非空 Agent 标签。
|
|
29
|
+
*/
|
|
30
|
+
export function resolveAgentLabel(env, cwd) {
|
|
31
|
+
const explicit = (env.DINGTALK_AGENT_LABEL ?? '').trim();
|
|
32
|
+
if (explicit !== '') {
|
|
33
|
+
return explicit;
|
|
34
|
+
}
|
|
35
|
+
const segment = lastSegment(cwd);
|
|
36
|
+
return segment !== '' ? segment : FALLBACK_LABEL;
|
|
37
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「离开模式」开关状态:以 os.tmpdir() 下一个 flag 文件的存在与否表示。
|
|
3
|
+
*
|
|
4
|
+
* 语义:
|
|
5
|
+
* - 默认(无 flag,人在电脑前):命令审批钩子立刻回落终端,不推钉钉。
|
|
6
|
+
* - 离开模式开(有 flag):钩子才把命令审批推钉钉,用户手机上批/拒。
|
|
7
|
+
*
|
|
8
|
+
* 设计要点:
|
|
9
|
+
* - 纯逻辑(parseAwayCommand / awayFlagPath)与极薄文件 IO(isAway / setAway)分离,便于单测。
|
|
10
|
+
* - isAway / setAway 接受可选路径参数,测试注入隔离临时路径,绝不污染真实 flag。
|
|
11
|
+
* - 只用 Node 内置模块(fs/os/path)。日志留给调用方,本模块不打日志。
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
/**
|
|
17
|
+
* 离开模式遥控命令:用 `/` 前缀显式命令,避免与用户的自由意见/决策回复误撞。
|
|
18
|
+
* - `/away`、`/away on` → 进离开模式;
|
|
19
|
+
* - `/back`、`/away off` → 退出。
|
|
20
|
+
*/
|
|
21
|
+
/** 进离开模式命令的首 token。 */
|
|
22
|
+
const AWAY_HEAD = '/away';
|
|
23
|
+
/** 退出离开模式命令的首 token(`/away off` 亦可)。 */
|
|
24
|
+
const BACK_HEAD = '/back';
|
|
25
|
+
/**
|
|
26
|
+
* 解析离开模式遥控命令(纯函数)。
|
|
27
|
+
*
|
|
28
|
+
* 规则:trim + 小写后按空白切分;**首 token 必须精确是 `/away` 或 `/back`**——
|
|
29
|
+
* `/back` → off;`/away` → 看第二 token,为 `off` 则 off,否则 on;其余一律 null。
|
|
30
|
+
* 用显式 `/` 前缀命令而非汉字关键词,彻底杜绝「我先出去买杯咖啡」这类口语被误当命令。
|
|
31
|
+
*
|
|
32
|
+
* @param text 用户发来的原始文本。
|
|
33
|
+
* @returns `/away*` → 'on'/'off';`/back` → 'off';不是命令 → null。
|
|
34
|
+
*/
|
|
35
|
+
export function parseAwayCommand(text) {
|
|
36
|
+
const normalized = text.trim().toLowerCase();
|
|
37
|
+
if (normalized === '') {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const tokens = normalized.split(/\s+/);
|
|
41
|
+
const head = tokens[0];
|
|
42
|
+
if (head === BACK_HEAD) {
|
|
43
|
+
return 'off';
|
|
44
|
+
}
|
|
45
|
+
if (head === AWAY_HEAD) {
|
|
46
|
+
return tokens[1] === 'off' ? 'off' : 'on';
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 解析离开模式 flag 文件绝对路径(纯函数,便于钩子/CLI 复用与单测)。
|
|
52
|
+
*
|
|
53
|
+
* @returns os.tmpdir() 下固定文件名的绝对路径。
|
|
54
|
+
*/
|
|
55
|
+
export function awayFlagPath() {
|
|
56
|
+
return join(tmpdir(), 'dingtalk-ask-away.flag');
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 当前是否处于离开模式:flag 文件存在即为开。
|
|
60
|
+
*
|
|
61
|
+
* @param flagPath flag 文件路径;缺省用 awayFlagPath()(测试注入隔离路径)。
|
|
62
|
+
* @returns 文件存在 → true;不存在或读取异常 → false。
|
|
63
|
+
*/
|
|
64
|
+
export function isAway(flagPath = awayFlagPath()) {
|
|
65
|
+
try {
|
|
66
|
+
return existsSync(flagPath);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// 读取异常一律视为未开离开模式(安全默认:不推钉钉)。
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 设置离开模式开关。
|
|
75
|
+
*
|
|
76
|
+
* @param on true → 写 flag 文件(内容为 ISO 时间戳);false → 删除(不存在则忽略)。
|
|
77
|
+
* @param flagPath flag 文件路径;缺省用 awayFlagPath()(测试注入隔离路径)。
|
|
78
|
+
*/
|
|
79
|
+
export function setAway(on, flagPath = awayFlagPath()) {
|
|
80
|
+
if (on) {
|
|
81
|
+
writeFileSync(flagPath, new Date().toISOString(), 'utf8');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
unlinkSync(flagPath);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* flag 文件不存在或已删,忽略。 */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地 CLI:离开模式开关(不经钉钉,直接读写 flag 文件)。
|
|
3
|
+
*
|
|
4
|
+
* 构建到 dist/awayToggle.js,通过 npm scripts 调用:
|
|
5
|
+
* npm run away:on → 进入离开模式
|
|
6
|
+
* npm run away:off → 退出离开模式
|
|
7
|
+
* npm run away:status → 打印当前状态
|
|
8
|
+
*
|
|
9
|
+
* 注意:这是 CLI 不是 MCP 信道,可用 stdout 打印结果(与 MCP server 的 stdout 禁令无关)。
|
|
10
|
+
* 只用 Node 内置模块,不引入任何依赖。
|
|
11
|
+
*/
|
|
12
|
+
import { setAway, isAway } from './awayState.js';
|
|
13
|
+
/** 读 argv 执行开关;缺省或 status 打印当前状态。 */
|
|
14
|
+
function run() {
|
|
15
|
+
const arg = process.argv[2];
|
|
16
|
+
if (arg === 'on') {
|
|
17
|
+
setAway(true);
|
|
18
|
+
process.stdout.write('已进入离开模式:命令审批会推到钉钉。\n');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (arg === 'off') {
|
|
22
|
+
setAway(false);
|
|
23
|
+
process.stdout.write('已退出离开模式:命令审批只在终端。\n');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
// status 或缺省:只读当前状态。
|
|
27
|
+
const away = isAway();
|
|
28
|
+
process.stdout.write(`当前离开模式:${away ? '开(推钉钉)' : '关(只在终端)'}\n`);
|
|
29
|
+
}
|
|
30
|
+
run();
|
package/dist/cliArgs.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 命令行参数解析:允许通过 CLI 参数传钉钉配置,免去在 MCP 配置里写 env 段。
|
|
3
|
+
*
|
|
4
|
+
* 支持 `--key value` 与 `--key=value`;kebab-case 与 camelCase 皆可。解析结果映射为
|
|
5
|
+
* 与环境变量同名的键,便于与 process.env 合并后交给 resolveDingtalkConfig(参数优先于 env)。
|
|
6
|
+
*
|
|
7
|
+
* 安全提示:命令行参数在进程列表(ps/tasklist)中对同机其它进程可见,比 env 略易暴露;
|
|
8
|
+
* 介意者仍可用 env 段。日志由调用方负责,本模块不打日志。
|
|
9
|
+
*/
|
|
10
|
+
/** CLI 标志到环境变量名的映射(含 kebab/camel 双写法)。 */
|
|
11
|
+
const FLAG_TO_ENV = {
|
|
12
|
+
'client-id': 'DINGTALK_CLIENT_ID',
|
|
13
|
+
clientid: 'DINGTALK_CLIENT_ID',
|
|
14
|
+
'client-secret': 'DINGTALK_CLIENT_SECRET',
|
|
15
|
+
clientsecret: 'DINGTALK_CLIENT_SECRET',
|
|
16
|
+
'robot-code': 'DINGTALK_ROBOT_CODE',
|
|
17
|
+
robotcode: 'DINGTALK_ROBOT_CODE',
|
|
18
|
+
'user-id': 'DINGTALK_USER_ID',
|
|
19
|
+
userid: 'DINGTALK_USER_ID',
|
|
20
|
+
timeout: 'DINGTALK_ASK_TIMEOUT_MS',
|
|
21
|
+
'timeout-ms': 'DINGTALK_ASK_TIMEOUT_MS',
|
|
22
|
+
'agent-label': 'DINGTALK_AGENT_LABEL',
|
|
23
|
+
label: 'DINGTALK_AGENT_LABEL',
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* 解析 CLI 参数为「环境变量名 → 值」映射(纯函数,可测)。
|
|
27
|
+
*
|
|
28
|
+
* @param argv 参数数组(通常 process.argv.slice(2))。
|
|
29
|
+
* @returns 仅包含被显式提供项的映射;无则空对象。
|
|
30
|
+
*/
|
|
31
|
+
export function parseCliArgs(argv) {
|
|
32
|
+
const out = {};
|
|
33
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
34
|
+
const token = argv[i];
|
|
35
|
+
if (!token.startsWith('--')) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const body = token.slice(2);
|
|
39
|
+
const eq = body.indexOf('=');
|
|
40
|
+
let rawKey;
|
|
41
|
+
let value;
|
|
42
|
+
if (eq >= 0) {
|
|
43
|
+
rawKey = body.slice(0, eq);
|
|
44
|
+
value = body.slice(eq + 1);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
rawKey = body;
|
|
48
|
+
const next = argv[i + 1];
|
|
49
|
+
// 下一个 token 不是标志才当作值;否则该标志无值、跳过。
|
|
50
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
51
|
+
value = next;
|
|
52
|
+
i += 1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const envKey = FLAG_TO_ENV[rawKey.toLowerCase()];
|
|
56
|
+
if (envKey !== undefined && value !== undefined && value !== '') {
|
|
57
|
+
out[envKey] = value;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 合并 CLI 参数与环境变量(参数优先),得到供 resolveDingtalkConfig / resolveAgentLabel 用的 env 视图。
|
|
64
|
+
*
|
|
65
|
+
* @param env 基础环境变量(通常 process.env)。
|
|
66
|
+
* @param argv CLI 参数(通常 process.argv.slice(2))。
|
|
67
|
+
* @returns 合并后的环境变量对象(不改动入参)。
|
|
68
|
+
*/
|
|
69
|
+
export function mergeArgsIntoEnv(env, argv) {
|
|
70
|
+
return { ...env, ...parseCliArgs(argv) };
|
|
71
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 守护进程本地 HTTP 端点:只监听 127.0.0.1,供同机所有 Agent 的 MCP server 与钩子复用
|
|
3
|
+
* 守护进程持有的【唯一】钉钉 Stream 连接。
|
|
4
|
+
*
|
|
5
|
+
* 与旧 localEndpoint 的区别:
|
|
6
|
+
* - 不再负责锁文件(锁的原子选举归 daemon.ts + daemon/lock.ts)。
|
|
7
|
+
* - 新增 GET /health(带 token)返回 { ok, ready },供 ensureDaemon 探活与就绪判定。
|
|
8
|
+
* - POST /ask body 新增可选 label(Agent 标签),透传给决策层做消息前缀。
|
|
9
|
+
*
|
|
10
|
+
* 鉴权:请求头 x-ask-token 必须等于启动时随机 token(/ask 与 /health 都校验)。
|
|
11
|
+
* 只用 Node 内置模块(http/crypto),日志走 stderr。
|
|
12
|
+
*/
|
|
13
|
+
import { createServer } from 'node:http';
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
|
+
/** 请求体大小上限。 */
|
|
16
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
17
|
+
/**
|
|
18
|
+
* 启动本地端点(127.0.0.1:0,OS 分配端口)。
|
|
19
|
+
*
|
|
20
|
+
* @param askDecision 决策函数。
|
|
21
|
+
* @param options 端点选项。
|
|
22
|
+
* @returns 端点句柄(含实际端口与 token)。
|
|
23
|
+
* @throws 当监听失败时 reject。
|
|
24
|
+
*/
|
|
25
|
+
export async function startEndpoint(askDecision, options) {
|
|
26
|
+
const token = randomBytes(24).toString('hex');
|
|
27
|
+
const isReady = options.isReady ?? (() => true);
|
|
28
|
+
const server = createServer((req, res) => {
|
|
29
|
+
handleRequest(req, res, askDecision, options, token, isReady).catch((error) => {
|
|
30
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
31
|
+
console.error(`守护进程端点处理异常:${message}`);
|
|
32
|
+
if (!res.headersSent) {
|
|
33
|
+
sendJson(res, 500, { error: message });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
const port = await listen(server);
|
|
38
|
+
console.error(`守护进程 /ask 端点已启动:http://127.0.0.1:${port}/ask`);
|
|
39
|
+
return {
|
|
40
|
+
server,
|
|
41
|
+
port,
|
|
42
|
+
token,
|
|
43
|
+
async close() {
|
|
44
|
+
await closeServer(server);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** 监听 127.0.0.1:0 并返回实际端口。 */
|
|
49
|
+
function listen(server) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
server.once('error', reject);
|
|
52
|
+
server.listen(0, '127.0.0.1', () => {
|
|
53
|
+
const address = server.address();
|
|
54
|
+
if (address === null || typeof address === 'string') {
|
|
55
|
+
reject(new Error('无法确定端点端口(server.address 非预期)。'));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
server.removeListener('error', reject);
|
|
59
|
+
resolve(address.port);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** 路由 + 鉴权 + 分发。 */
|
|
64
|
+
async function handleRequest(req, res, askDecision, options, token, isReady) {
|
|
65
|
+
// 鉴权:token 不匹配一律 403。
|
|
66
|
+
if (req.headers['x-ask-token'] !== token) {
|
|
67
|
+
sendJson(res, 403, { error: 'forbidden' });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// GET /health:探活 + 就绪判定。
|
|
71
|
+
if (req.method === 'GET' && req.url === '/health') {
|
|
72
|
+
sendJson(res, 200, { ok: true, ready: isReady() });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (req.method !== 'POST' || req.url !== '/ask') {
|
|
76
|
+
sendJson(res, 404, { error: 'not found' });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
let body;
|
|
80
|
+
try {
|
|
81
|
+
body = await readBody(req);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
let parsed;
|
|
88
|
+
try {
|
|
89
|
+
parsed = normalizeInput(JSON.parse(body));
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
sendJson(res, 400, { error: `请求体非法:${error instanceof Error ? error.message : String(error)}` });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const result = await askDecision({
|
|
97
|
+
question: parsed.question,
|
|
98
|
+
options: parsed.options,
|
|
99
|
+
label: parsed.label,
|
|
100
|
+
timeoutMs: parsed.timeoutMs ?? options.defaultTimeoutMs,
|
|
101
|
+
});
|
|
102
|
+
sendJson(res, 200, result);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
106
|
+
console.error(`/ask 决策失败:${message}`);
|
|
107
|
+
sendJson(res, 500, { error: message });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** 读取并限制请求体大小。 */
|
|
111
|
+
function readBody(req) {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
let size = 0;
|
|
114
|
+
const chunks = [];
|
|
115
|
+
req.on('data', (chunk) => {
|
|
116
|
+
size += chunk.length;
|
|
117
|
+
if (size > MAX_BODY_BYTES) {
|
|
118
|
+
reject(new Error('请求体过大'));
|
|
119
|
+
req.destroy();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
chunks.push(chunk);
|
|
123
|
+
});
|
|
124
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
125
|
+
req.on('error', reject);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/** 校验并规整请求 JSON 为 AskDecisionInput。 */
|
|
129
|
+
function normalizeInput(raw) {
|
|
130
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
131
|
+
throw new Error('必须是 JSON 对象');
|
|
132
|
+
}
|
|
133
|
+
const obj = raw;
|
|
134
|
+
if (typeof obj.question !== 'string' || obj.question.trim() === '') {
|
|
135
|
+
throw new Error('question 必填且为非空字符串');
|
|
136
|
+
}
|
|
137
|
+
let options;
|
|
138
|
+
if (obj.options !== undefined) {
|
|
139
|
+
if (!Array.isArray(obj.options) || obj.options.some((o) => typeof o !== 'string')) {
|
|
140
|
+
throw new Error('options 必须是字符串数组');
|
|
141
|
+
}
|
|
142
|
+
options = obj.options;
|
|
143
|
+
}
|
|
144
|
+
let label;
|
|
145
|
+
if (obj.label !== undefined) {
|
|
146
|
+
if (typeof obj.label !== 'string') {
|
|
147
|
+
throw new Error('label 必须是字符串');
|
|
148
|
+
}
|
|
149
|
+
label = obj.label;
|
|
150
|
+
}
|
|
151
|
+
let timeoutMs;
|
|
152
|
+
if (obj.timeoutMs !== undefined) {
|
|
153
|
+
if (typeof obj.timeoutMs !== 'number' || !Number.isInteger(obj.timeoutMs) || obj.timeoutMs <= 0) {
|
|
154
|
+
throw new Error('timeoutMs 必须是正整数');
|
|
155
|
+
}
|
|
156
|
+
timeoutMs = obj.timeoutMs;
|
|
157
|
+
}
|
|
158
|
+
return { question: obj.question, options, label, timeoutMs };
|
|
159
|
+
}
|
|
160
|
+
/** 写 JSON 响应。 */
|
|
161
|
+
function sendJson(res, status, body) {
|
|
162
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
163
|
+
res.end(JSON.stringify(body));
|
|
164
|
+
}
|
|
165
|
+
/** 关闭 server。 */
|
|
166
|
+
function closeServer(server) {
|
|
167
|
+
return new Promise((resolve) => {
|
|
168
|
+
server.close(() => resolve());
|
|
169
|
+
});
|
|
170
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 守护进程锁文件:os.tmpdir()/dingtalk-daemon.json,记录唯一守护进程的
|
|
3
|
+
* { port, token, pid, startedAt }。它既是端点会合点(客户端据此定位 /ask),
|
|
4
|
+
* 也是单例选举的原子闸——用 O_EXCL('wx')独占创建,只有一个进程能创建成功。
|
|
5
|
+
*
|
|
6
|
+
* 纯解析(parseDaemonLock)与极薄 IO(read/write/remove)分离,便于单测;
|
|
7
|
+
* 只用 Node 内置 fs/os/path。日志留给调用方。
|
|
8
|
+
*/
|
|
9
|
+
import { writeFileSync, readFileSync, unlinkSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
/**
|
|
13
|
+
* 守护进程锁文件绝对路径。
|
|
14
|
+
*
|
|
15
|
+
* @returns os.tmpdir() 下固定文件名。
|
|
16
|
+
*/
|
|
17
|
+
export function daemonLockPath() {
|
|
18
|
+
return join(tmpdir(), 'dingtalk-daemon.json');
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 解析锁文件内容(纯函数)。port 为数字且 token 非空才有效;pid/startedAt 缺省用默认。
|
|
22
|
+
*
|
|
23
|
+
* @param raw 锁文件文本。
|
|
24
|
+
* @returns 合法则返回 DaemonLock,否则 undefined。
|
|
25
|
+
*/
|
|
26
|
+
export function parseDaemonLock(raw) {
|
|
27
|
+
let obj;
|
|
28
|
+
try {
|
|
29
|
+
obj = JSON.parse(raw);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
if (typeof obj.port !== 'number' || typeof obj.token !== 'string' || obj.token === '') {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
port: obj.port,
|
|
39
|
+
token: obj.token,
|
|
40
|
+
pid: typeof obj.pid === 'number' ? obj.pid : 0,
|
|
41
|
+
startedAt: typeof obj.startedAt === 'string' ? obj.startedAt : '',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 读并解析锁文件;不存在/损坏返回 undefined。
|
|
46
|
+
*
|
|
47
|
+
* @param path 锁文件路径;缺省用 daemonLockPath()。
|
|
48
|
+
* @returns 合法锁内容或 undefined。
|
|
49
|
+
*/
|
|
50
|
+
export function readDaemonLock(path = daemonLockPath()) {
|
|
51
|
+
try {
|
|
52
|
+
return parseDaemonLock(readFileSync(path, 'utf8'));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 独占创建锁文件(O_EXCL)。已存在则不覆盖、返回 false(选举落败)。
|
|
60
|
+
*
|
|
61
|
+
* @param lock 要写入的锁内容。
|
|
62
|
+
* @param path 锁文件路径;缺省用 daemonLockPath()。
|
|
63
|
+
* @returns 创建成功 true;已存在 false。
|
|
64
|
+
* @throws 其它 IO 错误(非 EEXIST)向上抛。
|
|
65
|
+
*/
|
|
66
|
+
export function writeDaemonLockExclusive(lock, path = daemonLockPath()) {
|
|
67
|
+
try {
|
|
68
|
+
writeFileSync(path, JSON.stringify(lock), { encoding: 'utf8', flag: 'wx' });
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error.code === 'EEXIST') {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* 删除锁文件(不存在则忽略)。
|
|
80
|
+
*
|
|
81
|
+
* @param path 锁文件路径;缺省用 daemonLockPath()。
|
|
82
|
+
*/
|
|
83
|
+
export function removeDaemonLock(path = daemonLockPath()) {
|
|
84
|
+
try {
|
|
85
|
+
unlinkSync(path);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* 不存在或已删,忽略。 */
|
|
89
|
+
}
|
|
90
|
+
}
|