@hedeqiang/dsh-nl2sql 0.0.0-stage → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/im.js ADDED
@@ -0,0 +1,511 @@
1
+ /**
2
+ * IM delivery: Lark / Feishu.
3
+ *
4
+ * Two channel kinds:
5
+ * - lark_webhook: a group custom bot (webhook URL + optional signing secret).
6
+ * Cannot upload images, so charts are sent as tables.
7
+ * - lark_app: a custom app bot (app_id + app_secret) sending to a chat,
8
+ * user or email through the IM API; charts are rendered to PNG on the
9
+ * Host and uploaded as images.
10
+ *
11
+ * Messages are interactive cards (JSON 2.0) built from a title, Markdown text
12
+ * and query results.
13
+ */
14
+ import { createHmac } from 'node:crypto'
15
+ import * as echarts from 'echarts'
16
+ import { Resvg } from '@resvg/resvg-js'
17
+
18
+ export class ImError extends Error {
19
+ constructor(code, message) {
20
+ super(message)
21
+ this.code = code
22
+ }
23
+ }
24
+
25
+ const HOSTS = { feishu: 'https://open.feishu.cn', lark: 'https://open.larksuite.com' }
26
+ export const RECEIVE_ID_TYPES = ['chat_id', 'open_id', 'user_id', 'union_id', 'email']
27
+
28
+ function hostFor(channel) {
29
+ return HOSTS[channel.domain === 'lark' ? 'lark' : 'feishu']
30
+ }
31
+
32
+ async function postJson(url, body, headers = {}, timeoutMs = 15000) {
33
+ const controller = new AbortController()
34
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
35
+ try {
36
+ const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8', ...headers }, body: JSON.stringify(body), signal: controller.signal })
37
+ const text = await res.text()
38
+ let json
39
+ try { json = JSON.parse(text) } catch { json = { raw: text.slice(0, 300) } }
40
+ return { status: res.status, json }
41
+ } catch (error) {
42
+ throw new ImError('im/network', error.name === 'AbortError' ? '请求飞书接口超时' : `无法访问飞书接口:${error.cause?.code ?? error.message}`)
43
+ } finally {
44
+ clearTimeout(timer)
45
+ }
46
+ }
47
+
48
+ /** Custom-bot signature: HMAC-SHA256 keyed by `${timestamp}\n${secret}` over an empty message, base64. */
49
+ export function larkSign(secret, timestamp) {
50
+ return createHmac('sha256', `${timestamp}\n${secret}`).update('').digest('base64')
51
+ }
52
+
53
+ function larkError(json, status) {
54
+ const code = json?.code ?? json?.StatusCode
55
+ const msg = json?.msg ?? json?.StatusMessage ?? json?.raw ?? `HTTP ${status}`
56
+ const hints = {
57
+ 19021: '签名校验失败:请检查签名密钥,以及本机时间是否准确',
58
+ 19024: '关键词校验失败:消息中没有包含机器人设置的关键词',
59
+ 19022: 'IP 不在机器人白名单中',
60
+ 9499: '请求格式错误',
61
+ 11232: '发送频率过高,请稍后再试',
62
+ 99991663: 'tenant_access_token 无效',
63
+ 99991672: '应用缺少权限,请在开发者后台为应用开通 im:message:send_as_bot 和 im:resource 权限',
64
+ 230002: '机器人不在该群中,请先把应用机器人拉进群',
65
+ 230001: '接收者 ID 无效',
66
+ 230013: '接收者不在应用的可用范围内',
67
+ 10003: 'app_id 或 app_secret 错误',
68
+ }
69
+ return new ImError(`im/lark-${code ?? status}`, `${hints[code] ? `${hints[code]}(${msg})` : `飞书返回错误 ${code ?? status}:${msg}`}`)
70
+ }
71
+
72
+ // ----------------------------------------------------------- app tokens
73
+
74
+ const tokenCache = new Map()
75
+
76
+ async function tenantToken(channel, secrets) {
77
+ const key = `${hostFor(channel)}|${channel.appId}`
78
+ const cached = tokenCache.get(key)
79
+ if (cached && cached.expiresAt > Date.now() + 60000) return cached.token
80
+ if (!channel.appId || !secrets.appSecret) throw new ImError('im/config', '缺少 App ID 或 App Secret')
81
+ const { status, json } = await postJson(`${hostFor(channel)}/open-apis/auth/v3/tenant_access_token/internal`, { app_id: channel.appId, app_secret: secrets.appSecret })
82
+ if (json.code !== 0 || !json.tenant_access_token) throw larkError(json, status)
83
+ tokenCache.set(key, { token: json.tenant_access_token, expiresAt: Date.now() + (json.expire ?? 7200) * 1000 })
84
+ return json.tenant_access_token
85
+ }
86
+
87
+ async function uploadImage(channel, secrets, png) {
88
+ const token = await tenantToken(channel, secrets)
89
+ const form = new FormData()
90
+ form.set('image_type', 'message')
91
+ form.set('image', new Blob([png], { type: 'image/png' }), 'chart.png')
92
+ const controller = new AbortController()
93
+ const timer = setTimeout(() => controller.abort(), 30000)
94
+ try {
95
+ const res = await fetch(`${hostFor(channel)}/open-apis/im/v1/images`, { method: 'POST', headers: { authorization: `Bearer ${token}` }, body: form, signal: controller.signal })
96
+ const json = await res.json().catch(() => ({}))
97
+ if (json.code !== 0) throw larkError(json, res.status)
98
+ return json.data.image_key
99
+ } finally {
100
+ clearTimeout(timer)
101
+ }
102
+ }
103
+
104
+ // ------------------------------------------------------- chart rendering
105
+
106
+ const PALETTE = ['#3370ff', '#34c724', '#ff8800', '#f54a45', '#7f3bf5', '#14c0ff', '#f14bc1', '#8f959e', '#a1d33b', '#ff7a45']
107
+
108
+ function num(v) {
109
+ return v === null || v === undefined || v === '' ? null : Number(v)
110
+ }
111
+
112
+ function fmtNum(v) {
113
+ const n = Number(v)
114
+ if (!Number.isFinite(n)) return String(v ?? '')
115
+ if (Math.abs(n) >= 1e8) return `${(n / 1e8).toFixed(2)}亿`
116
+ if (Math.abs(n) >= 1e4) return `${(n / 1e4).toFixed(2)}万`
117
+ return Number.isInteger(n) ? String(n) : n.toFixed(2)
118
+ }
119
+
120
+ /** Move series onto a second y axis when magnitudes differ by more than 10x. Mutates series. */
121
+ export function dualAxis(series) {
122
+ if (series.length < 2 || series.length > 4) return false
123
+ const peak = series.map(s => Math.max(0, ...s.data.filter(v => Number.isFinite(v)).map(Math.abs)))
124
+ const top = Math.max(...peak)
125
+ if (!(top > 0)) return false
126
+ const small = peak.map(p => p > 0 && top / p > 10)
127
+ if (!small.some(Boolean) || small.every(Boolean)) return false
128
+ series.forEach((s, i) => { if (small[i]) s.yAxisIndex = 1 })
129
+ return true
130
+ }
131
+
132
+ /** ECharts option for a result, light theme for IM. */
133
+ export function chartOption(result) {
134
+ const chart = result.chart ?? { type: 'table' }
135
+ const idx = (name) => result.columns.findIndex(c => c.name === name)
136
+ const xi = idx(chart.x)
137
+ const ys = (chart.y ?? []).map(idx).filter(i => i >= 0)
138
+ const si = chart.series ? idx(chart.series) : -1
139
+ const base = { color: PALETTE, backgroundColor: '#ffffff', animation: false, textStyle: { fontFamily: 'PingFang SC, Microsoft YaHei, Noto Sans CJK SC, sans-serif' } }
140
+ if (chart.type === 'pie' || chart.type === 'funnel') {
141
+ const data = result.rows.slice(0, 20).map(r => ({ name: String(r[xi] ?? 'NULL'), value: num(r[ys[0]]) }))
142
+ return { ...base, legend: { bottom: 0, type: 'scroll' }, series: [chart.type === 'pie' ? { type: 'pie', radius: ['35%', '62%'], center: ['50%', '46%'], data, label: { formatter: '{b}\n{d}%' } } : { type: 'funnel', top: 20, bottom: 40, data }] }
143
+ }
144
+ if (chart.type === 'scatter') {
145
+ return { ...base, grid: { left: 60, right: 30, top: 30, bottom: 40 }, xAxis: { type: 'value', name: chart.x }, yAxis: { type: 'value', name: result.columns[ys[0]]?.name }, series: [{ type: 'scatter', symbolSize: 7, data: result.rows.map(r => [num(r[xi]), num(r[ys[0]])]) }] }
146
+ }
147
+ const horizontal = chart.type === 'barh'
148
+ const seriesType = chart.type === 'line' || chart.type === 'area' ? 'line' : 'bar'
149
+ let categories
150
+ let series
151
+ if (si >= 0 && ys.length) {
152
+ categories = [...new Set(result.rows.map(r => String(r[xi] ?? 'NULL')))]
153
+ const groups = new Map()
154
+ for (const r of result.rows) {
155
+ const k = String(r[si] ?? 'NULL')
156
+ if (!groups.has(k)) groups.set(k, new Map())
157
+ groups.get(k).set(String(r[xi] ?? 'NULL'), num(r[ys[0]]))
158
+ }
159
+ series = [...groups.entries()].slice(0, 12).map(([name, m]) => ({ name, type: seriesType, data: categories.map(c => m.get(c) ?? null) }))
160
+ } else {
161
+ categories = result.rows.map(r => String(r[xi] ?? 'NULL'))
162
+ series = ys.map(i => ({ name: result.columns[i].name, type: seriesType, data: result.rows.map(r => num(r[i])) }))
163
+ }
164
+ for (const s of series) {
165
+ if (seriesType === 'line') { s.smooth = true; s.showSymbol = categories.length <= 31 }
166
+ if (chart.type === 'area') s.areaStyle = { opacity: 0.15 }
167
+ if (chart.type === 'stacked') s.stack = 'total'
168
+ if (seriesType === 'bar') s.barMaxWidth = 32
169
+ }
170
+ const cat = { type: 'category', data: categories, axisLabel: { hideOverlap: true, color: '#646a73' } }
171
+ const val = { type: 'value', axisLabel: { color: '#646a73', formatter: (v) => fmtNum(v) }, splitLine: { lineStyle: { color: '#eff0f1' } } }
172
+ const dual = !horizontal && chart.type !== 'stacked' && dualAxis(series)
173
+ return {
174
+ ...base,
175
+ legend: { show: series.length > 1, top: 0, type: 'scroll' },
176
+ grid: { left: 16, right: 24, top: series.length > 1 ? 36 : 16, bottom: 8, containLabel: true },
177
+ xAxis: horizontal ? val : cat,
178
+ yAxis: horizontal ? { ...cat, inverse: true } : dual ? [val, { ...val, splitLine: { show: false } }] : val,
179
+ series,
180
+ }
181
+ }
182
+
183
+ export function renderChartPng(result, { width = 960, height = 480 } = {}) {
184
+ const chart = echarts.init(null, null, { renderer: 'svg', ssr: true, width, height })
185
+ try {
186
+ chart.setOption(chartOption(result))
187
+ const svg = chart.renderToSVGString()
188
+ return new Resvg(svg, { fitTo: { mode: 'width', value: width * 2 }, font: { loadSystemFonts: true, defaultFontFamily: 'PingFang SC' } }).render().asPng()
189
+ } finally {
190
+ chart.dispose()
191
+ }
192
+ }
193
+
194
+ export function chartable(result) {
195
+ const t = result.chart?.type
196
+ return Boolean(t && t !== 'table' && t !== 'kpi' && result.rows.length > 0 && (result.chart.x || t === 'scatter'))
197
+ }
198
+
199
+ // ------------------------------------------------------------ card build
200
+
201
+ const CARD_LIMIT = 28000
202
+
203
+ function cellText(v) {
204
+ if (v === null || v === undefined) return ''
205
+ const s = String(v)
206
+ return s.length > 80 ? `${s.slice(0, 79)}…` : s
207
+ }
208
+
209
+ function tableElement(result, maxRows) {
210
+ const cols = result.columns.slice(0, 8)
211
+ return {
212
+ tag: 'table',
213
+ page_size: Math.min(10, Math.max(1, Math.min(maxRows, result.rows.length))),
214
+ row_height: 'low',
215
+ header_style: { background_style: 'grey', bold: true, text_size: 'normal' },
216
+ columns: cols.map((c, i) => ({ name: `c${i}`, display_name: c.name, data_type: c.kind === 'number' ? 'number' : 'text', ...(c.kind === 'number' ? { format: { separator: true } } : {}), width: 'auto' })),
217
+ rows: result.rows.slice(0, maxRows).map(r => Object.fromEntries(cols.map((c, i) => [`c${i}`, c.kind === 'number' && Number.isFinite(Number(r[i])) ? Number(r[i]) : cellText(r[i])]))),
218
+ }
219
+ }
220
+
221
+ function kpiMarkdown(result) {
222
+ const row = result.rows[0] ?? []
223
+ return result.columns.map((c, i) => `**${c.name}**:${c.kind === 'number' ? fmtNum(row[i]) : cellText(row[i])}`).join(' | ')
224
+ }
225
+
226
+ /**
227
+ * A native card chart (VChart spec) for a result. Works for webhook and app
228
+ * bots alike and stays interactive in the client.
229
+ */
230
+ export function chartElement(result, { maxPoints = 200 } = {}) {
231
+ const chart = result.chart ?? {}
232
+ const idx = (name) => result.columns.findIndex(c => c.name === name)
233
+ const xi = idx(chart.x)
234
+ const ys = (chart.y ?? []).map(idx).filter(i => i >= 0)
235
+ const si = chart.series ? idx(chart.series) : -1
236
+ if (xi < 0 || !ys.length) return undefined
237
+ const rows = result.rows.slice(0, maxPoints)
238
+ const label = (v) => (v === null || v === undefined ? 'NULL' : String(v))
239
+ let spec
240
+ if (chart.type === 'pie' || chart.type === 'funnel') {
241
+ const values = rows.slice(0, 20).map(r => ({ type: label(r[xi]), value: num(r[ys[0]]) }))
242
+ spec = chart.type === 'pie'
243
+ ? { type: 'pie', data: { values }, valueField: 'value', categoryField: 'type', outerRadius: 0.85, innerRadius: 0.45, label: { visible: true }, legends: { visible: true, orient: 'bottom' } }
244
+ : { type: 'funnel', data: { values }, categoryField: 'type', valueField: 'value', isTransform: true, label: { visible: true }, transformLabel: { visible: true }, outerLabel: { visible: false } }
245
+ } else if (chart.type === 'scatter') {
246
+ spec = { type: 'scatter', data: { values: rows.map(r => ({ x: num(r[xi]), y: num(r[ys[0]]) })) }, xField: 'x', yField: 'y', axes: [{ orient: 'bottom', type: 'linear', title: { visible: true, text: chart.x } }, { orient: 'left', type: 'linear', title: { visible: true, text: result.columns[ys[0]].name } }] }
247
+ } else {
248
+ // Long format: one row per (x, series) so one spec covers multi-measure and series charts.
249
+ const values = []
250
+ if (si >= 0) {
251
+ for (const r of rows) values.push({ x: label(r[xi]), s: label(r[si]), v: num(r[ys[0]]) })
252
+ } else {
253
+ for (const r of rows) for (const i of ys) values.push({ x: label(r[xi]), s: result.columns[i].name, v: num(r[i]) })
254
+ }
255
+ const multi = si >= 0 || ys.length > 1
256
+ const type = chart.type === 'line' ? 'line' : chart.type === 'area' ? 'area' : 'bar'
257
+ const horizontal = chart.type === 'barh'
258
+ // Second axis when magnitudes differ by more than 10x (two-measure line/area/bar).
259
+ if (!horizontal && chart.type !== 'stacked' && si < 0 && ys.length === 2) {
260
+ const peaks = ys.map(i => Math.max(0, ...rows.map(r => Math.abs(num(r[i]) ?? 0))))
261
+ const [hi, lo] = peaks[0] >= peaks[1] ? [0, 1] : [1, 0]
262
+ if (peaks[lo] > 0 && peaks[hi] / peaks[lo] > 10) {
263
+ const name = (k) => result.columns[ys[k]].name
264
+ spec = {
265
+ type: 'common',
266
+ data: [{ id: 'a', values: rows.map(r => ({ x: label(r[xi]), v: num(r[ys[hi]]), s: name(hi) })) }, { id: 'b', values: rows.map(r => ({ x: label(r[xi]), v: num(r[ys[lo]]), s: name(lo) })) }],
267
+ series: [
268
+ { type: type === 'bar' ? 'bar' : type, dataIndex: 0, xField: 'x', yField: 'v', seriesField: 's' },
269
+ { type: 'line', dataIndex: 1, xField: 'x', yField: 'v', seriesField: 's' },
270
+ ],
271
+ axes: [{ orient: 'left', seriesIndex: [0] }, { orient: 'right', seriesIndex: [1], grid: { visible: false } }, { orient: 'bottom', type: 'band' }],
272
+ legends: { visible: true, orient: 'bottom' },
273
+ }
274
+ }
275
+ }
276
+ spec ??= horizontal
277
+ ? { type: 'bar', direction: 'horizontal', data: { values }, xField: 'v', yField: multi ? ['x', 's'] : 'x', ...(multi ? { seriesField: 's' } : {}), axes: [{ orient: 'left', type: 'band', inverse: true }] }
278
+ : {
279
+ type, data: { values },
280
+ xField: type === 'bar' && multi && chart.type !== 'stacked' ? ['x', 's'] : 'x',
281
+ yField: 'v',
282
+ ...(multi ? { seriesField: 's', legends: { visible: true, orient: 'bottom' } } : {}),
283
+ ...(chart.type === 'stacked' ? { stack: true } : {}),
284
+ ...(type !== 'bar' ? { point: { visible: rows.length <= 31 } } : {}),
285
+ }
286
+ }
287
+ return { tag: 'chart', aspect_ratio: chart.type === 'pie' || chart.type === 'funnel' ? '4:3' : '16:9', chart_spec: { ...spec, title: { visible: false } } }
288
+ }
289
+
290
+ // Feishu markdown tables show at most five rows per page and four tables per
291
+ // component; bigger tables become table components instead.
292
+ const MD_TABLE = /(^|\n)(\|[^\n]*\|[ \t]*\n\|[ \t:|-]+\|[ \t]*\n(?:\|[^\n]*\|[ \t]*(?:\n|$))+)/g
293
+
294
+ function parseMdTable(block) {
295
+ const lines = block.trim().split('\n').map(l => l.trim())
296
+ const split = (l) => l.replace(/^\||\|$/g, '').split(/(?<!\\)\|/).map(c => c.trim().replace(/\\\|/g, '|'))
297
+ const header = split(lines[0])
298
+ const rows = lines.slice(2).map(split)
299
+ const numeric = header.map((_, i) => rows.length > 0 && rows.every(r => r[i] === undefined || r[i] === '' || /^-?[\d,]+(\.\d+)?%?$/.test(r[i])))
300
+ return {
301
+ columns: header.map((name, i) => ({ name: name.replace(/\*\*/g, '') || ' ', kind: numeric[i] && rows.every(r => !/%$/.test(r[i] ?? '')) ? 'number' : 'text' })),
302
+ rows: rows.map(r => header.map((_, i) => {
303
+ const v = r[i] ?? ''
304
+ return numeric[i] && !/%$/.test(v) && v !== '' ? Number(v.replace(/,/g, '')) : v
305
+ })),
306
+ }
307
+ }
308
+
309
+ /** Split Markdown into card elements: text stays Markdown; large tables become table components. */
310
+ export function markdownElements(md) {
311
+ const text = String(md ?? '').replace(/\r\n?/g, '\n').trim()
312
+ if (!text) return []
313
+ const out = []
314
+ let last = 0
315
+ let mdTables = 0
316
+ for (const m of text.matchAll(MD_TABLE)) {
317
+ const block = m[2]
318
+ const start = m.index + m[1].length
319
+ const table = parseMdTable(block)
320
+ // Small tables render fine inline (≤ 5 rows, ≤ 4 per component); keep them in the Markdown.
321
+ if (table.rows.length <= 5 && mdTables < 4 && table.columns.length <= 8) { mdTables++; continue }
322
+ const before = text.slice(last, start).trim()
323
+ if (before) out.push({ tag: 'markdown', content: before })
324
+ out.push(tableElement(table, 50))
325
+ last = start + block.length
326
+ }
327
+ const rest = text.slice(last).trim()
328
+ if (rest) {
329
+ // A component holds at most ~10k characters comfortably; split long text on paragraph boundaries.
330
+ let chunk = ''
331
+ for (const para of rest.split(/\n{2,}/)) {
332
+ if (chunk && chunk.length + para.length > 6000) { out.push({ tag: 'markdown', content: chunk }); chunk = '' }
333
+ chunk = chunk ? `${chunk}\n\n${para}` : para
334
+ }
335
+ if (chunk) out.push({ tag: 'markdown', content: chunk })
336
+ }
337
+ return out
338
+ }
339
+
340
+ /**
341
+ * Interactive controls under one result (card JSON 2.0, callback behaviors).
342
+ * spec: { buttons?: [{ text, value, type? }], select?: { placeholder, value, options: [{ text, value }] } }
343
+ * `value` objects are echoed back verbatim in card.action.trigger: keep them
344
+ * opaque (a short token) — the receiver must not trust them.
345
+ */
346
+ export function actionElement(spec) {
347
+ const items = []
348
+ const plain = (s, n = 40) => ({ tag: 'plain_text', content: String(s ?? '').slice(0, n) })
349
+ if (spec?.select?.options?.length) {
350
+ items.push({
351
+ tag: 'select_static',
352
+ placeholder: plain(spec.select.placeholder),
353
+ width: 'default',
354
+ options: spec.select.options.slice(0, 10).map(o => ({ text: plain(o.text, 60), value: String(o.value).slice(0, 64) })),
355
+ behaviors: [{ type: 'callback', value: spec.select.value }],
356
+ })
357
+ }
358
+ for (const b of spec?.buttons ?? []) {
359
+ items.push({ tag: 'button', text: plain(b.text, 20), type: b.type ?? 'default', size: 'small', behaviors: [{ type: 'callback', value: b.value }] })
360
+ }
361
+ if (!items.length) return undefined
362
+ return { tag: 'column_set', flex_mode: 'flow', horizontal_spacing: 'small', columns: items.map(el => ({ tag: 'column', width: 'auto', vertical_align: 'center', elements: [el] })) }
363
+ }
364
+
365
+ /**
366
+ * Build a JSON 2.0 card.
367
+ * @param {{ title: string, text?: string, results?: object[], footer?: string, template?: string, includeSql?: boolean }} message
368
+ * text is Markdown (headings, lists, code, tables, links, <font>, <at> ...).
369
+ * results may carry imageKey when a PNG was uploaded (app bots, fallback).
370
+ */
371
+ export function buildCard(message, { maxRows = 10, charts = true, actions = true } = {}) {
372
+ const elements = [...markdownElements(message.text)]
373
+ let tables = elements.filter(e => e.tag === 'table').length
374
+ let chartCount = 0
375
+ for (const r of message.results ?? []) {
376
+ if (elements.length) elements.push({ tag: 'hr' })
377
+ if (r.error) {
378
+ elements.push({ tag: 'markdown', content: `**${r.title || '查询'}**\n<font color='red'>${r.error}</font>` })
379
+ continue
380
+ }
381
+ const head = [`**${r.title || '查询结果'}**`, `<font color='grey'>${r.rowCount ?? r.rows.length} 行${r.truncated ? '(已截断)' : ''}${r.filters?.length ? ` · 已筛选:${r.filters.map(f => f.description).join(';')}` : ''}</font>`].join(' ')
382
+ elements.push({ tag: 'markdown', content: head })
383
+ const nativeChart = charts && chartCount < 5 && chartable(r) ? chartElement(r) : undefined
384
+ if (r.rows.length === 0) {
385
+ elements.push({ tag: 'markdown', content: '<font color=\'grey\'>无数据</font>' })
386
+ } else if (r.chart?.type === 'kpi' || (r.rows.length === 1 && r.columns.length <= 6)) {
387
+ elements.push({ tag: 'markdown', content: kpiMarkdown(r) })
388
+ } else if (nativeChart) {
389
+ elements.push(nativeChart)
390
+ chartCount++
391
+ } else if (r.imageKey) {
392
+ elements.push({ tag: 'img', img_key: r.imageKey, alt: { tag: 'plain_text', content: r.title || 'chart' }, scale_type: 'fit_horizontal', preview: true })
393
+ } else if (tables < 5) {
394
+ elements.push(tableElement(r, maxRows))
395
+ tables++
396
+ } else {
397
+ elements.push({ tag: 'markdown', content: r.rows.slice(0, 5).map(row => row.slice(0, 6).map(cellText).join(' | ')).join('\n') })
398
+ }
399
+ if (r.cardActions && actions) {
400
+ const el = actionElement(r.cardActions)
401
+ if (el) elements.push(el)
402
+ }
403
+ if (r.sql && message.includeSql) elements.push({ tag: 'markdown', content: `\`\`\`sql\n${r.sql.slice(0, 1500)}\n\`\`\`` })
404
+ }
405
+ if (message.footer) elements.push({ tag: 'markdown', content: `<font color='grey'>${message.footer}</font>`, text_size: 'notation' })
406
+ const card = {
407
+ schema: '2.0',
408
+ config: { wide_screen_mode: true, summary: { content: String(message.title).slice(0, 60) } },
409
+ header: { template: message.template ?? 'blue', title: { tag: 'plain_text', content: String(message.title).slice(0, 100) } },
410
+ body: { elements },
411
+ }
412
+ // Card bodies are limited to ~30 KB: shrink tables/charts until it fits.
413
+ const size = Buffer.byteLength(JSON.stringify(card))
414
+ if (size > CARD_LIMIT && maxRows > 3) return buildCard(message, { maxRows: Math.max(3, Math.floor(maxRows / 2)), charts, actions })
415
+ if (size > CARD_LIMIT && charts) return buildCard(message, { maxRows, charts: false, actions })
416
+ if (size > CARD_LIMIT) {
417
+ card.body.elements = elements.filter(e => e.tag !== 'table')
418
+ card.body.elements.push({ tag: 'markdown', content: '<font color=\'grey\'>(数据过多,表格已省略)</font>' })
419
+ }
420
+ return card
421
+ }
422
+
423
+ /** JSON 1.0 fallback: lark_md only, for bots/clients rejecting 2.0 components. */
424
+ function legacyCard(card) {
425
+ const md = card.body.elements.map(e => {
426
+ if (e.tag === 'markdown') return e.content
427
+ if (e.tag === 'hr') return '---'
428
+ if (e.tag === 'table') return [e.columns.map(c => c.display_name).join(' | '), ...e.rows.slice(0, 10).map(r => e.columns.map(c => cellText(r[c.name])).join(' | '))].join('\n')
429
+ if (e.tag === 'chart') {
430
+ const v = e.chart_spec.data?.values ?? e.chart_spec.data?.[0]?.values ?? []
431
+ return v.slice(0, 10).map(x => Object.values(x).map(cellText).join(' | ')).join('\n')
432
+ }
433
+ return ''
434
+ }).filter(Boolean).join('\n\n')
435
+ return { config: { wide_screen_mode: true }, header: card.header, elements: [{ tag: 'markdown', content: md.slice(0, 20000) }] }
436
+ }
437
+
438
+ // ------------------------------------------------------------------ send
439
+
440
+ /**
441
+ * Send a message to a channel.
442
+ * @param channel - channel record ({ type, domain, appId, receiveIdType, receiveId }).
443
+ * @param secrets - { webhookUrl, signSecret, appSecret }.
444
+ * @param message - { title, text, results, footer, includeSql, template }.
445
+ * @returns { messageId?, images }
446
+ */
447
+ export async function sendMessage(channel, secrets, message) {
448
+ let images = 0
449
+ const results = []
450
+ // Native card charts are the default. Images are only uploaded when the
451
+ // caller asks for them (app bots; e.g. clients that cannot render charts).
452
+ for (const r of message.results ?? []) {
453
+ if (channel.type === 'lark_app' && message.chartMode === 'image' && chartable(r)) {
454
+ try {
455
+ const png = renderChartPng(r)
456
+ results.push({ ...r, imageKey: await uploadImage(channel, secrets, png), chart: { ...r.chart, nativeDisabled: true } })
457
+ images++
458
+ continue
459
+ } catch (error) {
460
+ if (error instanceof ImError && /99991672|234007/.test(error.code)) throw error
461
+ }
462
+ }
463
+ results.push(r)
464
+ }
465
+ const card = buildCard({ ...message, results }, { charts: message.chartMode !== 'image' && message.chartMode !== 'table' })
466
+ if (channel.type === 'lark_webhook') {
467
+ if (!secrets.webhookUrl) throw new ImError('im/config', '缺少 Webhook 地址')
468
+ const url = secrets.webhookUrl
469
+ if (!/^https:\/\/open\.(feishu\.cn|larksuite\.com)\/open-apis\/bot\/v2\/hook\/[\w-]+$/.test(url)) throw new ImError('im/config', 'Webhook 地址格式不正确,应为 https://open.feishu.cn/open-apis/bot/v2/hook/… 或 open.larksuite.com')
470
+ const send = async (c) => {
471
+ const timestamp = String(Math.floor(Date.now() / 1000))
472
+ const body = { msg_type: 'interactive', card: c, ...(secrets.signSecret ? { timestamp, sign: larkSign(secrets.signSecret, timestamp) } : {}) }
473
+ return postJson(url, body)
474
+ }
475
+ let { status, json } = await send(card)
476
+ const code = json?.code ?? json?.StatusCode
477
+ if (code !== 0 && ![19021, 19022, 19024, 11232].includes(code) && card.body.elements.some(e => e.tag === 'chart')) ({ status, json } = await send(buildCard({ ...message, results }, { charts: false })))
478
+ if ((json?.code ?? json?.StatusCode) !== 0 && ![19021, 19022, 19024, 11232].includes(json?.code ?? json?.StatusCode)) ({ status, json } = await send(legacyCard(card)))
479
+ if ((json?.code ?? json?.StatusCode) !== 0) throw larkError(json, status)
480
+ return { images }
481
+ }
482
+ if (channel.type === 'lark_app') {
483
+ if (!channel.receiveId) throw new ImError('im/config', '缺少接收者 ID')
484
+ const token = await tenantToken(channel, secrets)
485
+ const type = RECEIVE_ID_TYPES.includes(channel.receiveIdType) ? channel.receiveIdType : 'chat_id'
486
+ const url = `${hostFor(channel)}/open-apis/im/v1/messages?receive_id_type=${type}`
487
+ const send = (c) => postJson(url, { receive_id: channel.receiveId, msg_type: 'interactive', content: JSON.stringify(c) }, { authorization: `Bearer ${token}` })
488
+ const fatal = [230002, 230001, 230013, 99991672, 99991663]
489
+ let { status, json } = await send(card)
490
+ if (json.code !== 0 && !fatal.includes(json.code) && card.body.elements.some(e => e.tag === 'chart')) ({ status, json } = await send(buildCard({ ...message, results }, { charts: false })))
491
+ if (json.code !== 0 && !fatal.includes(json.code)) ({ status, json } = await send(legacyCard(card)))
492
+ if (json.code !== 0) throw larkError(json, status)
493
+ return { messageId: json.data?.message_id, images }
494
+ }
495
+ throw new ImError('im/config', `不支持的渠道类型:${channel.type}`)
496
+ }
497
+
498
+ /** List chats the app bot belongs to (helps pick a chat_id). */
499
+ export async function listBotChats(channel, secrets) {
500
+ const token = await tenantToken(channel, secrets)
501
+ const controller = new AbortController()
502
+ const timer = setTimeout(() => controller.abort(), 15000)
503
+ try {
504
+ const res = await fetch(`${hostFor(channel)}/open-apis/im/v1/chats?page_size=100`, { headers: { authorization: `Bearer ${token}` }, signal: controller.signal })
505
+ const json = await res.json().catch(() => ({}))
506
+ if (json.code !== 0) throw larkError(json, res.status)
507
+ return (json.data?.items ?? []).map(c => ({ chatId: c.chat_id, name: c.name, description: c.description ?? '' }))
508
+ } finally {
509
+ clearTimeout(timer)
510
+ }
511
+ }