@hedeqiang/dsh-nl2sql 0.10.0 → 0.11.1

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/CHANGELOG.md CHANGED
@@ -4,9 +4,29 @@
4
4
 
5
5
  ## [未发布]
6
6
 
7
+ ## [0.11.1] - 2026-11-12
8
+
9
+ ### 修复
10
+ - 中文界面下渠道类型、配置说明、推荐问题等显示为英文:读取 Harness 语言设置的方式有误(LocaleSnapshot 的 `active` 字段),已修正并加了回归测试。
11
+
12
+ ### 新增
13
+ - GitHub Actions 发布流程:推送 `v*` 版本标签后自动检查、打包,并通过 npm 可信发布(OIDC,带来源证明)发布到 npm,同时创建 GitHub Release。
14
+
15
+ ## [0.11.0] - 2026-11-12
16
+
17
+ ### 新增
18
+ - 渠道架构:IM 平台改为适配器(`channels/`),统一的消息模型、能力声明、表单定义和机器人接口,详见 [docs/CHANNELS.md](docs/CHANNELS.md)。
19
+ - 新平台:Telegram(推送、长轮询机器人、按钮)、企业微信(群机器人推送;智能机器人长连接问答与推送)、钉钉(自定义机器人推送;企业内部应用机器人 Stream 模式问答与推送)。以上新平台仅用模拟服务测试,尚未在真实账号上验证。
20
+ - 成员身份支持多平台(`identities`),机器人按渠道配置,可同时运行多个。
21
+ - 页面:「渠道」按平台分组、两步添加(选平台与类型 → 按表单填写),能力标签、配置步骤、选择群聊、测试;成员页「机器人」总览;成员身份编辑;设置「渠道与机器人」。
22
+ - 问答页「试试这些问题」支持「换一批」;删除单条历史记录;「最近的提问」可删除并支持「显示更多」。
23
+
24
+ ### 兼容
25
+ - 旧的飞书渠道、机器人配置、成员字段和设置在首次加载时自动迁移;旧的 RPC 与数据格式继续可用。
26
+
27
+ ## [0.10.0] - 2026-11-12
28
+
7
29
  ### 新增
8
- - 问答页「试试这些问题」支持「换一批」:问题池混合指标库、👍 问题、已验证样例和表结构模板,分页轮换。
9
- - 删除单条历史记录:历史页每行可删除;问答页「最近的提问」可删除(同一问题的多次记录一起删除),并支持「显示更多」。
10
30
  - 飞书回复卡片上的交互按钮:按维度拆分、为什么变化、明细、固定到看板(经长连接回传,服务端校验权限)。
11
31
  - 业务知识「建议」:从 👍 反馈、重复出现的查询、常用聚合和取值映射中自动生成样例、指标、备注和词表建议,人工确认后采纳。
12
32
  - 开源准备:MIT 许可证、README、贡献指南、安全策略、CI。
package/README.en.md CHANGED
@@ -12,7 +12,8 @@ An enterprise NL2SQL plugin for DeepSeek Harness: connect databases, ask in plai
12
12
  - **Drill-down and "why did it change":** split by a dimension, show rows, rank dimensions by their contribution to a change.
13
13
  - **Metric library and business knowledge:** glossary, governed metrics (`nl2sql_metric`), column notes, verified examples; import / export; accuracy evaluation against the examples.
14
14
  - **Dashboards:** multiple boards, global filters, drag-to-reorder, three widths, snapshots, Excel export.
15
- - **Automation:** Lark push (Markdown + native charts), scheduled reports, metric alerts (threshold / change / anomaly / missing), schema change detection, an @-mention bot over the Lark long connection.
15
+ - **Channels:** Lark / Feishu, Telegram, WeCom and DingTalk through pluggable adapters ([docs/CHANNELS.md](docs/CHANNELS.md)); push Markdown and results (native charts, images or tables per platform), and @-mention bots that need no public URL.
16
+ - **Automation:** scheduled reports, metric alerts (threshold / change / anomaly / missing), schema change detection.
16
17
  - **Safety:**
17
18
  - read-only admission with per-dialect lexing, read-only transactions, server-side timeouts, streamed truncation;
18
19
  - table / column access policies with masking, full-scan guard;
package/README.md CHANGED
@@ -16,9 +16,9 @@
16
16
 
17
17
  **看板与推送**
18
18
  - 多看板、全局筛选(时间范围 / 维度)、拖拽排序、三档宽度、快照、Excel 导出。
19
- - 飞书 / Lark 推送:Webhook 机器人或应用机器人,Markdown + 原生图表卡片。
19
+ - IM 渠道:飞书 / Lark、Telegram、企业微信、钉钉(推送 Markdown 与结果,图表按平台能力用原生图表、图片或表格);架构见 [docs/CHANNELS.md](docs/CHANNELS.md),新增平台只需实现一个适配器。
20
20
  - 定时报告(基于 Harness 定时任务)、指标告警(阈值 / 环比 / 异常 / 无数据)、表结构变更检测。
21
- - 飞书群 @机器人提问:通过长连接接收消息,不需要公网地址。
21
+ - 群聊 @机器人提问:飞书长连接、Telegram 长轮询、企业微信智能机器人长连接、钉钉 Stream 模式,都不需要公网地址。
22
22
 
23
23
  **安全与治理**
24
24
  - 只读执行:按方言词法检查语句(MySQL / PostgreSQL / SQLite / ES),只读事务,PostgreSQL 使用扩展协议拒绝多语句,服务端超时,大结果流式截断。
@@ -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} 行`)
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', 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', 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
+ }