dsh-remote-web 0.7.11-beta.3 → 0.7.12-beta.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/lib/client.js +703 -115
- package/lib/index.js +487 -31
- package/package.json +1 -1
- package/runtime/clients/dsh-remote/dingtalk-bot.mjs +1318 -0
- package/runtime/clients/dsh-remote/docs/feishu-bot-contract.md +177 -0
- package/runtime/clients/dsh-remote/dsh-bridge.mjs +398 -80
- package/runtime/clients/dsh-remote/feishu-bot.mjs +1668 -0
- package/runtime/clients/dsh-remote/im-format.mjs +372 -0
- package/runtime/clients/dsh-remote/mirror-diag-script.js +53 -14
- package/runtime/clients/dsh-remote/mobile-adapter.mjs +355 -40
- package/runtime/clients/dsh-remote/upstream-discovery.mjs +495 -43
- package/runtime/clients/dsh-remote/wecom-bot.mjs +238 -12
- package/runtime/dsh-setup.mjs +78 -7
|
@@ -0,0 +1,1318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dingtalk-bot — 钉钉「Stream 模式」长连接通道(2026-11-08,与 wecom-bot 同构,**零新增依赖**)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要自写(官方明确鼓励):
|
|
5
|
+
* 钉钉把 Stream 协议公开成文档,并写明「平台也向开发者开放了 Stream 模式的接入协议,
|
|
6
|
+
* 鼓励各语言开发者根据此协议完成 Stream 客户端开发」。核心 = HTTP 换 ticket + WebSocket 收帧 + ACK,
|
|
7
|
+
* 链路 ~150 行,不需要 SDK。/tmp/dt-probe 实测官方 SDK 仅 68KB 包(4.3MB 依赖树),
|
|
8
|
+
* 自写主要是为了与企微通道同构、零新增依赖。
|
|
9
|
+
*
|
|
10
|
+
* 协议(来源:research/2026-11-strategy/06-multiagent/dingtalk-stream.md,均来自官方文档/官方 SDK 源码)
|
|
11
|
+
* -----------------------------------------------------------------
|
|
12
|
+
* 1) 建连前换 ticket:POST {gatewayUrl}(默认 https://api.dingtalk.com/v1.0/gateway/connections/open)
|
|
13
|
+
* body {clientId, clientSecret, subscriptions:[{type:"CALLBACK",topic:"/v1.0/im/bot/messages/get"}], ua}
|
|
14
|
+
* → {endpoint, ticket};**ticket 一次性 + 90s 有效**(每次重连都必须重新 open,绝不复用)。
|
|
15
|
+
* 2) wss://{endpoint路径}?ticket=… → 帧是纯 JSON:{type:"SYSTEM"|"EVENT"|"CALLBACK", headers:{messageId,topic,contentType,time}, data}
|
|
16
|
+
* ⚠️ `data` 是 **JSON 字符串**(要二次 parse;本模块容错 object/string 两种)。
|
|
17
|
+
* 3) **每帧必须 ACK**(CALLBACK 不自动 ACK):
|
|
18
|
+
* {code:200, headers:{messageId, contentType:"application/json"}, message:"OK", data: "<JSON 字符串>"}
|
|
19
|
+
* · SYSTEM ping → data `{"opaque":<原样回传>}`(messageId 必须一致)
|
|
20
|
+
* · EVENT → data `{"status":"SUCCESS"}`
|
|
21
|
+
* · 机器人消息 → data `{"response":null}`(钉钉不读,只认 code=200)
|
|
22
|
+
* · SYSTEM disconnect → **不 ACK**,主动重连(官方给 10s 宽限;我们直接换新连接)
|
|
23
|
+
* · CONNECTED/REGISTERED/KEEPALIVE/未知 SYSTEM → 只记 debug,**忽略不崩**(官方协议文档没写这几个 topic)
|
|
24
|
+
* ⚠️ ACK 的 `data` 是 JSON 字符串(与官方 SDK 一致);task 文档里提过的 base64 变体标 UNVERIFIED,
|
|
25
|
+
* 真机若要求 base64 只需改 `buildAck` 一处(有单测夹具钉住当前形状)。
|
|
26
|
+
* 4) 入站机器人消息(topic /v1.0/im/bot/messages/get)data:conversationId / conversationType(1=单聊 2=群) /
|
|
27
|
+
* senderStaffId / msgtype / text.content / sessionWebhook / robotCode / msgId …
|
|
28
|
+
* 5) **Stream 里不能内联回复**:回复走 HTTP —— 默认 `POST {sessionWebhook}` body
|
|
29
|
+
* `{msgtype:"markdown", markdown:{title, text}}`(markdown 支持、表格不支持 → 统一走 `im-format.mjs`
|
|
30
|
+
* 的 `formatForChannel("dingtalk", …)`);可选 `replyMode:"openapi"` 走 accessToken + OpenAPI(标 UNVERIFIED)。
|
|
31
|
+
* 6) 重连:每次先重新 open 拿新 ticket;退避 min(1000×2^n + 抖动, 60000)。**open 返回 4xx = 凭据错 → 不重连**,
|
|
32
|
+
* 状态置 failed 并给明确错误。
|
|
33
|
+
* 7) accessToken(仅 openapi 模式):POST https://api.dingtalk.com/v1.0/oauth2/accessToken
|
|
34
|
+
* {appKey: clientId, appSecret: clientSecret} → {accessToken, expireIn};内存缓存(提前 5 分钟过期)。
|
|
35
|
+
*
|
|
36
|
+
* 安全/契约
|
|
37
|
+
* -----------------------------------------------------------------
|
|
38
|
+
* · clientSecret / ticket / accessToken **绝不进日志与状态文件**(状态只留 ticketExpiresAt 时间戳):
|
|
39
|
+
* `maskSecrets` + 只打 cmd/topic/chatid 哈希。
|
|
40
|
+
* · 配置:`im-bots.json` 的 `dingtalk` 段(与插件线约定,见 normalizeDingtalkConfig);
|
|
41
|
+
* 状态:`im-bot-status.json` 的 `dingtalk` 段(字段与企微对齐 + subscriptions/ticketExpiresAt)。
|
|
42
|
+
* · 会员判定、会话绑定、通知/回答回传、格式化、状态原子写 **全部复用** 企微那套(同一套能力表/口径)。
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import fs from "node:fs";
|
|
46
|
+
import path from "node:path";
|
|
47
|
+
import crypto from "node:crypto";
|
|
48
|
+
import { EventEmitter } from "node:events";
|
|
49
|
+
|
|
50
|
+
import WebSocket from "ws";
|
|
51
|
+
|
|
52
|
+
import { createEventSubscriber, NODE_KINDS } from "./dsh-events.mjs";
|
|
53
|
+
import { formatForChannel } from "./im-format.mjs";
|
|
54
|
+
import {
|
|
55
|
+
WECHAT_CAPABILITY_TABLE,
|
|
56
|
+
WECHAT_TIER_LIMITS,
|
|
57
|
+
capabilitiesFor,
|
|
58
|
+
grantsCap,
|
|
59
|
+
limitsFor
|
|
60
|
+
} from "./wechat-runtime.mjs";
|
|
61
|
+
import {
|
|
62
|
+
CONFIG_POLL_MS,
|
|
63
|
+
IM_BOTS_FILE,
|
|
64
|
+
IM_BOT_STATUS_FILE,
|
|
65
|
+
atomicWriteJson,
|
|
66
|
+
chatIdHash,
|
|
67
|
+
createScopedLogger,
|
|
68
|
+
maskSecrets,
|
|
69
|
+
normalizeAllowList,
|
|
70
|
+
readJsonFile,
|
|
71
|
+
toMarkdownNotification,
|
|
72
|
+
truncateText
|
|
73
|
+
} from "./wecom-bot.mjs";
|
|
74
|
+
|
|
75
|
+
// ===========================================================================
|
|
76
|
+
// 1. 常量 / 协议纯函数(可直接单测,不建连、不打网络)
|
|
77
|
+
// ===========================================================================
|
|
78
|
+
|
|
79
|
+
/** Stream 建连网关(私有化/测试可覆盖)。 */
|
|
80
|
+
export const DINGTALK_OPEN_URL = "https://api.dingtalk.com/v1.0/gateway/connections/open";
|
|
81
|
+
/** accessToken 端点(仅 openapi 回复模式)。 */
|
|
82
|
+
export const DINGTALK_TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken";
|
|
83
|
+
/** 单聊/群聊主动发消息 OpenAPI(仅 openapi 回复模式;标 UNVERIFIED,真机需一次核验)。 */
|
|
84
|
+
export const DINGTALK_O2O_SEND_URL = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
|
|
85
|
+
export const DINGTALK_GROUP_SEND_URL = "https://api.dingtalk.com/v1.0/robot/groupMessages/send";
|
|
86
|
+
/** 机器人消息订阅 topic。 */
|
|
87
|
+
export const TOPIC_ROBOT = "/v1.0/im/bot/messages/get";
|
|
88
|
+
/** 卡片按钮回调 topic(卡片拍板本轮**后置**;收到也只 ACK + 记日志,不崩)。 */
|
|
89
|
+
export const TOPIC_CARD_CALLBACK = "/v1.0/card/instances/callback";
|
|
90
|
+
/** 默认订阅(与调研一致:只订机器人消息;卡片后置再加)。 */
|
|
91
|
+
export const DEFAULT_SUBSCRIPTIONS = Object.freeze([{ type: "CALLBACK", topic: TOPIC_ROBOT }]);
|
|
92
|
+
/** ticket 有效期(官方:90s / 一次性)。状态文件只记过期时间,不存 ticket。 */
|
|
93
|
+
export const TICKET_TTL_MS = 90_000;
|
|
94
|
+
/** 重连退避:1s 起、翻倍、60s 封顶(+0~1s 抖动,照官方 SDK 行为)。 */
|
|
95
|
+
export const RECONNECT_BASE_MS = 1_000;
|
|
96
|
+
export const RECONNECT_MAX_MS = 60_000;
|
|
97
|
+
/** open 请求超时。 */
|
|
98
|
+
const OPEN_TIMEOUT_MS = 10_000;
|
|
99
|
+
/** 机器人消息 ACK(官方:`{"response":null}`;钉钉不读该字段,只认 code=200)。 */
|
|
100
|
+
export const BOT_CALLBACK_ACK = Object.freeze({ response: null });
|
|
101
|
+
/** 事件订阅 ACK。 */
|
|
102
|
+
export const EVENT_ACK = Object.freeze({ status: "SUCCESS" });
|
|
103
|
+
/** 单条消息去重上限。 */
|
|
104
|
+
const SEEN_MSG_MAX = 200;
|
|
105
|
+
|
|
106
|
+
/** 构造 open 请求体(**只含契约字段**;用户可注入订阅列表,默认只订机器人消息)。 */
|
|
107
|
+
export function buildOpenBody({ clientId, clientSecret, subscriptions, ua, localIp } = {}) {
|
|
108
|
+
const subs = Array.isArray(subscriptions) && subscriptions.length
|
|
109
|
+
? subscriptions
|
|
110
|
+
.filter((s) => s && typeof s === "object" && typeof s.topic === "string" && s.topic)
|
|
111
|
+
.map((s) => ({ type: String(s.type || "CALLBACK"), topic: String(s.topic) }))
|
|
112
|
+
: DEFAULT_SUBSCRIPTIONS.map((s) => ({ ...s }));
|
|
113
|
+
const body = {
|
|
114
|
+
clientId: String(clientId || ""),
|
|
115
|
+
clientSecret: String(clientSecret || ""),
|
|
116
|
+
subscriptions: subs
|
|
117
|
+
};
|
|
118
|
+
if (ua) body.ua = String(ua);
|
|
119
|
+
if (localIp) body.localIp = String(localIp);
|
|
120
|
+
return body;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 构造 ACK 帧。`data` 是 **JSON 字符串**(官方 SDK 行为;task 里提过的 base64 变体标 UNVERIFIED)。
|
|
125
|
+
* @param {string} messageId 原样带回
|
|
126
|
+
* @param {object} payload ACK data 的对象
|
|
127
|
+
*/
|
|
128
|
+
export function buildAck(messageId, payload) {
|
|
129
|
+
return {
|
|
130
|
+
code: 200,
|
|
131
|
+
headers: { messageId: String(messageId || ""), contentType: "application/json" },
|
|
132
|
+
message: "OK",
|
|
133
|
+
data: JSON.stringify(payload === undefined ? {} : payload)
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** SYSTEM ping 的 ACK(把 data.opaque 原样回传;messageId 必须一致)。 */
|
|
138
|
+
export function buildPingAck(messageId, opaque) {
|
|
139
|
+
return buildAck(messageId, { opaque: opaque === undefined ? "" : opaque });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 解析下行帧。
|
|
144
|
+
* @returns {{type:string, headers:object, data:any, raw:object}|null}
|
|
145
|
+
*/
|
|
146
|
+
export function parseFrame(raw) {
|
|
147
|
+
let frame = null;
|
|
148
|
+
try {
|
|
149
|
+
const text = typeof raw === "string" ? raw : Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
|
|
150
|
+
frame = JSON.parse(text);
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
if (!frame || typeof frame !== "object" || Array.isArray(frame)) return null;
|
|
155
|
+
const headers = frame.headers && typeof frame.headers === "object" ? frame.headers : {};
|
|
156
|
+
let data = frame.data;
|
|
157
|
+
// ⚠️ 官方:data 是 JSON 字符串(要二次 parse)。这里同时容错 object/空值。
|
|
158
|
+
if (typeof data === "string") {
|
|
159
|
+
const s = data.trim();
|
|
160
|
+
if (!s) data = null;
|
|
161
|
+
else {
|
|
162
|
+
try { data = JSON.parse(s); } catch { data = null; }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { type: String(frame.type || "").toUpperCase(), headers, data, raw: frame };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 把机器人消息 data 归一成内部结构(字段名以官方「机器人接收消息」为准)。 */
|
|
169
|
+
export function normalizeBotMessage(data) {
|
|
170
|
+
const d = data && typeof data === "object" && !Array.isArray(data) ? data : {};
|
|
171
|
+
const textObj = d.text && typeof d.text === "object" ? d.text : null;
|
|
172
|
+
const text = textObj && typeof textObj.content === "string"
|
|
173
|
+
? textObj.content
|
|
174
|
+
: (typeof d.content === "string" ? d.content : "");
|
|
175
|
+
const conversationType = String(d.conversationType || "");
|
|
176
|
+
const conversationId = String(d.conversationId || "");
|
|
177
|
+
const senderStaffId = String(d.senderStaffId || "");
|
|
178
|
+
return {
|
|
179
|
+
text: String(text || "").trim(),
|
|
180
|
+
msgId: String(d.msgId || ""),
|
|
181
|
+
msgtype: String(d.msgtype || ""),
|
|
182
|
+
conversationId,
|
|
183
|
+
conversationType,
|
|
184
|
+
isGroup: conversationType === "2",
|
|
185
|
+
senderStaffId,
|
|
186
|
+
senderNick: String(d.senderNick || ""),
|
|
187
|
+
senderId: String(d.senderId || ""),
|
|
188
|
+
sessionWebhook: String(d.sessionWebhook || ""),
|
|
189
|
+
sessionWebhookExpiredTime: Number(d.sessionWebhookExpiredTime) || 0,
|
|
190
|
+
robotCode: String(d.robotCode || ""),
|
|
191
|
+
createAt: Number(d.createAt) || 0,
|
|
192
|
+
/** 主动发消息/去重用的目标:群=conversationId,单聊=senderStaffId。 */
|
|
193
|
+
chatId: conversationId || senderStaffId,
|
|
194
|
+
raw: d
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** sessionWebhook 回复体(与自定义机器人一致;markdown 支持表格? → 不支持,文本已由 im-format 适配)。 */
|
|
199
|
+
export function buildSessionWebhookBody({ title, text } = {}) {
|
|
200
|
+
const t = String(title || "").trim() || "DeepSeek Harness";
|
|
201
|
+
return { msgtype: "markdown", markdown: { title: truncateText(t, 64), text: String(text == null ? "" : text) } };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 配置规范化:`im-bots.json` 的 `dingtalk` 段(缺字段补默认,非法值当未配置,绝不抛)。 */
|
|
205
|
+
export function normalizeDingtalkConfig(raw) {
|
|
206
|
+
const src = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
207
|
+
const str = (v) => (typeof v === "string" ? v.trim() : "");
|
|
208
|
+
const replyMode = str(src.replyMode).toLowerCase() === "openapi" ? "openapi" : "sessionWebhook";
|
|
209
|
+
return {
|
|
210
|
+
enabled: src.enabled === true,
|
|
211
|
+
clientId: str(src.clientId),
|
|
212
|
+
clientSecret: typeof src.clientSecret === "string" ? src.clientSecret : "",
|
|
213
|
+
robotCode: str(src.robotCode),
|
|
214
|
+
replyMode,
|
|
215
|
+
sessionWebhook: str(src.sessionWebhook),
|
|
216
|
+
boundChatId: str(src.boundChatId),
|
|
217
|
+
allowFrom: normalizeAllowList(src.allowFrom),
|
|
218
|
+
cardTemplateId: str(src.cardTemplateId),
|
|
219
|
+
streamUrl: str(src.streamUrl),
|
|
220
|
+
// 会话可选绑定(与企微同口径)
|
|
221
|
+
sessionId: str(src.sessionId),
|
|
222
|
+
sessionTitle: str(src.sessionTitle)
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 读取 `im-bots.json` 的 dingtalk 段(文件不存在 → null)。 */
|
|
227
|
+
export function readDingtalkConfig(relayDir) {
|
|
228
|
+
if (!relayDir) return null;
|
|
229
|
+
const raw = readJsonFile(path.join(String(relayDir), IM_BOTS_FILE));
|
|
230
|
+
if (raw === null) return null;
|
|
231
|
+
const section = raw && typeof raw === "object" && raw.dingtalk && typeof raw.dingtalk === "object"
|
|
232
|
+
? raw.dingtalk : {};
|
|
233
|
+
return normalizeDingtalkConfig(section);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ===========================================================================
|
|
237
|
+
// 2. 长连接 bot(与 createWecomBot 同形)
|
|
238
|
+
// ===========================================================================
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* @param {{
|
|
242
|
+
* clientId: string, clientSecret: string, robotCode?: string,
|
|
243
|
+
* replyMode?: "sessionWebhook"|"openapi",
|
|
244
|
+
* gatewayUrl?: string, // 覆盖 open 端点(私有化/测试)
|
|
245
|
+
* tokenUrl?: string, o2oSendUrl?: string, groupSendUrl?: string,
|
|
246
|
+
* subscriptions?: Array<{type:string, topic:string}>,
|
|
247
|
+
* relayDir?: string, // 有值 → 写 im-bot-status.json 的 dingtalk 段
|
|
248
|
+
* allowFrom?: string[], targetChatId?: string,
|
|
249
|
+
* configuredSessionId?: string, configuredSessionTitle?: string,
|
|
250
|
+
* capsProvider?: () => Promise<string|object>, tier?: string, appUrl?: string,
|
|
251
|
+
* notify?: boolean, formatChannel?: string,
|
|
252
|
+
* logger?: Function|object, clock?: () => number,
|
|
253
|
+
* wsFactory?: (url:string)=>any, fetchImpl?: typeof fetch,
|
|
254
|
+
* upstream?: string|(()=>string), cookie?: string|(()=>string), subscriber?: any,
|
|
255
|
+
* reconnectBaseMs?: number, reconnectMaxMs?: number, openTimeoutMs?: number,
|
|
256
|
+
* tierDenyThrottleMs?: number, sessionIdProvider?: () => string
|
|
257
|
+
* }} options
|
|
258
|
+
*/
|
|
259
|
+
export function createDingtalkBot(options = {}) {
|
|
260
|
+
const clientId = String(options.clientId || "").trim();
|
|
261
|
+
const clientSecret = typeof options.clientSecret === "string" ? options.clientSecret : "";
|
|
262
|
+
const robotCode = String(options.robotCode || "").trim();
|
|
263
|
+
const replyMode = String(options.replyMode || "sessionWebhook").toLowerCase() === "openapi" ? "openapi" : "sessionWebhook";
|
|
264
|
+
const gatewayUrl = String(options.gatewayUrl || "").trim() || DINGTALK_OPEN_URL;
|
|
265
|
+
const tokenUrl = String(options.tokenUrl || "").trim() || DINGTALK_TOKEN_URL;
|
|
266
|
+
const o2oSendUrl = String(options.o2oSendUrl || "").trim() || DINGTALK_O2O_SEND_URL;
|
|
267
|
+
const groupSendUrl = String(options.groupSendUrl || "").trim() || DINGTALK_GROUP_SEND_URL;
|
|
268
|
+
const relayDir = options.relayDir ? String(options.relayDir) : "";
|
|
269
|
+
const allowFrom = normalizeAllowList(options.allowFrom);
|
|
270
|
+
const targetChatId = String(options.targetChatId || "").trim();
|
|
271
|
+
const configuredSessionId = String(options.configuredSessionId || "").trim();
|
|
272
|
+
const configuredSessionTitle = truncateText(options.configuredSessionTitle || "", 60);
|
|
273
|
+
const now = typeof options.clock === "function" ? options.clock : () => Date.now();
|
|
274
|
+
const doFetch = typeof options.fetchImpl === "function" ? options.fetchImpl : globalThis.fetch;
|
|
275
|
+
const wsFactory = typeof options.wsFactory === "function" ? options.wsFactory : (url) => new WebSocket(url);
|
|
276
|
+
const reconnectBaseMs = positiveNumber(options.reconnectBaseMs, RECONNECT_BASE_MS);
|
|
277
|
+
const reconnectMaxMs = Math.max(reconnectBaseMs, positiveNumber(options.reconnectMaxMs, RECONNECT_MAX_MS));
|
|
278
|
+
const openTimeoutMs = positiveNumber(options.openTimeoutMs, OPEN_TIMEOUT_MS);
|
|
279
|
+
const notifyAllowed = options.notify !== false;
|
|
280
|
+
const formatChannel = String(options.formatChannel || "dingtalk").trim() || "dingtalk";
|
|
281
|
+
const ua = String(options.ua || "dsh-remote-bridge/dingtalk-bot");
|
|
282
|
+
const subscriptions = Array.isArray(options.subscriptions) && options.subscriptions.length
|
|
283
|
+
? options.subscriptions : DEFAULT_SUBSCRIPTIONS;
|
|
284
|
+
const log = createScopedLogger({ sink: options.logger, secrets: [clientSecret], prefix: "[dingtalk]" });
|
|
285
|
+
|
|
286
|
+
// 会员判定(与企微同口径:通知/拍板免费、互动免费 20 条/月)
|
|
287
|
+
const capsProvider = typeof options.capsProvider === "function" ? options.capsProvider : null;
|
|
288
|
+
const tierOverride = typeof options.tier === "string" && options.tier.trim() ? options.tier.trim() : "";
|
|
289
|
+
const capabilityTable = options.capabilityTable && typeof options.capabilityTable === "object" ? options.capabilityTable : WECHAT_CAPABILITY_TABLE;
|
|
290
|
+
const limitTable = options.limitTable && typeof options.limitTable === "object" ? options.limitTable : WECHAT_TIER_LIMITS;
|
|
291
|
+
const tierDenyThrottleMs = Number.isFinite(Number(options.tierDenyThrottleMs)) ? Math.max(0, Number(options.tierDenyThrottleMs)) : 5000;
|
|
292
|
+
|
|
293
|
+
const statusFile = relayDir ? path.join(relayDir, IM_BOT_STATUS_FILE) : "";
|
|
294
|
+
const emitter = new EventEmitter();
|
|
295
|
+
emitter.setMaxListeners(20);
|
|
296
|
+
|
|
297
|
+
/** 连接状态机:idle | connecting | connected | reconnecting | failed(与企微/飞书统一)。 */
|
|
298
|
+
const state = {
|
|
299
|
+
running: false,
|
|
300
|
+
phase: "idle",
|
|
301
|
+
connected: false,
|
|
302
|
+
authenticated: false,
|
|
303
|
+
lastError: "",
|
|
304
|
+
lastConnectedAt: 0,
|
|
305
|
+
lastMessageAt: 0,
|
|
306
|
+
openCount: 0,
|
|
307
|
+
ticketExpiresAt: 0,
|
|
308
|
+
reconnectAttempts: 0,
|
|
309
|
+
nextRetryInMs: 0,
|
|
310
|
+
boundChatId: "",
|
|
311
|
+
boundConversationId: "",
|
|
312
|
+
boundUserId: "",
|
|
313
|
+
boundSessionWebhook: "",
|
|
314
|
+
boundSessionWebhookExpiredAt: 0,
|
|
315
|
+
messages: 0,
|
|
316
|
+
gated: 0
|
|
317
|
+
};
|
|
318
|
+
const membership = { tier: "", entitlements: null, checkedAt: 0, usage: { month: "", count: 0 } };
|
|
319
|
+
|
|
320
|
+
let ws = null;
|
|
321
|
+
let stopped = true;
|
|
322
|
+
let reconnectTimer = null;
|
|
323
|
+
let tierTimer = null;
|
|
324
|
+
let connecting = false;
|
|
325
|
+
let subscriber = null;
|
|
326
|
+
let ownsSubscriber = false;
|
|
327
|
+
let wired = false;
|
|
328
|
+
let lastSeenSessionId = "";
|
|
329
|
+
let lastInbound = { sessionId: "", at: 0 };
|
|
330
|
+
let lastNoTargetLogAt = 0;
|
|
331
|
+
let boundSessionId = "";
|
|
332
|
+
const seenMsgs = new Map();
|
|
333
|
+
const pendingToken = { value: "", expiresAt: 0 };
|
|
334
|
+
|
|
335
|
+
// 配置里持久化的 sessionWebhook(插件/上次运行留下的):首次入站前也能主动发;
|
|
336
|
+
// ⚠️ webhook 里带会话令牌,**绝不写状态文件**,只在内存里用。
|
|
337
|
+
if (options.sessionWebhook) state.boundSessionWebhook = String(options.sessionWebhook || "");
|
|
338
|
+
if (targetChatId && !state.boundChatId) state.boundChatId = targetChatId;
|
|
339
|
+
|
|
340
|
+
// 重启恢复:用量 / 绑定目标 / 档位快照(均不含凭据)
|
|
341
|
+
if (relayDir) {
|
|
342
|
+
try {
|
|
343
|
+
const prev = readJsonFile(statusFile);
|
|
344
|
+
const d = prev && prev.dingtalk && typeof prev.dingtalk === "object" ? prev.dingtalk : {};
|
|
345
|
+
if (typeof d.boundChatId === "string" && d.boundChatId) state.boundChatId = d.boundChatId;
|
|
346
|
+
if (typeof d.boundConversationId === "string") state.boundConversationId = d.boundConversationId;
|
|
347
|
+
if (typeof d.boundUserId === "string") state.boundUserId = d.boundUserId;
|
|
348
|
+
if (typeof d.boundSessionWebhook === "string") state.boundSessionWebhook = d.boundSessionWebhook;
|
|
349
|
+
if (Number.isFinite(Number(d.boundSessionWebhookExpiredAt))) state.boundSessionWebhookExpiredAt = Number(d.boundSessionWebhookExpiredAt);
|
|
350
|
+
if (typeof d.boundSessionId === "string") boundSessionId = d.boundSessionId;
|
|
351
|
+
if (d.usage && typeof d.usage === "object" && typeof d.usage.month === "string") {
|
|
352
|
+
membership.usage = { month: d.usage.month, count: Math.max(0, Number(d.usage.messages ?? d.usage.count) || 0) };
|
|
353
|
+
}
|
|
354
|
+
if (capsProvider && typeof d.tier === "string" && d.tier) {
|
|
355
|
+
membership.tier = d.tier;
|
|
356
|
+
membership.entitlements = {
|
|
357
|
+
rev: typeof d.entitlementsRev === "string" ? d.entitlementsRev : "",
|
|
358
|
+
plan: d.tier,
|
|
359
|
+
caps: Array.isArray(d.caps) ? d.caps.slice() : null,
|
|
360
|
+
limits: d.limits && typeof d.limits === "object" ? d.limits : null
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
} catch { /* 忽略 */ }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---------- 会员(与 wecom 同口径) ----------
|
|
367
|
+
|
|
368
|
+
function monthKey(at = now()) {
|
|
369
|
+
const d = new Date(at);
|
|
370
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
371
|
+
}
|
|
372
|
+
function tierNow() { return tierOverride || membership.tier || "free"; }
|
|
373
|
+
function capsNow() {
|
|
374
|
+
const server = membership.entitlements && Array.isArray(membership.entitlements.caps) ? membership.entitlements.caps : null;
|
|
375
|
+
if (server) return server;
|
|
376
|
+
return capabilitiesFor(tierNow(), capabilityTable) || [];
|
|
377
|
+
}
|
|
378
|
+
function limitsNow() {
|
|
379
|
+
const server = membership.entitlements && membership.entitlements.limits;
|
|
380
|
+
if (server && typeof server === "object") return server;
|
|
381
|
+
return limitsFor(tierNow(), limitTable) || {};
|
|
382
|
+
}
|
|
383
|
+
function canCap(cap) { return grantsCap(capsNow(), cap); }
|
|
384
|
+
function usageThisMonth() { return membership.usage.month === monthKey() ? Number(membership.usage.count || 0) : 0; }
|
|
385
|
+
function messageLimitNow() {
|
|
386
|
+
const n = Number((limitsNow() || {}).messages_per_month || 0);
|
|
387
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
388
|
+
}
|
|
389
|
+
function bindingGatedNow() { return Boolean(configuredSessionId) && !canCap("sessions"); }
|
|
390
|
+
async function refreshTier({ force = false, minIntervalMs = 30_000 } = {}) {
|
|
391
|
+
if (!capsProvider) return tierNow();
|
|
392
|
+
const at = now();
|
|
393
|
+
if (!force && at - membership.checkedAt < minIntervalMs) return tierNow();
|
|
394
|
+
membership.checkedAt = at;
|
|
395
|
+
let got = null;
|
|
396
|
+
try { got = await capsProvider(); } catch (e) {
|
|
397
|
+
log.warn(`读取账号档位失败(沿用上次):${truncateText(e && e.message ? e.message : e, 100)}`);
|
|
398
|
+
return tierNow();
|
|
399
|
+
}
|
|
400
|
+
const isObj = Boolean(got) && typeof got === "object";
|
|
401
|
+
const plan = String((isObj ? got.plan : got) || "").trim();
|
|
402
|
+
if (!plan) return tierNow();
|
|
403
|
+
const caps = isObj && Array.isArray(got.caps) ? got.caps : null;
|
|
404
|
+
const limits = isObj && got.limits && typeof got.limits === "object" ? got.limits : null;
|
|
405
|
+
membership.tier = plan;
|
|
406
|
+
if (caps || limits) {
|
|
407
|
+
membership.entitlements = {
|
|
408
|
+
rev: isObj ? String(got.rev || "") : "",
|
|
409
|
+
plan,
|
|
410
|
+
caps: caps || (membership.entitlements && membership.entitlements.caps) || null,
|
|
411
|
+
limits: limits || (membership.entitlements && membership.entitlements.limits) || null
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
writeStatus();
|
|
415
|
+
return tierNow();
|
|
416
|
+
}
|
|
417
|
+
async function canCapNow(cap) {
|
|
418
|
+
if (canCap(cap)) return true;
|
|
419
|
+
await refreshTier({ minIntervalMs: tierDenyThrottleMs });
|
|
420
|
+
return canCap(cap);
|
|
421
|
+
}
|
|
422
|
+
function bumpUsage() {
|
|
423
|
+
const m = monthKey();
|
|
424
|
+
const used = membership.usage.month === m ? Number(membership.usage.count || 0) : 0;
|
|
425
|
+
membership.usage = { month: m, count: used + 1 };
|
|
426
|
+
writeStatus();
|
|
427
|
+
}
|
|
428
|
+
function upgradeText(reason) {
|
|
429
|
+
const freeLimit = Number((limitsFor("free", limitTable) || {}).messages_per_month || 20);
|
|
430
|
+
const used = usageThisMonth();
|
|
431
|
+
const rows = [
|
|
432
|
+
reason === "quota" ? `📵 本月免费指令已用完(${used}/${freeLimit},下月 1 号自动恢复)。` : "🔒 继续在钉钉里派活/追问是会员功能。",
|
|
433
|
+
"",
|
|
434
|
+
"免费可以用:接收任务通知" + (reason === "quota" ? "" : `;每月 ${freeLimit} 条指令(本月已用 ${used}/${freeLimit})`),
|
|
435
|
+
"升级 Pro:指令不限量、可开新任务、可切换会话"
|
|
436
|
+
];
|
|
437
|
+
const appUrl = String(options.appUrl || "").trim();
|
|
438
|
+
if (appUrl) rows.push(`👉 ${appUrl.replace(/\/+$/, "")}/promo`);
|
|
439
|
+
return rows.join("\n");
|
|
440
|
+
}
|
|
441
|
+
async function membershipGate(info) {
|
|
442
|
+
if (!canCap("assign.continue")) {
|
|
443
|
+
if (await canCapNow("assign.continue")) return null;
|
|
444
|
+
reply(info, upgradeText("capability"));
|
|
445
|
+
state.gated += 1;
|
|
446
|
+
emitter.emit("inbound-gated", { ...info, reason: "capability", tier: tierNow() });
|
|
447
|
+
return { reason: "capability" };
|
|
448
|
+
}
|
|
449
|
+
const limit = messageLimitNow();
|
|
450
|
+
if (limit > 0 && usageThisMonth() >= limit) {
|
|
451
|
+
await refreshTier({ minIntervalMs: tierDenyThrottleMs });
|
|
452
|
+
const limit2 = messageLimitNow();
|
|
453
|
+
if (!(limit2 > 0 && usageThisMonth() >= limit2)) return null;
|
|
454
|
+
reply(info, upgradeText("quota"));
|
|
455
|
+
state.gated += 1;
|
|
456
|
+
emitter.emit("inbound-gated", { ...info, reason: "quota", tier: tierNow(), limit: limit2 });
|
|
457
|
+
return { reason: "quota" };
|
|
458
|
+
}
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
function bindingGatedNowSafe() { try { return bindingGatedNow(); } catch { return false; } }
|
|
462
|
+
|
|
463
|
+
// ---------- 状态文件(契约) ----------
|
|
464
|
+
|
|
465
|
+
function statusSection() {
|
|
466
|
+
return {
|
|
467
|
+
enabled: Boolean(clientId && clientSecret),
|
|
468
|
+
state: state.phase,
|
|
469
|
+
connected: state.connected === true,
|
|
470
|
+
authenticated: state.authenticated === true,
|
|
471
|
+
lastError: truncateText(maskSecrets(state.lastError, [clientSecret]), 160),
|
|
472
|
+
lastConnectedAt: Number(state.lastConnectedAt) || 0,
|
|
473
|
+
lastMessageAt: Number(state.lastMessageAt) || 0,
|
|
474
|
+
boundChatId: state.boundChatId || "",
|
|
475
|
+
// 钉钉独有(排障用;**绝不含 ticket/token**,只留过期时间戳)
|
|
476
|
+
subscriptions: subscriptions.map((s) => String(s.topic || "")).filter(Boolean),
|
|
477
|
+
ticketExpiresAt: Number(state.ticketExpiresAt) || 0,
|
|
478
|
+
connectedAt: Number(state.lastConnectedAt) || 0,
|
|
479
|
+
openCount: Number(state.openCount) || 0,
|
|
480
|
+
reconnectAttempts: Number(state.reconnectAttempts) || 0,
|
|
481
|
+
// 会员 + 用量(与企微对齐)
|
|
482
|
+
tier: tierNow(),
|
|
483
|
+
caps: capsNow().slice(),
|
|
484
|
+
capsCount: capsNow().length,
|
|
485
|
+
bindingGated: bindingGatedNowSafe(),
|
|
486
|
+
messagesPerMonth: messageLimitNow(),
|
|
487
|
+
remainingMessages: messageLimitNow() ? Math.max(0, messageLimitNow() - usageThisMonth()) : null,
|
|
488
|
+
usage: { month: membership.usage.month || monthKey(), messages: usageThisMonth() },
|
|
489
|
+
// 会话绑定(与企微对齐;本轮不提供 sessions[] 列表)
|
|
490
|
+
boundSessionId: boundSessionId || "",
|
|
491
|
+
activeSessionId: lastSeenSessionId || ""
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
function writeStatus() {
|
|
495
|
+
if (!statusFile) return;
|
|
496
|
+
try {
|
|
497
|
+
const prev = readJsonFile(statusFile);
|
|
498
|
+
const base = prev && typeof prev === "object" && !Array.isArray(prev) ? prev : {};
|
|
499
|
+
atomicWriteJson(statusFile, {
|
|
500
|
+
...base,
|
|
501
|
+
version: 1,
|
|
502
|
+
updatedAt: Number(now()) || Date.now(),
|
|
503
|
+
dingtalk: { ...(base.dingtalk && typeof base.dingtalk === "object" ? base.dingtalk : {}), ...statusSection() }
|
|
504
|
+
}, { onWarn: (m) => log.warn(m) });
|
|
505
|
+
} catch (e) {
|
|
506
|
+
log.warn(`状态文件写入失败(忽略):${truncateText(e && e.message ? e.message : e, 120)}`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ---------- 连接 / 重连 ----------
|
|
511
|
+
|
|
512
|
+
function setPhase(phase, error) {
|
|
513
|
+
state.phase = phase;
|
|
514
|
+
if (error !== undefined) state.lastError = String(error || "");
|
|
515
|
+
writeStatus();
|
|
516
|
+
}
|
|
517
|
+
function clearReconnect() {
|
|
518
|
+
if (reconnectTimer) { try { clearTimeout(reconnectTimer); } catch { /* 忽略 */ } }
|
|
519
|
+
reconnectTimer = null;
|
|
520
|
+
state.nextRetryInMs = 0;
|
|
521
|
+
}
|
|
522
|
+
function dropSocket() {
|
|
523
|
+
const sock = ws;
|
|
524
|
+
ws = null;
|
|
525
|
+
if (!sock) return;
|
|
526
|
+
try {
|
|
527
|
+
sock.removeAllListeners();
|
|
528
|
+
sock.on("error", () => { /* 关闭途中的错误不冒泡 */ });
|
|
529
|
+
if (typeof sock.terminate === "function" && sock.readyState === 1) sock.terminate();
|
|
530
|
+
else if (typeof sock.close === "function") sock.close();
|
|
531
|
+
} catch { /* 忽略 */ }
|
|
532
|
+
}
|
|
533
|
+
function scheduleReconnect(reason) {
|
|
534
|
+
if (stopped) return;
|
|
535
|
+
if (reconnectTimer) return;
|
|
536
|
+
const attempt = state.reconnectAttempts;
|
|
537
|
+
const base = Math.min(reconnectMaxMs, Math.round(reconnectBaseMs * Math.pow(2, attempt)));
|
|
538
|
+
const jitter = Math.floor(Math.random() * Math.min(1000, base));
|
|
539
|
+
const delay = Math.min(reconnectMaxMs, base + jitter);
|
|
540
|
+
state.reconnectAttempts = attempt + 1;
|
|
541
|
+
state.nextRetryInMs = delay;
|
|
542
|
+
setPhase("reconnecting", state.lastError || `连接断开:${truncateText(reason, 80)}`);
|
|
543
|
+
log.warn(`连接断开(${reason}),${delay}ms 后重新换 ticket 重连(第 ${state.reconnectAttempts} 次)`);
|
|
544
|
+
reconnectTimer = setTimeout(() => {
|
|
545
|
+
reconnectTimer = null;
|
|
546
|
+
state.nextRetryInMs = 0;
|
|
547
|
+
void connect().catch(() => {});
|
|
548
|
+
}, delay);
|
|
549
|
+
if (typeof reconnectTimer.unref === "function") reconnectTimer.unref();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** ① 换 ticket(每次重连都必须重新 open;ticket 一次性 + 90s)。 */
|
|
553
|
+
async function openConnection() {
|
|
554
|
+
state.openCount += 1;
|
|
555
|
+
const body = buildOpenBody({ clientId, clientSecret, subscriptions, ua, localIp: options.localIp });
|
|
556
|
+
let res = null;
|
|
557
|
+
try {
|
|
558
|
+
if (typeof doFetch !== "function") throw new Error("no fetch");
|
|
559
|
+
res = await doFetch(gatewayUrl, {
|
|
560
|
+
method: "POST",
|
|
561
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
562
|
+
body: JSON.stringify(body),
|
|
563
|
+
signal: typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(openTimeoutMs) : undefined
|
|
564
|
+
});
|
|
565
|
+
} catch (e) {
|
|
566
|
+
return { ok: false, network: true, error: `连接网关不可达:${truncateText(e && e.message ? e.message : e, 100)}` };
|
|
567
|
+
}
|
|
568
|
+
const httpStatus = Number(res && res.status) || 0;
|
|
569
|
+
let json = null;
|
|
570
|
+
try { json = res && typeof res.json === "function" ? await res.json() : null; } catch { json = null; }
|
|
571
|
+
if (res && res.ok === false) {
|
|
572
|
+
const credErr = httpStatus >= 400 && httpStatus < 500;
|
|
573
|
+
const msg = (json && (json.message || json.errmsg)) || "";
|
|
574
|
+
return {
|
|
575
|
+
ok: false,
|
|
576
|
+
httpStatus,
|
|
577
|
+
credential: credErr,
|
|
578
|
+
error: `建连网关拒绝(HTTP ${httpStatus})${msg ? `:${truncateText(msg, 80)}` : ""}`
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
const endpoint = String((json && json.endpoint) || "").trim();
|
|
582
|
+
const ticket = String((json && json.ticket) || "").trim();
|
|
583
|
+
if (!endpoint || !ticket) {
|
|
584
|
+
return { ok: false, protocol: true, error: "建连网关响应缺少 endpoint/ticket" };
|
|
585
|
+
}
|
|
586
|
+
state.ticketExpiresAt = now() + TICKET_TTL_MS;
|
|
587
|
+
return { ok: true, endpoint, ticket };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** ② 用 ticket 建 WebSocket(ticket 校验即鉴权;WS 上不再有应用层认证帧)。 */
|
|
591
|
+
async function connect() {
|
|
592
|
+
if (stopped || connecting) return;
|
|
593
|
+
connecting = true;
|
|
594
|
+
clearReconnect();
|
|
595
|
+
try {
|
|
596
|
+
setPhase(state.lastConnectedAt ? "reconnecting" : "connecting");
|
|
597
|
+
const opened = await openConnection();
|
|
598
|
+
if (stopped) return;
|
|
599
|
+
if (!opened.ok) {
|
|
600
|
+
if (opened.credential) {
|
|
601
|
+
// 凭据错(4xx)→ **不重连**,状态给人话(用户需要去改 clientId/clientSecret)
|
|
602
|
+
state.connected = false;
|
|
603
|
+
state.authenticated = false;
|
|
604
|
+
setPhase("failed", `${opened.error};请检查 Client ID / Client Secret(或应用是否已开通 Stream 模式)`);
|
|
605
|
+
log.warn(state.lastError);
|
|
606
|
+
emitter.emit("auth-failed", { httpStatus: opened.httpStatus || 0, message: opened.error, at: now() });
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
scheduleReconnect(opened.error || "open 失败");
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const sep = opened.endpoint.includes("?") ? "&" : "?";
|
|
613
|
+
const url = `${opened.endpoint}${sep}ticket=${encodeURIComponent(opened.ticket)}`;
|
|
614
|
+
let sock;
|
|
615
|
+
try {
|
|
616
|
+
sock = wsFactory(url);
|
|
617
|
+
} catch (e) {
|
|
618
|
+
scheduleReconnect(`ws 建立失败:${truncateText(e && e.message ? e.message : e, 80)}`);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
ws = sock;
|
|
622
|
+
try {
|
|
623
|
+
sock.on("open", () => onSocketOpen());
|
|
624
|
+
sock.on("message", (raw) => onSocketMessage(raw));
|
|
625
|
+
sock.on("error", (e) => log.warn(`WebSocket 异常:${truncateText(e && e.message ? e.message : e, 120)}`));
|
|
626
|
+
sock.on("close", (code, reason) => onSocketClose(code, reason));
|
|
627
|
+
} catch (e) {
|
|
628
|
+
log.warn(`挂监听失败:${truncateText(e && e.message ? e.message : e, 120)}`);
|
|
629
|
+
}
|
|
630
|
+
} finally {
|
|
631
|
+
connecting = false;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function onSocketOpen() {
|
|
636
|
+
if (stopped) return;
|
|
637
|
+
state.connected = true;
|
|
638
|
+
state.authenticated = true; // ticket 校验即鉴权(WS 上无认证帧)
|
|
639
|
+
state.lastConnectedAt = now();
|
|
640
|
+
state.reconnectAttempts = 0;
|
|
641
|
+
setPhase("connected", "");
|
|
642
|
+
log.info("已连上钉钉 Stream 网关(ticket 鉴权完成)");
|
|
643
|
+
emitter.emit("connected", { at: now() });
|
|
644
|
+
// 会员档位:连上后刷一次(不阻塞收帧)
|
|
645
|
+
void refreshTier({ force: true }).catch(() => {});
|
|
646
|
+
if (!tierTimer && capsProvider) {
|
|
647
|
+
tierTimer = setInterval(() => { void refreshTier().catch(() => {}); }, 10 * 60 * 1000);
|
|
648
|
+
if (typeof tierTimer.unref === "function") tierTimer.unref();
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function onSocketClose(code, reason) {
|
|
653
|
+
if (stopped) return;
|
|
654
|
+
const why = `close ${code === undefined ? "?" : code}${reason ? ` ${truncateText(String(reason), 60)}` : ""}`;
|
|
655
|
+
state.connected = false;
|
|
656
|
+
state.authenticated = false;
|
|
657
|
+
scheduleReconnect(why);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ---------- 帧处理(每帧都要 ACK,除 disconnect) ----------
|
|
661
|
+
|
|
662
|
+
function rawSend(obj) {
|
|
663
|
+
const sock = ws;
|
|
664
|
+
if (!sock || sock.readyState !== 1) return false;
|
|
665
|
+
try { sock.send(JSON.stringify(obj)); return true; } catch { return false; }
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function onSocketMessage(raw) {
|
|
669
|
+
const frame = parseFrame(raw);
|
|
670
|
+
if (!frame) { log.warn("收到无法解析的帧(已忽略)"); return; }
|
|
671
|
+
const messageId = String((frame.headers && frame.headers.messageId) || "");
|
|
672
|
+
const topic = String((frame.headers && frame.headers.topic) || "");
|
|
673
|
+
if (frame.type === "SYSTEM") return onSystemFrame({ messageId, topic, data: frame.data });
|
|
674
|
+
if (frame.type === "EVENT") {
|
|
675
|
+
rawSend(buildAck(messageId, EVENT_ACK));
|
|
676
|
+
emitter.emit("event", { topic, data: frame.data, at: now() });
|
|
677
|
+
log.debug(`EVENT topic=${truncateText(topic || "?", 40)}`);
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if (frame.type === "CALLBACK") return onCallbackFrame({ messageId, topic, data: frame.data });
|
|
681
|
+
log.debug(`未处理的帧 type=${truncateText(frame.type || "?", 20)}`);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function onSystemFrame({ messageId, topic, data }) {
|
|
685
|
+
const t = String(topic || "").toLowerCase();
|
|
686
|
+
if (t === "ping") {
|
|
687
|
+
// ⚠️ 官方:data.opaque 原样回传,messageId 必须一致
|
|
688
|
+
const opaque = data && typeof data === "object" ? data.opaque : data;
|
|
689
|
+
rawSend(buildPingAck(messageId, opaque));
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
if (t === "disconnect") {
|
|
693
|
+
// 官方给 10s 宽限;我们主动换新连接(旧 socket 走 close → 由 scheduleReconnect 兜住)。
|
|
694
|
+
log.warn("收到 SYSTEM disconnect,主动换新 ticket 重连");
|
|
695
|
+
dropSocket();
|
|
696
|
+
state.connected = false;
|
|
697
|
+
state.authenticated = false;
|
|
698
|
+
scheduleReconnect("system disconnect");
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
// CONNECTED/REGISTERED/KEEPALIVE/未知:官方协议文档没写,只记 debug、不崩、不重连
|
|
702
|
+
log.debug(`忽略 SYSTEM topic=${truncateText(topic || "?", 40)}`);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function onCallbackFrame({ messageId, topic, data }) {
|
|
706
|
+
if (topic === TOPIC_ROBOT) {
|
|
707
|
+
// 机器人消息:先 ACK(fire-and-forget;钉钉不重推),再异步处理
|
|
708
|
+
rawSend(buildAck(messageId, BOT_CALLBACK_ACK));
|
|
709
|
+
void handleBotMessage(data).catch((e) => log.warn(`处理机器人消息失败(已忽略):${truncateText(e && e.message ? e.message : e, 120)}`));
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (topic === TOPIC_CARD_CALLBACK) {
|
|
713
|
+
// 卡片按钮:本轮卡片拍板后置;仍要 ACK(2s 时限),ACK data 先给空响应(UNVERIFIED,真机核验后替换为卡片更新数据)
|
|
714
|
+
rawSend(buildAck(messageId, BOT_CALLBACK_ACK));
|
|
715
|
+
log.info("收到卡片按钮回调(卡片拍板本轮后置,已 ACK)");
|
|
716
|
+
emitter.emit("card-action", { data, at: now() });
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
// 未知 CALLBACK topic:也要 ACK(否则可能被重推),内容给空响应
|
|
720
|
+
rawSend(buildAck(messageId, BOT_CALLBACK_ACK));
|
|
721
|
+
log.debug(`未处理的 CALLBACK topic=${truncateText(topic || "?", 40)}`);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// ---------- 入站机器人消息 ----------
|
|
725
|
+
|
|
726
|
+
function pruneSeen() {
|
|
727
|
+
const at = now();
|
|
728
|
+
for (const [k, t] of seenMsgs) {
|
|
729
|
+
if (at - t > 10 * 60 * 1000) seenMsgs.delete(k);
|
|
730
|
+
else break;
|
|
731
|
+
}
|
|
732
|
+
while (seenMsgs.size > SEEN_MSG_MAX) {
|
|
733
|
+
const first = seenMsgs.keys().next();
|
|
734
|
+
if (first.done) break;
|
|
735
|
+
seenMsgs.delete(first.value);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** 更新「回复目标」:sessionWebhook 优先(即时回复),同时记住 openapi 需要的 conversation/user。 */
|
|
740
|
+
function bindTarget(info) {
|
|
741
|
+
if (info.conversationId) state.boundConversationId = info.conversationId;
|
|
742
|
+
if (info.senderStaffId) state.boundUserId = info.senderStaffId;
|
|
743
|
+
if (info.chatId && info.chatId !== state.boundChatId) {
|
|
744
|
+
state.boundChatId = info.chatId;
|
|
745
|
+
log.info(`已绑定推送会话 chat=${chatIdHash(info.chatId)}${info.isGroup ? "(群)" : "(单聊)"}`);
|
|
746
|
+
}
|
|
747
|
+
if (info.sessionWebhook) {
|
|
748
|
+
state.boundSessionWebhook = info.sessionWebhook;
|
|
749
|
+
state.boundSessionWebhookExpiredAt = info.sessionWebhookExpiredTime || 0;
|
|
750
|
+
}
|
|
751
|
+
writeStatus();
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async function handleBotMessage(data) {
|
|
755
|
+
const info = normalizeBotMessage(data);
|
|
756
|
+
state.lastMessageAt = now();
|
|
757
|
+
if (info.msgId) {
|
|
758
|
+
pruneSeen();
|
|
759
|
+
if (seenMsgs.has(info.msgId)) { log.debug("重复消息已忽略(msgId 命中)"); return; }
|
|
760
|
+
seenMsgs.set(info.msgId, now());
|
|
761
|
+
}
|
|
762
|
+
state.messages += 1;
|
|
763
|
+
// 安全:allowFrom 白名单(senderStaffId / conversationId;空 = 首次发送者自动绑定)
|
|
764
|
+
if (allowFrom.length && !allowFrom.includes(info.senderStaffId) && !allowFrom.includes(info.conversationId)) {
|
|
765
|
+
log.warn(`拒绝非白名单发送者(from=${chatIdHash(info.senderStaffId) || "?"} chat=${chatIdHash(info.conversationId) || "?"})`);
|
|
766
|
+
reply(info, "你暂时没有这台电脑的远程通道权限。请让电脑那边把你的钉钉 userId 加进白名单。");
|
|
767
|
+
emitter.emit("inbound-rejected", info);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
bindTarget(info);
|
|
771
|
+
writeStatus();
|
|
772
|
+
if (!info.text) {
|
|
773
|
+
log.info(`收到非文本消息(msgtype=${truncateText(info.msgtype || "?", 20)}),未转发`);
|
|
774
|
+
reply(info, "目前只支持文本消息(图片/文件/语音暂不转发)。");
|
|
775
|
+
emitter.emit("inbound-ignored", info);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
emitter.emit("message", info);
|
|
779
|
+
void dispatchInbound(info).catch((e) => log.warn(`入站转发失败(已忽略):${truncateText(e && e.message ? e.message : e, 120)}`));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** 入站 → 会员闸 → 转发到 DSH 会话(尽力、可降级)。 */
|
|
783
|
+
async function dispatchInbound(info) {
|
|
784
|
+
const sub = subscriber;
|
|
785
|
+
const gated = await membershipGate(info);
|
|
786
|
+
if (gated) return;
|
|
787
|
+
const sessionId = await resolveSessionId(sub);
|
|
788
|
+
if (!sub || typeof sub.promptSession !== "function") {
|
|
789
|
+
log.warn("入站消息无法转发:事件订阅器未就绪");
|
|
790
|
+
reply(info, "已收到你的消息,但后台还没连上 DeepSeek Harness 的事件通道,暂时转不过去。请确认电脑端 dsh web 正在运行。");
|
|
791
|
+
emitter.emit("inbound-failed", { ...info, reason: "no_subscriber" });
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (!sessionId) {
|
|
795
|
+
log.warn("入站消息无法转发:还没有可接管的会话");
|
|
796
|
+
reply(info, "已收到,但还没在桌面端选择要接管的会话。请先在电脑的 DeepSeek Harness 里打开一个会话,再发一次。");
|
|
797
|
+
emitter.emit("inbound-failed", { ...info, reason: "no_session" });
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
let result;
|
|
801
|
+
try { result = await sub.promptSession({ sessionId, text: info.text }); } catch (e) {
|
|
802
|
+
result = { ok: false, message: e && e.message ? e.message : String(e) };
|
|
803
|
+
}
|
|
804
|
+
if (result && result.ok) {
|
|
805
|
+
lastInbound = { sessionId, at: now() };
|
|
806
|
+
bumpUsage();
|
|
807
|
+
if (boundSessionId !== sessionId) { boundSessionId = sessionId; writeStatus(); }
|
|
808
|
+
log.info(`入站文本已转给会话 session=${shortSession(sessionId)}`);
|
|
809
|
+
reply(info, `已转给桌面端会话 ✓(${shortSession(sessionId)})`);
|
|
810
|
+
emitter.emit("inbound-routed", { ...info, sessionId });
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
const why = truncateText((result && (result.code || result.message)) || "未知错误", 80);
|
|
814
|
+
log.warn(`入站文本转发失败:${why}`);
|
|
815
|
+
reply(info, `转发失败:${why}。请到电脑上确认会话还在运行。`);
|
|
816
|
+
emitter.emit("inbound-failed", { ...info, sessionId, reason: why });
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async function resolveSessionId(sub) {
|
|
820
|
+
try {
|
|
821
|
+
if (typeof options.sessionIdProvider === "function") {
|
|
822
|
+
const got = String(options.sessionIdProvider() || "");
|
|
823
|
+
if (got) return got;
|
|
824
|
+
}
|
|
825
|
+
} catch { /* 忽略 */ }
|
|
826
|
+
// 配置固定会话是会员能力(sessions);免费档忽略并回退「跟随活跃会话」
|
|
827
|
+
if (configuredSessionId && canCap("sessions")) return configuredSessionId;
|
|
828
|
+
if (lastSeenSessionId) return lastSeenSessionId;
|
|
829
|
+
if (!sub || typeof sub.listSessions !== "function") return "";
|
|
830
|
+
try {
|
|
831
|
+
const r = await sub.listSessions();
|
|
832
|
+
const list = r && r.ok && Array.isArray(r.sessions) ? r.sessions : [];
|
|
833
|
+
const pick = list.find((s) => s && s.running) || list[0];
|
|
834
|
+
return pick && typeof pick.sessionId === "string" ? pick.sessionId : "";
|
|
835
|
+
} catch { return ""; }
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// ---------- 回复(Stream 内不能回;默认 sessionWebhook,可选 openapi) ----------
|
|
839
|
+
|
|
840
|
+
/** 即时回复当前会话(sessionWebhook;不需要 token/权限)。 */
|
|
841
|
+
async function replySessionWebhook(webhookUrl, { title, text } = {}) {
|
|
842
|
+
const url = String(webhookUrl || "").trim();
|
|
843
|
+
if (!url) return { ok: false, reason: "no_webhook" };
|
|
844
|
+
if (typeof doFetch !== "function") return { ok: false, reason: "no_fetch" };
|
|
845
|
+
const formatted = formatForChannel(formatChannel, { title, text: text == null ? "" : String(text) });
|
|
846
|
+
const body = buildSessionWebhookBody({ title: title || "DeepSeek Harness", text: formatted });
|
|
847
|
+
try {
|
|
848
|
+
const res = await doFetch(url, {
|
|
849
|
+
method: "POST",
|
|
850
|
+
headers: { "content-type": "application/json" },
|
|
851
|
+
body: JSON.stringify(body)
|
|
852
|
+
});
|
|
853
|
+
const status = Number(res && res.status) || 0;
|
|
854
|
+
let json = null; try { json = res && typeof res.json === "function" ? await res.json() : null; } catch { json = null; }
|
|
855
|
+
if (!res || res.ok === false) {
|
|
856
|
+
return { ok: false, status, reason: `sessionWebhook HTTP ${status}`, errcode: (json && json.errcode) || 0 };
|
|
857
|
+
}
|
|
858
|
+
return { ok: true, status };
|
|
859
|
+
} catch (e) {
|
|
860
|
+
return { ok: false, reason: truncateText(e && e.message ? e.message : e, 100) };
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** accessToken(仅 openapi 模式;内存缓存,提前 5 分钟过期)。 */
|
|
865
|
+
async function getAccessToken() {
|
|
866
|
+
const at = now();
|
|
867
|
+
if (pendingToken.value && at < pendingToken.expiresAt) return pendingToken.value;
|
|
868
|
+
if (typeof doFetch !== "function") return "";
|
|
869
|
+
try {
|
|
870
|
+
const res = await doFetch(tokenUrl, {
|
|
871
|
+
method: "POST",
|
|
872
|
+
headers: { "content-type": "application/json" },
|
|
873
|
+
body: JSON.stringify({ appKey: clientId, appSecret: clientSecret })
|
|
874
|
+
});
|
|
875
|
+
const json = res && typeof res.json === "function" ? await res.json() : null;
|
|
876
|
+
const token = String((json && (json.accessToken || json.access_token)) || "");
|
|
877
|
+
const expireIn = Number((json && (json.expireIn || json.expires_in)) || 7200);
|
|
878
|
+
if (!res || res.ok === false || !token) return "";
|
|
879
|
+
pendingToken.value = token;
|
|
880
|
+
pendingToken.expiresAt = at + Math.max(60, expireIn - 300) * 1000;
|
|
881
|
+
return token;
|
|
882
|
+
} catch { return ""; }
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** 主动发消息(openapi 模式;标 UNVERIFIED:msgKey/msgParam 形状来自官方文档,未经真机核验)。 */
|
|
886
|
+
async function sendOpenapi(text, { title } = {}) {
|
|
887
|
+
if (!state.authenticated) return { ok: false, reason: "not_connected" };
|
|
888
|
+
const token = await getAccessToken();
|
|
889
|
+
if (!token) return { ok: false, reason: "no_access_token" };
|
|
890
|
+
const formatted = formatForChannel(formatChannel, { title, text: text == null ? "" : String(text) });
|
|
891
|
+
const msgParam = JSON.stringify({ title: truncateText(title || "DeepSeek Harness", 64), text: formatted });
|
|
892
|
+
const isGroup = state.boundConversationId && state.boundChatId === state.boundConversationId;
|
|
893
|
+
const useRobot = robotCode || "";
|
|
894
|
+
if (!useRobot) return { ok: false, reason: "no_robot_code" };
|
|
895
|
+
const url = isGroup ? groupSendUrl : o2oSendUrl;
|
|
896
|
+
const body = isGroup
|
|
897
|
+
? { robotCode: useRobot, openConversationId: state.boundConversationId, msgKey: "sampleMarkdown", msgParam }
|
|
898
|
+
: { robotCode: useRobot, userIds: [state.boundUserId].filter(Boolean), msgKey: "sampleMarkdown", msgParam };
|
|
899
|
+
if (!isGroup && !body.userIds.length) return { ok: false, reason: "no_target_user" };
|
|
900
|
+
try {
|
|
901
|
+
const res = await doFetch(url, {
|
|
902
|
+
method: "POST",
|
|
903
|
+
headers: { "content-type": "application/json", "x-acs-dingtalk-access-token": token },
|
|
904
|
+
body: JSON.stringify(body)
|
|
905
|
+
});
|
|
906
|
+
if (!res || res.ok === false) {
|
|
907
|
+
let json = null; try { json = res && typeof res.json === "function" ? await res.json() : null; } catch { json = null; }
|
|
908
|
+
return { ok: false, status: Number(res && res.status) || 0, reason: truncateText((json && (json.message || json.code)) || "openapi 发送失败", 80) };
|
|
909
|
+
}
|
|
910
|
+
return { ok: true };
|
|
911
|
+
} catch (e) {
|
|
912
|
+
return { ok: false, reason: truncateText(e && e.message ? e.message : e, 100) };
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* 发一条 markdown(通知/回答回传/确认文案统一入口)。
|
|
918
|
+
* 目标选择:① 最近一次入站的 sessionWebhook(未过期,最快、无需 token)
|
|
919
|
+
* ② replyMode=openapi(或 webhook 不可用)且有 robotCode → OpenAPI
|
|
920
|
+
* ③ 都没有 → 记日志跳过(不抛错)
|
|
921
|
+
*/
|
|
922
|
+
async function sendMarkdown(text, opts = {}) {
|
|
923
|
+
const title = opts.title || "";
|
|
924
|
+
const webhook = state.boundSessionWebhook;
|
|
925
|
+
const webhookOk = Boolean(webhook) && (!state.boundSessionWebhookExpiredAt || now() < state.boundSessionWebhookExpiredAt);
|
|
926
|
+
if (replyMode !== "openapi" && webhookOk) {
|
|
927
|
+
const r = await replySessionWebhook(webhook, { title, text });
|
|
928
|
+
if (r.ok) return r;
|
|
929
|
+
log.warn(`sessionWebhook 回复失败(${r.reason}),尝试 OpenAPI 兜底`);
|
|
930
|
+
}
|
|
931
|
+
const fallback = await sendOpenapi(text, { title });
|
|
932
|
+
if (fallback.ok) return fallback;
|
|
933
|
+
const at = now();
|
|
934
|
+
if (at - lastNoTargetLogAt > 10 * 60 * 1000) {
|
|
935
|
+
lastNoTargetLogAt = at;
|
|
936
|
+
log.info(`没有可用的回复通道(sessionWebhook 未就绪 / openapi 未配置):${truncateText(fallback.reason || "?", 60)}`);
|
|
937
|
+
}
|
|
938
|
+
return fallback;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** 回复一句人话:优先 sessionWebhook(即时),失败/缺失时退回主动发。 */
|
|
942
|
+
function reply(info, text) {
|
|
943
|
+
const webhook = info && info.sessionWebhook ? info.sessionWebhook : state.boundSessionWebhook;
|
|
944
|
+
if (webhook) {
|
|
945
|
+
void replySessionWebhook(webhook, { text }).then((r) => {
|
|
946
|
+
if (r.ok) return;
|
|
947
|
+
log.warn(`回复失败(${r.reason}),尝试主动发兜底`);
|
|
948
|
+
void sendMarkdown(text).catch(() => {});
|
|
949
|
+
}).catch(() => {});
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
void sendMarkdown(text).catch(() => {});
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// ---------- DSH 事件订阅(通知 + 回答回传 + 活跃会话) ----------
|
|
956
|
+
|
|
957
|
+
function ensureSubscriber() {
|
|
958
|
+
if (subscriber) { wireSubscriber(); return; }
|
|
959
|
+
if (options.subscriber) { subscriber = options.subscriber; ownsSubscriber = false; }
|
|
960
|
+
else if (typeof options.createSubscriber === "function") { subscriber = options.createSubscriber(); ownsSubscriber = true; }
|
|
961
|
+
else if (options.upstream !== undefined) {
|
|
962
|
+
try {
|
|
963
|
+
subscriber = createEventSubscriber({
|
|
964
|
+
upstream: typeof options.upstream === "function" ? options.upstream : String(options.upstream || ""),
|
|
965
|
+
cookie: typeof options.cookie === "function" ? options.cookie : (options.cookie || ""),
|
|
966
|
+
follow: true,
|
|
967
|
+
log: (level, message) => { if (level === "warn" || level === "error") log.warn(`[events] ${message}`); else log.debug(`[events] ${message}`); }
|
|
968
|
+
});
|
|
969
|
+
ownsSubscriber = true;
|
|
970
|
+
} catch (e) {
|
|
971
|
+
log.warn(`事件订阅器创建失败:${truncateText(e && e.message ? e.message : e, 120)}`);
|
|
972
|
+
subscriber = null;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
wireSubscriber();
|
|
976
|
+
if (subscriber && typeof subscriber.start === "function") {
|
|
977
|
+
try { subscriber.start(); } catch (e) { log.warn(`事件订阅器启动失败:${truncateText(e && e.message ? e.message : e, 120)}`); }
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
function wireSubscriber() {
|
|
981
|
+
const sub = subscriber;
|
|
982
|
+
if (!sub || wired) return;
|
|
983
|
+
wired = true;
|
|
984
|
+
try {
|
|
985
|
+
sub.on("event", (node) => onEventNode(node));
|
|
986
|
+
sub.on("session-state", (info) => {
|
|
987
|
+
if (info && typeof info.sessionId === "string" && info.state === "running") lastSeenSessionId = info.sessionId;
|
|
988
|
+
});
|
|
989
|
+
sub.on("control-activity", (info) => {
|
|
990
|
+
if (info && typeof info.sessionId === "string" && info.sessionId) lastSeenSessionId = info.sessionId;
|
|
991
|
+
});
|
|
992
|
+
sub.on("gap", (info) => log.warn(`事件订阅断线窗口(通知不可补发):${new Date((info && info.from) || Date.now()).toISOString()}`));
|
|
993
|
+
sub.on("auth-error", (info) => log.warn(`事件订阅鉴权失败(${(info && info.surface) || "?"})`));
|
|
994
|
+
sub.on("fault", (info) => {
|
|
995
|
+
const expected = info && info.expected === true;
|
|
996
|
+
const msg = `事件订阅异常 code=${(info && info.code) || "?"}`;
|
|
997
|
+
if (expected) log.debug(msg); else log.warn(`${msg}:${truncateText(info && info.message, 80)}`);
|
|
998
|
+
});
|
|
999
|
+
} catch (e) {
|
|
1000
|
+
log.warn(`挂事件处理器失败:${truncateText(e && e.message ? e.message : e, 120)}`);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function onEventNode(node) {
|
|
1005
|
+
if (!node || typeof node !== "object") return;
|
|
1006
|
+
if (typeof node.sessionId === "string" && node.sessionId) lastSeenSessionId = node.sessionId;
|
|
1007
|
+
if (stopped || !notifyAllowed || !state.authenticated) return;
|
|
1008
|
+
// 回答回传:入站后的同一会话 turn/end → 把 agent 最终回答发回钉钉(与企微同语义)
|
|
1009
|
+
if (shouldRelayAnswer(node)) {
|
|
1010
|
+
const key = notifyKey(node);
|
|
1011
|
+
if (key && !rememberNotifyKey(key)) return;
|
|
1012
|
+
const sid = node.sessionId;
|
|
1013
|
+
lastInbound = { sessionId: "", at: 0 };
|
|
1014
|
+
void relayAnswer(node, sid);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
const markdown = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
|
|
1018
|
+
if (!markdown) return;
|
|
1019
|
+
const key = notifyKey(node);
|
|
1020
|
+
if (key && !rememberNotifyKey(key)) return;
|
|
1021
|
+
if (!notifyRateOk()) { log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)"); return; }
|
|
1022
|
+
void sendMarkdown(markdown).catch(() => {});
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const notifiedKeys = new Map();
|
|
1026
|
+
const notifyTimes = [];
|
|
1027
|
+
function notifyKey(node) {
|
|
1028
|
+
const sid = String(node.sessionId || "");
|
|
1029
|
+
if (node.kind === NODE_KINDS.TURN_END) return `turn:${sid}:${node.turn ?? "?"}:${node.seq ?? "?"}:${node.reason || "?"}`;
|
|
1030
|
+
if (node.kind === NODE_KINDS.APPROVAL_REQUEST || node.kind === NODE_KINDS.USER_QUESTION || node.kind === NODE_KINDS.PLAN_REVIEW) return `approval:${node.eventId || ""}:${sid}`;
|
|
1031
|
+
if (node.kind === NODE_KINDS.SESSION_ERROR) return `error:${sid}:${node.code || node.message || ""}:${node.at || ""}`;
|
|
1032
|
+
return "";
|
|
1033
|
+
}
|
|
1034
|
+
function rememberNotifyKey(key) {
|
|
1035
|
+
const at = now();
|
|
1036
|
+
for (const [k, t] of notifiedKeys) { if (at - t > 10 * 60 * 1000) notifiedKeys.delete(k); else break; }
|
|
1037
|
+
if (notifiedKeys.has(key)) return false;
|
|
1038
|
+
notifiedKeys.set(key, at);
|
|
1039
|
+
if (notifiedKeys.size > 200) { const first = notifiedKeys.keys().next(); if (!first.done) notifiedKeys.delete(first.value); }
|
|
1040
|
+
return true;
|
|
1041
|
+
}
|
|
1042
|
+
function notifyRateOk() {
|
|
1043
|
+
const at = now();
|
|
1044
|
+
while (notifyTimes.length && at - notifyTimes[0] > 60_000) notifyTimes.shift();
|
|
1045
|
+
if (notifyTimes.length >= 10) return false;
|
|
1046
|
+
notifyTimes.push(at);
|
|
1047
|
+
return true;
|
|
1048
|
+
}
|
|
1049
|
+
function shouldRelayAnswer(node) {
|
|
1050
|
+
if (node.kind !== NODE_KINDS.TURN_END) return false;
|
|
1051
|
+
if (node.fromSnapshot === true) return false;
|
|
1052
|
+
if (!lastInbound.sessionId || node.sessionId !== lastInbound.sessionId) return false;
|
|
1053
|
+
if (now() - lastInbound.at >= 10 * 60 * 1000) return false;
|
|
1054
|
+
if (Number.isFinite(node.at) && node.at > 0 && node.at < lastInbound.at - 5000) return false;
|
|
1055
|
+
return true;
|
|
1056
|
+
}
|
|
1057
|
+
async function relayAnswer(node, sessionId) {
|
|
1058
|
+
let text = "";
|
|
1059
|
+
const sub = subscriber;
|
|
1060
|
+
if (sub && typeof sub.lastAssistantText === "function") {
|
|
1061
|
+
try {
|
|
1062
|
+
const r = await sub.lastAssistantText({ sessionId });
|
|
1063
|
+
if (r && r.ok && typeof r.text === "string") text = r.text.trim();
|
|
1064
|
+
} catch { /* 忽略 */ }
|
|
1065
|
+
}
|
|
1066
|
+
const markdown = text || toMarkdownNotification(node, { sessionTitle: options.sessionTitle }) || "";
|
|
1067
|
+
if (!markdown) return;
|
|
1068
|
+
if (!notifyRateOk()) { log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)"); return; }
|
|
1069
|
+
const r = await sendMarkdown(markdown);
|
|
1070
|
+
log.info(`回答回传 chat=${chatIdHash(state.boundChatId) || "?"} len=${markdown.length}${r && r.ok ? "" : ` 发送失败=${(r && r.reason) || "?"}`}`);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// ---------- 对外 API ----------
|
|
1074
|
+
|
|
1075
|
+
function shortSession(sessionId) {
|
|
1076
|
+
const s = String(sessionId || "").replace(/^session-/, "");
|
|
1077
|
+
if (!s) return "";
|
|
1078
|
+
return s.length <= 12 ? s : s.slice(0, 8) + "…";
|
|
1079
|
+
}
|
|
1080
|
+
function status() {
|
|
1081
|
+
return {
|
|
1082
|
+
enabled: Boolean(clientId && clientSecret),
|
|
1083
|
+
running: state.running === true,
|
|
1084
|
+
state: state.phase,
|
|
1085
|
+
connected: state.connected === true,
|
|
1086
|
+
authenticated: state.authenticated === true,
|
|
1087
|
+
lastError: truncateText(maskSecrets(state.lastError, [clientSecret]), 160),
|
|
1088
|
+
lastConnectedAt: Number(state.lastConnectedAt) || 0,
|
|
1089
|
+
lastMessageAt: Number(state.lastMessageAt) || 0,
|
|
1090
|
+
boundChatId: state.boundChatId || "",
|
|
1091
|
+
boundConversationId: state.boundConversationId || "",
|
|
1092
|
+
boundUserId: state.boundUserId || "",
|
|
1093
|
+
ticketExpiresAt: Number(state.ticketExpiresAt) || 0,
|
|
1094
|
+
openCount: Number(state.openCount) || 0,
|
|
1095
|
+
reconnectAttempts: Number(state.reconnectAttempts) || 0,
|
|
1096
|
+
nextRetryInMs: state.nextRetryInMs,
|
|
1097
|
+
subscriptions: subscriptions.map((s) => String(s.topic || "")).filter(Boolean),
|
|
1098
|
+
messages: state.messages,
|
|
1099
|
+
gated: state.gated,
|
|
1100
|
+
tier: tierNow(),
|
|
1101
|
+
caps: capsNow().slice(),
|
|
1102
|
+
bindingGated: bindingGatedNowSafe(),
|
|
1103
|
+
messagesPerMonth: messageLimitNow(),
|
|
1104
|
+
usage: { month: membership.usage.month || monthKey(), messages: usageThisMonth() },
|
|
1105
|
+
sessions: [],
|
|
1106
|
+
activeSessionId: lastSeenSessionId || "",
|
|
1107
|
+
boundSessionId: boundSessionId || "",
|
|
1108
|
+
subscriber: subscriber && typeof subscriber.state === "string" ? subscriber.state : "none"
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
return {
|
|
1113
|
+
start() {
|
|
1114
|
+
if (!stopped) return status();
|
|
1115
|
+
if (!clientId || !clientSecret) {
|
|
1116
|
+
state.lastError = "未配置 Client ID / Client Secret";
|
|
1117
|
+
state.phase = "failed";
|
|
1118
|
+
writeStatus();
|
|
1119
|
+
return status();
|
|
1120
|
+
}
|
|
1121
|
+
stopped = false;
|
|
1122
|
+
state.running = true;
|
|
1123
|
+
state.phase = "connecting";
|
|
1124
|
+
writeStatus();
|
|
1125
|
+
ensureSubscriber();
|
|
1126
|
+
void connect().catch((e) => {
|
|
1127
|
+
log.warn(`连接失败:${truncateText(e && e.message ? e.message : e, 120)}`);
|
|
1128
|
+
scheduleReconnect("connect 异常");
|
|
1129
|
+
});
|
|
1130
|
+
return status();
|
|
1131
|
+
},
|
|
1132
|
+
async stop() {
|
|
1133
|
+
if (stopped) return status();
|
|
1134
|
+
stopped = true;
|
|
1135
|
+
state.running = false;
|
|
1136
|
+
state.connected = false;
|
|
1137
|
+
state.authenticated = false;
|
|
1138
|
+
state.phase = "idle";
|
|
1139
|
+
clearReconnect();
|
|
1140
|
+
if (tierTimer) { try { clearInterval(tierTimer); } catch { /* 忽略 */ } tierTimer = null; }
|
|
1141
|
+
if (ownsSubscriber && subscriber && typeof subscriber.close === "function") {
|
|
1142
|
+
try { subscriber.close(); } catch { /* 忽略 */ }
|
|
1143
|
+
}
|
|
1144
|
+
subscriber = null;
|
|
1145
|
+
wired = false;
|
|
1146
|
+
dropSocket();
|
|
1147
|
+
writeStatus();
|
|
1148
|
+
log.info("钉钉 Stream 长连接已停止");
|
|
1149
|
+
return status();
|
|
1150
|
+
},
|
|
1151
|
+
status,
|
|
1152
|
+
replySessionWebhook,
|
|
1153
|
+
sendMarkdown,
|
|
1154
|
+
getAccessToken,
|
|
1155
|
+
on(event, listener) { emitter.on(event, listener); return () => emitter.off(event, listener); },
|
|
1156
|
+
once(event, listener) { emitter.once(event, listener); return () => emitter.off(event, listener); },
|
|
1157
|
+
off(event, listener) { emitter.off(event, listener); },
|
|
1158
|
+
_logger: log
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function positiveNumber(value, fallback) {
|
|
1163
|
+
const n = Number(value);
|
|
1164
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// ===========================================================================
|
|
1168
|
+
// 3. 控制器:配置读取 + 热更新(bridge 入口,与 wecom 同构)
|
|
1169
|
+
// ===========================================================================
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* 创建钉钉 Stream 通道控制器(bridge 侧入口)。
|
|
1173
|
+
*
|
|
1174
|
+
* 默认关闭:`im-bots.json` 的 `dingtalk.enabled !== true` 或缺 clientId/clientSecret → 不建连。
|
|
1175
|
+
* `fs.watchFile(2s)` 监听配置变化 → 变了才重建(与 wecom 同一个文件,两段互不影响)。
|
|
1176
|
+
* 环境开关:`DSH_IM_BOTS=0` 全关;`DSH_IM_BOT_NOTIFY=0` / `DSH_PUSH_REPORT=0` 只关出站通知。
|
|
1177
|
+
*/
|
|
1178
|
+
export function createDingtalkBotController(options = {}) {
|
|
1179
|
+
const relayDir = String(options.relayDir || "");
|
|
1180
|
+
const env = options.env && typeof options.env === "object" ? options.env : process.env;
|
|
1181
|
+
const now = typeof options.clock === "function" ? options.clock : () => Date.now();
|
|
1182
|
+
const pollMs = positiveNumber(options.configPollMs, CONFIG_POLL_MS);
|
|
1183
|
+
const log = createScopedLogger({ sink: options.log, prefix: "[dingtalk]" });
|
|
1184
|
+
const configFile = path.join(relayDir, IM_BOTS_FILE);
|
|
1185
|
+
const statusFile = path.join(relayDir, IM_BOT_STATUS_FILE);
|
|
1186
|
+
const botFactory = typeof options.createBot === "function" ? options.createBot : createDingtalkBot;
|
|
1187
|
+
const state = { started: false, enabled: false, configFound: false, reason: "not_started", lastError: "" };
|
|
1188
|
+
let bot = null;
|
|
1189
|
+
let currentDigest = "";
|
|
1190
|
+
let listener = null;
|
|
1191
|
+
let reloading = false;
|
|
1192
|
+
|
|
1193
|
+
function notifyAllowed() {
|
|
1194
|
+
if (String(env.DSH_IM_BOT_NOTIFY || "") === "0") return false;
|
|
1195
|
+
if (String(env.DSH_PUSH_REPORT || "") === "0") return false;
|
|
1196
|
+
return options.notify !== false;
|
|
1197
|
+
}
|
|
1198
|
+
function hardDisabled() { return String(env.DSH_IM_BOTS || "") === "0"; }
|
|
1199
|
+
function digestOf(cfg) {
|
|
1200
|
+
if (!cfg) return "missing";
|
|
1201
|
+
const secretHash = cfg.clientSecret ? crypto.createHash("sha1").update(String(cfg.clientSecret)).digest("hex") : "";
|
|
1202
|
+
return JSON.stringify([cfg.enabled, cfg.clientId || "", secretHash, cfg.robotCode || "", cfg.replyMode || "", cfg.sessionId || "", cfg.sessionTitle || "", cfg.allowFrom || [], notifyAllowed()]);
|
|
1203
|
+
}
|
|
1204
|
+
function writeDisabledStatus(reason) {
|
|
1205
|
+
if (!relayDir) return;
|
|
1206
|
+
try {
|
|
1207
|
+
const prev = readJsonFile(statusFile);
|
|
1208
|
+
const base = prev && typeof prev === "object" && !Array.isArray(prev) ? prev : {};
|
|
1209
|
+
atomicWriteJson(statusFile, {
|
|
1210
|
+
...base,
|
|
1211
|
+
version: 1,
|
|
1212
|
+
updatedAt: Number(now()) || Date.now(),
|
|
1213
|
+
dingtalk: {
|
|
1214
|
+
...(base.dingtalk && typeof base.dingtalk === "object" ? base.dingtalk : {}),
|
|
1215
|
+
enabled: false, state: "idle", connected: false, authenticated: false,
|
|
1216
|
+
lastError: truncateText(reason, 160)
|
|
1217
|
+
}
|
|
1218
|
+
}, { onWarn: (m) => log.warn(m) });
|
|
1219
|
+
} catch (e) { log.warn(`状态文件写入失败(忽略):${truncateText(e && e.message ? e.message : e, 120)}`); }
|
|
1220
|
+
}
|
|
1221
|
+
async function stopBot() {
|
|
1222
|
+
const b = bot;
|
|
1223
|
+
bot = null;
|
|
1224
|
+
if (!b) return;
|
|
1225
|
+
try { await b.stop(); } catch (e) { log.warn(`停止钉钉连接失败(忽略):${truncateText(e && e.message ? e.message : e, 120)}`); }
|
|
1226
|
+
}
|
|
1227
|
+
async function applyConfig() {
|
|
1228
|
+
if (reloading) return;
|
|
1229
|
+
reloading = true;
|
|
1230
|
+
try {
|
|
1231
|
+
if (hardDisabled()) { state.enabled = false; state.reason = "disabled_by_env"; await stopBot(); return; }
|
|
1232
|
+
const cfg = readDingtalkConfig(relayDir);
|
|
1233
|
+
state.configFound = cfg !== null;
|
|
1234
|
+
const digest = digestOf(cfg);
|
|
1235
|
+
if (digest === currentDigest) return;
|
|
1236
|
+
currentDigest = digest;
|
|
1237
|
+
await stopBot();
|
|
1238
|
+
if (!cfg) { state.enabled = false; state.reason = "no_config"; log.info("未找到 im-bots.json,钉钉通道不启动"); return; }
|
|
1239
|
+
if (!cfg.enabled) { state.enabled = false; state.reason = "disabled"; log.info("im-bots.json 里 dingtalk.enabled=false"); writeDisabledStatus("未启用(配置里 dingtalk.enabled=false)"); return; }
|
|
1240
|
+
if (!cfg.clientId || !cfg.clientSecret) {
|
|
1241
|
+
state.enabled = false; state.reason = "missing_credentials";
|
|
1242
|
+
log.warn("im-bots.json 里缺少 clientId/clientSecret,钉钉通道不启动");
|
|
1243
|
+
writeDisabledStatus("缺少 Client ID / Client Secret(请在插件「钉钉机器人」里填写)");
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
const botOptions = {
|
|
1247
|
+
clientId: cfg.clientId,
|
|
1248
|
+
clientSecret: cfg.clientSecret,
|
|
1249
|
+
robotCode: cfg.robotCode,
|
|
1250
|
+
replyMode: cfg.replyMode,
|
|
1251
|
+
gatewayUrl: cfg.streamUrl || undefined,
|
|
1252
|
+
relayDir,
|
|
1253
|
+
allowFrom: cfg.allowFrom,
|
|
1254
|
+
configuredSessionId: cfg.sessionId,
|
|
1255
|
+
configuredSessionTitle: cfg.sessionTitle,
|
|
1256
|
+
capsProvider: typeof options.capsProvider === "function" ? options.capsProvider : undefined,
|
|
1257
|
+
appUrl: options.appUrl,
|
|
1258
|
+
tier: options.tier,
|
|
1259
|
+
notify: notifyAllowed(),
|
|
1260
|
+
logger: options.log,
|
|
1261
|
+
clock: options.clock,
|
|
1262
|
+
...(options.botOptions && typeof options.botOptions === "object" ? options.botOptions : {})
|
|
1263
|
+
};
|
|
1264
|
+
if (cfg.sessionWebhook) botOptions.sessionWebhook = cfg.sessionWebhook;
|
|
1265
|
+
bot = botFactory(botOptions);
|
|
1266
|
+
if (!bot || typeof bot.start !== "function") {
|
|
1267
|
+
state.enabled = false; state.reason = "bot_factory_invalid"; state.lastError = "bot 工厂返回无效对象"; return;
|
|
1268
|
+
}
|
|
1269
|
+
state.enabled = true;
|
|
1270
|
+
state.reason = "started";
|
|
1271
|
+
state.lastError = "";
|
|
1272
|
+
bot.start();
|
|
1273
|
+
log.info(`钉钉 Stream 通道已启动(replyMode=${cfg.replyMode},通知=${notifyAllowed() ? "开" : "关"})`);
|
|
1274
|
+
} catch (e) {
|
|
1275
|
+
state.enabled = false; state.reason = "apply_failed";
|
|
1276
|
+
state.lastError = truncateText(e && e.message ? e.message : e, 160);
|
|
1277
|
+
log.warn(`应用钉钉配置失败(忽略):${state.lastError}`);
|
|
1278
|
+
} finally { reloading = false; }
|
|
1279
|
+
}
|
|
1280
|
+
function watchConfig() {
|
|
1281
|
+
if (listener) return;
|
|
1282
|
+
listener = () => { void applyConfig(); };
|
|
1283
|
+
try { fs.watchFile(configFile, { interval: pollMs }, listener); }
|
|
1284
|
+
catch (e) { log.warn(`监听 im-bots.json 失败:${truncateText(e && e.message ? e.message : e, 120)}`); listener = null; }
|
|
1285
|
+
}
|
|
1286
|
+
function unwatchConfig() {
|
|
1287
|
+
if (!listener) return;
|
|
1288
|
+
try { fs.unwatchFile(configFile, listener); } catch { /* 忽略 */ }
|
|
1289
|
+
listener = null;
|
|
1290
|
+
}
|
|
1291
|
+
function statusSnapshot() {
|
|
1292
|
+
const bs = bot && typeof bot.status === "function" ? bot.status() : null;
|
|
1293
|
+
return {
|
|
1294
|
+
started: state.started, enabled: state.enabled, configFound: state.configFound,
|
|
1295
|
+
reason: state.reason, configPath: configFile, statusPath: statusFile,
|
|
1296
|
+
lastError: state.lastError || (bs && bs.lastError) || "", bot: bs
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
return {
|
|
1300
|
+
async start() {
|
|
1301
|
+
if (state.started) return statusSnapshot();
|
|
1302
|
+
state.started = true;
|
|
1303
|
+
if (hardDisabled()) { state.reason = "disabled_by_env"; log.info("钉钉通道已被 DSH_IM_BOTS=0 关闭"); return statusSnapshot(); }
|
|
1304
|
+
if (!relayDir) { state.reason = "no_relay_dir"; log.warn("缺少 relayDir,钉钉通道不启动"); return statusSnapshot(); }
|
|
1305
|
+
await applyConfig();
|
|
1306
|
+
watchConfig();
|
|
1307
|
+
return statusSnapshot();
|
|
1308
|
+
},
|
|
1309
|
+
async stop() {
|
|
1310
|
+
unwatchConfig();
|
|
1311
|
+
state.started = false; state.enabled = false; state.reason = "stopped";
|
|
1312
|
+
await stopBot();
|
|
1313
|
+
return statusSnapshot();
|
|
1314
|
+
},
|
|
1315
|
+
status: statusSnapshot,
|
|
1316
|
+
_bot() { return bot; }
|
|
1317
|
+
};
|
|
1318
|
+
}
|