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
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 多 Agent 并发决策的回复路由(纯函数,可测)。
|
|
3
|
+
*
|
|
4
|
+
* 并发/回复模型 = 并行 + 编号回复:
|
|
5
|
+
* - 多个 Agent 的问题同时挂在钉钉,每条带 #序号;用户回 "1 允许"/"2 拒绝" 按序号路由。
|
|
6
|
+
* - 只有一个待答时可免序号,整串即答案。
|
|
7
|
+
*
|
|
8
|
+
* 本模块只负责"从原始回复文本 + 当前待答序号集"判断该路由给谁 / 是否需要提示补全,
|
|
9
|
+
* 不涉及 IM/网络/选项匹配(选项匹配交给已测的 replyMatcher.resolveReplyValue)。
|
|
10
|
+
*/
|
|
11
|
+
/** 拆"前缀数字 + 其余"的正则:其余用 [\s\S]* 以容纳多行自由文本。 */
|
|
12
|
+
const SEQ_PREFIX = /^\s*(\d+)\s*([\s\S]*)$/;
|
|
13
|
+
/**
|
|
14
|
+
* 分配一个不在活跃集合里的最小正整数序号(答完释放、复用,保持号短好记)。
|
|
15
|
+
*
|
|
16
|
+
* @param activeSeqs 当前已占用的序号集合(任意可迭代)。
|
|
17
|
+
* @returns 最小的、未被占用的正整数。
|
|
18
|
+
*/
|
|
19
|
+
export function allocSeq(activeSeqs) {
|
|
20
|
+
const set = new Set(activeSeqs);
|
|
21
|
+
let n = 1;
|
|
22
|
+
while (set.has(n)) {
|
|
23
|
+
n += 1;
|
|
24
|
+
}
|
|
25
|
+
return n;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* 把一条用户回复分流为路由结果。
|
|
29
|
+
*
|
|
30
|
+
* 规则:
|
|
31
|
+
* - 待答数 0 → none(上层走离开模式遥控命令判定)。
|
|
32
|
+
* - 待答数 1 → single,整串 trim 后为答案(免序号;即使以数字开头也当答案,避免歧义)。
|
|
33
|
+
* - 待答数 >1 → 必须带命中的前缀序号:
|
|
34
|
+
* · 无数字前缀 → need-seq;
|
|
35
|
+
* · 有前缀但不在待答集 → unknown-seq;
|
|
36
|
+
* · 有前缀在待答集但答案为空 → need-answer;
|
|
37
|
+
* · 否则 → routed(answer 为去掉序号后的原文,保留内部空格/换行)。
|
|
38
|
+
*
|
|
39
|
+
* @param text 用户回复原文。
|
|
40
|
+
* @param pendingSeqs 当前待答的序号集合。
|
|
41
|
+
* @returns 路由结果。
|
|
42
|
+
*/
|
|
43
|
+
export function routeReply(text, pendingSeqs) {
|
|
44
|
+
if (pendingSeqs.length === 0) {
|
|
45
|
+
return { kind: 'none' };
|
|
46
|
+
}
|
|
47
|
+
if (pendingSeqs.length === 1) {
|
|
48
|
+
return { kind: 'single', answer: text.trim() };
|
|
49
|
+
}
|
|
50
|
+
const match = SEQ_PREFIX.exec(text);
|
|
51
|
+
if (match === null) {
|
|
52
|
+
return { kind: 'need-seq' };
|
|
53
|
+
}
|
|
54
|
+
const seq = Number(match[1]);
|
|
55
|
+
if (!pendingSeqs.includes(seq)) {
|
|
56
|
+
return { kind: 'unknown-seq', seq };
|
|
57
|
+
}
|
|
58
|
+
const answer = match[2].trim();
|
|
59
|
+
if (answer === '') {
|
|
60
|
+
return { kind: 'need-answer', seq };
|
|
61
|
+
}
|
|
62
|
+
return { kind: 'routed', seq, answer };
|
|
63
|
+
}
|
|
64
|
+
/** 批量回复的分隔符:序号前须是行首或这些分隔符之一,才当作新段起点(避免答案里的数字被误拆)。 */
|
|
65
|
+
const BATCH_DELIM = /(?:^|[\s、,,;;])(\d+)/g;
|
|
66
|
+
/**
|
|
67
|
+
* 从一条消息里解析出多段"序号 答案"(支持用户把多个回复写在同一条消息,如 "1 允许、2 拒绝")。
|
|
68
|
+
*
|
|
69
|
+
* 只把"行首或分隔符后紧跟的、且命中待答集的数字"当作段起点;答案取到下一个段起点之前,
|
|
70
|
+
* 去掉首尾分隔符与空白。这样 "我选方案2" 里的 2 不会被误当序号(前面是汉字非分隔符)。
|
|
71
|
+
*
|
|
72
|
+
* @param text 用户回复原文。
|
|
73
|
+
* @param pendingSeqs 当前待答序号集合。
|
|
74
|
+
* @returns 依出现顺序的 {seq, answer} 段;答案为空的段丢弃;无有效段则空数组。
|
|
75
|
+
*/
|
|
76
|
+
export function parseBatchReply(text, pendingSeqs) {
|
|
77
|
+
const pending = new Set(pendingSeqs);
|
|
78
|
+
const marks = [];
|
|
79
|
+
BATCH_DELIM.lastIndex = 0;
|
|
80
|
+
let m;
|
|
81
|
+
while ((m = BATCH_DELIM.exec(text)) !== null) {
|
|
82
|
+
const seq = Number(m[1]);
|
|
83
|
+
if (pending.has(seq)) {
|
|
84
|
+
marks.push({ seq, matchStart: m.index, answerStart: m.index + m[0].length });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const segments = [];
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
for (let i = 0; i < marks.length; i += 1) {
|
|
90
|
+
const cur = marks[i];
|
|
91
|
+
if (seen.has(cur.seq)) {
|
|
92
|
+
continue; // 同一序号重复出现,保留首个。
|
|
93
|
+
}
|
|
94
|
+
const end = i + 1 < marks.length ? marks[i + 1].matchStart : text.length;
|
|
95
|
+
const answer = text.slice(cur.answerStart, end).replace(/^[\s、,,;;]+/, '').trim();
|
|
96
|
+
if (answer !== '') {
|
|
97
|
+
seen.add(cur.seq);
|
|
98
|
+
segments.push({ seq: cur.seq, answer });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return segments;
|
|
102
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** markdown 形态消息标题(钉钉 markdown 消息体要求 title 非空)。 */
|
|
2
|
+
const MARKDOWN_TITLE = '需要你拍板一个决策';
|
|
3
|
+
/**
|
|
4
|
+
* 拼 `#seq [label] ` 前缀(两者按各自有无分别拼,允许只有其一)。
|
|
5
|
+
*
|
|
6
|
+
* @param ctx 渲染上下文。
|
|
7
|
+
* @returns 前缀串(末尾带一个空格);无 seq 无 label 时为空串。
|
|
8
|
+
*/
|
|
9
|
+
function buildPrefix(ctx) {
|
|
10
|
+
const parts = [];
|
|
11
|
+
if (ctx.seq !== undefined) {
|
|
12
|
+
parts.push(`#${ctx.seq}`);
|
|
13
|
+
}
|
|
14
|
+
if (ctx.label !== undefined && ctx.label !== '') {
|
|
15
|
+
parts.push(`[${ctx.label}]`);
|
|
16
|
+
}
|
|
17
|
+
return parts.length > 0 ? `${parts.join(' ')} ` : '';
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 拼编号回复提示(仅当有 seq 时)。示例优先用首个选项,否则用「你的决定」。
|
|
21
|
+
*
|
|
22
|
+
* @param ctx 渲染上下文。
|
|
23
|
+
* @param options 候选选项(用于取示例词)。
|
|
24
|
+
* @returns 编号提示串;无 seq 时为空串。
|
|
25
|
+
*/
|
|
26
|
+
function buildNumberHint(ctx, options) {
|
|
27
|
+
if (ctx.seq === undefined) {
|
|
28
|
+
return '';
|
|
29
|
+
}
|
|
30
|
+
const example = options !== undefined && options.length > 0 ? options[0] : '你的决定';
|
|
31
|
+
return `\n\n(多个问题时在前面加编号回复,如「${ctx.seq} ${example}」;只有一个待答时可直接回复)`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 由决策请求构造主动发送消息体。
|
|
35
|
+
*
|
|
36
|
+
* @param req 决策请求,含 question 与可选 options。
|
|
37
|
+
* @param ctx 多 Agent 渲染上下文(seq/label);缺省则为单 Agent 旧形态。
|
|
38
|
+
* @returns 钉钉 OTO 主动发送消息体(文本或 markdown)。
|
|
39
|
+
*/
|
|
40
|
+
export function buildAskMessage(req, ctx = {}) {
|
|
41
|
+
const options = req.options;
|
|
42
|
+
const prefix = buildPrefix(ctx);
|
|
43
|
+
const heading = `${prefix}${req.question}`;
|
|
44
|
+
const numberHint = buildNumberHint(ctx, options);
|
|
45
|
+
// 无 options(含空数组):纯文本问答,提示直接回复决定。
|
|
46
|
+
if (options === undefined || options.length === 0) {
|
|
47
|
+
const content = `${heading}\n\n(直接回复你的决定即可)${numberHint}`;
|
|
48
|
+
return {
|
|
49
|
+
msgKey: 'sampleText',
|
|
50
|
+
msgParam: JSON.stringify({ content })
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// 有 options:markdown 列出带 1-based 序号的选项,提示回复序号或自由文本。
|
|
54
|
+
const optionLines = options.map((opt, i) => `${i + 1}. ${opt}`).join('\n');
|
|
55
|
+
const text = `${heading}\n\n` +
|
|
56
|
+
`${optionLines}\n\n` +
|
|
57
|
+
`(回复序号选择,或直接输入你的选择/意见)${numberHint}`;
|
|
58
|
+
return {
|
|
59
|
+
msgKey: 'sampleMarkdown',
|
|
60
|
+
msgParam: JSON.stringify({ title: MARKDOWN_TITLE, text })
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 构造一条纯文本 OTO 主动发送消息体(纯函数,可测)。
|
|
65
|
+
*
|
|
66
|
+
* 用于系统回执类消息(如离开模式开关确认),不走决策问答格式。
|
|
67
|
+
*
|
|
68
|
+
* @param content 文本正文。
|
|
69
|
+
* @returns sampleText 形态消息体(msgParam 为 { content } 的 JSON 字符串)。
|
|
70
|
+
*/
|
|
71
|
+
export function buildTextMessage(content) {
|
|
72
|
+
return {
|
|
73
|
+
msgKey: 'sampleText',
|
|
74
|
+
msgParam: JSON.stringify({ content })
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { resolveReplyValue } from './replyMatcher.js';
|
|
2
|
+
import { buildAskMessage } from './cardPayload.js';
|
|
3
|
+
// resolveReplyValue 已归位到 replyMatcher.ts(channel 与 registry 共用);此处 re-export 保持既有引用。
|
|
4
|
+
export { resolveReplyValue };
|
|
5
|
+
/**
|
|
6
|
+
* 决定一条回复是否应 resolve 当前决策、以及 resolve 的值:见 replyMatcher.resolveReplyValue。
|
|
7
|
+
*/
|
|
8
|
+
export class DingtalkChannel {
|
|
9
|
+
client;
|
|
10
|
+
/** 单槽 pending:至多一个待决策;无待决策时为 undefined。 */
|
|
11
|
+
active;
|
|
12
|
+
constructor(client) {
|
|
13
|
+
this.client = client;
|
|
14
|
+
// 注册一次文本回复处理器;返回是否存在待决策(有则本消息归决策,不当遥控命令)。
|
|
15
|
+
this.client.onReply((text) => this.handleReply(text));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 推送决策到钉钉并等待用户文本回复。
|
|
19
|
+
*
|
|
20
|
+
* @param req 决策请求。
|
|
21
|
+
* @param signal abort 信号:超时/取消时清空 active 并放弃等待。
|
|
22
|
+
* @returns 用户回复解析后的值(选项文本或自由文本原文)。
|
|
23
|
+
* @throws 当 sendOto 主动发送失败时抛错(交 coordinator,最终转可读文本给模型)。
|
|
24
|
+
*/
|
|
25
|
+
async wait(req, signal) {
|
|
26
|
+
// 先推送再挂 pending:推送失败直接抛错,不占用单槽。
|
|
27
|
+
await this.client.sendOto(buildAskMessage(req));
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
// 已 abort(极端时序):立即放弃。
|
|
30
|
+
if (signal.aborted) {
|
|
31
|
+
reject(new Error('决策等待已被取消(abort)。'));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
this.active = { request: req, resolve };
|
|
35
|
+
const onAbort = () => {
|
|
36
|
+
// 超时/取消:清空单槽并 reject,coordinator 由超时侧接管返回 timeout。
|
|
37
|
+
if (this.active?.request.id === req.id) {
|
|
38
|
+
this.active = undefined;
|
|
39
|
+
}
|
|
40
|
+
reject(new Error('决策等待超时或被取消。'));
|
|
41
|
+
};
|
|
42
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 处理一条文本回复:仅在有 active 决策且回复有效时 resolve 并清空单槽。
|
|
47
|
+
*
|
|
48
|
+
* @param text 用户回复原文。
|
|
49
|
+
* @returns true 表示当前有待决策、本消息归它(即便空白也不外泄给遥控命令判定);
|
|
50
|
+
* false 表示无待决策(上层可再判离开模式遥控命令)。
|
|
51
|
+
*/
|
|
52
|
+
handleReply(text) {
|
|
53
|
+
if (this.active === undefined) {
|
|
54
|
+
// 无待决策:交回上层(可能是离开模式遥控命令)。
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
const value = resolveReplyValue(text, this.active.request.options);
|
|
58
|
+
if (value === undefined) {
|
|
59
|
+
// 空白回复:不结束等待,但有待决策,消息仍归它(不当遥控命令)。
|
|
60
|
+
console.error('收到空白钉钉回复,已忽略,继续等待。');
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
const { resolve } = this.active;
|
|
64
|
+
this.active = undefined;
|
|
65
|
+
resolve(value);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 钉钉 SDK/网络调用的集中封装:所有与 dingtalk-stream / axios 的交互都收敛到本文件,
|
|
3
|
+
* 便于上层(channel.ts)注入 mock 单测,且把易变的"官方端点/鉴权细节"集中在一处。
|
|
4
|
+
*
|
|
5
|
+
* 职责:
|
|
6
|
+
* - connect():建 DWClient、订阅机器人消息回调(TOPIC_ROBOT)、建立 Stream 长连。
|
|
7
|
+
* 收到单聊文本时解析出 text.content + senderStaffId,**只接受来自 config.userId 的消息**,
|
|
8
|
+
* 转交上层注册的 reply 处理器。
|
|
9
|
+
* - sendOto():取 accessToken → axios POST 机器人 OTO 批量发单聊(batchSend)。
|
|
10
|
+
* - onReply():注册文本回复回调(channel.ts 用之等待回复)。
|
|
11
|
+
* - close():断开 Stream 长连。
|
|
12
|
+
*
|
|
13
|
+
* 纯逻辑抽出:buildBatchSendBody(config, message) —— 组装 batchSend 请求 body,无副作用可单测。
|
|
14
|
+
*
|
|
15
|
+
* 安全:绝不硬编码任何凭证;一切从注入的 DingtalkConfig(其源头是 process.env)读取。
|
|
16
|
+
* 日志一律走 stderr(stdout 是 MCP 协议信道)。
|
|
17
|
+
*/
|
|
18
|
+
import axios from 'axios';
|
|
19
|
+
import { DWClient, TOPIC_ROBOT } from 'dingtalk-stream';
|
|
20
|
+
import { buildTextMessage } from './cardPayload.js';
|
|
21
|
+
import { setAway, parseAwayCommand } from '../awayState.js';
|
|
22
|
+
/** 机器人 OTO 批量发单聊端点(官方 v1.0)。 */
|
|
23
|
+
const BATCH_SEND_URL = 'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend';
|
|
24
|
+
/** accessToken 兜底端点(SDK getAccessToken 不可用时用)。 */
|
|
25
|
+
const ACCESS_TOKEN_URL = 'https://api.dingtalk.com/v1.0/oauth2/accessToken';
|
|
26
|
+
/**
|
|
27
|
+
* 组装机器人 OTO 批量发单聊的请求 body(纯函数,可单测)。
|
|
28
|
+
*
|
|
29
|
+
* @param config 钉钉配置,提供 robotCode 与目标 userId。
|
|
30
|
+
* @param message 消息内容(msgKey + msgParam,来自 buildAskMessage)。
|
|
31
|
+
* @returns 可直接作为 batchSend 请求体的对象。
|
|
32
|
+
*/
|
|
33
|
+
export function buildBatchSendBody(config, message) {
|
|
34
|
+
return {
|
|
35
|
+
robotCode: config.robotCode,
|
|
36
|
+
// 单聊单目标:MVP 只推给配置的本人 userId。
|
|
37
|
+
userIds: [config.userId],
|
|
38
|
+
msgKey: message.msgKey,
|
|
39
|
+
msgParam: message.msgParam,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 钉钉客户端:集中所有 SDK/网络调用。进程级单例,server 启动时 connect,退出时 close。
|
|
44
|
+
*/
|
|
45
|
+
export class DingtalkClient {
|
|
46
|
+
config;
|
|
47
|
+
/** Stream 长连客户端;connect 前为 undefined。 */
|
|
48
|
+
dwClient;
|
|
49
|
+
/** 上层注册的文本回复处理器;未注册时收到消息仅记日志。 */
|
|
50
|
+
replyHandler;
|
|
51
|
+
constructor(config) {
|
|
52
|
+
this.config = config;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* 建立 Stream 长连并订阅机器人消息回调。
|
|
56
|
+
*
|
|
57
|
+
* 收到消息时:解析 data JSON → 取 text.content + senderStaffId → 仅接受 config.userId
|
|
58
|
+
* 的文本消息 → 转交 replyHandler。非目标用户/非文本/无处理器时忽略(记 stderr)。
|
|
59
|
+
*
|
|
60
|
+
* @returns 长连建立完成的 Promise。
|
|
61
|
+
* @throws 当 SDK 连接失败时抛错,交上层决定告警/降级。
|
|
62
|
+
*/
|
|
63
|
+
async connect() {
|
|
64
|
+
const client = new DWClient({
|
|
65
|
+
clientId: this.config.clientId,
|
|
66
|
+
clientSecret: this.config.clientSecret,
|
|
67
|
+
// 心跳保活:MCP server 会挂着等待用户决策(分钟~十几分钟级),
|
|
68
|
+
// 不开保活时空闲长连可能被钉钉网关断开,导致回复丢失。
|
|
69
|
+
keepAlive: true,
|
|
70
|
+
// 可选 debug(DINGTALK_DEBUG=1):打印握手/订阅/每帧下行,便于排查连接问题。
|
|
71
|
+
debug: process.env.DINGTALK_DEBUG === '1',
|
|
72
|
+
});
|
|
73
|
+
client.registerCallbackListener(TOPIC_ROBOT, (res) => {
|
|
74
|
+
// 无论后续处理成败,都先 ack,避免服务端 60s 内重推同一消息。
|
|
75
|
+
try {
|
|
76
|
+
client.socketCallBackResponse(res.headers.messageId, { status: 'SUCCESS' });
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// ack 失败不致命(最多重复推送),不阻断消息处理。
|
|
80
|
+
}
|
|
81
|
+
this.handleRobotMessage(res.data);
|
|
82
|
+
});
|
|
83
|
+
await client.connect();
|
|
84
|
+
this.dwClient = client;
|
|
85
|
+
// SDK 的 connect() 会吞掉网关握手失败(内部 catch + 后台自动重连后正常返回),
|
|
86
|
+
// 因此这里显式校验 connected,避免"假报连接成功"。未连上仅告警不抛:
|
|
87
|
+
// autoReconnect 会在后台持续重试,恢复后即可收到回复(期间 ask_human 走超时分支)。
|
|
88
|
+
if (!client.connected) {
|
|
89
|
+
console.error('钉钉 Stream 首次握手未成功(可能凭证/消息接收模式/发布状态有误);已启用后台自动重连。');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 解析并分发一条机器人消息(从 registerCallbackListener 回调进入)。
|
|
94
|
+
*
|
|
95
|
+
* 拆成独立方法便于阅读与将来单测;只处理"文本 + 目标用户"的消息,其余忽略并记 stderr。
|
|
96
|
+
*
|
|
97
|
+
* @param rawData Stream 下行消息的 data 字段(JSON 字符串)。
|
|
98
|
+
*/
|
|
99
|
+
handleRobotMessage(rawData) {
|
|
100
|
+
let parsed;
|
|
101
|
+
try {
|
|
102
|
+
parsed = JSON.parse(rawData);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
console.error('钉钉消息解析失败:data 非合法 JSON,已忽略。');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const senderStaffId = parsed.senderStaffId ?? '';
|
|
109
|
+
// 只接受配置的本人 userId 的消息,防止其他人误触决策。
|
|
110
|
+
if (senderStaffId !== this.config.userId) {
|
|
111
|
+
console.error(`钉钉消息来自非目标用户(senderStaffId=${senderStaffId}),已忽略。`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// M2 只处理文本消息;非文本(图片/卡片等)暂不支持。
|
|
115
|
+
if (parsed.msgtype !== 'text' || parsed.text?.content === undefined) {
|
|
116
|
+
console.error(`钉钉消息非文本类型(msgtype=${parsed.msgtype}),已忽略。`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const content = parsed.text.content;
|
|
120
|
+
// 决策优先:有待决策时,回复先归决策消费,避免被误当遥控命令。
|
|
121
|
+
// replyHandler 返回 true 表示存在待决策并已消费本消息。
|
|
122
|
+
const consumedByDecision = this.replyHandler?.(content, senderStaffId) ?? false;
|
|
123
|
+
if (consumedByDecision) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// 无待决策 → 再判离开模式遥控命令(手机遥控开关):命中则切换状态 + 回执确认。
|
|
127
|
+
const awayCmd = parseAwayCommand(content);
|
|
128
|
+
if (awayCmd !== null) {
|
|
129
|
+
setAway(awayCmd === 'on');
|
|
130
|
+
const receipt = awayCmd === 'on'
|
|
131
|
+
? '✅ 已进入离开模式,命令审批与决策会推到这里(发 /back 退出)'
|
|
132
|
+
: '✅ 已退出离开模式,命令审批与决策只在终端(发 /away 再进入)';
|
|
133
|
+
// 回执发送失败仅告警不抛:遥控状态已切换成功,回执只是告知。
|
|
134
|
+
this.sendOto(buildTextMessage(receipt)).catch((error) => {
|
|
135
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
136
|
+
console.error(`离开模式回执发送失败:${message}`);
|
|
137
|
+
});
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// 既无待决策、也非遥控命令:忽略(记 stderr)。
|
|
141
|
+
console.error('收到钉钉消息但当前无待决策、也非遥控命令,已忽略。');
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* 注册文本回复回调(channel.ts 用之接收用户回复)。MVP 单处理器,后注册覆盖先注册。
|
|
145
|
+
*
|
|
146
|
+
* @param handler 文本回复处理器。
|
|
147
|
+
*/
|
|
148
|
+
onReply(handler) {
|
|
149
|
+
this.replyHandler = handler;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* 主动发送 OTO 单聊消息(batchSend)。
|
|
153
|
+
*
|
|
154
|
+
* 流程:取 accessToken → axios POST batchSend(robotCode + userIds=[config.userId] + msgKey/msgParam)。
|
|
155
|
+
* 失败抛错,错误信息含 HTTP 状态与钉钉 errcode/message,便于定位。
|
|
156
|
+
*
|
|
157
|
+
* @param message 消息内容(来自 buildAskMessage)。
|
|
158
|
+
* @throws 当取 token 或 batchSend 请求失败时抛错。
|
|
159
|
+
*/
|
|
160
|
+
async sendOto(message) {
|
|
161
|
+
const token = await this.getAccessToken();
|
|
162
|
+
const body = buildBatchSendBody(this.config, message);
|
|
163
|
+
try {
|
|
164
|
+
await axios.post(BATCH_SEND_URL, body, {
|
|
165
|
+
headers: {
|
|
166
|
+
'x-acs-dingtalk-access-token': token,
|
|
167
|
+
'Content-Type': 'application/json',
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
throw new Error(`钉钉 OTO 主动发送失败:${describeAxiosError(error)}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* 取 accessToken:优先复用 SDK 的 DWClient.getAccessToken()(已建连时);
|
|
177
|
+
* 否则兜底走官方 oauth2/accessToken 端点。
|
|
178
|
+
*
|
|
179
|
+
* @returns accessToken 字符串。
|
|
180
|
+
* @throws 当两条路径都取不到 token 时抛错。
|
|
181
|
+
*/
|
|
182
|
+
async getAccessToken() {
|
|
183
|
+
if (this.dwClient !== undefined) {
|
|
184
|
+
try {
|
|
185
|
+
const token = await this.dwClient.getAccessToken();
|
|
186
|
+
if (typeof token === 'string' && token !== '') {
|
|
187
|
+
return token;
|
|
188
|
+
}
|
|
189
|
+
console.error('SDK getAccessToken 返回空,改用 oauth2 兜底。');
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
193
|
+
console.error(`SDK getAccessToken 失败(${message}),改用 oauth2 兜底。`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// 兜底:官方 v1.0 oauth2 端点,body { appKey, appSecret } → { accessToken }。
|
|
197
|
+
try {
|
|
198
|
+
const res = await axios.post(ACCESS_TOKEN_URL, {
|
|
199
|
+
appKey: this.config.clientId,
|
|
200
|
+
appSecret: this.config.clientSecret,
|
|
201
|
+
});
|
|
202
|
+
const accessToken = res.data.accessToken;
|
|
203
|
+
if (typeof accessToken !== 'string' || accessToken === '') {
|
|
204
|
+
throw new Error('响应缺少 accessToken 字段');
|
|
205
|
+
}
|
|
206
|
+
return accessToken;
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
throw new Error(`获取钉钉 accessToken 失败:${describeAxiosError(error)}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* 断开 Stream 长连(若已建连)。进程退出前调用,释放 socket。
|
|
214
|
+
*
|
|
215
|
+
* @returns 断连完成的 Promise(SDK disconnect 为同步,这里统一成 async 契约)。
|
|
216
|
+
*/
|
|
217
|
+
async close() {
|
|
218
|
+
if (this.dwClient !== undefined) {
|
|
219
|
+
this.dwClient.disconnect();
|
|
220
|
+
this.dwClient = undefined;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* 把 axios / 未知错误转成含 HTTP 状态与钉钉 errcode/message 的可读文本。
|
|
226
|
+
*
|
|
227
|
+
* @param error 捕获到的未知错误。
|
|
228
|
+
* @returns 可读错误描述。
|
|
229
|
+
*/
|
|
230
|
+
function describeAxiosError(error) {
|
|
231
|
+
if (axios.isAxiosError(error)) {
|
|
232
|
+
const axiosError = error;
|
|
233
|
+
const status = axiosError.response?.status;
|
|
234
|
+
const data = axiosError.response?.data;
|
|
235
|
+
const parts = [];
|
|
236
|
+
if (status !== undefined) {
|
|
237
|
+
parts.push(`HTTP ${status}`);
|
|
238
|
+
}
|
|
239
|
+
// 钉钉 v1.0 错误体常见 { code, message };旧接口为 { errcode, errmsg },两者都收。
|
|
240
|
+
if (data?.errcode !== undefined) {
|
|
241
|
+
parts.push(`errcode=${data.errcode}`);
|
|
242
|
+
}
|
|
243
|
+
if (data?.code !== undefined) {
|
|
244
|
+
parts.push(`code=${data.code}`);
|
|
245
|
+
}
|
|
246
|
+
const detail = data?.message ?? data?.errmsg ?? axiosError.message;
|
|
247
|
+
if (detail !== undefined && detail !== '') {
|
|
248
|
+
parts.push(detail);
|
|
249
|
+
}
|
|
250
|
+
return parts.length > 0 ? parts.join(' ') : axiosError.message;
|
|
251
|
+
}
|
|
252
|
+
return error instanceof Error ? error.message : String(error);
|
|
253
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 钉钉凭证与运行参数解析(纯函数,可测:给假 env 注入,不读模块顶层 process.env)。
|
|
3
|
+
*
|
|
4
|
+
* 从传入的 env 解析四项必填凭证与一项可选超时:
|
|
5
|
+
* - 必填:DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET / DINGTALK_ROBOT_CODE / DINGTALK_USER_ID。
|
|
6
|
+
* - 可选:DINGTALK_ASK_TIMEOUT_MS(默认 1_500_000 ≈ 25 分钟,留足宿主 MCP 工具超时余量)。
|
|
7
|
+
*
|
|
8
|
+
* 设计取舍:
|
|
9
|
+
* - 不在模块顶层读 process.env,改为接收 env 参数,便于单测注入且无副作用。
|
|
10
|
+
* - 任一必填缺失/空串(含仅空白)→ 抛错,错误信息逐项列出所有缺失项,方便一次补齐(AC5)。
|
|
11
|
+
* - 超时非法(非数字/非正整数)视为配置错误直接抛错,而非静默回退默认——避免用户误配
|
|
12
|
+
* 却拿到与预期不符的等待时长;空串/仅空白视为"未提供",回退默认。
|
|
13
|
+
*/
|
|
14
|
+
/** 内部超时默认值(毫秒):约 25 分钟,留足宿主 MCP 工具超时余量。 */
|
|
15
|
+
export const DEFAULT_ASK_TIMEOUT_MS = 1_500_000;
|
|
16
|
+
/** 必填环境变量名,顺序即错误信息中的列出顺序。 */
|
|
17
|
+
const REQUIRED_KEYS = [
|
|
18
|
+
'DINGTALK_CLIENT_ID',
|
|
19
|
+
'DINGTALK_CLIENT_SECRET',
|
|
20
|
+
'DINGTALK_ROBOT_CODE',
|
|
21
|
+
'DINGTALK_USER_ID'
|
|
22
|
+
];
|
|
23
|
+
/** 可选超时环境变量名。 */
|
|
24
|
+
const TIMEOUT_KEY = 'DINGTALK_ASK_TIMEOUT_MS';
|
|
25
|
+
/**
|
|
26
|
+
* 从 env 解析钉钉配置。
|
|
27
|
+
*
|
|
28
|
+
* @param env 环境变量对象(生产传 process.env,测试传假对象)。
|
|
29
|
+
* @returns 已校验的 DingtalkConfig。
|
|
30
|
+
* @throws 当任一必填缺失/空,或 timeout 非法(非正整数)时抛错,信息明确指出问题项。
|
|
31
|
+
*/
|
|
32
|
+
export function resolveDingtalkConfig(env) {
|
|
33
|
+
// 逐项收集缺失的必填项,一次性列全,避免用户"补一个报一个"。
|
|
34
|
+
const missing = [];
|
|
35
|
+
const values = {
|
|
36
|
+
DINGTALK_CLIENT_ID: '',
|
|
37
|
+
DINGTALK_CLIENT_SECRET: '',
|
|
38
|
+
DINGTALK_ROBOT_CODE: '',
|
|
39
|
+
DINGTALK_USER_ID: ''
|
|
40
|
+
};
|
|
41
|
+
for (const key of REQUIRED_KEYS) {
|
|
42
|
+
const trimmed = (env[key] ?? '').trim();
|
|
43
|
+
if (trimmed === '') {
|
|
44
|
+
missing.push(key);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
values[key] = trimmed;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (missing.length > 0) {
|
|
51
|
+
throw new Error(`缺少必填钉钉环境变量:${missing.join('、')}`);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
clientId: values.DINGTALK_CLIENT_ID,
|
|
55
|
+
clientSecret: values.DINGTALK_CLIENT_SECRET,
|
|
56
|
+
robotCode: values.DINGTALK_ROBOT_CODE,
|
|
57
|
+
userId: values.DINGTALK_USER_ID,
|
|
58
|
+
timeoutMs: resolveTimeoutMs(env[TIMEOUT_KEY])
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* 解析可选超时:未提供/空串 → 默认;提供则必须为正整数,否则抛错。
|
|
63
|
+
*
|
|
64
|
+
* @param raw 原始超时字符串(可能为 undefined)。
|
|
65
|
+
* @returns 校验后的正整数毫秒数。
|
|
66
|
+
* @throws 当提供了非正整数值时抛错。
|
|
67
|
+
*/
|
|
68
|
+
function resolveTimeoutMs(raw) {
|
|
69
|
+
const trimmed = (raw ?? '').trim();
|
|
70
|
+
if (trimmed === '') {
|
|
71
|
+
return DEFAULT_ASK_TIMEOUT_MS;
|
|
72
|
+
}
|
|
73
|
+
// 用 Number 而非 parseInt,避免 "1000abc" 被静默截断为 1000。
|
|
74
|
+
const parsed = Number(trimmed);
|
|
75
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
76
|
+
throw new Error(`环境变量 ${TIMEOUT_KEY} 必须为正整数毫秒数,当前值非法:${trimmed}`);
|
|
77
|
+
}
|
|
78
|
+
return parsed;
|
|
79
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 回复匹配(纯函数,可测):把用户在钉钉里的文本回复映射到候选选项,或作为自由文本。
|
|
3
|
+
*
|
|
4
|
+
* 匹配优先级(trim 首尾空白后):
|
|
5
|
+
* 1. 序号:用户回 "1"/"2"(1-based),映射 options[i-1];越界或 0/负数当作非序号继续后续匹配。
|
|
6
|
+
* 2. 精确文本命中:trim 后的回复等于某个 option。
|
|
7
|
+
* 3. 包含命中:回复文本包含某个 option(如"我选继续部署吧"含"继续部署")。
|
|
8
|
+
*
|
|
9
|
+
* 边界约定:
|
|
10
|
+
* - 无 options:任何非空回复直接作为自由文本(kind: "free");纯数字也是自由文本(无候选可映射)。
|
|
11
|
+
* - 有 options 但都不中:作为自由文本透传原文(trim 后),让上层/模型自行判断,不强行归类。
|
|
12
|
+
* - 多个 option 同时被"包含"命中:取 options 顺序中第一个命中者(稳定、可预期)。
|
|
13
|
+
* - 空串/仅空白:作为 free,value 为空串。
|
|
14
|
+
*
|
|
15
|
+
* 返回结构用 kind 区分命中的是候选选项还是自由文本,option 情形附带 0-based index,
|
|
16
|
+
* 便于上层(如卡片回传核对)使用。
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* 匹配用户回复。
|
|
20
|
+
*
|
|
21
|
+
* @param replyText 用户在钉钉里的原始回复文本。
|
|
22
|
+
* @param options 候选选项;缺省或空数组时任何非空回复都作为自由文本。
|
|
23
|
+
* @returns 命中的选项或自由文本结果。
|
|
24
|
+
*/
|
|
25
|
+
export function matchReply(replyText, options) {
|
|
26
|
+
const trimmed = replyText.trim();
|
|
27
|
+
// 无候选:直接作为自由文本(含纯数字,因为没有可映射的候选)。
|
|
28
|
+
if (options === undefined || options.length === 0) {
|
|
29
|
+
return { kind: 'free', value: trimmed };
|
|
30
|
+
}
|
|
31
|
+
// 1. 序号命中:整串是纯正整数且落在 1..options.length 范围内。
|
|
32
|
+
if (/^\d+$/.test(trimmed)) {
|
|
33
|
+
const ordinal = Number(trimmed);
|
|
34
|
+
if (ordinal >= 1 && ordinal <= options.length) {
|
|
35
|
+
const index = ordinal - 1;
|
|
36
|
+
return { kind: 'option', value: options[index], index };
|
|
37
|
+
}
|
|
38
|
+
// 越界/为 0:不 return,落到后续文本匹配(通常会归为 free)。
|
|
39
|
+
}
|
|
40
|
+
// 2. 精确文本命中。
|
|
41
|
+
const exactIndex = options.findIndex((opt) => opt === trimmed);
|
|
42
|
+
if (exactIndex !== -1) {
|
|
43
|
+
return { kind: 'option', value: options[exactIndex], index: exactIndex };
|
|
44
|
+
}
|
|
45
|
+
// 3. 包含命中:按 options 顺序取第一个被回复文本包含的选项。
|
|
46
|
+
const containIndex = options.findIndex((opt) => opt !== '' && trimmed.includes(opt));
|
|
47
|
+
if (containIndex !== -1) {
|
|
48
|
+
return { kind: 'option', value: options[containIndex], index: containIndex };
|
|
49
|
+
}
|
|
50
|
+
// 有 options 但都不中:透传为自由文本,交上层/模型判断。
|
|
51
|
+
return { kind: 'free', value: trimmed };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 决定一条回复应 resolve 的值(纯判定,复用已测 matchReply)。
|
|
55
|
+
*
|
|
56
|
+
* 任何非空回复都会给值:命中选项取选项文本,否则取自由文本原文(透传交模型判断)。
|
|
57
|
+
* 空串/仅空白(matchReply 返回 free 且 value 为空)视为无效回复,返回 undefined(不结束等待)。
|
|
58
|
+
*
|
|
59
|
+
* @param text 用户回复原文。
|
|
60
|
+
* @param options 当前决策的候选选项。
|
|
61
|
+
* @returns 应 resolve 的值;undefined 表示忽略此回复继续等待。
|
|
62
|
+
*/
|
|
63
|
+
export function resolveReplyValue(text, options) {
|
|
64
|
+
const match = matchReply(text, options);
|
|
65
|
+
if (match.value === '') {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
return match.value;
|
|
69
|
+
}
|