@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,585 @@
1
+ /**
2
+ * WeCom 企业微信 channel adapter (platform 'wecom').
3
+ *
4
+ * Kinds:
5
+ * wecom_webhook 群机器人(消息推送)webhook
6
+ * wecom_aibot 智能机器人 API 模式 · 长连接 (WebSocket)
7
+ *
8
+ * Official documentation followed (read 2026-11):
9
+ * - 消息推送配置说明(群机器人 webhook): https://developer.work.weixin.qq.com/document/path/91770
10
+ * msgtype text / markdown (content ≤ 4096 bytes, <font color="info|comment|warning">) /
11
+ * markdown_v2 (≤ 4096 bytes; tables, lists, code; NO font colors, NO <@userid> mentions) /
12
+ * image (base64 + md5 of raw bytes, ≤ 2 MB, JPG/PNG); text.mentioned_list ["@all"];
13
+ * "每个消息推送发送的消息不能超过20条/分钟"
14
+ * - 全局错误码: https://developer.work.weixin.qq.com/document/path/90313
15
+ * 45009 接口调用超过限制, 93000 webhook url 不合法或已被移出群, 93004 消息推送被停用,
16
+ * 93008 不在群里, 40058 参数不合法, 93017 内容为空, 93018 图片大小超过限制
17
+ * - 智能机器人长连接: https://developer.work.weixin.qq.com/document/path/101463
18
+ * wss://openws.work.weixin.qq.com; frames { cmd, headers:{req_id}, body };
19
+ * aibot_subscribe {bot_id, secret} → { headers:{req_id}, errcode, errmsg };
20
+ * ping every 30 s; aibot_msg_callback (chattype single|group, chatid only for groups,
21
+ * from.userid, msgtype text|mixed|voice|...); aibot_event_callback (disconnected_event when a
22
+ * newer connection takes over); aibot_respond_msg (stream: {id, finish, content ≤ 20480 bytes},
23
+ * same req_id as the callback, finish within 10 min); aibot_send_msg (active push, body.chatid,
24
+ * chat_type 1 single / 2 group, msgtype markdown|template_card; the user must have messaged the
25
+ * bot in that chat first); 30 msgs/min & 1000/hour per chat.
26
+ * - 长连接概述: https://developer.work.weixin.qq.com/document/path/101956
27
+ * - 被动回复消息(stream / msg_item image base64+md5): https://developer.work.weixin.qq.com/document/path/101031
28
+ * - Official Node SDK @wecom/aibot-node-sdk 1.0.7 (https://github.com/WecomTeam/aibot-node-sdk) was
29
+ * READ (not used) to confirm: req_id format `${cmd}_${ts}_${rand}`, ack frames have no cmd and are
30
+ * matched by req_id, heartbeat ack resets a missed-pong counter (2 misses → reconnect),
31
+ * exponential back-off 1 s … 30 s, no auto-reconnect after disconnected_event.
32
+ *
33
+ * Not implemented (documented but out of scope / unverifiable here): template-card buttons on the AI
34
+ * bot (template_card_event → aibot_respond_update_msg), media upload over the long connection, image
35
+ * msg_item in stream replies. Charts on the AI bot therefore fall back to Markdown tables.
36
+ */
37
+ import { createHash, randomBytes } from 'node:crypto'
38
+ import { ChannelError, postJson } from './http.js'
39
+ import { renderChartPng as defaultRenderChartPng, chartable, resultMeta, isKpi, kpiText, resultAsMarkdownTable, toPlainMarkdown, mentionsAll, splitText } from './message.js'
40
+
41
+ export const WEBHOOK_HOST = 'https://qyapi.weixin.qq.com'
42
+ export const DEFAULT_GATEWAY = 'wss://openws.work.weixin.qq.com'
43
+ const WEBHOOK_LIMIT = 4096
44
+ const AIBOT_LIMIT = 20480
45
+ const IMAGE_LIMIT = 2 * 1024 * 1024
46
+
47
+ const cfg = (channel, key) => channel?.[key] ?? channel?.config?.[key]
48
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms))
49
+ const md5 = (buf) => createHash('md5').update(buf).digest('hex')
50
+ export const reqId = (cmd) => `${cmd}_${Date.now()}_${randomBytes(4).toString('hex')}`
51
+
52
+ // ------------------------------------------------------------- errors
53
+
54
+ const ERRORS = {
55
+ 45009: ['channel/rate-limit', '发送太频繁:企业微信群机器人每分钟最多 20 条消息,请稍后再试'],
56
+ 93000: ['channel/auth', 'Webhook 地址无效,或机器人已被移出群聊(errcode 93000)'],
57
+ 93001: ['channel/rejected', '当前群聊不允许推送消息'],
58
+ 93004: ['channel/rejected', '该消息推送(群机器人)已被停用'],
59
+ 93006: ['channel/config', '不合法的群 ID'],
60
+ 93008: ['channel/rejected', '机器人不在群里'],
61
+ 93017: ['channel/rejected', '消息内容不能为空'],
62
+ 93018: ['channel/rejected', '图片超过大小限制(2MB)'],
63
+ 93019: ['channel/auth', '不合法的机器人 ID'],
64
+ 40008: ['channel/rejected', '不支持的消息类型'],
65
+ 40058: ['channel/rejected', '请求参数不合法(内容可能超长或格式错误)'],
66
+ 44004: ['channel/rejected', '消息内容为空'],
67
+ 45002: ['channel/rejected', '消息内容超过长度限制'],
68
+ 40001: ['channel/auth', 'Secret 不正确'],
69
+ }
70
+
71
+ /** Map a WeCom errcode/errmsg to ChannelError. */
72
+ export function wecomError(json, status, { subscribe = false } = {}) {
73
+ const code = json?.errcode
74
+ const msg = String(json?.errmsg ?? json?.raw ?? '').slice(0, 200)
75
+ const known = ERRORS[code]
76
+ let err
77
+ if (known) err = new ChannelError(known[0], known[1])
78
+ else if (subscribe) err = new ChannelError('channel/auth', `智能机器人订阅失败:Bot ID 或 Secret 不正确,或未开启「API 模式 · 长连接」(errcode ${code ?? '?'}:${msg})`)
79
+ else if (status && status >= 400 && code === undefined) err = new ChannelError('channel/network', `企业微信接口返回 HTTP ${status}`)
80
+ else err = new ChannelError('channel/rejected', `企业微信返回错误(errcode ${code ?? '?'}):${msg || '未知错误'}`)
81
+ err.platformCode = code
82
+ return err
83
+ }
84
+
85
+ // ----------------------------------------------------------- rendering
86
+
87
+ /** Lark-style <font color> → WeCom markdown colours (info green / warning orange-red / comment grey). */
88
+ export function toWecomMarkdown(md) {
89
+ const COLORS = { green: 'info', red: 'warning', orange: 'warning', grey: 'comment', gray: 'comment', info: 'info', warning: 'warning', comment: 'comment' }
90
+ const withColors = String(md ?? '').replace(/<font\b[^>]*color\s*=\s*['"]?([\w-]+)['"]?[^>]*>([\s\S]*?)<\/font>/gi, (_, c, body) => {
91
+ const w = COLORS[String(c).toLowerCase()]
92
+ return w ? `\u0000font color="${w}"\u0001${body}\u0000/font\u0001` : body
93
+ })
94
+ return toPlainMarkdown(withColors, { mentionAll: '' }).replace(/\u0000/g, '<').replace(/\u0001/g, '>')
95
+ }
96
+
97
+ function resultBlock(r, { includeSql, maxRows = 10, table = true } = {}) {
98
+ const head = `**${r.title || '查询结果'}** ${resultMeta(r)}`
99
+ if (r.error) return `${head}\n${r.error}`
100
+ const lines = [head]
101
+ if (!r.rows?.length) lines.push('无数据')
102
+ else if (isKpi(r)) lines.push(kpiText(r, { separator: '\n' }))
103
+ else if (table) lines.push(resultAsMarkdownTable(r, maxRows, { maxCols: 8 }))
104
+ else lines.push(resultAsText(r, maxRows))
105
+ if (r.sql && includeSql) lines.push('```sql\n' + r.sql.slice(0, 1500) + '\n```')
106
+ return lines.join('\n')
107
+ }
108
+
109
+ function resultAsText(r, maxRows) {
110
+ const cols = r.columns.slice(0, 6)
111
+ const rows = r.rows.slice(0, maxRows)
112
+ const out = [`> ${cols.map(c => c.name).join(' | ')}`, ...rows.map(row => `> ${cols.map((c, i) => String(row[i] ?? '')).join(' | ')}`)]
113
+ if ((r.rowCount ?? r.rows.length) > rows.length) out.push(`<font color="comment">…共 ${r.rowCount ?? r.rows.length} 行</font>`)
114
+ return out.join('\n')
115
+ }
116
+
117
+ /**
118
+ * Plan a webhook delivery: [{ msgtype:'markdown'|'markdown_v2', content } | { msgtype:'image', result } | { msgtype:'text', mentionAll:true }].
119
+ * markdown_v2 (tables) is used when the message contains tables; plain markdown (colours) otherwise.
120
+ */
121
+ export function planWebhook(message) {
122
+ const sections = []
123
+ const images = []
124
+ if (message.title) sections.push(`## ${message.title}`)
125
+ if (message.text) sections.push(message.text)
126
+ let hasTable = /(^|\n)\|[^\n]*\|\s*\n\|[\s:|-]+\|/.test(message.text ?? '')
127
+ for (const r of (message.results ?? []).slice(0, 10)) {
128
+ if (message.chartMode !== 'table' && !r.error && chartable(r)) {
129
+ sections.push(`**${r.title || '查询结果'}** ${resultMeta(r)}(见下方图表)${r.sql && message.includeSql ? '\n```sql\n' + r.sql.slice(0, 1500) + '\n```' : ''}`)
130
+ images.push({ msgtype: 'image', result: r })
131
+ continue
132
+ }
133
+ if (r.rows?.length && !isKpi(r) && !r.error) hasTable = true
134
+ sections.push(resultBlock(r, { includeSql: message.includeSql }))
135
+ }
136
+ if (message.footer) sections.push(message.footer)
137
+ const md = sections.filter(Boolean).join('\n\n')
138
+ const msgtype = hasTable ? 'markdown_v2' : 'markdown'
139
+ const content = msgtype === 'markdown_v2' ? toPlainMarkdown(md, { mentionAll: '' }) : toWecomMarkdown(md)
140
+ const parts = splitText(content, WEBHOOK_LIMIT - 16).map(c => ({ msgtype, content: c }))
141
+ const plan = [...parts, ...images]
142
+ // markdown_v2 cannot @; markdown supports <@userid> only. The documented @all is text.mentioned_list.
143
+ if (message.mentionAll || mentionsAll(message.text) || mentionsAll(message.footer)) plan.push({ msgtype: 'text', mentionAll: true })
144
+ return plan
145
+ }
146
+
147
+ // ------------------------------------------------------------- webhook
148
+
149
+ export const WEBHOOK_RE = /^https:\/\/qyapi\.weixin\.qq\.com\/cgi-bin\/webhook\/send\?key=[\w-]{8,}$/
150
+
151
+ function webhookUrl(channel, secrets) {
152
+ const url = secrets?.webhookUrl
153
+ if (!url) throw new ChannelError('channel/config', '缺少 Webhook 地址')
154
+ const override = cfg(channel, 'apiBase')
155
+ if (override) return url.replace(WEBHOOK_HOST, String(override).replace(/\/+$/, ''))
156
+ return url
157
+ }
158
+
159
+ /** Per-webhook sliding window (20 msgs / 60 s). Waits up to `maxWaitMs`, else throws rate-limit. */
160
+ const windows = new Map()
161
+ async function throttle(key, { limit = 20, windowMs = 60000, maxWaitMs = 15000 } = {}) {
162
+ const now = Date.now()
163
+ const list = (windows.get(key) ?? []).filter(t => now - t < windowMs)
164
+ if (list.length >= limit) {
165
+ const wait = windowMs - (now - list[0]) + 50
166
+ if (wait > maxWaitMs) throw new ChannelError('channel/rate-limit', `发送太频繁:企业微信群机器人每分钟最多 ${limit} 条消息,请约 ${Math.ceil(wait / 1000)} 秒后再试`)
167
+ await sleep(wait)
168
+ return throttle(key, { limit, windowMs, maxWaitMs })
169
+ }
170
+ list.push(now)
171
+ windows.set(key, list)
172
+ }
173
+ export function resetThrottle() { windows.clear() }
174
+
175
+ async function postWebhook(url, body, ctx, throttleOpts) {
176
+ await throttle(url, throttleOpts)
177
+ const { status, json } = await postJson(url, body, {}, { label: '企业微信', fetch: ctx.fetch, timeoutMs: 20000 })
178
+ if (json?.errcode !== 0) throw wecomError(json, status)
179
+ return json
180
+ }
181
+
182
+ async function sendWebhook(channel, secrets, message, ctx = {}) {
183
+ const url = webhookUrl(channel, secrets)
184
+ const render = ctx.renderChartPng ?? defaultRenderChartPng
185
+ const throttleOpts = ctx.throttle
186
+ let images = 0
187
+ for (const step of planWebhook(message)) {
188
+ if (step.msgtype === 'image') {
189
+ let png
190
+ try { png = await render(step.result) } catch (e) { ctx.logger?.warn?.(`wecom: chart render failed: ${e?.message}`) }
191
+ if (png && png.length <= IMAGE_LIMIT) {
192
+ await postWebhook(url, { msgtype: 'image', image: { base64: Buffer.from(png).toString('base64'), md5: md5(png) } }, ctx, throttleOpts)
193
+ images++
194
+ } else {
195
+ await postWebhook(url, { msgtype: 'markdown_v2', markdown_v2: { content: toPlainMarkdown(resultBlock(step.result, { includeSql: false })).slice(0, 3000) } }, ctx, throttleOpts)
196
+ }
197
+ continue
198
+ }
199
+ if (step.msgtype === 'text') {
200
+ await postWebhook(url, { msgtype: 'text', text: { content: '请查看上方消息', mentioned_list: ['@all'] } }, ctx, throttleOpts)
201
+ continue
202
+ }
203
+ await postWebhook(url, { msgtype: step.msgtype, [step.msgtype]: { content: step.content } }, ctx, throttleOpts)
204
+ }
205
+ return { images }
206
+ }
207
+
208
+ function validateWebhook(input = {}, existing = {}) {
209
+ const given = input.secrets ?? input
210
+ const url = String(given.webhookUrl ?? '').trim()
211
+ const secrets = {}
212
+ if (url) {
213
+ if (!WEBHOOK_RE.test(url)) throw new ChannelError('channel/config', 'Webhook 地址格式不正确,应为 https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=…(在群聊 → 消息推送 中复制)', { field: 'webhookUrl' })
214
+ secrets.webhookUrl = url
215
+ } else if (!existing?.secrets?.webhookUrl) {
216
+ throw new ChannelError('channel/config', '请填写 Webhook 地址', { field: 'webhookUrl' })
217
+ }
218
+ return { config: {}, secrets }
219
+ }
220
+
221
+ // --------------------------------------------------------------- AI bot
222
+
223
+ /** Strip "@Bot" mentions from a group text (WeCom delivers "@RobotA hello"). */
224
+ export function stripMention(text, botName) {
225
+ let s = String(text ?? '')
226
+ if (botName) s = s.split(`@${botName}`).join(' ')
227
+ // The callback only reaches us when the bot was @-mentioned in a group: drop a leading @name token.
228
+ else s = s.replace(/^\s*@\S+\s*/, ' ')
229
+ return s.replace(/\s+/g, ' ').trim()
230
+ }
231
+
232
+ function inboundText(body) {
233
+ if (body.msgtype === 'text') return body.text?.content ?? ''
234
+ if (body.msgtype === 'voice') return body.voice?.content ?? ''
235
+ if (body.msgtype === 'mixed') return (body.mixed?.msg_item ?? []).filter(i => i.msgtype === 'text').map(i => i.text?.content ?? '').join('\n')
236
+ return ''
237
+ }
238
+
239
+ function aibotMarkdown(message) {
240
+ const parts = []
241
+ if (message.title) parts.push(`**${message.title}**`)
242
+ if (message.text) parts.push(toPlainMarkdown(message.text, { mentionAll: '' }))
243
+ for (const r of (message.results ?? []).slice(0, 10)) parts.push(resultBlock(r, { includeSql: message.includeSql }))
244
+ if (message.footer) parts.push(toPlainMarkdown(message.footer, { mentionAll: '' }))
245
+ return parts.join('\n\n')
246
+ }
247
+
248
+ function wsUrl(channel) {
249
+ return String(cfg(channel, 'gatewayBase') || DEFAULT_GATEWAY)
250
+ }
251
+
252
+ /**
253
+ * One long connection: open, subscribe, heartbeat, request/ack by req_id.
254
+ * Used by createBot (persistent) and by test()/send() (short-lived).
255
+ */
256
+ function openConnection(url, { botId, secret, WebSocketImpl = globalThis.WebSocket, onFrame, onClose, timeoutMs = 10000, heartbeatMs = 30000, logger } = {}) {
257
+ return new Promise((resolve, reject) => {
258
+ let ws
259
+ try { ws = new WebSocketImpl(url) } catch (e) { reject(new ChannelError('channel/network', `无法连接企业微信长连接网关:${e?.message ?? e}`)); return }
260
+ const pending = new Map()
261
+ let subscribed = false
262
+ let hb = null
263
+ let missed = 0
264
+ let closed = false
265
+ const timer = setTimeout(() => fail(new ChannelError('channel/network', '连接企业微信长连接网关超时')), timeoutMs)
266
+
267
+ function fail(err) {
268
+ clearTimeout(timer)
269
+ if (!subscribed) reject(err)
270
+ try { ws.close() } catch {}
271
+ }
272
+ const conn = {
273
+ request(cmd, body, { id = reqId(cmd), ackTimeoutMs = 10000 } = {}) {
274
+ return new Promise((res, rej) => {
275
+ if (closed || ws.readyState !== 1) return rej(new ChannelError('channel/network', '企业微信长连接未连接'))
276
+ const t = setTimeout(() => { pending.delete(id); rej(new ChannelError('channel/network', `企业微信未确认请求(${cmd})`)) }, ackTimeoutMs)
277
+ pending.set(id, { res, rej, t, cmd })
278
+ ws.send(JSON.stringify({ cmd, headers: { req_id: id }, ...(body !== undefined ? { body } : {}) }))
279
+ })
280
+ },
281
+ close() { closed = true; clearInterval(hb); try { ws.close() } catch {} },
282
+ get open() { return !closed && ws.readyState === 1 },
283
+ }
284
+ // A reply to a callback reuses the callback's req_id; several replies may share it, so queue per id.
285
+ const queues = new Map()
286
+ conn.reply = (id, cmd, body) => {
287
+ const prev = queues.get(id) ?? Promise.resolve()
288
+ const next = prev.catch(() => {}).then(() => conn.request(cmd, body, { id }))
289
+ queues.set(id, next)
290
+ next.finally(() => { if (queues.get(id) === next) queues.delete(id) }).catch(() => {})
291
+ return next
292
+ }
293
+
294
+ ws.addEventListener('open', () => {
295
+ const id = reqId('aibot_subscribe')
296
+ pending.set(id, {
297
+ cmd: 'aibot_subscribe',
298
+ res: () => {
299
+ subscribed = true
300
+ clearTimeout(timer)
301
+ hb = setInterval(() => {
302
+ if (missed >= 2) { logger?.warn?.('wecom: heartbeat lost, reconnecting'); try { ws.close() } catch {}; return }
303
+ missed++
304
+ const pid = reqId('ping')
305
+ pending.set(pid, { cmd: 'ping', res: () => { missed = 0 }, rej: () => {}, t: null })
306
+ try { ws.send(JSON.stringify({ cmd: 'ping', headers: { req_id: pid } })) } catch {}
307
+ }, heartbeatMs)
308
+ resolve(conn)
309
+ },
310
+ rej: (e) => fail(e),
311
+ t: null,
312
+ })
313
+ ws.send(JSON.stringify({ cmd: 'aibot_subscribe', headers: { req_id: id }, body: { bot_id: botId, secret } }))
314
+ })
315
+ ws.addEventListener('message', (ev) => {
316
+ let frame
317
+ try { frame = JSON.parse(String(typeof ev.data === 'string' ? ev.data : Buffer.from(ev.data)).replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F]/g, '')) } catch { return }
318
+ const id = frame?.headers?.req_id ?? ''
319
+ if (frame.cmd) { onFrame?.(frame, conn); return }
320
+ const p = pending.get(id)
321
+ if (!p) return
322
+ pending.delete(id)
323
+ if (p.t) clearTimeout(p.t)
324
+ if (frame.errcode === 0 || frame.errcode === undefined) p.res(frame)
325
+ else p.rej(wecomError(frame, undefined, { subscribe: p.cmd === 'aibot_subscribe' }))
326
+ })
327
+ ws.addEventListener('error', () => { if (!subscribed) fail(new ChannelError('channel/network', '无法连接企业微信长连接网关')) })
328
+ ws.addEventListener('close', () => {
329
+ closed = true
330
+ clearTimeout(timer)
331
+ clearInterval(hb)
332
+ for (const p of pending.values()) { if (p.t) clearTimeout(p.t); p.rej(new ChannelError('channel/network', '企业微信长连接已断开')) }
333
+ pending.clear()
334
+ if (!subscribed) reject(new ChannelError('channel/network', '企业微信长连接在订阅完成前断开'))
335
+ else onClose?.()
336
+ })
337
+ })
338
+ }
339
+
340
+ /** Live AI-bot connections by bot id so send() can reuse a running bot (the platform allows one connection per bot). */
341
+ const liveConnections = new Map()
342
+
343
+ async function withConnection(channel, secrets, ctx, fn) {
344
+ const botId = cfg(channel, 'botId')
345
+ if (!botId || !secrets?.secret) throw new ChannelError('channel/config', '缺少 Bot ID 或 Secret')
346
+ const live = liveConnections.get(botId)
347
+ if (live?.open) return fn(live)
348
+ // NOTE: a short-lived subscribe kicks any other connection of this bot (one connection per bot).
349
+ const conn = await openConnection(wsUrl(channel), { botId, secret: secrets.secret, WebSocketImpl: ctx.WebSocket, logger: ctx.logger })
350
+ try { return await fn(conn) } finally { conn.close() }
351
+ }
352
+
353
+ async function sendAibot(channel, secrets, message, ctx = {}) {
354
+ const chatId = cfg(channel, 'chatId')
355
+ if (!chatId) throw new ChannelError('channel/config', '缺少推送目标会话 ID(群聊 chatid 或成员 userid)')
356
+ const chatType = cfg(channel, 'chatType') === 'single' ? 1 : 2
357
+ const chunks = splitText(aibotMarkdown(message), AIBOT_LIMIT - 64)
358
+ await withConnection(channel, secrets, ctx, async (conn) => {
359
+ for (const content of chunks) await conn.request('aibot_send_msg', { chatid: chatId, chat_type: chatType, msgtype: 'markdown', markdown: { content } })
360
+ })
361
+ return { images: 0 }
362
+ }
363
+
364
+ async function testAibot(channel, secrets, ctx = {}) {
365
+ const botId = cfg(channel, 'botId')
366
+ if (liveConnections.get(botId)?.open) return { ok: true, detail: '机器人长连接在线' }
367
+ await withConnection(channel, secrets, ctx, async () => {})
368
+ return { ok: true, detail: '订阅成功:Bot ID 与 Secret 有效(注意:测试连接会顶掉该机器人在别处的长连接)' }
369
+ }
370
+
371
+ function validateAibot(input = {}, existing = {}) {
372
+ const config = input.config ?? input
373
+ const given = input.secrets ?? input
374
+ const out = {}
375
+ const botId = String(config.botId ?? '').trim()
376
+ if (!botId) throw new ChannelError('channel/config', '请填写智能机器人的 Bot ID', { field: 'botId' })
377
+ if (!/^[\w-]{6,128}$/.test(botId)) throw new ChannelError('channel/config', 'Bot ID 格式不正确(在 管理后台 → 智能机器人 → API 模式 中查看)', { field: 'botId' })
378
+ out.botId = botId
379
+ const chatId = String(config.chatId ?? '').trim()
380
+ if (chatId) {
381
+ if (!/^[\w@.-]{1,128}$/.test(chatId)) throw new ChannelError('channel/config', '会话 ID 格式不正确', { field: 'chatId' })
382
+ out.chatId = chatId
383
+ out.chatType = config.chatType === 'single' ? 'single' : 'group'
384
+ }
385
+ const gw = String(config.gatewayBase ?? '').trim()
386
+ if (gw) {
387
+ if (!/^wss?:\/\/[^\s]+$/.test(gw)) throw new ChannelError('channel/config', '网关地址必须以 wss:// 开头', { field: 'gatewayBase' })
388
+ out.gatewayBase = gw
389
+ }
390
+ const secrets = {}
391
+ const secret = String(given.secret ?? '').trim()
392
+ if (secret) {
393
+ if (secret.length < 8 || /\s/.test(secret)) throw new ChannelError('channel/config', 'Secret 格式不正确', { field: 'secret' })
394
+ secrets.secret = secret
395
+ } else if (!existing?.secrets?.secret) {
396
+ throw new ChannelError('channel/config', '请填写长连接 Secret', { field: 'secret' })
397
+ }
398
+ return { config: out, secrets }
399
+ }
400
+
401
+ function createBot(channel, secrets, handlers = {}, opts = {}) {
402
+ const logger = opts.logger
403
+ const botId = cfg(channel, 'botId')
404
+ const botName = cfg(channel, 'botName')
405
+ const backoffBase = opts.backoffMs ?? 1000
406
+ const backoffMax = opts.maxBackoffMs ?? 30000
407
+ let state = 'idle'
408
+ let error
409
+ let since = Date.now()
410
+ let conn = null
411
+ let stopped = true
412
+ let attempts = 0
413
+ let timer = null
414
+ const seen = new Set()
415
+ const set = (s, e) => { state = s; error = e; since = Date.now() }
416
+
417
+ function onFrame(frame, c) {
418
+ const body = frame.body ?? {}
419
+ const id = frame.headers?.req_id
420
+ if (frame.cmd === 'aibot_event_callback') {
421
+ if (body.event?.eventtype === 'disconnected_event') {
422
+ // A newer connection for this bot took over: do not fight it.
423
+ logger?.warn?.('wecom: disconnected_event — another connection took over this bot')
424
+ stopped = true
425
+ set('error', '该智能机器人在别处建立了新的长连接,本连接已被顶下线')
426
+ c.close()
427
+ }
428
+ return
429
+ }
430
+ if (frame.cmd !== 'aibot_msg_callback') return
431
+ if (body.msgid) {
432
+ if (seen.has(body.msgid)) return
433
+ seen.add(body.msgid)
434
+ if (seen.size > 2000) seen.delete(seen.values().next().value)
435
+ }
436
+ const chatType = body.chattype === 'group' ? 'group' : 'p2p'
437
+ if (chatType === 'p2p' && channel?.bot?.allowP2p === false) return
438
+ const raw = inboundText(body)
439
+ const text = chatType === 'group' ? stripMention(raw, botName) : raw.trim()
440
+ if (!text) return
441
+ const chatId = chatType === 'group' ? body.chatid : body.from?.userid
442
+ let streamId = null
443
+ let replies = 0
444
+ const stream = (content, finish) => {
445
+ streamId ??= `s_${randomBytes(8).toString('hex')}`
446
+ return c.reply(id, 'aibot_respond_msg', { msgtype: 'stream', stream: { id: streamId, finish, content: String(content).slice(0, AIBOT_LIMIT) } })
447
+ }
448
+ const inbound = {
449
+ platform: 'wecom',
450
+ channelId: channel?.id,
451
+ messageId: body.msgid ?? id,
452
+ chatId,
453
+ chatType,
454
+ text,
455
+ mentionedBot: chatType === 'group',
456
+ sender: { id: body.from?.userid ?? '' },
457
+ raw: body,
458
+ async reply(om) {
459
+ const chunks = splitText(aibotMarkdown(om), AIBOT_LIMIT - 64)
460
+ // First chunk finishes the reply stream; extras go out as active pushes.
461
+ await stream(chunks[0], true)
462
+ replies++
463
+ for (const extra of chunks.slice(1)) await c.request('aibot_send_msg', { chatid: chatId, chat_type: chatType === 'group' ? 2 : 1, msgtype: 'markdown', markdown: { content: extra } })
464
+ return { messageId: streamId }
465
+ },
466
+ async replyText(s) {
467
+ if (replies === 0 && !streamId) { await stream(String(s), false); return } // progress text, finished by reply()
468
+ await c.request('aibot_send_msg', { chatid: chatId, chat_type: chatType === 'group' ? 2 : 1, msgtype: 'markdown', markdown: { content: String(s) } })
469
+ },
470
+ }
471
+ Promise.resolve(handlers.onMessage?.(inbound)).catch(e => logger?.warn?.(`wecom: onMessage failed: ${e?.message ?? e}`))
472
+ }
473
+
474
+ async function connect() {
475
+ if (stopped) return
476
+ set('connecting')
477
+ try {
478
+ const c = await openConnection(wsUrl(channel), { botId, secret: secrets?.secret, WebSocketImpl: opts.WebSocket, onFrame, logger, heartbeatMs: opts.heartbeatMs, onClose: () => {
479
+ if (liveConnections.get(botId) === c) liveConnections.delete(botId)
480
+ conn = null
481
+ if (!stopped) { set('connecting', '连接已断开,正在重连'); schedule() }
482
+ } })
483
+ if (stopped) { c.close(); return }
484
+ conn = c
485
+ liveConnections.set(botId, c)
486
+ attempts = 0
487
+ set('connected')
488
+ } catch (e) {
489
+ if (stopped) return
490
+ set('error', e.message)
491
+ logger?.warn?.(`wecom: connect failed: ${e.message}`)
492
+ schedule(e.code === 'channel/auth')
493
+ }
494
+ }
495
+ function schedule(auth = false) {
496
+ clearTimeout(timer)
497
+ attempts++
498
+ const delay = Math.min(backoffBase * 2 ** (attempts - 1), backoffMax) * (auth ? 2 : 1)
499
+ timer = setTimeout(connect, delay)
500
+ }
501
+
502
+ return {
503
+ start() { if (!stopped) return; stopped = false; attempts = 0; connect() },
504
+ async stop() {
505
+ stopped = true
506
+ clearTimeout(timer)
507
+ if (conn) { if (liveConnections.get(botId) === conn) liveConnections.delete(botId); conn.close(); conn = null }
508
+ set('stopped')
509
+ },
510
+ status() { return { state, ...(error ? { error } : {}), since } },
511
+ info() { return botId ? { id: botId, ...(botName ? { name: botName } : {}) } : null },
512
+ }
513
+ }
514
+
515
+ // ---------------------------------------------------------------- kinds
516
+
517
+ const webhookKind = {
518
+ id: 'wecom_webhook',
519
+ platform: 'wecom',
520
+ label: { zh: '企业微信群机器人(Webhook)', en: 'WeCom group bot (webhook)' },
521
+ description: { zh: '向企业微信群推送 Markdown 和图表图片;仅推送,不能接收提问。', en: 'Push Markdown and chart images to a WeCom group; push only.' },
522
+ fields: [
523
+ { key: 'webhookUrl', type: 'secret', secret: true, required: true, label: { zh: 'Webhook 地址', en: 'Webhook URL' }, placeholder: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=…', pattern: WEBHOOK_RE.source, help: { zh: '群聊 → 右上角 … → 消息推送 → 添加,复制 Webhook 地址', en: 'Group chat → … → Message push → Add, copy the webhook URL' } },
524
+ ],
525
+ capabilities: { push: true, markdown: 'basic', charts: 'image', chartModes: ['image', 'table'], tables: 'markdown', tableRows: 10, files: false, buttons: false, bot: false, listTargets: false, mentionAll: true, maxMessageBytes: WEBHOOK_LIMIT },
526
+ setupSteps: {
527
+ zh: [
528
+ '在企业微信群聊中点击右上角「…」→「消息推送」(旧版为「群机器人」)→「添加」,创建后复制 Webhook 地址。',
529
+ '含表格的消息使用 markdown_v2 发送(需客户端 4.1.36+),其余使用 markdown(支持绿/橙/灰三种字体颜色)。',
530
+ '图表以 PNG 图片(base64,≤2MB)单独发送;@所有人通过一条附加的文本消息实现(markdown 不支持 @all)。',
531
+ '每个群机器人每分钟最多 20 条消息(错误码 45009),插件会自动限速;请勿泄露 Webhook 地址。',
532
+ ],
533
+ en: [
534
+ 'In a WeCom group: … → Message push (formerly Group bot) → Add, then copy the webhook URL.',
535
+ 'Messages with tables use markdown_v2 (client 4.1.36+); others use markdown (three font colours).',
536
+ 'Charts are sent as separate PNG images (base64, ≤ 2 MB); @all is sent as an extra text message (markdown cannot @all).',
537
+ 'Limit: 20 messages per minute per bot (errcode 45009); the plugin throttles. Keep the URL secret.',
538
+ ],
539
+ },
540
+ validate: validateWebhook,
541
+ send: sendWebhook,
542
+ }
543
+
544
+ const aibotKind = {
545
+ id: 'wecom_aibot',
546
+ platform: 'wecom',
547
+ label: { zh: '企业微信智能机器人(长连接)', en: 'WeCom AI bot (long connection)' },
548
+ description: { zh: '智能机器人 API 模式长连接:群里 @机器人 或单聊提问,流式回复;可向已对话过的会话主动推送 Markdown。无需公网地址。', en: 'AI bot API mode over a long connection: answers @mentions and direct chats with streamed replies; can push Markdown to chats that have talked to the bot. No public URL.' },
549
+ fields: [
550
+ { key: 'botId', type: 'text', required: true, label: { zh: 'Bot ID', en: 'Bot ID' }, help: { zh: '管理后台 → 应用管理 → 智能机器人 → 详情 → API 模式(长连接)中查看', en: 'Admin console → AI bot → API mode (long connection)' } },
551
+ { key: 'secret', type: 'secret', secret: true, required: true, label: { zh: '长连接 Secret', en: 'Long-connection secret' }, help: { zh: '长连接专用 Secret(不同于回调模式的 Token / EncodingAESKey)', en: 'Long-connection secret (not the callback Token/EncodingAESKey)' } },
552
+ { key: 'botName', type: 'text', required: false, label: { zh: '机器人名称(可选)', en: 'Bot name (optional)' }, help: { zh: '用于从群消息中去掉 “@机器人名”', en: 'Used to strip "@name" from group messages' } },
553
+ { key: 'chatId', type: 'text', required: false, label: { zh: '推送目标会话 ID(可选)', en: 'Push target chat id (optional)' }, help: { zh: '群聊 chatid(来自机器人收到的消息)或成员 userid;会话需先给机器人发过消息', en: 'Group chatid (from a received message) or member userid; the chat must have messaged the bot first' } },
554
+ { key: 'chatType', type: 'select', required: false, label: { zh: '会话类型', en: 'Chat type' }, options: [{ value: 'group', label: { zh: '群聊', en: 'Group' } }, { value: 'single', label: { zh: '单聊', en: 'Single' } }] },
555
+ ],
556
+ capabilities: { push: true, markdown: 'full', charts: 'none', chartModes: ['table'], tables: 'markdown', tableRows: 10, files: false, buttons: false, bot: true, listTargets: false, mentionAll: false, maxMessageBytes: AIBOT_LIMIT },
557
+ setupSteps: {
558
+ zh: [
559
+ '企业微信管理后台 → 应用管理 → 智能机器人 → 创建(或选择)机器人 → 开启「API 模式」并选择「长连接」。',
560
+ '复制 Bot ID 与长连接专用 Secret 填入;开启后原「接收消息 URL」回调失效。',
561
+ '每个机器人同一时间只能有一条长连接:新连接会把旧连接顶下线(包括本插件的“测试连接”和主动推送),请勿在多处同时运行。',
562
+ '把机器人添加到群聊;群里 @机器人 或单聊发送消息即可提问,回复以流式消息呈现(24 小时内可回复)。',
563
+ '主动推送(aibot_send_msg)只能发往曾给机器人发过消息的会话;每个会话限 30 条/分钟、1000 条/小时。',
564
+ '暂不支持:图表图片与卡片按钮(图表以 Markdown 表格展示)。',
565
+ ],
566
+ en: [
567
+ 'WeCom admin console → App management → AI bot → create/select a bot → enable API mode with “long connection”.',
568
+ 'Copy the Bot ID and the long-connection secret; the callback URL mode stops working.',
569
+ 'Only one long connection per bot: a new one (including “test” and push from this plugin) kicks the old one. Run it in one place only.',
570
+ 'Add the bot to groups; @mention it or chat directly. Replies are streamed (allowed within 24 h).',
571
+ 'Active push (aibot_send_msg) only reaches chats that have messaged the bot; 30/min and 1000/hour per chat.',
572
+ 'Not supported yet: chart images and card buttons (charts become Markdown tables).',
573
+ ],
574
+ },
575
+ validate: validateAibot,
576
+ send: sendAibot,
577
+ test: testAibot,
578
+ }
579
+
580
+ export default {
581
+ id: 'wecom',
582
+ name: { zh: '企业微信', en: 'WeCom' },
583
+ kinds: [webhookKind, aibotKind],
584
+ createBot,
585
+ }
package/cordis.patch.yml CHANGED
@@ -9,7 +9,7 @@
9
9
  config:
10
10
  id: nl2sql
11
11
  name: 数据问答
12
- description: 用自然语言查询企业数据库(MySQL / PostgreSQL / Elasticsearch),自动生成 SQL、图表与报告,并可推送到飞书 / Lark。
12
+ description: 用自然语言查询企业数据库(MySQL / PostgreSQL / Elasticsearch),自动生成 SQL、图表与报告,并可推送到飞书 / Lark、Telegram、企业微信、钉钉等 IM。
13
13
  order: 20
14
14
  plugins:
15
15
  - id: persona
@@ -33,11 +33,11 @@
33
33
  7. When the user asks for a general picture of a table ("看看这张表", "概览", "overview"), call nl2sql_overview instead of hand-writing many queries, then interpret the charts.
34
34
  8. When you run as a scheduled report (the message contains [数据问答定时报告], usually inside a [SCHEDULE REMINDER] wrapper), bind the named data source first, run the queries, and end with a concise written report; do not ask questions back, state assumptions instead.
35
35
  9. When the user says a result is wrong (often after rating it 👎), treat their comment as the correct definition: find the cause (table, metric, filter, time range), fix the SQL, and after they confirm the fix offer to save it as an example.
36
- 10. When the user asks to share, push or send something to Lark/Feishu (发到飞书/群里/推送), call nl2sql_send with a short title, a well-structured Markdown body written for chat readers (## headings, bullet lists, **key numbers**, <font color='green'>/<font color='red'> for up/down changes, small Markdown tables for comparisons), and the resultIds of the relevant queries, which render as native interactive charts; call nl2sql_send action=list first if the user names a channel you have not seen. Never send unprompted, except when a scheduled report tells you to.
36
+ 10. When the user asks to share, push or send something to an IM channel (Lark/Feishu, Telegram, WeCom, DingTalk; 发到飞书/群里/推送), call nl2sql_send with a short title, a well-structured Markdown body written for chat readers (## headings, bullet lists, **key numbers**, <font color='green'>/<font color='red'> for up/down changes, small Markdown tables for comparisons), and the resultIds of the relevant queries, which each platform renders as natively as it can (Lark: interactive card charts; others: chart images or tables); call nl2sql_send action=list first if the user names a channel you have not seen. Never send unprompted, except when a scheduled report tells you to.
37
37
  11. If the metric library defines the metric being asked about (nl2sql_knowledge search shows it under metrics), query it with nl2sql_metric instead of writing your own SQL, so the governed definition is used; use nl2sql_query only for things the library does not cover.
38
38
  12. For follow-ups like "按渠道拆开看", "这一天的明细", or "为什么下降/上涨", use nl2sql_drill on the previous resultId (mode breakdown / detail / why) rather than rewriting the query by hand; summarise the why-result in plain language (which dimension values contributed most).
39
39
  13. Local file data sources (type sqlite) hold user-uploaded CSV/Excel tables; write SQLite SQL for them (strftime/date, double-quoted identifiers, Chinese column names must be quoted).
40
- 14. Messages tagged [飞书…] come from Lark chat members: answer in Markdown without asking questions (state assumptions instead); a member marked 只读成员 may only use nl2sql_metric or reuse verified example SQL from nl2sql_knowledge — if a query is refused for row-level or viewer restrictions, explain the restriction instead of retrying variations.
40
+ 14. Messages tagged [飞书…], [Telegram…], [企业微信…] or [钉钉…] come from IM chat members through a bot: answer in Markdown without asking questions (state assumptions instead); a member marked 只读成员 may only use nl2sql_metric or reuse verified example SQL from nl2sql_knowledge — if a query is refused for row-level or viewer restrictions, explain the restriction instead of retrying variations.
41
41
  15. When the user confirms an answer is correct (e.g. "对", "没问题", "就是这个"), offer to store it; on agreement call nl2sql_knowledge action=save_example with the question and the final SQL.
42
42
 
43
43
  You may only read data. Never attempt to modify data or schema; the tools reject anything but SELECT/SHOW/DESC/EXPLAIN.