@hedeqiang/dsh-nl2sql 0.10.0 → 0.11.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.
@@ -0,0 +1,574 @@
1
+ /**
2
+ * DingTalk 钉钉 channel adapter (platform 'dingtalk').
3
+ *
4
+ * Kinds:
5
+ * dingtalk_webhook 自定义机器人 webhook(可选加签)
6
+ * dingtalk_app 企业内部应用机器人 · Stream 模式
7
+ *
8
+ * Official documentation followed (read 2026-11; open.dingtalk.com pages are JS-rendered, the same
9
+ * official docs are served as Markdown by the DingTalk Help Center):
10
+ * - 自定义机器人发送群消息: https://open.dingtalk.com/document/orgapp/custom-robots-send-group-messages
11
+ * (https://help.dingtalk.io/zh/open/development/custom-robots-send-group-messages.md)
12
+ * POST https://oapi.dingtalk.com/robot/send?access_token=…[&timestamp=&sign=]; msgtype text/markdown
13
+ * (title + text)/actionCard/link/feedCard; at.isAtAll; 20 msgs/min per robot (then throttled 10 min);
14
+ * errcode 310000 (keywords / invalid timestamp / sign not match / ip whitelist), 400101 token missing,
15
+ * 400102 robot disabled, 410100 too fast, 430101-430104 content blocked.
16
+ * - 自定义机器人安全设置(加签): https://open.dingtalk.com/document/robots/customize-robot-security-settings
17
+ * (https://help.dingtalk.io/zh/open/dingstart/customize-robot-security-settings.md)
18
+ * sign = urlEncode(base64(HMAC(SHA-256)(key=secret, msg=timestamp + "\n" + secret))), timestamp in ms, ±1 h.
19
+ * - 消息类型和数据格式: https://help.dingtalk.io/zh/open/development/robot-message-types-and-data-format.md
20
+ * Webhook markdown subset: headings, quote, bold, italic, links, images (URL), lists — NO tables;
21
+ * webhook cannot send image messages; API msgKeys sampleText / sampleMarkdown {title,text} / sampleActionCard…
22
+ * - 服务端 Stream 模式: https://open.dingtalk.com/document/resourcedownload/introduction-to-stream-mode
23
+ * topic /v1.0/im/bot/messages/get, card callback topic /v1.0/card/instances/callback
24
+ * - Stream 协议: https://open.dingtalk.com/document/direction/stream-mode-protocol-access-description
25
+ * (https://github.com/open-dingtalk/developerpedia/blob/main/docs/learn/stream/protocol.md)
26
+ * POST https://api.dingtalk.com/v1.0/gateway/connections/open {clientId, clientSecret, subscriptions, ua}
27
+ * → {endpoint, ticket} (ticket 90 s, single use); WebSocket `${endpoint}?ticket=…`;
28
+ * down frames {specVersion, type SYSTEM|EVENT|CALLBACK, headers{topic,messageId,…}, data: json string};
29
+ * ack {code:200, headers:{contentType:'application/json', messageId}, message:'OK', data:'{"response":null}'};
30
+ * SYSTEM ping → echo data (opaque); SYSTEM disconnect → server closes after 10 s → reconnect with new ticket.
31
+ * - Official Node SDK dingtalk-stream (https://github.com/open-dingtalk/dingtalk-stream-sdk-nodejs) was READ
32
+ * (not used) to confirm the same frame handling and exponential back-off with jitter.
33
+ * - 机器人接收消息 (fields): https://open.dingtalk.com/document/orgapp/receive-message
34
+ * (https://help.dingtalk.io/open/development/receive-message.md) conversationType "1" single / "2" group,
35
+ * isInAtList, senderStaffId, senderNick, sessionWebhook + sessionWebhookExpiredTime, robotCode, text.content.
36
+ * - 机器人回复/发送消息: https://help.dingtalk.io/zh/open/dingstart/robot-reply-and-send-messages.md
37
+ * (reply via sessionWebhook with the webhook message format)
38
+ * - 机器人发送群聊消息: https://help.dingtalk.io/zh/open/development/the-robot-sends-a-group-message.md
39
+ * POST https://api.dingtalk.com/v1.0/robot/groupMessages/send, header x-acs-dingtalk-access-token,
40
+ * {msgParam (json string ≤ 15000 bytes), msgKey, openConversationId, robotCode} → {processQueryKey}
41
+ * - 批量发送单聊消息: https://help.dingtalk.io/zh/open/development/chatbots-send-one-on-one-chat-messages-in-batches.md
42
+ * POST /v1.0/robot/oToMessages/batchSend {robotCode, userIds ≤ 20, msgKey, msgParam}
43
+ * - 获取企业内部应用的accessToken: https://help.dingtalk.io/zh/open/development/obtain-the-access-token-of-an-internal-app.md
44
+ * POST https://api.dingtalk.com/v1.0/oauth2/accessToken {appKey, appSecret} → {accessToken, expireIn: 7200}
45
+ *
46
+ * Not implemented: interactive cards (need a card template + /v1.0/card/instances/callback handling) →
47
+ * buttons: false. Charts: webhook cannot upload images → tables as text (DingTalk markdown has no tables).
48
+ */
49
+ import { createHmac } from 'node:crypto'
50
+ import { ChannelError, postJson, request } from './http.js'
51
+ import { resultMeta, isKpi, kpiText, fmtNum, cellText, toPlainMarkdown, mentionsAll, splitText, MD_TABLE, parseMdTable } from './message.js'
52
+
53
+ export const OAPI = 'https://oapi.dingtalk.com'
54
+ export const API = 'https://api.dingtalk.com'
55
+ export const BOT_TOPIC = '/v1.0/im/bot/messages/get'
56
+ const MD_LIMIT = 15000 // msgParam limit for the robot API; webhook markdown has no documented limit → same budget
57
+ const UA = 'nl2sql-dsh/1.0'
58
+
59
+ const cfg = (channel, key) => channel?.[key] ?? channel?.config?.[key]
60
+
61
+ // --------------------------------------------------------------- signing
62
+
63
+ /** DingTalk custom robot signature: urlEncode(base64(HMAC(SHA-256)(secret, `${timestamp}\n${secret}`))). */
64
+ export function dingSign(secret, timestamp) {
65
+ const b64 = createHmac('sha256', secret).update(`${timestamp}\n${secret}`, 'utf8').digest('base64')
66
+ return encodeURIComponent(b64)
67
+ }
68
+
69
+ /** Webhook URL with timestamp + sign appended when a secret is configured. */
70
+ export function signedUrl(url, secret, now = Date.now()) {
71
+ if (!secret) return url
72
+ const ts = String(now)
73
+ return `${url}&timestamp=${ts}&sign=${dingSign(secret, ts)}`
74
+ }
75
+
76
+ // ---------------------------------------------------------------- errors
77
+
78
+ const WEBHOOK_ERRORS = {
79
+ 310000: null, // decided by message
80
+ 400013: ['channel/rejected', '群已被解散'],
81
+ 400101: ['channel/auth', 'access_token 不存在:请检查 Webhook 地址是否完整'],
82
+ 400102: ['channel/rejected', '机器人已停用,请联系群管理员启用'],
83
+ 400105: ['channel/rejected', '不支持的消息类型'],
84
+ 400106: ['channel/rejected', '机器人不存在:请确认机器人仍在群中'],
85
+ 410100: ['channel/rate-limit', '发送太快被限流:每个机器人每分钟最多 20 条,超出后限流 10 分钟'],
86
+ 430101: ['channel/rejected', '消息含有不安全的外链'],
87
+ 430102: ['channel/rejected', '消息含有不合适的文本'],
88
+ 430103: ['channel/rejected', '消息含有不合适的图片'],
89
+ 430104: ['channel/rejected', '消息含有不合适的内容'],
90
+ 40035: ['channel/rejected', '缺少消息参数'],
91
+ 43004: ['channel/rejected', '无效的 Content-Type'],
92
+ }
93
+
94
+ export function dingWebhookError(json, status) {
95
+ const code = Number(json?.errcode)
96
+ const msg = String(json?.errmsg ?? json?.raw ?? '').slice(0, 300)
97
+ let err
98
+ if (code === 310000) {
99
+ if (/keyword|关键词/i.test(msg)) err = new ChannelError('channel/dingtalk-keyword', '关键词校验失败:消息中不包含机器人安全设置里的自定义关键词(可在渠道标题中加入该关键词,或改用加签)')
100
+ else if (/sign|签名/i.test(msg)) err = new ChannelError('channel/dingtalk-sign', '加签校验失败:签名不匹配,请检查加签密钥(SEC 开头)是否正确')
101
+ else if (/timestamp/i.test(msg)) err = new ChannelError('channel/dingtalk-sign', '加签校验失败:timestamp 无效,请检查本机时间是否准确(误差需小于 1 小时)')
102
+ else if (/ip|whitelist|白名单/i.test(msg)) err = new ChannelError('channel/rejected', 'IP 地址不在机器人安全设置的白名单中')
103
+ else err = new ChannelError('channel/rejected', `钉钉安全设置校验失败:${msg}`)
104
+ } else if (WEBHOOK_ERRORS[code]) {
105
+ err = new ChannelError(...WEBHOOK_ERRORS[code])
106
+ } else if (status >= 400 && Number.isNaN(code)) {
107
+ err = new ChannelError('channel/network', `钉钉接口返回 HTTP ${status}`)
108
+ } else {
109
+ err = new ChannelError('channel/rejected', `钉钉返回错误(errcode ${json?.errcode ?? '?'}):${msg || '未知错误'}`)
110
+ }
111
+ err.platformCode = json?.errcode
112
+ return err
113
+ }
114
+
115
+ const API_ERRORS = {
116
+ invalidClientIdOrSecret: ['channel/auth', 'Client ID(AppKey)或 Client Secret(AppSecret)不正确'],
117
+ 'invalidParameter.robotCode.auth': ['channel/auth', 'AppKey 与机器人 robotCode 不匹配,请使用机器人所属应用的凭证'],
118
+ 'invalidParameter.robotCode.notExsit': ['channel/config', '机器人不存在,请检查 robotCode'],
119
+ 'invalidParameter.robotCode.invalid': ['channel/config', '无效的 robotCode'],
120
+ 'invalidParameter.robotCode.empty': ['channel/rejected', '机器人不在该群中(robotCode 无效)'],
121
+ 'invalid.openConversationId': ['channel/config', '无效的群会话 ID(openConversationId)'],
122
+ 'miss.openConversationId': ['channel/config', '缺少群会话 ID(openConversationId)'],
123
+ 'group.disbanded': ['channel/rejected', '群已解散'],
124
+ 'bot.stopped': ['channel/rejected', '群机器人已停用'],
125
+ 'send.too.fast': ['channel/rate-limit', '发送过快被钉钉限流,请稍后再试'],
126
+ 'send.byToken.tooFast': ['channel/rate-limit', '发送过快被钉钉限流,请稍后再试'],
127
+ 'too.many.group': ['channel/rate-limit', '发送频繁被限流,请稍后再试'],
128
+ 'too.many.people': ['channel/rate-limit', '发送频繁被限流,请稍后再试'],
129
+ 'invalidParameter.msgParam.tooLong': ['channel/rejected', '消息内容太长'],
130
+ 'conversation.org.notMatch': ['channel/rejected', '会话不属于该企业,或应用无权操作该会话'],
131
+ 'resource.unavailable': ['channel/rejected', '资源不可用:机器人可能已停用或未添加到该群'],
132
+ 'session.expired': ['channel/rejected', '会话 Webhook 已过期'],
133
+ 'robot.oto.notExist': ['channel/rejected', '该用户与机器人之间没有单聊会话'],
134
+ Forbidden: ['channel/auth', '应用缺少「企业内机器人发送消息」权限(qyapi_robot_sendmsg)'],
135
+ 'Forbidden.AccessDenied.AccessTokenPermissionDenied': ['channel/auth', '应用缺少所需接口权限(如 qyapi_robot_sendmsg),请在开发者后台 → 权限管理中申请'],
136
+ InvalidAuthentication: ['channel/auth', 'accessToken 无效或已过期'],
137
+ }
138
+
139
+ export function dingApiError(json, status) {
140
+ const code = json?.code ?? json?.errcode
141
+ const msg = String(json?.message ?? json?.errmsg ?? json?.raw ?? '').slice(0, 300)
142
+ const known = API_ERRORS[code]
143
+ let err
144
+ if (known) err = new ChannelError(...known)
145
+ else if (status === 401 || status === 403) err = new ChannelError('channel/auth', `钉钉鉴权失败:${msg || code || status}`)
146
+ else if (status === 429) err = new ChannelError('channel/rate-limit', '钉钉接口限流,请稍后再试')
147
+ else err = new ChannelError('channel/rejected', `钉钉接口错误(${code ?? status}):${msg || '未知错误'}`)
148
+ err.platformCode = code
149
+ err.status = status
150
+ return err
151
+ }
152
+
153
+ // ------------------------------------------------------------- rendering
154
+
155
+ /** A result as text lines (DingTalk markdown has no tables). */
156
+ export function resultAsDingText(r, maxRows = 10, maxCols = 6) {
157
+ const cols = r.columns.slice(0, maxCols)
158
+ const rows = r.rows.slice(0, maxRows)
159
+ const cell = (c, v) => (c.kind === 'number' ? fmtNum(v) : cellText(v, 30)).replace(/\|/g, '|')
160
+ const out = [`**${cols.map(c => c.name).join(' | ')}**`, '', ...rows.map((row, i) => `${i + 1}. ${cols.map((c, j) => cell(c, row[j])).join(' | ')}`)]
161
+ const total = r.rowCount ?? r.rows.length
162
+ if (total > rows.length) out.push('', `> 共 ${total} 行,仅显示前 ${rows.length} 行;完整数据可在数据问答页面查看或导出 Excel`)
163
+ return out.join('\n')
164
+ }
165
+
166
+ /** Markdown with tables → markdown without tables (rows become numbered "a | b" lines). */
167
+ export function stripTables(md) {
168
+ return String(md ?? '').replace(MD_TABLE, (m, lead, block) => {
169
+ const t = parseMdTable(block)
170
+ return `${lead}${resultAsDingText({ ...t, rowCount: t.rows.length }, 50, 8)}\n`
171
+ })
172
+ }
173
+
174
+ /** Lark-flavoured Markdown → DingTalk markdown subset. */
175
+ export function toDingMarkdown(md) {
176
+ return stripTables(toPlainMarkdown(md, { mentionAll: '@所有人' }))
177
+ }
178
+
179
+ /** OutboundMessage → { title, text } for msgtype markdown / sampleMarkdown. */
180
+ export function renderMarkdown(message, { maxRows = 10 } = {}) {
181
+ const parts = []
182
+ if (message.title) parts.push(`### ${message.title}`)
183
+ if (message.text) parts.push(toDingMarkdown(message.text))
184
+ for (const r of (message.results ?? []).slice(0, 10)) {
185
+ const lines = [`#### ${r.title || '查询结果'}`, `> ${resultMeta(r)}`, '']
186
+ if (r.error) lines.push(r.error)
187
+ else if (!r.rows?.length) lines.push('无数据')
188
+ else if (isKpi(r)) lines.push(kpiText(r, { separator: '\n\n' }))
189
+ else lines.push(resultAsDingText(r, maxRows))
190
+ if (r.sql && message.includeSql) lines.push('', '```sql\n' + r.sql.slice(0, 1500) + '\n```')
191
+ parts.push(lines.join('\n'))
192
+ }
193
+ if (message.footer) parts.push(toDingMarkdown(message.footer))
194
+ const all = message.mentionAll || mentionsAll(message.text) || mentionsAll(message.footer)
195
+ if (all && !parts.some(p => p.includes('@所有人'))) parts.push('@所有人')
196
+ const title = String(message.title || '数据问答').slice(0, 60)
197
+ return { title, chunks: splitText(parts.join('\n\n'), MD_LIMIT), atAll: Boolean(all) }
198
+ }
199
+
200
+ // --------------------------------------------------------------- webhook
201
+
202
+ export const WEBHOOK_RE = /^https:\/\/oapi\.dingtalk\.(com|io)\/robot\/send\?access_token=[\w-]{16,}$/
203
+
204
+ async function postWebhook(url, body, ctx) {
205
+ const { status, json } = await postJson(url, body, {}, { label: '钉钉', fetch: ctx.fetch, timeoutMs: 15000 })
206
+ if (Number(json?.errcode) !== 0) throw dingWebhookError(json, status)
207
+ return json
208
+ }
209
+
210
+ function webhookBase(channel, secrets) {
211
+ const url = secrets?.webhookUrl
212
+ if (!url) throw new ChannelError('channel/config', '缺少 Webhook 地址')
213
+ const override = cfg(channel, 'apiBase')
214
+ return override ? url.replace(/^https:\/\/oapi\.dingtalk\.(com|io)/, String(override).replace(/\/+$/, '')) : url
215
+ }
216
+
217
+ async function sendWebhook(channel, secrets, message, ctx = {}) {
218
+ const base = webhookBase(channel, secrets)
219
+ const { title, chunks, atAll } = renderMarkdown(message)
220
+ for (let i = 0; i < chunks.length; i++) {
221
+ await postWebhook(signedUrl(base, secrets.signSecret), {
222
+ msgtype: 'markdown',
223
+ markdown: { title: i ? `${title}(续)` : title, text: chunks[i] },
224
+ at: { isAtAll: atAll && i === chunks.length - 1 },
225
+ }, ctx)
226
+ }
227
+ return { images: 0 }
228
+ }
229
+
230
+ function validateWebhook(input = {}, existing = {}) {
231
+ const given = input.secrets ?? input
232
+ const secrets = {}
233
+ const url = String(given.webhookUrl ?? '').trim()
234
+ if (url) {
235
+ if (!WEBHOOK_RE.test(url)) throw new ChannelError('channel/config', 'Webhook 地址格式不正确,应为 https://oapi.dingtalk.com/robot/send?access_token=…(群设置 → 机器人 → 自定义机器人中复制)', { field: 'webhookUrl' })
236
+ secrets.webhookUrl = url
237
+ } else if (!existing?.secrets?.webhookUrl) {
238
+ throw new ChannelError('channel/config', '请填写 Webhook 地址', { field: 'webhookUrl' })
239
+ }
240
+ if (given.signSecret !== undefined && given.signSecret !== null) {
241
+ const s = String(given.signSecret).trim()
242
+ if (s && !/^SEC[0-9a-f]{20,}$/i.test(s)) throw new ChannelError('channel/config', '加签密钥格式不正确,应为机器人安全设置中以 SEC 开头的字符串', { field: 'signSecret' })
243
+ if (s) secrets.signSecret = s
244
+ }
245
+ return { config: {}, secrets }
246
+ }
247
+
248
+ // ------------------------------------------------------------- app (API)
249
+
250
+ const tokenCache = new Map()
251
+
252
+ export function clearTokenCache() { tokenCache.clear() }
253
+
254
+ async function accessToken(channel, secrets, ctx = {}) {
255
+ const appKey = cfg(channel, 'clientId')
256
+ if (!appKey || !secrets?.clientSecret) throw new ChannelError('channel/config', '缺少 Client ID 或 Client Secret')
257
+ const base = String(cfg(channel, 'apiBase') || API).replace(/\/+$/, '')
258
+ const key = `${base}|${appKey}`
259
+ const hit = tokenCache.get(key)
260
+ if (hit && hit.expires > Date.now()) return hit.token
261
+ const { status, json } = await postJson(`${base}/v1.0/oauth2/accessToken`, { appKey, appSecret: secrets.clientSecret }, {}, { label: '钉钉', fetch: ctx.fetch })
262
+ if (!json?.accessToken) throw dingApiError(json, status)
263
+ const ttl = Number(json.expireIn ?? 7200)
264
+ if (ttl > 120) tokenCache.set(key, { token: json.accessToken, expires: Date.now() + (ttl - 300) * 1000 })
265
+ return json.accessToken
266
+ }
267
+
268
+ async function callRobotApi(channel, secrets, path, body, ctx = {}) {
269
+ const base = String(cfg(channel, 'apiBase') || API).replace(/\/+$/, '')
270
+ let token = await accessToken(channel, secrets, ctx)
271
+ let res = await postJson(`${base}${path}`, body, { 'x-acs-dingtalk-access-token': token }, { label: '钉钉', fetch: ctx.fetch })
272
+ if (res.status === 401 || res.json?.code === 'InvalidAuthentication') {
273
+ clearTokenCache()
274
+ token = await accessToken(channel, secrets, ctx)
275
+ res = await postJson(`${base}${path}`, body, { 'x-acs-dingtalk-access-token': token }, { label: '钉钉', fetch: ctx.fetch })
276
+ }
277
+ if (res.status >= 300 || res.json?.code) throw dingApiError(res.json, res.status)
278
+ return res.json
279
+ }
280
+
281
+ async function sendApp(channel, secrets, message, ctx = {}) {
282
+ const target = cfg(channel, 'openConversationId')
283
+ const userId = cfg(channel, 'userId')
284
+ if (!target && !userId) throw new ChannelError('channel/config', '缺少推送目标:请填写群会话 ID(openConversationId)或成员 userId')
285
+ const robotCode = cfg(channel, 'robotCode') || cfg(channel, 'clientId')
286
+ const { title, chunks } = renderMarkdown(message)
287
+ let messageId
288
+ for (let i = 0; i < chunks.length; i++) {
289
+ const msgParam = JSON.stringify({ title: i ? `${title}(续)` : title, text: chunks[i] })
290
+ const json = target
291
+ ? await callRobotApi(channel, secrets, '/v1.0/robot/groupMessages/send', { msgParam, msgKey: 'sampleMarkdown', openConversationId: target, robotCode }, ctx)
292
+ : await callRobotApi(channel, secrets, '/v1.0/robot/oToMessages/batchSend', { robotCode, userIds: [userId], msgKey: 'sampleMarkdown', msgParam }, ctx)
293
+ messageId ??= json?.processQueryKey
294
+ }
295
+ return { messageId, images: 0 }
296
+ }
297
+
298
+ async function testApp(channel, secrets, ctx = {}) {
299
+ clearTokenCache()
300
+ await accessToken(channel, secrets, ctx)
301
+ return { ok: true, detail: '已获取 accessToken:Client ID / Client Secret 有效' }
302
+ }
303
+
304
+ function validateApp(input = {}, existing = {}) {
305
+ const config = input.config ?? input
306
+ const given = input.secrets ?? input
307
+ const out = {}
308
+ const clientId = String(config.clientId ?? '').trim()
309
+ if (!clientId) throw new ChannelError('channel/config', '请填写 Client ID(原 AppKey)', { field: 'clientId' })
310
+ if (!/^[\w-]{8,64}$/.test(clientId)) throw new ChannelError('channel/config', 'Client ID 格式不正确(开发者后台 → 应用 → 凭证与基础信息)', { field: 'clientId' })
311
+ out.clientId = clientId
312
+ const robotCode = String(config.robotCode ?? '').trim()
313
+ if (robotCode) {
314
+ if (!/^[\w-]{6,64}$/.test(robotCode)) throw new ChannelError('channel/config', 'robotCode 格式不正确', { field: 'robotCode' })
315
+ out.robotCode = robotCode
316
+ }
317
+ const conv = String(config.openConversationId ?? '').trim()
318
+ if (conv) {
319
+ if (!/^cid[\w+/=-]{6,}$/.test(conv)) throw new ChannelError('channel/config', '群会话 ID 格式不正确,应以 cid 开头(机器人收到群消息时的 conversationId)', { field: 'openConversationId' })
320
+ out.openConversationId = conv
321
+ }
322
+ const userId = String(config.userId ?? '').trim()
323
+ if (userId) {
324
+ if (!/^[\w-]{1,64}$/.test(userId)) throw new ChannelError('channel/config', 'userId 格式不正确', { field: 'userId' })
325
+ out.userId = userId
326
+ }
327
+ for (const key of ['apiBase', 'gatewayBase']) {
328
+ const v = String(config[key] ?? '').trim()
329
+ if (!v) continue
330
+ if (!/^https?:\/\/\S+$/.test(v)) throw new ChannelError('channel/config', `${key} 必须是 http(s) 地址`, { field: key })
331
+ out[key] = v.replace(/\/+$/, '')
332
+ }
333
+ const secrets = {}
334
+ const secret = String(given.clientSecret ?? '').trim()
335
+ if (secret) {
336
+ if (secret.length < 16 || /\s/.test(secret)) throw new ChannelError('channel/config', 'Client Secret 格式不正确', { field: 'clientSecret' })
337
+ secrets.clientSecret = secret
338
+ } else if (!existing?.secrets?.clientSecret) {
339
+ throw new ChannelError('channel/config', '请填写 Client Secret(原 AppSecret)', { field: 'clientSecret' })
340
+ }
341
+ return { config: out, secrets }
342
+ }
343
+
344
+ // ------------------------------------------------------------ Stream bot
345
+
346
+ /** Strip @mentions from a DingTalk text (the platform leaves a leading space / "@Bot"). */
347
+ export function cleanText(text, botName) {
348
+ let s = String(text ?? '')
349
+ if (botName) s = s.split(`@${botName}`).join(' ')
350
+ return s.replace(/\s+/g, ' ').trim()
351
+ }
352
+
353
+ function createBot(channel, secrets, handlers = {}, opts = {}) {
354
+ const logger = opts.logger
355
+ const WebSocketImpl = opts.WebSocket ?? globalThis.WebSocket
356
+ const backoffBase = opts.backoffMs ?? 1000
357
+ const backoffMax = opts.maxBackoffMs ?? 60000
358
+ const gateway = String(cfg(channel, 'gatewayBase') || API).replace(/\/+$/, '')
359
+ const clientId = cfg(channel, 'clientId')
360
+ const botName = cfg(channel, 'botName')
361
+ let state = 'idle'
362
+ let error
363
+ let since = Date.now()
364
+ let ws = null
365
+ let stopped = true
366
+ let attempts = 0
367
+ let timer = null
368
+ let robot = null
369
+ const set = (s, e) => { state = s; error = e; since = Date.now() }
370
+
371
+ const send = (sock, frame) => { try { if (sock.readyState === 1) sock.send(JSON.stringify(frame)) } catch {} }
372
+ const ack = (sock, headers, data) => send(sock, { code: 200, headers: { contentType: 'application/json', messageId: headers?.messageId }, message: 'OK', data })
373
+
374
+ async function replyVia(msg, body) {
375
+ const expired = Number(msg.sessionWebhookExpiredTime ?? 0) && Date.now() > Number(msg.sessionWebhookExpiredTime) - 5000
376
+ if (msg.sessionWebhook && !expired) {
377
+ const { status, json } = await postJson(msg.sessionWebhook, body, {}, { label: '钉钉', fetch: opts.fetch, timeoutMs: 15000 })
378
+ if (Number(json?.errcode ?? 0) !== 0) throw dingWebhookError(json, status)
379
+ return
380
+ }
381
+ // Session webhook expired: fall back to the robot API (needs qyapi_robot_sendmsg).
382
+ const robotCode = msg.robotCode || cfg(channel, 'robotCode') || clientId
383
+ const msgKey = body.msgtype === 'text' ? 'sampleText' : 'sampleMarkdown'
384
+ const msgParam = JSON.stringify(body.msgtype === 'text' ? { content: body.text.content } : body.markdown)
385
+ const ctx = { fetch: opts.fetch }
386
+ const ch = { ...channel, apiBase: cfg(channel, 'apiBase') }
387
+ if (msg.conversationType === '2') await callRobotApi(ch, secrets, '/v1.0/robot/groupMessages/send', { msgParam, msgKey, openConversationId: msg.conversationId, robotCode }, ctx)
388
+ else await callRobotApi(ch, secrets, '/v1.0/robot/oToMessages/batchSend', { robotCode, userIds: [msg.senderStaffId], msgKey, msgParam }, ctx)
389
+ }
390
+
391
+ function onBotMessage(msg) {
392
+ robot ??= { id: msg.chatbotUserId, robotCode: msg.robotCode }
393
+ const chatType = msg.conversationType === '2' ? 'group' : 'p2p'
394
+ if (chatType === 'group' && msg.isInAtList === false) return
395
+ if (chatType === 'p2p' && channel?.bot?.allowP2p === false) return
396
+ const raw = msg.msgtype === 'text' ? msg.text?.content : msg.content?.recognition ?? ''
397
+ const text = cleanText(raw, botName)
398
+ if (!text) return
399
+ const inbound = {
400
+ platform: 'dingtalk',
401
+ channelId: channel?.id,
402
+ messageId: msg.msgId,
403
+ chatId: msg.conversationId,
404
+ chatType,
405
+ text,
406
+ mentionedBot: chatType === 'group',
407
+ sender: { id: msg.senderStaffId || msg.senderId, name: msg.senderNick, ...(msg.senderStaffId ? {} : { encryptedId: msg.senderId }) },
408
+ raw: msg,
409
+ async reply(om) {
410
+ const { title, chunks } = renderMarkdown({ ...om, mentionAll: false })
411
+ for (let i = 0; i < chunks.length; i++) await replyVia(msg, { msgtype: 'markdown', markdown: { title: i ? `${title}(续)` : title, text: chunks[i] } })
412
+ return {}
413
+ },
414
+ async replyText(s) { await replyVia(msg, { msgtype: 'text', text: { content: String(s ?? '').slice(0, 5000) } }) },
415
+ }
416
+ Promise.resolve(handlers.onMessage?.(inbound)).catch(e => logger?.warn?.(`dingtalk: onMessage failed: ${e?.message ?? e}`))
417
+ }
418
+
419
+ function onFrame(sock, frame) {
420
+ const topic = frame?.headers?.topic
421
+ if (frame.type === 'SYSTEM') {
422
+ if (topic === 'ping') ack(sock, frame.headers, frame.data)
423
+ else if (topic === 'disconnect') { logger?.info?.('dingtalk: server requested disconnect; reconnecting'); try { sock.close() } catch {} }
424
+ return
425
+ }
426
+ if (frame.type === 'CALLBACK' && topic === BOT_TOPIC) {
427
+ ack(sock, frame.headers, JSON.stringify({ response: null }))
428
+ let msg
429
+ try { msg = JSON.parse(frame.data) } catch { return }
430
+ try { onBotMessage(msg) } catch (e) { logger?.warn?.(`dingtalk: message failed: ${e?.message}`) }
431
+ return
432
+ }
433
+ if (frame.type === 'EVENT') ack(sock, frame.headers, JSON.stringify({ status: 'SUCCESS', message: 'ignored' }))
434
+ }
435
+
436
+ async function connect() {
437
+ if (stopped) return
438
+ set('connecting')
439
+ try {
440
+ if (!clientId || !secrets?.clientSecret) throw new ChannelError('channel/config', '缺少 Client ID 或 Client Secret')
441
+ const { status, json } = await request(`${gateway}/v1.0/gateway/connections/open`, {
442
+ method: 'POST',
443
+ headers: { accept: 'application/json' },
444
+ json: { clientId, clientSecret: secrets.clientSecret, ua: UA, subscriptions: [{ type: 'CALLBACK', topic: BOT_TOPIC }] },
445
+ label: '钉钉', fetch: opts.fetch, timeoutMs: 10000,
446
+ })
447
+ if (!json?.endpoint || !json?.ticket) {
448
+ throw status === 401 || status === 403 || /clientId|secret|auth/i.test(json?.code ?? json?.message ?? '') ? new ChannelError('channel/auth', `Stream 连接注册失败:Client ID / Client Secret 不正确或应用未开启 Stream 模式(${json?.code ?? status})`) : dingApiError(json, status)
449
+ }
450
+ if (stopped) return
451
+ const sock = new WebSocketImpl(`${json.endpoint}?ticket=${encodeURIComponent(json.ticket)}`)
452
+ ws = sock
453
+ await new Promise((resolve, reject) => {
454
+ const t = setTimeout(() => { reject(new ChannelError('channel/network', '连接钉钉 Stream 网关超时')); try { sock.close() } catch {} }, 10000)
455
+ sock.addEventListener('open', () => { clearTimeout(t); resolve() })
456
+ sock.addEventListener('error', () => { clearTimeout(t); reject(new ChannelError('channel/network', '无法连接钉钉 Stream 网关')) })
457
+ })
458
+ sock.addEventListener('message', (ev) => {
459
+ let frame
460
+ try { frame = JSON.parse(typeof ev.data === 'string' ? ev.data : Buffer.from(ev.data).toString()) } catch { return }
461
+ onFrame(sock, frame)
462
+ })
463
+ sock.addEventListener('close', () => {
464
+ if (ws !== sock) return
465
+ ws = null
466
+ if (!stopped) { set('connecting', '连接已断开,正在重连'); schedule() }
467
+ })
468
+ if (sock.readyState !== 1) throw new ChannelError('channel/network', '钉钉 Stream 连接已关闭')
469
+ attempts = 0
470
+ set('connected')
471
+ } catch (e) {
472
+ if (stopped) return
473
+ set('error', e.message)
474
+ logger?.warn?.(`dingtalk: connect failed: ${e.message}`)
475
+ schedule()
476
+ }
477
+ }
478
+ function schedule() {
479
+ clearTimeout(timer)
480
+ const delay = Math.min(backoffBase * 2 ** attempts + Math.random() * Math.min(1000, backoffBase), backoffMax)
481
+ attempts++
482
+ timer = setTimeout(connect, delay)
483
+ }
484
+
485
+ return {
486
+ start() { if (!stopped) return; stopped = false; attempts = 0; connect() },
487
+ async stop() {
488
+ stopped = true
489
+ clearTimeout(timer)
490
+ const s = ws
491
+ ws = null
492
+ try { s?.close() } catch {}
493
+ set('stopped')
494
+ },
495
+ status() { return { state, ...(error ? { error } : {}), since } },
496
+ info() { return clientId ? { id: robot?.robotCode ?? clientId, ...(botName ? { name: botName } : {}) } : null },
497
+ }
498
+ }
499
+
500
+ // ---------------------------------------------------------------- kinds
501
+
502
+ const webhookKind = {
503
+ id: 'dingtalk_webhook',
504
+ platform: 'dingtalk',
505
+ label: { zh: '钉钉自定义机器人(Webhook)', en: 'DingTalk custom robot (webhook)' },
506
+ description: { zh: '向钉钉群推送 Markdown 消息(支持加签);仅推送。图表以文本表格呈现。', en: 'Push Markdown to a DingTalk group (optional signing); push only. Charts become text tables.' },
507
+ fields: [
508
+ { key: 'webhookUrl', type: 'secret', secret: true, required: true, label: { zh: 'Webhook 地址', en: 'Webhook URL' }, placeholder: 'https://oapi.dingtalk.com/robot/send?access_token=…', pattern: WEBHOOK_RE.source, help: { zh: '群设置 → 机器人 → 添加机器人 → 自定义,完成后复制 Webhook', en: 'Group settings → Bots → Add → Custom, then copy the webhook' } },
509
+ { key: 'signSecret', type: 'secret', secret: true, required: false, label: { zh: '加签密钥(可选)', en: 'Signing secret (optional)' }, placeholder: 'SEC…', help: { zh: '安全设置选择「加签」时显示的 SEC 开头字符串', en: 'The SEC… string shown when “Sign” is selected in security settings' } },
510
+ ],
511
+ capabilities: { push: true, markdown: 'basic', charts: 'none', chartModes: ['table'], tables: 'text', tableRows: 10, files: false, buttons: false, bot: false, listTargets: false, mentionAll: true, maxMessageBytes: MD_LIMIT },
512
+ setupSteps: {
513
+ zh: [
514
+ '在钉钉群:群设置 → 机器人 → 添加机器人 → 自定义(通过 Webhook 接入)。',
515
+ '安全设置至少选一项:推荐「加签」并把 SEC 开头的密钥填入;若用「自定义关键词」,消息标题或内容须包含该关键词。',
516
+ '复制 Webhook 地址(https://oapi.dingtalk.com/robot/send?access_token=…)。',
517
+ '钉钉 Markdown 不支持表格,结果以编号行展示;Webhook 不能上传图片,图表以文本表格代替。',
518
+ '每个机器人每分钟最多 20 条消息,超出将被限流 10 分钟。',
519
+ ],
520
+ en: [
521
+ 'In a DingTalk group: Settings → Bots → Add → Custom (webhook).',
522
+ 'Pick a security setting: “Sign” recommended (paste the SEC… secret); with keywords, the title or text must contain one.',
523
+ 'Copy the webhook URL (https://oapi.dingtalk.com/robot/send?access_token=…).',
524
+ 'DingTalk Markdown has no tables (rows become numbered lines); webhooks cannot upload images, so charts become text tables.',
525
+ 'At most 20 messages per minute per robot, otherwise throttled for 10 minutes.',
526
+ ],
527
+ },
528
+ validate: validateWebhook,
529
+ send: sendWebhook,
530
+ }
531
+
532
+ const appKind = {
533
+ id: 'dingtalk_app',
534
+ platform: 'dingtalk',
535
+ label: { zh: '钉钉企业内部应用机器人(Stream)', en: 'DingTalk internal app robot (Stream)' },
536
+ description: { zh: 'Stream 模式接收群里 @机器人 和单聊提问(无需公网地址),通过会话 Webhook 回复;可通过机器人 API 主动推送到群或成员。', en: 'Receives @mentions and direct chats over Stream mode (no public URL), replies via the session webhook; can push to a group or member via the robot API.' },
537
+ fields: [
538
+ { key: 'clientId', type: 'text', required: true, label: { zh: 'Client ID(原 AppKey)', en: 'Client ID (AppKey)' }, help: { zh: '开发者后台 → 应用开发 → 企业内部应用 → 凭证与基础信息', en: 'Developer console → internal app → Credentials' } },
539
+ { key: 'clientSecret', type: 'secret', secret: true, required: true, label: { zh: 'Client Secret(原 AppSecret)', en: 'Client Secret (AppSecret)' } },
540
+ { key: 'robotCode', type: 'text', required: false, label: { zh: 'robotCode(可选)', en: 'robotCode (optional)' }, help: { zh: '机器人配置页中的 RobotCode;留空时使用 Client ID(企业内部应用机器人通常相同)', en: 'RobotCode from the bot page; defaults to the Client ID (usually identical)' } },
541
+ { key: 'botName', type: 'text', required: false, label: { zh: '机器人名称(可选)', en: 'Bot name (optional)' }, help: { zh: '用于从群消息中去掉 “@机器人名”', en: 'Used to strip "@name" from group messages' } },
542
+ { key: 'openConversationId', type: 'text', required: false, label: { zh: '推送目标群(openConversationId,可选)', en: 'Push target group (openConversationId, optional)' }, placeholder: 'cid…', help: { zh: '机器人收到该群消息时的 conversationId(以 cid 开头)', en: 'The conversationId (cid…) seen in a message from that group' } },
543
+ { key: 'userId', type: 'text', required: false, label: { zh: '推送目标成员 userId(可选)', en: 'Push target member userId (optional)' }, help: { zh: '未填群会话 ID 时,推送到该成员与机器人的单聊', en: 'Used when no group is set: pushes to the member’s chat with the bot' } },
544
+ ],
545
+ capabilities: { push: true, markdown: 'basic', charts: 'none', chartModes: ['table'], tables: 'text', tableRows: 10, files: false, buttons: false, bot: true, listTargets: false, mentionAll: false, maxMessageBytes: MD_LIMIT },
546
+ setupSteps: {
547
+ zh: [
548
+ '钉钉开发者后台(open-dev.dingtalk.com)→ 应用开发 → 企业内部应用 → 创建应用,记下 Client ID 与 Client Secret。',
549
+ '应用能力 → 添加「机器人」,消息接收模式选择「Stream 模式」,发布机器人。',
550
+ '权限管理中申请「企业内机器人发送消息权限」(qyapi_robot_sendmsg)以支持主动推送和会话 Webhook 过期后的回复。',
551
+ '把机器人添加到群(群设置 → 机器人 → 添加 → 选择该应用机器人);群里 @机器人 或单聊即可提问。',
552
+ '主动推送需要群的 openConversationId(cid 开头,可从机器人收到的消息中获得)或成员 userId。',
553
+ '暂不支持互动卡片按钮和图表图片(图表以文本表格展示)。',
554
+ ],
555
+ en: [
556
+ 'DingTalk developer console (open-dev.dingtalk.com) → internal app → create; note Client ID and Client Secret.',
557
+ 'Add the “Robot” capability, choose “Stream mode” for message receiving, publish the robot.',
558
+ 'Request the qyapi_robot_sendmsg permission for push and for replies after the session webhook expires.',
559
+ 'Add the robot to groups (Group settings → Bots → Add → your app robot); @mention it or chat directly.',
560
+ 'Push needs the group openConversationId (cid…, seen in received messages) or a member userId.',
561
+ 'Not supported: interactive card buttons and chart images (charts become text tables).',
562
+ ],
563
+ },
564
+ validate: validateApp,
565
+ send: sendApp,
566
+ test: testApp,
567
+ }
568
+
569
+ export default {
570
+ id: 'dingtalk',
571
+ name: { zh: '钉钉', en: 'DingTalk' },
572
+ kinds: [webhookKind, appKind],
573
+ createBot,
574
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Shared HTTP helpers for channel adapters.
3
+ *
4
+ * Adapters throw `ChannelError(code, message)`: a stable code such as
5
+ * `channel/auth`, `channel/config`, `channel/network`, `channel/rate-limit`,
6
+ * `channel/rejected` (or a platform-specific `channel/<platform>-<code>`) and
7
+ * a Chinese user-facing message.
8
+ */
9
+
10
+ export class ChannelError extends Error {
11
+ /** @param {{ field?: string }} [opts] - `field`: the form field key the error belongs to. */
12
+ constructor(code, message, { field } = {}) {
13
+ super(message)
14
+ this.name = 'ChannelError'
15
+ this.code = code
16
+ if (field) this.field = field
17
+ }
18
+ }
19
+
20
+ function parseBody(text) {
21
+ try { return JSON.parse(text) } catch { return { raw: String(text ?? '').slice(0, 300) } }
22
+ }
23
+
24
+ /**
25
+ * fetch with a timeout. Returns { status, json, headers } — json is the parsed
26
+ * body, or `{ raw }` (first 300 chars) when the body is not JSON.
27
+ * Network failures and timeouts throw ChannelError('channel/network').
28
+ * @param {string} url
29
+ * @param {{ method?: string, headers?: object, body?: any, json?: any, timeoutMs?: number, label?: string, fetch?: Function }} [opts]
30
+ * `json` is serialized as the request body with a JSON content type;
31
+ * `label` names the platform in error messages (e.g. '飞书').
32
+ */
33
+ export async function request(url, { method, headers = {}, body, json, timeoutMs = 15000, label = '', fetch: fetchImpl = globalThis.fetch } = {}) {
34
+ const controller = new AbortController()
35
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
36
+ const init = { method: method ?? (json !== undefined || body !== undefined ? 'POST' : 'GET'), headers: { ...headers }, signal: controller.signal }
37
+ if (json !== undefined) {
38
+ init.headers['content-type'] ??= 'application/json; charset=utf-8'
39
+ init.body = JSON.stringify(json)
40
+ } else if (body !== undefined) {
41
+ init.body = body
42
+ }
43
+ try {
44
+ const res = await fetchImpl(url, init)
45
+ const text = await res.text()
46
+ return { status: res.status, json: parseBody(text), headers: res.headers }
47
+ } catch (error) {
48
+ const who = label ? `${label}接口` : '接口'
49
+ throw new ChannelError('channel/network', error?.name === 'AbortError' ? `请求${who}超时` : `无法访问${who}:${error?.cause?.code ?? error?.message ?? error}`)
50
+ } finally {
51
+ clearTimeout(timer)
52
+ }
53
+ }
54
+
55
+ /** POST a JSON body. Returns { status, json }. */
56
+ export function postJson(url, body, headers = {}, opts = {}) {
57
+ return request(url, { ...opts, method: 'POST', headers, json: body })
58
+ }
59
+
60
+ /** GET a URL. Returns { status, json }. */
61
+ export function getJson(url, headers = {}, opts = {}) {
62
+ return request(url, { ...opts, method: 'GET', headers })
63
+ }