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/dist/daemon.js ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * dingtalk-daemon:唯一持有钉钉 Stream 连接的共享守护进程。
3
+ *
4
+ * 多个 Agent(多个 Claude Code 窗口 / 未来 Codex)的 MCP server 与命令审批钩子都作为 HTTP
5
+ * 瘦客户端连到本进程的 /ask 端点,共用这一条连接——规避"同应用并发 Stream 连接被负载均衡"
6
+ * 导致回复投给随机 Agent 的问题。并发决策由 DecisionRegistry 多槽 + 编号路由处理。
7
+ *
8
+ * 单例选举:先绑定端点拿到 port/token,再用 O_EXCL 独占创建锁文件;创建成功者才连接钉钉,
9
+ * 落败者关端点退出(从不建连,避免多开连接)。凭证仅经 env(由拉起它的 MCP server 转发)。
10
+ *
11
+ * 本进程由 daemonClient.spawnDaemon detached 拉起;stderr 重定向到 tmpdir/dingtalk-daemon.log。
12
+ */
13
+ import { resolveDingtalkConfig } from './dingtalk/config.js';
14
+ import { DingtalkClient } from './dingtalk/client.js';
15
+ import { DecisionRegistry } from './decision/registry.js';
16
+ import { askWithTimeout } from './decision/coordinator.js';
17
+ import { startEndpoint } from './daemon/endpoint.js';
18
+ import { daemonLockPath, writeDaemonLockExclusive, readDaemonLock, removeDaemonLock, } from './daemon/lock.js';
19
+ import { probeHealth } from './daemonClient.js';
20
+ /** 选举重试次数(应对陈旧锁的回收竞争)。 */
21
+ const ELECT_ATTEMPTS = 3;
22
+ /** 空闲自关默认阈值(毫秒):无待答且这么久无 /ask 则退出。0 = 禁用(常驻)。 */
23
+ const DEFAULT_IDLE_MS = 30 * 60_000;
24
+ /** 空闲检查间隔(毫秒)。 */
25
+ const IDLE_CHECK_INTERVAL_MS = 60_000;
26
+ /**
27
+ * 解析空闲自关阈值(env DINGTALK_DAEMON_IDLE_MS 覆盖,非法则用默认;0 = 禁用)。
28
+ *
29
+ * @param raw 环境变量原值。
30
+ * @returns 阈值毫秒;0 表示禁用。
31
+ */
32
+ function resolveIdleMs(raw) {
33
+ const trimmed = (raw ?? '').trim();
34
+ if (trimmed === '') {
35
+ return DEFAULT_IDLE_MS;
36
+ }
37
+ const parsed = Number(trimmed);
38
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : DEFAULT_IDLE_MS;
39
+ }
40
+ /** 解析配置;缺凭证 → stderr 打印并 exit(1)。 */
41
+ function loadConfigOrExit() {
42
+ try {
43
+ return resolveDingtalkConfig(process.env);
44
+ }
45
+ catch (error) {
46
+ console.error(`dingtalk-daemon 配置错误:${error instanceof Error ? error.message : String(error)}`);
47
+ process.exit(1);
48
+ }
49
+ }
50
+ /** 生成决策 id(单进程内唯一即可)。 */
51
+ function randomId() {
52
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
53
+ }
54
+ /**
55
+ * 单例选举:独占创建锁则当选;已存在且属活着的守护进程则退让;陈旧锁则回收后重试。
56
+ *
57
+ * @param lock 本进程锁(port + token)。
58
+ * @param lockPath 锁路径。
59
+ * @returns true 当选;false 退让。
60
+ */
61
+ async function elect(lock, lockPath) {
62
+ for (let i = 0; i < ELECT_ATTEMPTS; i += 1) {
63
+ if (writeDaemonLockExclusive(lock, lockPath)) {
64
+ return true;
65
+ }
66
+ const existing = readDaemonLock(lockPath);
67
+ if (existing !== undefined && (await probeHealth(existing)).ok) {
68
+ return false; // 有活跃守护进程占位 → 退让。
69
+ }
70
+ // 陈旧锁(端点不应答)→ 回收后重试独占创建。
71
+ removeDaemonLock(lockPath);
72
+ }
73
+ return false;
74
+ }
75
+ /** 注册退出清理:删锁 + 断连。 */
76
+ function registerCleanup(endpoint, client, lockPath) {
77
+ const onSignal = (signal) => {
78
+ console.error(`收到 ${signal},清理锁文件并断开钉钉连接。`);
79
+ removeDaemonLock(lockPath);
80
+ void client.close();
81
+ void endpoint.close();
82
+ process.exit(0);
83
+ };
84
+ process.once('SIGINT', () => onSignal('SIGINT'));
85
+ process.once('SIGTERM', () => onSignal('SIGTERM'));
86
+ process.once('beforeExit', () => removeDaemonLock(lockPath));
87
+ }
88
+ /** 守护进程主入口。 */
89
+ async function main() {
90
+ const config = loadConfigOrExit();
91
+ const lockPath = daemonLockPath();
92
+ let ready = false;
93
+ let registry;
94
+ // 最近一次 /ask 活动时间(空闲自关判定用)。
95
+ let lastActivityMs = Date.now();
96
+ // 端点决策函数:去掉进程级 mutex,交 registry 多槽并发;askWithTimeout 保证超时不挂死。
97
+ const askDecision = async (input) => {
98
+ if (registry === undefined) {
99
+ throw new Error('守护进程尚未就绪。');
100
+ }
101
+ lastActivityMs = Date.now();
102
+ return askWithTimeout(registry, {
103
+ id: randomId(),
104
+ question: input.question,
105
+ options: input.options,
106
+ label: input.label,
107
+ timeoutMs: input.timeoutMs ?? config.timeoutMs,
108
+ });
109
+ };
110
+ // 先绑定端点(拿 port/token),此时 ready=false;接好钉钉后置 true。
111
+ const endpoint = await startEndpoint(askDecision, {
112
+ defaultTimeoutMs: config.timeoutMs,
113
+ isReady: () => ready,
114
+ });
115
+ const lock = {
116
+ port: endpoint.port,
117
+ token: endpoint.token,
118
+ pid: process.pid,
119
+ startedAt: new Date().toISOString(),
120
+ };
121
+ const won = await elect(lock, lockPath);
122
+ if (!won) {
123
+ console.error('已有活跃钉钉守护进程,本进程退让退出。');
124
+ await endpoint.close();
125
+ process.exit(0);
126
+ }
127
+ // 当选后才建连(落败者从不建连,避免同应用多开 Stream 连接)。
128
+ const client = new DingtalkClient(config);
129
+ try {
130
+ await client.connect();
131
+ console.error('钉钉 Stream 长连已建立。');
132
+ }
133
+ catch (error) {
134
+ console.error(`钉钉 Stream 长连建立失败:${error instanceof Error ? error.message : String(error)}。已启用后台重连,恢复前回复无法送达。`);
135
+ }
136
+ registry = new DecisionRegistry(client);
137
+ ready = true;
138
+ registerCleanup(endpoint, client, lockPath);
139
+ // 空闲自关:无待答且超过阈值无 /ask 活动则退出删锁(下次 MCP server 再拉起)。
140
+ const idleMs = resolveIdleMs(process.env.DINGTALK_DAEMON_IDLE_MS);
141
+ if (idleMs > 0) {
142
+ const timer = setInterval(() => {
143
+ if (registry !== undefined && registry.pendingCount() === 0 && Date.now() - lastActivityMs > idleMs) {
144
+ console.error(`空闲超过 ${Math.round(idleMs / 60_000)} 分钟且无待答,守护进程自动退出。`);
145
+ clearInterval(timer);
146
+ removeDaemonLock(lockPath);
147
+ void client.close();
148
+ void endpoint.close();
149
+ process.exit(0);
150
+ }
151
+ }, IDLE_CHECK_INTERVAL_MS);
152
+ // 不阻止进程退出(守护进程本就靠 http/stream 存活)。
153
+ timer.unref();
154
+ }
155
+ console.error(`钉钉守护进程就绪(port=${endpoint.port},pid=${process.pid},空闲自关=${idleMs > 0 ? `${Math.round(idleMs / 60_000)}分钟` : '禁用'})。`);
156
+ }
157
+ main().catch((error) => {
158
+ console.error(`dingtalk-daemon 启动失败:${error instanceof Error ? error.message : String(error)}`);
159
+ process.exit(1);
160
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 守护进程 CLI:查看/停止共享钉钉守护进程。
3
+ *
4
+ * 用法(仓库根目录):
5
+ * npm run daemon:status --workspace mcps/dingtalk-ask-mcp-server
6
+ * npm run daemon:stop --workspace mcps/dingtalk-ask-mcp-server
7
+ *
8
+ * status:读锁 + 探活,打印 port/pid/就绪态。
9
+ * stop:读锁 → 向 pid 发 SIGTERM → 删锁(守护进程自身也会在退出时删锁,这里兜底)。
10
+ */
11
+ import { daemonLockPath, readDaemonLock, removeDaemonLock } from './daemon/lock.js';
12
+ import { probeHealth } from './daemonClient.js';
13
+ async function status() {
14
+ const lock = readDaemonLock(daemonLockPath());
15
+ if (lock === undefined) {
16
+ console.log('钉钉守护进程:未运行(无锁文件)。');
17
+ return;
18
+ }
19
+ const probe = await probeHealth(lock);
20
+ console.log(`钉钉守护进程:${probe.ok ? '在跑' : '锁存在但端点无应答(可能已死)'}` +
21
+ `,port=${lock.port},pid=${lock.pid},就绪=${probe.ready}。`);
22
+ }
23
+ function stop() {
24
+ const lock = readDaemonLock(daemonLockPath());
25
+ if (lock === undefined) {
26
+ console.log('钉钉守护进程:无锁文件,无需停止。');
27
+ return;
28
+ }
29
+ if (lock.pid > 0) {
30
+ try {
31
+ process.kill(lock.pid, 'SIGTERM');
32
+ console.log(`已向守护进程 pid=${lock.pid} 发送 SIGTERM。`);
33
+ }
34
+ catch (error) {
35
+ console.log(`发送 SIGTERM 失败(进程可能已退出):${error instanceof Error ? error.message : String(error)}`);
36
+ }
37
+ }
38
+ removeDaemonLock(daemonLockPath());
39
+ console.log('已清理锁文件。');
40
+ }
41
+ const command = process.argv[2];
42
+ if (command === 'status') {
43
+ void status();
44
+ }
45
+ else if (command === 'stop') {
46
+ stop();
47
+ }
48
+ else {
49
+ console.log('用法:node dist/daemonCli.js <status|stop>');
50
+ process.exit(1);
51
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * 守护进程客户端:MCP server 用之确保守护进程在跑(ensureDaemon)并向其转发决策
3
+ * (postAskToDaemon);daemon.ts 选举时也用 probeHealth 判定既有锁是否属活着的守护进程。
4
+ *
5
+ * 只用 Node 内置 http/child_process/url。日志走 stderr。
6
+ */
7
+ import { request } from 'node:http';
8
+ import { spawn } from 'node:child_process';
9
+ import { openSync, existsSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { daemonLockPath, readDaemonLock } from './daemon/lock.js';
14
+ /** 探活默认超时(毫秒)。 */
15
+ const HEALTH_TIMEOUT_MS = 1500;
16
+ /** ensureDaemon 等待守护进程就绪的总时长与轮询间隔。 */
17
+ const SPAWN_WAIT_MS = 10_000;
18
+ const POLL_INTERVAL_MS = 200;
19
+ /** 睡眠。 */
20
+ function sleep(ms) {
21
+ return new Promise((resolve) => setTimeout(resolve, ms));
22
+ }
23
+ /**
24
+ * 探活守护进程端点。
25
+ *
26
+ * @param lock 锁(含 port + token)。
27
+ * @param timeoutMs 探活超时。
28
+ * @returns { ok, ready };任何错误/超时 → { ok:false, ready:false }。
29
+ */
30
+ export function probeHealth(lock, timeoutMs = HEALTH_TIMEOUT_MS) {
31
+ return new Promise((resolve) => {
32
+ const req = request({
33
+ host: '127.0.0.1',
34
+ port: lock.port,
35
+ path: '/health',
36
+ method: 'GET',
37
+ headers: { 'x-ask-token': lock.token },
38
+ timeout: timeoutMs,
39
+ }, (res) => {
40
+ let body = '';
41
+ res.setEncoding('utf8');
42
+ res.on('data', (c) => {
43
+ body += c;
44
+ });
45
+ res.on('end', () => {
46
+ if (res.statusCode !== 200) {
47
+ resolve({ ok: false, ready: false });
48
+ return;
49
+ }
50
+ try {
51
+ const parsed = JSON.parse(body);
52
+ resolve({ ok: true, ready: parsed.ready === true });
53
+ }
54
+ catch {
55
+ resolve({ ok: true, ready: false });
56
+ }
57
+ });
58
+ });
59
+ req.on('error', () => resolve({ ok: false, ready: false }));
60
+ req.on('timeout', () => {
61
+ req.destroy();
62
+ resolve({ ok: false, ready: false });
63
+ });
64
+ req.end();
65
+ });
66
+ }
67
+ /**
68
+ * 向守护进程转发一次决策,阻塞等待结果。
69
+ *
70
+ * @param lock 锁(含 port + token)。
71
+ * @param input 决策入参(含 label、timeoutMs)。
72
+ * @returns 决策结果 { kind, value }。
73
+ * @throws 网络/超时/非 200/解析错误时抛。
74
+ */
75
+ export function postAskToDaemon(lock, input) {
76
+ return new Promise((resolve, reject) => {
77
+ const payload = JSON.stringify(input);
78
+ // HTTP 兜底超时略大于决策超时,防连接吊死。
79
+ const httpTimeout = (input.timeoutMs ?? 60_000) + 5_000;
80
+ const req = request({
81
+ host: '127.0.0.1',
82
+ port: lock.port,
83
+ path: '/ask',
84
+ method: 'POST',
85
+ headers: {
86
+ 'Content-Type': 'application/json',
87
+ 'Content-Length': Buffer.byteLength(payload),
88
+ 'x-ask-token': lock.token,
89
+ },
90
+ timeout: httpTimeout,
91
+ }, (res) => {
92
+ let body = '';
93
+ res.setEncoding('utf8');
94
+ res.on('data', (c) => {
95
+ body += c;
96
+ });
97
+ res.on('end', () => {
98
+ if (res.statusCode !== 200) {
99
+ reject(new Error(`/ask 返回非 200:${res.statusCode ?? '?'}(${body})`));
100
+ return;
101
+ }
102
+ try {
103
+ const parsed = JSON.parse(body);
104
+ if (parsed.kind !== 'answered' && parsed.kind !== 'timeout') {
105
+ reject(new Error('/ask 响应 kind 非法'));
106
+ return;
107
+ }
108
+ resolve(parsed);
109
+ }
110
+ catch (error) {
111
+ reject(error instanceof Error ? error : new Error(String(error)));
112
+ }
113
+ });
114
+ });
115
+ req.on('error', reject);
116
+ req.on('timeout', () => {
117
+ req.destroy(new Error('/ask 请求超时'));
118
+ });
119
+ req.write(payload);
120
+ req.end();
121
+ });
122
+ }
123
+ /** 守护进程 stderr 重定向日志路径(便于排查)。 */
124
+ function daemonLogPath() {
125
+ return join(tmpdir(), 'dingtalk-daemon.log');
126
+ }
127
+ /**
128
+ * 解析要拉起的守护进程脚本路径。
129
+ *
130
+ * 守护进程必须是编译后的 `dist/daemon.js`(用 node 拉起,跑不了 .ts)。生产下本模块位于
131
+ * `dist/daemonClient.js`,同级解析即得 `dist/daemon.js`;但在 dev/tsx 下本模块从 `src/`
132
+ * 运行,同级会解析到不存在的 `src/daemon.js` —— 此时回退到 `dist/daemon.js`。
133
+ *
134
+ * @returns 存在的守护进程脚本绝对路径。
135
+ */
136
+ function resolveDaemonScript() {
137
+ const sibling = fileURLToPath(new URL('./daemon.js', import.meta.url));
138
+ if (existsSync(sibling)) {
139
+ return sibling;
140
+ }
141
+ // dev/tsx:本模块在 src,编译产物在 dist。
142
+ const inDist = sibling.replace(/([\\/])src\1/, '$1dist$1');
143
+ return existsSync(inDist) ? inDist : sibling;
144
+ }
145
+ /**
146
+ * detached 拉起守护进程子进程(脱离父进程存活)。
147
+ *
148
+ * 把已解析的钉钉配置注入子进程 env —— 这样即便 MCP server 的凭证来自 CLI 参数(非 env),
149
+ * 守护进程(只读 process.env)也能拿到。凭证只经 env 传递,不落盘、不入命令行。
150
+ *
151
+ * @param config 已解析的钉钉配置。
152
+ */
153
+ function spawnDaemon(config) {
154
+ const daemonScript = resolveDaemonScript();
155
+ const logFd = openSync(daemonLogPath(), 'a');
156
+ const child = spawn(process.execPath, [daemonScript], {
157
+ detached: true,
158
+ windowsHide: true,
159
+ stdio: ['ignore', logFd, logFd],
160
+ env: {
161
+ ...process.env,
162
+ // 注入解析后的凭证,覆盖可能缺失/过期的 env(支持 CLI 参数来源)。
163
+ DINGTALK_CLIENT_ID: config.clientId,
164
+ DINGTALK_CLIENT_SECRET: config.clientSecret,
165
+ DINGTALK_ROBOT_CODE: config.robotCode,
166
+ DINGTALK_USER_ID: config.userId,
167
+ DINGTALK_ASK_TIMEOUT_MS: String(config.timeoutMs),
168
+ },
169
+ });
170
+ child.unref();
171
+ console.error(`已拉起钉钉守护进程(pid=${child.pid ?? '?'},脚本 ${daemonScript},日志 ${daemonLogPath()})。`);
172
+ }
173
+ /**
174
+ * 确保守护进程在跑并就绪,返回其锁(port + token)。
175
+ *
176
+ * 流程:读锁探活——就绪则直接用;有锁但活着未就绪则轮询等待;无锁/锁属死进程则拉起并轮询。
177
+ *
178
+ * @param config 钉钉配置(拉起子进程需具备凭证)。
179
+ * @param lockPath 锁路径;缺省用 daemonLockPath()(测试注入隔离路径)。
180
+ * @returns 就绪守护进程的锁。
181
+ * @throws 当在 SPAWN_WAIT_MS 内仍未就绪时抛错。
182
+ */
183
+ export async function ensureDaemon(config, lockPath = daemonLockPath()) {
184
+ const existing = readDaemonLock(lockPath);
185
+ if (existing !== undefined) {
186
+ const probe = await probeHealth(existing);
187
+ if (probe.ready) {
188
+ return existing;
189
+ }
190
+ // 有锁但未就绪:若端点根本不应答(死进程留下的陈旧锁)才拉起,否则等它就绪。
191
+ if (!probe.ok) {
192
+ spawnDaemon(config);
193
+ }
194
+ }
195
+ else {
196
+ spawnDaemon(config);
197
+ }
198
+ const deadline = Date.now() + SPAWN_WAIT_MS;
199
+ while (Date.now() < deadline) {
200
+ await sleep(POLL_INTERVAL_MS);
201
+ const lock = readDaemonLock(lockPath);
202
+ if (lock !== undefined && (await probeHealth(lock)).ready) {
203
+ return lock;
204
+ }
205
+ }
206
+ throw new Error(`守护进程未在 ${SPAWN_WAIT_MS}ms 内就绪(见日志 ${daemonLogPath()})。`);
207
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * 推送决策并等待回答,超时则返回 timeout 结果。
3
+ *
4
+ * @param channel 决策通道(钉钉等实现之)。
5
+ * @param req 决策请求,含 timeoutMs。
6
+ * @returns 决策结果,永不因超时挂死。
7
+ */
8
+ export async function askWithTimeout(channel, req) {
9
+ const controller = new AbortController();
10
+ let timer;
11
+ // 超时侧:到点后返回一个专属哨兵,用于区分是超时还是 channel 答复。
12
+ const timeoutSignal = Symbol("timeout");
13
+ const timeoutPromise = new Promise((resolve) => {
14
+ timer = setTimeout(() => resolve(timeoutSignal), req.timeoutMs);
15
+ });
16
+ try {
17
+ // channel.wait 在 abort 时可能 reject,但只要超时侧先胜出即由外层处理。
18
+ const winner = await Promise.race([
19
+ channel.wait(req, controller.signal),
20
+ timeoutPromise,
21
+ ]);
22
+ if (winner === timeoutSignal) {
23
+ // 超时先到:abort channel,让其放弃等待并清理 pending。
24
+ controller.abort();
25
+ return { kind: "timeout" };
26
+ }
27
+ // channel 先答:winner 是回复字符串。
28
+ return { kind: "answered", value: winner };
29
+ }
30
+ finally {
31
+ // 无论何种结局都清除超时定时器,防止泄漏。
32
+ if (timer !== undefined) {
33
+ clearTimeout(timer);
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * DecisionRegistry:多 Agent 并发决策的多槽通道(替代单槽 DingtalkChannel)。
3
+ *
4
+ * 实现同一个 DecisionChannel.wait(req, signal) 抽象,coordinator 不变;去掉进程级 mutex 后
5
+ * 多个 Agent 的决策可并发挂起。每个待决策分配一个短序号 seq(答完释放、复用最小空号),
6
+ * 消息带 `#seq [label]` 前缀推到钉钉;用户回复经 routeReply 按序号路由到对应槽。
7
+ *
8
+ * 单一 onReply 处理器:命中则 resolve 对应槽;多待答时歧义回复回一条编号提示(消费但不 resolve);
9
+ * 零待答时返回 false,交上层(client.handleRobotMessage)判离开模式遥控命令——保持"决策优先"。
10
+ *
11
+ * 依赖注入 RegistryClient(真实为 DingtalkClient),便于用假 client 单测,不碰 SDK/网络。
12
+ * 日志走 stderr。
13
+ */
14
+ import { allocSeq, routeReply, parseBatchReply } from './routing.js';
15
+ import { resolveReplyValue } from '../dingtalk/replyMatcher.js';
16
+ import { buildAskMessage, buildTextMessage } from '../dingtalk/cardPayload.js';
17
+ import { parseAwayCommand } from '../awayState.js';
18
+ /**
19
+ * 多槽决策通道。
20
+ */
21
+ export class DecisionRegistry {
22
+ client;
23
+ /** seq → 槽;并发多待决策。 */
24
+ slots = new Map();
25
+ constructor(client) {
26
+ this.client = client;
27
+ this.client.onReply((text) => this.handleReply(text));
28
+ }
29
+ /**
30
+ * 推送一次决策到钉钉并等待用户回复。
31
+ *
32
+ * @param req 决策请求(含可选 label,用于消息前缀)。
33
+ * @param signal abort 信号:超时/取消时清槽并 reject。
34
+ * @returns 用户回复解析后的值。
35
+ * @throws 当 sendOto 失败或 abort 时 reject。
36
+ */
37
+ wait(req, signal) {
38
+ if (signal.aborted) {
39
+ return Promise.reject(new Error('决策等待已被取消(abort)。'));
40
+ }
41
+ // 同步分配 seq 并占槽(无 await,保证并发 wait 拿到不同序号)。
42
+ const seq = allocSeq(this.slots.keys());
43
+ return new Promise((resolve, reject) => {
44
+ const onAbort = () => {
45
+ if (this.slots.get(seq) === slot) {
46
+ this.slots.delete(seq);
47
+ }
48
+ reject(new Error('决策等待超时或被取消。'));
49
+ };
50
+ signal.addEventListener('abort', onAbort, { once: true });
51
+ const slot = {
52
+ seq,
53
+ request: req,
54
+ resolve: (value) => {
55
+ signal.removeEventListener('abort', onAbort);
56
+ resolve(value);
57
+ },
58
+ };
59
+ this.slots.set(seq, slot);
60
+ // 推送失败:清槽、摘 abort 监听、reject(交 coordinator 收敛为可读文本)。
61
+ this.client.sendOto(buildAskMessage(req, { seq, label: req.label })).catch((error) => {
62
+ if (this.slots.get(seq) === slot) {
63
+ this.slots.delete(seq);
64
+ signal.removeEventListener('abort', onAbort);
65
+ reject(error instanceof Error ? error : new Error(String(error)));
66
+ }
67
+ });
68
+ });
69
+ }
70
+ /**
71
+ * 当前待答槽数(守护进程空闲自关判定用)。
72
+ *
73
+ * @returns 挂起中的决策数量。
74
+ */
75
+ pendingCount() {
76
+ return this.slots.size;
77
+ }
78
+ /**
79
+ * 处理一条文本回复:按当前待答数与序号路由。
80
+ *
81
+ * @param text 用户回复原文。
82
+ * @returns true 表示已被决策消费(不再当遥控命令);false 表示无待答,交上层。
83
+ */
84
+ handleReply(text) {
85
+ // /away、/back 等显式遥控命令优先于决策路由:即便有待答也交上层切换离开模式。
86
+ // 命令带 `/` 前缀、不会与决策答案混淆,避免"多个决策挂起时够不到离开遥控"的死角。
87
+ if (parseAwayCommand(text) !== null) {
88
+ return false;
89
+ }
90
+ const pendingSeqs = [...this.slots.keys()];
91
+ if (pendingSeqs.length === 0) {
92
+ return false; // 无待答:交上层判遥控命令。
93
+ }
94
+ // 恰一个待答:免序号,整串为答案。
95
+ if (pendingSeqs.length === 1) {
96
+ const slot = this.slots.get(pendingSeqs[0]);
97
+ return slot !== undefined ? this.tryResolve(slot, text.trim()) : false;
98
+ }
99
+ // 多待答:先尝试批量解析(一条消息答多个,如 "1 允许、2 拒绝")。
100
+ const batch = parseBatchReply(text, pendingSeqs);
101
+ if (batch.length > 0) {
102
+ for (const { seq, answer } of batch) {
103
+ const slot = this.slots.get(seq);
104
+ if (slot !== undefined) {
105
+ this.tryResolve(slot, answer);
106
+ }
107
+ }
108
+ return true;
109
+ }
110
+ // 无可解析序号:用 routeReply 给出针对性提示(缺序号/序号无效/缺答案)。
111
+ const route = routeReply(text, pendingSeqs);
112
+ if (route.kind === 'need-seq' || route.kind === 'unknown-seq' || route.kind === 'need-answer') {
113
+ this.sendNudge(route);
114
+ }
115
+ return true;
116
+ }
117
+ /**
118
+ * 尝试用答案 resolve 一个槽;空白答案则消费但继续等待。
119
+ *
120
+ * @param slot 目标槽。
121
+ * @param answer 去序号后的答案原文。
122
+ * @returns 恒为 true(消费本消息)。
123
+ */
124
+ tryResolve(slot, answer) {
125
+ const value = resolveReplyValue(answer, slot.request.options);
126
+ if (value === undefined) {
127
+ console.error('收到空白钉钉回复,已忽略,继续等待。');
128
+ return true;
129
+ }
130
+ this.slots.delete(slot.seq);
131
+ slot.resolve(value);
132
+ return true;
133
+ }
134
+ /**
135
+ * 对歧义回复回一条编号提示(不 resolve 任何槽)。
136
+ *
137
+ * @param route 需要提示的路由结果。
138
+ */
139
+ sendNudge(route) {
140
+ let text;
141
+ if (route.kind === 'need-answer') {
142
+ text = `请在编号 #${route.seq} 后带上你的选择,如「${route.seq} 允许」。`;
143
+ }
144
+ else if (route.kind === 'unknown-seq') {
145
+ text = `序号 #${route.seq} 无对应待答。当前待答:${this.describePending()},请用其中的编号回复(如「1 允许」)。`;
146
+ }
147
+ else {
148
+ text = `当前有多个待答,请在前面加编号回复,如「1 允许」。当前待答:${this.describePending()}`;
149
+ }
150
+ this.client.sendOto(buildTextMessage(text)).catch((error) => {
151
+ const message = error instanceof Error ? error.message : String(error);
152
+ console.error(`编号提示发送失败:${message}`);
153
+ });
154
+ }
155
+ /**
156
+ * 列出当前待答槽,形如 `#1 [web]、#2 [api]`(按 seq 升序)。
157
+ *
158
+ * @returns 待答描述串。
159
+ */
160
+ describePending() {
161
+ return [...this.slots.values()]
162
+ .sort((a, b) => a.seq - b.seq)
163
+ .map((s) => `#${s.seq} [${s.request.label ?? '?'}]`)
164
+ .join('、');
165
+ }
166
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 把决策结果转成工具返回字符串。
3
+ *
4
+ * @param outcome 决策结果。
5
+ * @param timeoutMs 该次决策的超时毫秒数,仅 timeout 分支用于换算分钟数展示。
6
+ */
7
+ export function resultText(outcome, timeoutMs = 0) {
8
+ if (outcome.kind === "answered") {
9
+ return `用户选择:${outcome.value ?? ""}`;
10
+ }
11
+ // timeout:换算成分钟(四舍五入),便于模型对用户复述等待时长。
12
+ const minutes = Math.round(timeoutMs / 60_000);
13
+ return (`用户未在约 ${minutes} 分钟内回复。请你自行决定:` +
14
+ `继续等待(可再次调用 ask_human)、选择一个安全默认、或停止并说明原因。`);
15
+ }