@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 +22 -2
- package/README.en.md +2 -1
- package/README.md +2 -2
- package/channels/dingtalk.js +574 -0
- package/channels/http.js +63 -0
- package/channels/index.js +138 -0
- package/channels/lark.js +1180 -0
- package/channels/message.js +267 -0
- package/channels/telegram.js +663 -0
- package/channels/wecom.js +585 -0
- package/cordis.patch.yml +3 -3
- package/docs/CHANNELS.md +184 -0
- package/im.js +6 -509
- package/index.js +25 -17
- package/lark-bot.js +7 -604
- package/lib/client.js +1097 -185
- package/package.json +14 -9
- package/service/access.js +116 -22
- package/service/admin.js +29 -13
- package/service/bots.js +665 -0
- package/service/channels.js +363 -0
- package/service/datasources.js +31 -8
- package/service/push.js +5 -156
- package/service/shared.js +1 -1
- package/service/lark-actions.js +0 -223
- package/service/lark.js +0 -250
package/im.js
CHANGED
|
@@ -1,511 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* IM delivery
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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.
|
|
2
|
+
* Compatibility shim: IM delivery moved to channels/ (see docs/CHANNELS.md).
|
|
3
|
+
* Platform-neutral helpers live in channels/message.js, Lark cards and push in
|
|
4
|
+
* channels/lark.js. `ImError` is the generic ChannelError.
|
|
13
5
|
*/
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
}
|
|
6
|
+
export { ChannelError, ChannelError as ImError } from './channels/http.js'
|
|
7
|
+
export { chartOption, chartable, dualAxis, renderChartPng } from './channels/message.js'
|
|
8
|
+
export { RECEIVE_ID_TYPES, actionElement, buildCard, chartElement, larkSign, listBotChats, markdownElements, sendMessage } from './channels/lark.js'
|