@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,1208 @@
1
+ /**
2
+ * Lark / Feishu channel adapter (see docs/CHANNELS.md).
3
+ *
4
+ * Kinds:
5
+ * - lark_webhook: a group custom bot (webhook URL + optional signing secret).
6
+ * Cards with native charts; cannot upload images or receive callbacks.
7
+ * - lark_app: a custom app bot (app_id + app_secret) sending to a chat, user
8
+ * or email through the IM API; optional PNG charts; inbound bot over the
9
+ * official long connection (`im.message.receive_v1`, `card.action.trigger`).
10
+ *
11
+ * Inbound: @larksuiteoapi/node-sdk WSClient + EventDispatcher. No public URL is
12
+ * needed; the app must be configured with "使用长连接接收事件" (and the
13
+ * callback "卡片回传交互" in long-connection mode) in the developer console.
14
+ * OpenAPI helpers use plain `fetch` (no SDK) so they can be tested against a
15
+ * mock server through the optional `baseUrl` parameter.
16
+ */
17
+ import { createHash, createHmac } from 'node:crypto'
18
+ import { ChannelError, request } from './http.js'
19
+ import { MD_TABLE, cellText, chartOption, chartable, dualAxis, kpiText, isKpi, needsAttachment, num, omittedNote, parseMdTable, renderChartPng, resultMeta } from './message.js'
20
+
21
+ export { ChannelError, ChannelError as ImError, chartOption, chartable, dualAxis, renderChartPng }
22
+
23
+ // ===========================================================================
24
+ // OpenAPI helpers and long connection
25
+ // ===========================================================================
26
+
27
+ const HOSTS = {
28
+ feishu: 'https://open.feishu.cn',
29
+ lark: 'https://open.larksuite.com',
30
+ }
31
+ const DEFAULT_TIMEOUT_MS = 15000
32
+ // Feishu waits 3 s for a card callback response; answer well before that.
33
+ const CARD_ACTION_TIMEOUT_MS = 2500
34
+ const APP_ID_RE = /^cli_[0-9a-fA-F]{16}$/
35
+ // Lark codes meaning "the tenant token is invalid/expired" → drop cache, retry once.
36
+ const TOKEN_INVALID_CODES = new Set([99991661, 99991663, 99991664, 99991668])
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // HTTP helpers (plain fetch)
40
+ // ---------------------------------------------------------------------------
41
+
42
+ export function resolveBaseUrl({ domain, baseUrl } = {}) {
43
+ if (baseUrl) return String(baseUrl).replace(/\/+$/, '')
44
+ return domain === 'lark' ? HOSTS.lark : HOSTS.feishu
45
+ }
46
+
47
+ function larkError(path, body, status) {
48
+ const code = body && typeof body.code === 'number' ? body.code : undefined
49
+ const msg = (body && body.msg) || (status ? `HTTP ${status}` : 'unknown error')
50
+ const err = new Error(`Lark API ${path} failed: ${code !== undefined ? `code=${code} ` : ''}msg=${msg}`)
51
+ err.code = code
52
+ err.larkMsg = msg
53
+ err.status = status
54
+ return err
55
+ }
56
+
57
+ async function rawRequest(base, path, { method = 'GET', token, body, signal, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
58
+ const timeout = AbortSignal.timeout(timeoutMs)
59
+ const sig = signal ? AbortSignal.any([signal, timeout]) : timeout
60
+ const headers = { 'Content-Type': 'application/json; charset=utf-8' }
61
+ if (token) headers.Authorization = `Bearer ${token}`
62
+ let res
63
+ try {
64
+ res = await fetch(base + path, {
65
+ method,
66
+ headers,
67
+ body: body === undefined ? undefined : JSON.stringify(body),
68
+ signal: sig,
69
+ })
70
+ } catch (e) {
71
+ const reason = e?.name === 'TimeoutError' ? `timeout after ${timeoutMs}ms`
72
+ : e?.name === 'AbortError' ? 'aborted'
73
+ : (e?.cause?.code || e?.cause?.message || e?.message || String(e))
74
+ const err = new Error(`Lark API ${path} request failed: ${reason}`)
75
+ err.cause = e
76
+ throw err
77
+ }
78
+ const text = await res.text()
79
+ let json
80
+ try { json = text ? JSON.parse(text) : {} } catch { json = undefined }
81
+ if (!json || typeof json !== 'object') throw larkError(path, { msg: `non-JSON response (HTTP ${res.status})` }, res.status)
82
+ if (json.code !== 0) throw larkError(path, json, res.status)
83
+ return json
84
+ }
85
+
86
+ const tokenCache = new Map() // key -> { token, expiresAt } | { pending: Promise }
87
+
88
+ function tokenKey(base, appId, appSecret) {
89
+ const h = createHash('sha256').update(String(appSecret)).digest('hex').slice(0, 16)
90
+ return `${base}|${appId}|${h}`
91
+ }
92
+
93
+ /**
94
+ * getTenantToken({ appId, appSecret, domain, baseUrl?, signal?, force? }) → Promise<string>
95
+ * tenant_access_token for an internal (self-built) app, cached in memory until
96
+ * `expire - 60s`. Concurrent calls share one request.
97
+ */
98
+ export async function getTenantToken({ appId, appSecret, domain, baseUrl, signal, force = false } = {}) {
99
+ if (!appId || !appSecret) throw new Error('Lark appId and appSecret are required')
100
+ const base = resolveBaseUrl({ domain, baseUrl })
101
+ const key = tokenKey(base, appId, appSecret)
102
+ const hit = tokenCache.get(key)
103
+ if (!force && hit) {
104
+ if (hit.pending) return hit.pending
105
+ if (hit.expiresAt > Date.now()) return hit.token
106
+ }
107
+ const pending = (async () => {
108
+ const json = await rawRequest(base, '/open-apis/auth/v3/tenant_access_token/internal', {
109
+ method: 'POST',
110
+ body: { app_id: appId, app_secret: appSecret },
111
+ signal,
112
+ })
113
+ const token = json.tenant_access_token
114
+ if (!token) throw larkError('/open-apis/auth/v3/tenant_access_token/internal', { msg: 'no tenant_access_token in response' })
115
+ const expireSec = Number(json.expire) || 7200
116
+ tokenCache.set(key, { token, expiresAt: Date.now() + Math.max(0, expireSec - 60) * 1000 })
117
+ return token
118
+ })()
119
+ tokenCache.set(key, { pending })
120
+ try {
121
+ return await pending
122
+ } catch (e) {
123
+ if (tokenCache.get(key)?.pending === pending) tokenCache.delete(key)
124
+ throw e
125
+ }
126
+ }
127
+
128
+ /** Drop cached tenant tokens (all, or for one app). Mostly for tests. */
129
+ export function clearTenantTokenCache(appId) {
130
+ if (!appId) return tokenCache.clear()
131
+ for (const k of tokenCache.keys()) if (k.split('|')[1] === appId) tokenCache.delete(k)
132
+ }
133
+
134
+ /** Authenticated OpenAPI call; retries once with a fresh token on token-invalid codes. */
135
+ export async function larkApi(opts, path, { method = 'GET', body, signal } = {}) {
136
+ const base = resolveBaseUrl(opts)
137
+ const call = async (force) => {
138
+ const token = await getTenantToken({ ...opts, signal: signal || opts.signal, force })
139
+ return rawRequest(base, path, { method, token, body, signal: signal || opts.signal })
140
+ }
141
+ try {
142
+ return await call(false)
143
+ } catch (e) {
144
+ if (TOKEN_INVALID_CODES.has(e.code)) return call(true)
145
+ throw e
146
+ }
147
+ }
148
+
149
+ const enc = encodeURIComponent
150
+
151
+ /**
152
+ * replyMessage({ appId, appSecret, domain, baseUrl?, messageId, card, inThread = false, uuid? })
153
+ * → Promise<string> reply message_id. Throws Error (err.code = Lark code) on failure.
154
+ */
155
+ export async function replyMessage({ messageId, card, inThread = false, uuid, ...opts }) {
156
+ if (!messageId) throw new Error('messageId is required')
157
+ const body = { msg_type: 'interactive', content: typeof card === 'string' ? card : JSON.stringify(card), reply_in_thread: !!inThread }
158
+ if (uuid) body.uuid = uuid
159
+ const json = await larkApi(opts, `/open-apis/im/v1/messages/${enc(messageId)}/reply`, { method: 'POST', body })
160
+ return json.data?.message_id
161
+ }
162
+
163
+ /** replyText({ …, messageId, text, inThread = false }) → Promise<string> reply message_id. */
164
+ export async function replyText({ messageId, text, inThread = false, uuid, ...opts }) {
165
+ if (!messageId) throw new Error('messageId is required')
166
+ const body = { msg_type: 'text', content: JSON.stringify({ text: String(text ?? '') }), reply_in_thread: !!inThread }
167
+ if (uuid) body.uuid = uuid
168
+ const json = await larkApi(opts, `/open-apis/im/v1/messages/${enc(messageId)}/reply`, { method: 'POST', body })
169
+ return json.data?.message_id
170
+ }
171
+
172
+ /**
173
+ * sendText({ …, chatId, text }) → Promise<string> message_id. Sends a new text
174
+ * message to a chat. If only `messageId` is given it behaves like replyText.
175
+ */
176
+ export async function sendText({ chatId, messageId, text, uuid, ...opts }) {
177
+ if (!chatId && messageId) return replyText({ messageId, text, uuid, ...opts })
178
+ if (!chatId) throw new Error('chatId (or messageId) is required')
179
+ const body = { receive_id: chatId, msg_type: 'text', content: JSON.stringify({ text: String(text ?? '') }) }
180
+ if (uuid) body.uuid = uuid
181
+ const json = await larkApi(opts, '/open-apis/im/v1/messages?receive_id_type=chat_id', { method: 'POST', body })
182
+ return json.data?.message_id
183
+ }
184
+
185
+ /** sendCard({ …, chatId, card }) → Promise<string> message_id (new interactive message in a chat). */
186
+ export async function sendCard({ chatId, card, uuid, ...opts }) {
187
+ if (!chatId) throw new Error('chatId is required')
188
+ const body = { receive_id: chatId, msg_type: 'interactive', content: typeof card === 'string' ? card : JSON.stringify(card) }
189
+ if (uuid) body.uuid = uuid
190
+ const json = await larkApi(opts, '/open-apis/im/v1/messages?receive_id_type=chat_id', { method: 'POST', body })
191
+ return json.data?.message_id
192
+ }
193
+
194
+ /**
195
+ * updateCard({ …, messageId, card }) → Promise<void>. PATCH an interactive
196
+ * message previously sent by this bot (e.g. replace "正在查询…" with the result).
197
+ * The card must contain `config.update_multi: true` to be updatable for everyone.
198
+ */
199
+ export async function updateCard({ messageId, card, ...opts }) {
200
+ if (!messageId) throw new Error('messageId is required')
201
+ await larkApi(opts, `/open-apis/im/v1/messages/${enc(messageId)}`, {
202
+ method: 'PATCH',
203
+ body: { content: typeof card === 'string' ? card : JSON.stringify(card) },
204
+ })
205
+ }
206
+
207
+ /** addReaction({ …, messageId, emojiType }) → Promise<string|null> reaction_id. Best effort, never throws. */
208
+ export async function addReaction({ messageId, emojiType, logger, ...opts }) {
209
+ try {
210
+ const json = await larkApi(opts, `/open-apis/im/v1/messages/${enc(messageId)}/reactions`, {
211
+ method: 'POST',
212
+ body: { reaction_type: { emoji_type: emojiType } },
213
+ })
214
+ return json.data?.reaction_id ?? null
215
+ } catch (e) {
216
+ logger?.debug?.(`[lark] addReaction ${emojiType} failed: ${e.message}`)
217
+ return null
218
+ }
219
+ }
220
+
221
+ /** getChatInfo({ …, chatId }) → Promise<{ name? }>. Best effort ({} on failure). */
222
+ export async function getChatInfo({ chatId, logger, ...opts }) {
223
+ try {
224
+ const json = await larkApi(opts, `/open-apis/im/v1/chats/${enc(chatId)}`)
225
+ const d = json.data || {}
226
+ return d.name !== undefined ? { name: d.name } : {}
227
+ } catch (e) {
228
+ logger?.debug?.(`[lark] getChatInfo failed: ${e.message}`)
229
+ return {}
230
+ }
231
+ }
232
+
233
+ /** getUserInfo({ …, openId }) → Promise<{ name?, email? }>. Needs contact permission; {} on failure. */
234
+ export async function getUserInfo({ openId, logger, ...opts }) {
235
+ try {
236
+ const json = await larkApi(opts, `/open-apis/contact/v3/users/${enc(openId)}?user_id_type=open_id`)
237
+ const u = json.data?.user || {}
238
+ const out = {}
239
+ if (u.name) out.name = u.name
240
+ if (u.email || u.enterprise_email) out.email = u.email || u.enterprise_email
241
+ return out
242
+ } catch (e) {
243
+ logger?.debug?.(`[lark] getUserInfo failed: ${e.message}`)
244
+ return {}
245
+ }
246
+ }
247
+
248
+ /** getBotInfo({ … }) → Promise<{ openId, name }>. Throws on failure. */
249
+ export async function getBotInfo(opts) {
250
+ const json = await larkApi(opts, '/open-apis/bot/v3/info')
251
+ const bot = json.bot || json.data?.bot || {}
252
+ return { openId: bot.open_id, name: bot.app_name }
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Event normalization
257
+ // ---------------------------------------------------------------------------
258
+
259
+ const MENTION_RE = /@_user_\d+/g
260
+
261
+ function stripMentions(s) {
262
+ return String(s ?? '').replace(MENTION_RE, '').replace(/[ \t]{2,}/g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n[ \t]+/g, '\n').trim()
263
+ }
264
+
265
+ function postToText(content) {
266
+ if (!content || typeof content !== 'object') return ''
267
+ // Received posts are { title, content: [[el…]…] }; tolerate the send-side
268
+ // locale wrapper { zh_cn: { title, content } } too.
269
+ let post = content
270
+ if (!Array.isArray(post.content)) {
271
+ const inner = Object.values(content).find((v) => v && typeof v === 'object' && Array.isArray(v.content))
272
+ if (!inner) return ''
273
+ post = inner
274
+ }
275
+ const lines = []
276
+ if (post.title) lines.push(String(post.title))
277
+ for (const line of post.content) {
278
+ if (!Array.isArray(line)) continue
279
+ let s = ''
280
+ for (const el of line) {
281
+ if (!el || typeof el !== 'object') continue
282
+ if (el.tag === 'text' || el.tag === 'a' || el.tag === 'md' || el.tag === 'code_block') s += el.text ?? ''
283
+ else if (el.tag === 'at') s += el.text ?? el.user_id ?? '' // placeholder (@_user_N) is stripped below
284
+ }
285
+ lines.push(s)
286
+ }
287
+ return stripMentions(lines.join('\n')).replace(/\n{3,}/g, '\n\n')
288
+ }
289
+
290
+ /**
291
+ * normalizeMessageEvent(data, { botOpenId? }) → normalized message | null
292
+ * `data` may be the flattened object the SDK EventDispatcher passes to handlers
293
+ * ({ message, sender, event_id, … }) or a raw v2 envelope ({ schema, header, event }).
294
+ * Returns null for events that must be ignored (sent by a bot/app, malformed).
295
+ */
296
+ export function normalizeMessageEvent(data, { botOpenId } = {}) {
297
+ const ev = data && data.event && !data.message ? data.event : data
298
+ const message = ev?.message
299
+ const sender = ev?.sender
300
+ if (!message || !message.message_id) return null
301
+ if (sender && (sender.sender_type === 'app' || sender.sender_type === 'bot')) return null
302
+
303
+ const mentions = (Array.isArray(message.mentions) ? message.mentions : []).map((m) => {
304
+ const openId = m?.id?.open_id
305
+ const isBot = botOpenId ? openId === botOpenId : m?.mentioned_type === 'bot'
306
+ return { key: m?.key, name: m?.name, openId, userId: m?.id?.user_id, unionId: m?.id?.union_id, isBot: !!isBot }
307
+ })
308
+
309
+ let content = {}
310
+ try { content = typeof message.content === 'string' ? JSON.parse(message.content) : (message.content || {}) } catch { content = {} }
311
+
312
+ const messageType = message.message_type
313
+ let text = ''
314
+ if (messageType === 'text') text = stripMentions(content.text)
315
+ else if (messageType === 'post') text = postToText(content)
316
+
317
+ const chatType = message.chat_type === 'p2p' ? 'p2p' : 'group'
318
+ const sid = sender?.sender_id || {}
319
+ const out = {
320
+ messageId: message.message_id,
321
+ chatId: message.chat_id,
322
+ chatType,
323
+ messageType,
324
+ text,
325
+ mentionedBot: chatType === 'p2p' || mentions.some((m) => m.isBot),
326
+ mentions,
327
+ sender: { openId: sid.open_id, userId: sid.user_id, unionId: sid.union_id },
328
+ rootId: message.root_id || undefined,
329
+ parentId: message.parent_id || undefined,
330
+ threadId: message.thread_id || undefined,
331
+ createTime: message.create_time ? Number(message.create_time) : undefined,
332
+ eventId: data?.event_id ?? data?.header?.event_id,
333
+ }
334
+ return out
335
+ }
336
+
337
+ /**
338
+ * normalizeCardActionEvent(data) → normalized card callback | null
339
+ * `card.action.trigger` (card JSON 2.0 callback) as delivered over the long
340
+ * connection: the SDK EventDispatcher flattens { schema, header, event } into
341
+ * one object; a raw envelope is accepted too. Message/chat ids live under
342
+ * `event.context` (older surfaces put them at the root).
343
+ * Everything here comes from the client and is untrusted: `value`, `option`
344
+ * and `formValue` must only be used as lookup keys, never for authorization.
345
+ */
346
+ export function normalizeCardActionEvent(data) {
347
+ if (!data || typeof data !== 'object') return null
348
+ const ev = data.event && !data.action ? data.event : data
349
+ const header = data.header ?? data
350
+ const op = ev?.operator
351
+ const action = ev?.action
352
+ if (!op?.open_id || !action || typeof action !== 'object') return null
353
+ let value = action.value
354
+ if (typeof value === 'string') { try { value = JSON.parse(value) } catch { /* keep string */ } }
355
+ const ctx = ev.context ?? {}
356
+ return {
357
+ eventId: header.event_id ?? data.event_id,
358
+ operator: { openId: op.open_id, userId: op.user_id || undefined, unionId: op.union_id || undefined, name: op.name || undefined },
359
+ tag: action.tag ?? 'unknown',
360
+ name: action.name || undefined,
361
+ value: value && typeof value === 'object' && !Array.isArray(value) ? value : (value === undefined ? {} : { raw: value }),
362
+ option: typeof action.option === 'string' ? action.option : undefined,
363
+ options: Array.isArray(action.options) ? action.options.map(String) : undefined,
364
+ formValue: action.form_value && typeof action.form_value === 'object' ? action.form_value : undefined,
365
+ inputValue: typeof action.input_value === 'string' ? action.input_value : undefined,
366
+ messageId: ctx.open_message_id ?? ev.open_message_id,
367
+ chatId: ctx.open_chat_id ?? ev.open_chat_id,
368
+ host: ev.host,
369
+ }
370
+ }
371
+
372
+ /** createDedupe(max = 500) → (id) => boolean (true = first time seen, false = duplicate). LRU. */
373
+ export function createDedupe(max = 500) {
374
+ const seen = new Map()
375
+ return (id) => {
376
+ if (!id) return true
377
+ if (seen.has(id)) {
378
+ seen.delete(id)
379
+ seen.set(id, true)
380
+ return false
381
+ }
382
+ seen.set(id, true)
383
+ if (seen.size > max) seen.delete(seen.keys().next().value)
384
+ return true
385
+ }
386
+ }
387
+
388
+ // ---------------------------------------------------------------------------
389
+ // Bot (WebSocket long connection)
390
+ // ---------------------------------------------------------------------------
391
+
392
+ const LEVELS = { fatal: 0, error: 1, warn: 2, info: 3, debug: 4, trace: 5 }
393
+
394
+ function fmtArgs(args) {
395
+ const flat = args.length === 1 && Array.isArray(args[0]) ? args[0] : args
396
+ return flat.map((a) => (a instanceof Error ? a.message : typeof a === 'string' ? a : (() => { try { return JSON.stringify(a) } catch { return String(a) } })())).join(' ')
397
+ }
398
+
399
+ let sdkPromise
400
+ function loadSdk() {
401
+ sdkPromise ??= import('@larksuiteoapi/node-sdk').then((m) => (m.WSClient ? m : m.default))
402
+ return sdkPromise
403
+ }
404
+
405
+ /**
406
+ * createLarkBot({ appId, appSecret, domain: 'feishu'|'lark', onMessage, onStatus, logger, logLevel = 'warn', baseUrl? })
407
+ * → { start(): Promise<void>, stop(): Promise<void>, status(): Status, botInfo(): { openId?, name? } }
408
+ *
409
+ * Status: { state: 'idle'|'connecting'|'connected'|'error'|'stopped', error?, since, lastEventAt? }
410
+ *
411
+ * start() never rejects: it resolves once the first connection attempt has
412
+ * settled (connected, or failed → state 'error'). The SDK keeps retrying
413
+ * transient (network) failures in the background; credential/config failures
414
+ * are terminal until start() is called again.
415
+ */
416
+ export function createLarkBot({ appId, appSecret, domain = 'feishu', onMessage, onCardAction, onStatus, logger, logLevel = 'warn', baseUrl, cardActionTimeoutMs = CARD_ACTION_TIMEOUT_MS } = {}) {
417
+ const log = {
418
+ error: (m) => logger?.error?.(m),
419
+ warn: (m) => logger?.warn?.(m),
420
+ info: (m) => logger?.info?.(m),
421
+ debug: (m) => logger?.debug?.(m),
422
+ }
423
+ const http = { appId, appSecret, domain, baseUrl }
424
+ const dedupe = createDedupe(500)
425
+ const cardDedupe = createDedupe(500)
426
+ let st = { state: 'idle', since: Date.now() }
427
+ let lastEventAt
428
+ let wsClient = null
429
+ let abort = null
430
+ let gen = 0
431
+ let starting = null
432
+ let settleFirst = null
433
+ let bot = {}
434
+
435
+ function setStatus(state, error) {
436
+ if (st.state === state && st.error === error) return
437
+ st = { state, since: Date.now(), ...(error ? { error } : {}) }
438
+ try { onStatus?.(status()) } catch (e) { log.error(`[lark] onStatus threw: ${e?.message || e}`) }
439
+ }
440
+ function status() {
441
+ return { ...st, ...(lastEventAt ? { lastEventAt } : {}) }
442
+ }
443
+
444
+ const userLevel = LEVELS[logLevel] ?? LEVELS.warn
445
+ function sdkLogger(myGen) {
446
+ // The SDK calls logger.<level>(argsArray). Errors logged while (re)connecting
447
+ // are the only signal for retryable failures, so surface them in status.
448
+ const forward = (level) => (...args) => {
449
+ if (LEVELS[level] > userLevel && level !== 'error') return
450
+ const msg = `[lark-sdk] ${fmtArgs(args)}`
451
+ if (level === 'error') {
452
+ if (myGen === gen && (st.state === 'connecting' || st.state === 'error') && /\[ws\]/.test(msg) && !/discarded socket/.test(msg)) {
453
+ const reason = msg.replace(/^\[lark-sdk\]\s*\[ws\]\s*/, '')
454
+ if (reason !== 'connect failed' || !st.error) setStatus('error', `Lark long connection failed: ${reason} (retrying)`)
455
+ settleFirst?.()
456
+ }
457
+ if (userLevel >= LEVELS.error) log.error(msg)
458
+ } else if (level === 'warn') log.warn(msg)
459
+ else if (level === 'info') log.info(msg)
460
+ else log.debug(msg)
461
+ }
462
+ return { error: forward('error'), warn: forward('warn'), info: forward('info'), debug: forward('debug'), trace: forward('trace') }
463
+ }
464
+
465
+ function handleEvent(data) {
466
+ // Must return quickly: the SDK acks the frame after this resolves.
467
+ try {
468
+ lastEventAt = Date.now()
469
+ const msg = normalizeMessageEvent(data, { botOpenId: bot.openId })
470
+ if (!msg) return
471
+ if (!dedupe(msg.messageId)) {
472
+ log.debug(`[lark] duplicate event for ${msg.messageId} ignored`)
473
+ return
474
+ }
475
+ if (!onMessage) return
476
+ Promise.resolve()
477
+ .then(() => onMessage(msg))
478
+ .catch((e) => log.error(`[lark] onMessage handler failed: ${e?.stack || e?.message || e}`))
479
+ } catch (e) {
480
+ log.error(`[lark] failed to handle im.message.receive_v1: ${e?.message || e}`)
481
+ }
482
+ }
483
+
484
+ /**
485
+ * card.action.trigger: the value returned here is what the SDK sends back
486
+ * as the callback response (WSClient.handleEventData base64-encodes the
487
+ * handler result into the ack frame). Feishu needs it within 3 s, so the
488
+ * handler is raced against a timeout and falls back to a neutral toast.
489
+ */
490
+ async function handleCardAction(data) {
491
+ lastEventAt = Date.now()
492
+ let evt
493
+ try { evt = normalizeCardActionEvent(data) } catch (e) { log.error(`[lark] bad card.action.trigger: ${e?.message || e}`) }
494
+ if (!evt) return undefined
495
+ if (evt.eventId && !cardDedupe(evt.eventId)) return { toast: { type: 'info', content: '已收到,正在处理…' } }
496
+ if (!onCardAction) return undefined
497
+ let timer
498
+ try {
499
+ const res = await Promise.race([
500
+ Promise.resolve().then(() => onCardAction(evt)),
501
+ new Promise((resolve) => { timer = setTimeout(() => resolve({ toast: { type: 'info', content: '已收到,正在处理…' } }), cardActionTimeoutMs) }),
502
+ ])
503
+ return res ?? undefined
504
+ } catch (e) {
505
+ log.error(`[lark] onCardAction handler failed: ${e?.stack || e?.message || e}`)
506
+ return { toast: { type: 'error', content: '处理失败,请稍后重试' } }
507
+ } finally {
508
+ clearTimeout(timer)
509
+ }
510
+ }
511
+
512
+ async function doStart(myGen) {
513
+ setStatus('connecting')
514
+ if (!appId || !appSecret) return setStatus('error', 'Lark App ID and App Secret are required')
515
+ if (!APP_ID_RE.test(appId)) return setStatus('error', `Invalid Lark App ID "${appId}" (expected cli_ followed by 16 hex chars)`)
516
+ abort = new AbortController()
517
+ const signal = abort.signal
518
+ try {
519
+ // Validates credentials with a readable error before opening the socket.
520
+ await getTenantToken({ ...http, signal })
521
+ } catch (e) {
522
+ if (myGen !== gen) return
523
+ return setStatus('error', `Lark credentials check failed: ${e.message}`)
524
+ }
525
+ if (myGen !== gen) return
526
+ try {
527
+ bot = await getBotInfo({ ...http, signal })
528
+ if (!bot.openId) log.warn('[lark] bot info has no open_id; @bot detection falls back to mentioned_type')
529
+ } catch (e) {
530
+ if (myGen !== gen) return
531
+ log.warn(`[lark] could not fetch bot info (is the bot capability enabled?): ${e.message}`)
532
+ }
533
+ if (myGen !== gen) return
534
+
535
+ let lark
536
+ try {
537
+ lark = await loadSdk()
538
+ } catch (e) {
539
+ return setStatus('error', `Failed to load @larksuiteoapi/node-sdk: ${e?.message || e}`)
540
+ }
541
+ if (myGen !== gen) return
542
+
543
+ // The SDK must always emit errors to us (they drive status); we filter by userLevel.
544
+ const level = Math.max(userLevel, LEVELS.error)
545
+ const sdkLog = sdkLogger(myGen)
546
+ const firstSettled = new Promise((resolve) => { settleFirst = resolve })
547
+ let client
548
+ try {
549
+ const params = {
550
+ appId,
551
+ appSecret,
552
+ domain: baseUrl || (domain === 'lark' ? lark.Domain.Lark : lark.Domain.Feishu),
553
+ loggerLevel: level,
554
+ logger: sdkLog,
555
+ autoReconnect: true,
556
+ handshakeTimeoutMs: DEFAULT_TIMEOUT_MS,
557
+ source: 'dsh-nl2sql',
558
+ onReady: () => { if (myGen === gen) { setStatus('connected'); settleFirst?.() } },
559
+ onReconnecting: () => { if (myGen === gen) setStatus('connecting') },
560
+ onReconnected: () => { if (myGen === gen) setStatus('connected') },
561
+ onError: (err) => { if (myGen === gen) { setStatus('error', `Lark long connection failed: ${err?.message || err}`); settleFirst?.() } },
562
+ }
563
+ client = new lark.WSClient(params)
564
+ const dispatcher = new lark.EventDispatcher({ loggerLevel: level, logger: sdkLog }).register({
565
+ 'im.message.receive_v1': (data) => { handleEvent(data) },
566
+ // Card callbacks arrive over the same long connection as event frames
567
+ // whose header.event_type is card.action.trigger (requires subscribing to
568
+ // the callback in the developer console, long-connection mode).
569
+ 'card.action.trigger': (data) => handleCardAction(data),
570
+ })
571
+ wsClient = client
572
+ await client.start({ eventDispatcher: dispatcher })
573
+ } catch (e) {
574
+ if (client) { try { client.close({ force: true }) } catch {} }
575
+ if (wsClient === client) wsClient = null
576
+ if (myGen === gen) setStatus('error', `Failed to start Lark long connection: ${e?.message || e}`)
577
+ return
578
+ }
579
+ await firstSettled
580
+ settleFirst = null
581
+ }
582
+
583
+ function start() {
584
+ if (starting) return starting
585
+ if (wsClient && (st.state === 'connected' || st.state === 'connecting')) return Promise.resolve()
586
+ if (wsClient) closeClient()
587
+ const myGen = ++gen
588
+ starting = doStart(myGen)
589
+ .catch((e) => { if (myGen === gen) setStatus('error', `Lark bot start failed: ${e?.message || e}`) })
590
+ .finally(() => { if (myGen === gen) starting = null })
591
+ return starting
592
+ }
593
+
594
+ function closeClient() {
595
+ const c = wsClient
596
+ wsClient = null
597
+ if (c) {
598
+ // close({ force: true }) sets the SDK's `closed` flag, bumps its reconnect
599
+ // generation, clears ping/reconnect/liveness timers and the fragment-cache
600
+ // interval, and terminates the socket immediately (no 30s close handshake).
601
+ try { c.close({ force: true }) } catch (e) { log.debug(`[lark] close failed: ${e?.message || e}`) }
602
+ }
603
+ }
604
+
605
+ async function stop() {
606
+ gen++
607
+ starting = null
608
+ abort?.abort()
609
+ abort = null
610
+ settleFirst?.()
611
+ settleFirst = null
612
+ closeClient()
613
+ setStatus('stopped')
614
+ }
615
+
616
+ return { start, stop, status, botInfo: () => ({ ...bot }) }
617
+ }
618
+
619
+ // ===========================================================================
620
+ // Push: signing, tokens, image upload, errors
621
+ // ===========================================================================
622
+
623
+ export const RECEIVE_ID_TYPES = ['chat_id', 'open_id', 'user_id', 'union_id', 'email']
624
+ const WEBHOOK_RE = /^https:\/\/open\.(feishu\.cn|larksuite\.com)\/open-apis\/bot\/v2\/hook\/[\w-]+$/
625
+
626
+ function hostFor(channel) {
627
+ return resolveBaseUrl({ domain: channel.domain, baseUrl: channel.baseUrl })
628
+ }
629
+
630
+ function postJson(url, body, headers = {}, timeoutMs = 15000) {
631
+ return request(url, { method: 'POST', headers, json: body, timeoutMs, label: '飞书' })
632
+ }
633
+
634
+ /** Custom-bot signature: HMAC-SHA256 keyed by `${timestamp}\n${secret}` over an empty message, base64. */
635
+ export function larkSign(secret, timestamp) {
636
+ return createHmac('sha256', `${timestamp}\n${secret}`).update('').digest('base64')
637
+ }
638
+
639
+ /** A Lark JSON error response as a ChannelError with a Chinese hint. Codes stay `im/lark-<code>` for compatibility. */
640
+ export function larkPushError(json, status) {
641
+ const code = json?.code ?? json?.StatusCode
642
+ const msg = json?.msg ?? json?.StatusMessage ?? json?.raw ?? `HTTP ${status}`
643
+ const hints = {
644
+ 19021: '签名校验失败:请检查签名密钥,以及本机时间是否准确',
645
+ 19024: '关键词校验失败:消息中没有包含机器人设置的关键词',
646
+ 19022: 'IP 不在机器人白名单中',
647
+ 9499: '请求格式错误',
648
+ 11232: '发送频率过高,请稍后再试',
649
+ 99991663: 'tenant_access_token 无效',
650
+ 99991672: '应用缺少权限,请在开发者后台为应用开通 im:message:send_as_bot 和 im:resource 权限',
651
+ 230002: '机器人不在该群中,请先把应用机器人拉进群',
652
+ 230001: '接收者 ID 无效',
653
+ 230013: '接收者不在应用的可用范围内',
654
+ 10003: 'app_id 或 app_secret 错误',
655
+ }
656
+ return new ChannelError(`im/lark-${code ?? status}`, `${hints[code] ? `${hints[code]}(${msg})` : `飞书返回错误 ${code ?? status}:${msg}`}`)
657
+ }
658
+
659
+ async function pushToken(channel, secrets) {
660
+ if (!channel.appId || !secrets.appSecret) throw new ChannelError('im/config', '缺少 App ID 或 App Secret')
661
+ try {
662
+ return await getTenantToken({ appId: channel.appId, appSecret: secrets.appSecret, domain: channel.domain, baseUrl: channel.baseUrl })
663
+ } catch (error) {
664
+ if (error instanceof ChannelError) throw error
665
+ if (typeof error.code === 'number') throw larkPushError({ code: error.code, msg: error.larkMsg }, error.status)
666
+ throw new ChannelError('channel/network', `无法访问飞书接口:${error.message}`)
667
+ }
668
+ }
669
+
670
+ async function uploadImage(channel, secrets, png) {
671
+ const token = await pushToken(channel, secrets)
672
+ const form = new FormData()
673
+ form.set('image_type', 'message')
674
+ form.set('image', new Blob([png], { type: 'image/png' }), 'chart.png')
675
+ const { status, json } = await request(`${hostFor(channel)}/open-apis/im/v1/images`, { method: 'POST', headers: { authorization: `Bearer ${token}` }, body: form, timeoutMs: 30000, label: '飞书' })
676
+ if (json.code !== 0) throw larkPushError(json, status)
677
+ return json.data.image_key
678
+ }
679
+
680
+ /** Upload a file (≤ 30 MB) for a file message; returns file_key. https://open.feishu.cn/document/server-docs/im-v1/file/create */
681
+ async function uploadFile(channel, secrets, buf, fileName, fileType = 'xls') {
682
+ const token = await pushToken(channel, secrets)
683
+ const form = new FormData()
684
+ form.set('file_type', fileType)
685
+ form.set('file_name', fileName)
686
+ form.set('file', new Blob([buf], { type: 'application/octet-stream' }), fileName)
687
+ const { status, json } = await request(`${hostFor(channel)}/open-apis/im/v1/files`, { method: 'POST', headers: { authorization: `Bearer ${token}` }, body: form, timeoutMs: 60000, label: '飞书' })
688
+ if (json.code !== 0) throw larkPushError(json, status)
689
+ return json.data.file_key
690
+ }
691
+
692
+ // ===========================================================================
693
+ // Cards (JSON 2.0)
694
+ // ===========================================================================
695
+
696
+ const CARD_LIMIT = 28000
697
+
698
+ function tableElement(result, maxRows) {
699
+ const cols = result.columns.slice(0, 8)
700
+ return {
701
+ tag: 'table',
702
+ // page_size is 1..10 per the card spec; more rows are paged in the client.
703
+ page_size: Math.min(10, Math.max(1, Math.min(maxRows, result.rows.length))),
704
+ row_height: 'low',
705
+ header_style: { background_style: 'grey', bold: true, text_size: 'normal' },
706
+ 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' })),
707
+ 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])]))),
708
+ }
709
+ }
710
+
711
+ /**
712
+ * A native card chart (VChart spec) for a result. Works for webhook and app
713
+ * bots alike and stays interactive in the client.
714
+ */
715
+ export function chartElement(result, { maxPoints = 200 } = {}) {
716
+ const chart = result.chart ?? {}
717
+ const idx = (name) => result.columns.findIndex(c => c.name === name)
718
+ const xi = idx(chart.x)
719
+ const ys = (chart.y ?? []).map(idx).filter(i => i >= 0)
720
+ const si = chart.series ? idx(chart.series) : -1
721
+ if (xi < 0 || !ys.length) return undefined
722
+ const rows = result.rows.slice(0, maxPoints)
723
+ const label = (v) => (v === null || v === undefined ? 'NULL' : String(v))
724
+ let spec
725
+ if (chart.type === 'pie' || chart.type === 'funnel') {
726
+ const values = rows.slice(0, 20).map(r => ({ type: label(r[xi]), value: num(r[ys[0]]) }))
727
+ spec = chart.type === 'pie'
728
+ ? { type: 'pie', data: { values }, valueField: 'value', categoryField: 'type', outerRadius: 0.85, innerRadius: 0.45, label: { visible: true }, legends: { visible: true, orient: 'bottom' } }
729
+ : { type: 'funnel', data: { values }, categoryField: 'type', valueField: 'value', isTransform: true, label: { visible: true }, transformLabel: { visible: true }, outerLabel: { visible: false } }
730
+ } else if (chart.type === 'scatter') {
731
+ 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 } }] }
732
+ } else {
733
+ // Long format: one row per (x, series) so one spec covers multi-measure and series charts.
734
+ const values = []
735
+ if (si >= 0) {
736
+ for (const r of rows) values.push({ x: label(r[xi]), s: label(r[si]), v: num(r[ys[0]]) })
737
+ } else {
738
+ 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]) })
739
+ }
740
+ const multi = si >= 0 || ys.length > 1
741
+ const type = chart.type === 'line' ? 'line' : chart.type === 'area' ? 'area' : 'bar'
742
+ const horizontal = chart.type === 'barh'
743
+ // Second axis when magnitudes differ by more than 10x (two-measure line/area/bar).
744
+ if (!horizontal && chart.type !== 'stacked' && si < 0 && ys.length === 2) {
745
+ const peaks = ys.map(i => Math.max(0, ...rows.map(r => Math.abs(num(r[i]) ?? 0))))
746
+ const [hi, lo] = peaks[0] >= peaks[1] ? [0, 1] : [1, 0]
747
+ if (peaks[lo] > 0 && peaks[hi] / peaks[lo] > 10) {
748
+ const name = (k) => result.columns[ys[k]].name
749
+ spec = {
750
+ type: 'common',
751
+ 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) })) }],
752
+ series: [
753
+ { type: type === 'bar' ? 'bar' : type, dataIndex: 0, xField: 'x', yField: 'v', seriesField: 's' },
754
+ { type: 'line', dataIndex: 1, xField: 'x', yField: 'v', seriesField: 's' },
755
+ ],
756
+ axes: [{ orient: 'left', seriesIndex: [0] }, { orient: 'right', seriesIndex: [1], grid: { visible: false } }, { orient: 'bottom', type: 'band' }],
757
+ legends: { visible: true, orient: 'bottom' },
758
+ }
759
+ }
760
+ }
761
+ spec ??= horizontal
762
+ ? { type: 'bar', direction: 'horizontal', data: { values }, xField: 'v', yField: multi ? ['x', 's'] : 'x', ...(multi ? { seriesField: 's' } : {}), axes: [{ orient: 'left', type: 'band', inverse: true }] }
763
+ : {
764
+ type, data: { values },
765
+ xField: type === 'bar' && multi && chart.type !== 'stacked' ? ['x', 's'] : 'x',
766
+ yField: 'v',
767
+ ...(multi ? { seriesField: 's', legends: { visible: true, orient: 'bottom' } } : {}),
768
+ ...(chart.type === 'stacked' ? { stack: true } : {}),
769
+ ...(type !== 'bar' ? { point: { visible: rows.length <= 31 } } : {}),
770
+ }
771
+ }
772
+ return { tag: 'chart', aspect_ratio: chart.type === 'pie' || chart.type === 'funnel' ? '4:3' : '16:9', chart_spec: { ...spec, title: { visible: false } } }
773
+ }
774
+
775
+ /** Split Markdown into card elements: text stays Markdown; large tables become table components. */
776
+ export function markdownElements(md) {
777
+ const text = String(md ?? '').replace(/\r\n?/g, '\n').trim()
778
+ if (!text) return []
779
+ const out = []
780
+ let last = 0
781
+ let mdTables = 0
782
+ // Feishu markdown tables show at most five rows per page and four tables per
783
+ // component; bigger tables become table components instead.
784
+ for (const m of text.matchAll(MD_TABLE)) {
785
+ const block = m[2]
786
+ const start = m.index + m[1].length
787
+ const table = parseMdTable(block)
788
+ if (table.rows.length <= 5 && mdTables < 4 && table.columns.length <= 8) { mdTables++; continue }
789
+ const before = text.slice(last, start).trim()
790
+ if (before) out.push({ tag: 'markdown', content: before })
791
+ out.push(tableElement(table, 50))
792
+ last = start + block.length
793
+ }
794
+ const rest = text.slice(last).trim()
795
+ if (rest) {
796
+ // A component holds at most ~10k characters comfortably; split long text on paragraph boundaries.
797
+ let chunk = ''
798
+ for (const para of rest.split(/\n{2,}/)) {
799
+ if (chunk && chunk.length + para.length > 6000) { out.push({ tag: 'markdown', content: chunk }); chunk = '' }
800
+ chunk = chunk ? `${chunk}\n\n${para}` : para
801
+ }
802
+ if (chunk) out.push({ tag: 'markdown', content: chunk })
803
+ }
804
+ return out
805
+ }
806
+
807
+ /**
808
+ * Interactive controls under one result (card JSON 2.0, callback behaviors).
809
+ * spec (ActionSpec): { buttons?: [{ text, value, type? }], select?: { placeholder, value, options: [{ text, value }] } }
810
+ * `value` objects are echoed back verbatim in card.action.trigger: keep them
811
+ * opaque (a short token) — the receiver must not trust them.
812
+ */
813
+ export function actionElement(spec) {
814
+ const items = []
815
+ const plain = (s, n = 40) => ({ tag: 'plain_text', content: String(s ?? '').slice(0, n) })
816
+ if (spec?.select?.options?.length) {
817
+ items.push({
818
+ tag: 'select_static',
819
+ placeholder: plain(spec.select.placeholder),
820
+ width: 'default',
821
+ options: spec.select.options.slice(0, 10).map(o => ({ text: plain(o.text, 60), value: String(o.value).slice(0, 64) })),
822
+ behaviors: [{ type: 'callback', value: spec.select.value }],
823
+ })
824
+ }
825
+ for (const b of spec?.buttons ?? []) {
826
+ items.push({ tag: 'button', text: plain(b.text, 20), type: b.type ?? 'default', size: 'small', behaviors: [{ type: 'callback', value: b.value }] })
827
+ }
828
+ if (!items.length) return undefined
829
+ return { tag: 'column_set', flex_mode: 'flow', horizontal_spacing: 'small', columns: items.map(el => ({ tag: 'column', width: 'auto', vertical_align: 'center', elements: [el] })) }
830
+ }
831
+
832
+ /**
833
+ * Build a JSON 2.0 card from an OutboundMessage.
834
+ * Results may carry `imageKey` (uploaded PNG) and `actions` (ActionSpec; legacy name `cardActions`).
835
+ */
836
+ export function buildCard(message, { maxRows = 50, charts = true, actions = true } = {}) {
837
+ const elements = [...markdownElements(message.text)]
838
+ let tables = elements.filter(e => e.tag === 'table').length
839
+ let chartCount = 0
840
+ for (const r of message.results ?? []) {
841
+ if (elements.length) elements.push({ tag: 'hr' })
842
+ if (r.error) {
843
+ elements.push({ tag: 'markdown', content: `**${r.title || '查询'}**\n<font color='red'>${r.error}</font>` })
844
+ continue
845
+ }
846
+ elements.push({ tag: 'markdown', content: [`**${r.title || '查询结果'}**`, `<font color='grey'>${resultMeta(r)}</font>`].join(' ') })
847
+ const nativeChart = charts && chartCount < 5 && chartable(r) ? chartElement(r) : undefined
848
+ if (r.rows.length === 0) {
849
+ elements.push({ tag: 'markdown', content: '<font color=\'grey\'>无数据</font>' })
850
+ } else if (isKpi(r)) {
851
+ elements.push({ tag: 'markdown', content: kpiText(r) })
852
+ } else if (nativeChart) {
853
+ elements.push(nativeChart)
854
+ chartCount++
855
+ } else if (r.imageKey) {
856
+ elements.push({ tag: 'img', img_key: r.imageKey, alt: { tag: 'plain_text', content: r.title || 'chart' }, scale_type: 'fit_horizontal', preview: true })
857
+ } else if (tables < 5) {
858
+ elements.push(tableElement(r, maxRows))
859
+ tables++
860
+ const note = omittedNote(r, Math.min(maxRows, r.rows.length), { attached: Boolean(r.attached) })
861
+ if (note) elements.push({ tag: 'markdown', content: `<font color='grey'>${note}</font>`, text_size: 'notation' })
862
+ } else {
863
+ elements.push({ tag: 'markdown', content: r.rows.slice(0, 5).map(row => row.slice(0, 6).map(v => cellText(v)).join(' | ')).join('\n') })
864
+ const note = omittedNote(r, Math.min(5, r.rows.length), { attached: Boolean(r.attached) })
865
+ if (note) elements.push({ tag: 'markdown', content: `<font color='grey'>${note}</font>`, text_size: 'notation' })
866
+ }
867
+ const spec = r.actions ?? r.cardActions
868
+ if (spec && actions) {
869
+ const el = actionElement(spec)
870
+ if (el) elements.push(el)
871
+ }
872
+ if (r.sql && message.includeSql) elements.push({ tag: 'markdown', content: `\`\`\`sql\n${r.sql.slice(0, 1500)}\n\`\`\`` })
873
+ }
874
+ if (message.footer) elements.push({ tag: 'markdown', content: `<font color='grey'>${message.footer}</font>`, text_size: 'notation' })
875
+ const card = {
876
+ schema: '2.0',
877
+ config: { wide_screen_mode: true, summary: { content: String(message.title).slice(0, 60) } },
878
+ header: { template: message.template ?? 'blue', title: { tag: 'plain_text', content: String(message.title).slice(0, 100) } },
879
+ body: { elements },
880
+ }
881
+ // Card bodies are limited to ~30 KB: shrink tables/charts until it fits.
882
+ const size = Buffer.byteLength(JSON.stringify(card))
883
+ if (size > CARD_LIMIT && maxRows > 5) return buildCard(message, { maxRows: Math.max(5, Math.floor(maxRows / 2)), charts, actions })
884
+ if (size > CARD_LIMIT && charts) return buildCard(message, { maxRows, charts: false, actions })
885
+ if (size > CARD_LIMIT) {
886
+ card.body.elements = elements.filter(e => e.tag !== 'table')
887
+ card.body.elements.push({ tag: 'markdown', content: '<font color=\'grey\'>(数据过多,表格已省略)</font>' })
888
+ }
889
+ return card
890
+ }
891
+
892
+ /** JSON 1.0 fallback: lark_md only, for bots/clients rejecting 2.0 components. */
893
+ export function legacyCard(card) {
894
+ const md = card.body.elements.map(e => {
895
+ if (e.tag === 'markdown') return e.content
896
+ if (e.tag === 'hr') return '---'
897
+ 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')
898
+ if (e.tag === 'chart') {
899
+ const v = e.chart_spec.data?.values ?? e.chart_spec.data?.[0]?.values ?? []
900
+ return v.slice(0, 10).map(x => Object.values(x).map(y => cellText(y)).join(' | ')).join('\n')
901
+ }
902
+ return ''
903
+ }).filter(Boolean).join('\n\n')
904
+ return { config: { wide_screen_mode: true }, header: card.header, elements: [{ tag: 'markdown', content: md.slice(0, 20000) }] }
905
+ }
906
+
907
+ // ===========================================================================
908
+ // Send
909
+ // ===========================================================================
910
+
911
+ /**
912
+ * Send an OutboundMessage to a Lark channel.
913
+ * @param channel - { type: 'lark_webhook'|'lark_app', domain, appId, receiveIdType, receiveId, baseUrl? }
914
+ * @param secrets - { webhookUrl, signSecret, appSecret }
915
+ * @returns { messageId?, images }
916
+ */
917
+ export async function sendMessage(channel, secrets, message) {
918
+ let images = 0
919
+ const results = []
920
+ // Native card charts are the default. Images are only uploaded when the
921
+ // caller asks for them (app bots; e.g. clients that cannot render charts).
922
+ for (const r of message.results ?? []) {
923
+ if (channel.type === 'lark_app' && message.chartMode === 'image' && chartable(r)) {
924
+ try {
925
+ const png = renderChartPng(r)
926
+ results.push({ ...r, imageKey: await uploadImage(channel, secrets, png), chart: { ...r.chart, nativeDisabled: true } })
927
+ images++
928
+ continue
929
+ } catch (error) {
930
+ if (error instanceof ChannelError && /99991672|234007/.test(error.code)) throw error
931
+ }
932
+ }
933
+ results.push(r)
934
+ }
935
+ if (channel.type === 'lark_app' && message.attachment?.buffer) for (let i = 0; i < results.length; i++) if (message.attachment.resultIndexes?.includes(i)) results[i] = { ...results[i], attached: true }
936
+ const card = buildCard({ ...message, results }, { charts: message.chartMode !== 'image' && message.chartMode !== 'table' })
937
+ if (channel.type === 'lark_webhook') {
938
+ if (!secrets.webhookUrl) throw new ChannelError('im/config', '缺少 Webhook 地址')
939
+ const url = secrets.webhookUrl
940
+ if (!WEBHOOK_RE.test(url) && !channel.baseUrl) throw new ChannelError('im/config', 'Webhook 地址格式不正确,应为 https://open.feishu.cn/open-apis/bot/v2/hook/… 或 open.larksuite.com')
941
+ const send = async (c) => {
942
+ const timestamp = String(Math.floor(Date.now() / 1000))
943
+ const body = { msg_type: 'interactive', card: c, ...(secrets.signSecret ? { timestamp, sign: larkSign(secrets.signSecret, timestamp) } : {}) }
944
+ return postJson(url, body)
945
+ }
946
+ const fatal = [19021, 19022, 19024, 11232]
947
+ const codeOf = (json) => json?.code ?? json?.StatusCode
948
+ let { status, json } = await send(card)
949
+ if (codeOf(json) !== 0 && !fatal.includes(codeOf(json)) && card.body.elements.some(e => e.tag === 'chart')) ({ status, json } = await send(buildCard({ ...message, results }, { charts: false })))
950
+ if (codeOf(json) !== 0 && !fatal.includes(codeOf(json))) ({ status, json } = await send(legacyCard(card)))
951
+ if (codeOf(json) !== 0) throw larkPushError(json, status)
952
+ return { images }
953
+ }
954
+ if (channel.type === 'lark_app') {
955
+ if (!channel.receiveId) throw new ChannelError('im/config', '缺少接收者 ID')
956
+ const token = await pushToken(channel, secrets)
957
+ const type = RECEIVE_ID_TYPES.includes(channel.receiveIdType) ? channel.receiveIdType : 'chat_id'
958
+ const url = `${hostFor(channel)}/open-apis/im/v1/messages?receive_id_type=${type}`
959
+ const send = (c) => postJson(url, { receive_id: channel.receiveId, msg_type: 'interactive', content: JSON.stringify(c) }, { authorization: `Bearer ${token}` })
960
+ const fatal = [230002, 230001, 230013, 99991672, 99991663]
961
+ let { status, json } = await send(card)
962
+ 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 })))
963
+ if (json.code !== 0 && !fatal.includes(json.code)) ({ status, json } = await send(legacyCard(card)))
964
+ if (json.code !== 0) throw larkPushError(json, status)
965
+ // Full data as an Excel file message after the card (app bots only; webhooks cannot send files).
966
+ let files = 0
967
+ if (message.attachment?.buffer) {
968
+ try {
969
+ const fileKey = await uploadFile(channel, secrets, message.attachment.buffer, message.attachment.fileName)
970
+ const fr = await postJson(url, { receive_id: channel.receiveId, msg_type: 'file', content: JSON.stringify({ file_key: fileKey }) }, { authorization: `Bearer ${token}` })
971
+ if (fr.json.code === 0) files++
972
+ else message.attachmentError = larkPushError(fr.json, fr.status).message
973
+ } catch (error) { message.attachmentError = error.message }
974
+ }
975
+ return { messageId: json.data?.message_id, images, files, ...(message.attachmentError ? { warning: `完整数据附件发送失败:${message.attachmentError}` } : {}) }
976
+ }
977
+ throw new ChannelError('im/config', `不支持的渠道类型:${channel.type}`)
978
+ }
979
+
980
+ /** Chats the app bot belongs to: [{ chatId, name, description }] (legacy shape). */
981
+ export async function listBotChats(channel, secrets) {
982
+ const token = await pushToken(channel, secrets)
983
+ const { status, json } = await request(`${hostFor(channel)}/open-apis/im/v1/chats?page_size=100`, { headers: { authorization: `Bearer ${token}` }, label: '飞书' })
984
+ if (json.code !== 0) throw larkPushError(json, status)
985
+ return (json.data?.items ?? []).map(c => ({ chatId: c.chat_id, name: c.name, description: c.description ?? '' }))
986
+ }
987
+
988
+ // ===========================================================================
989
+ // Bot: normalized Lark events → InboundMessage / InboundAction
990
+ // ===========================================================================
991
+
992
+ /**
993
+ * Reply to a message with a card, falling back to no charts, then no buttons,
994
+ * then plain text. Returns { messageId? } (never throws).
995
+ */
996
+ export async function replyCard(creds, messageId, message) {
997
+ const attempts = [{}, { charts: false }, { charts: false, actions: false }]
998
+ let lastError
999
+ for (const opts of attempts) {
1000
+ try { return { messageId: await replyMessage({ ...creds, messageId, card: buildCard(message, opts) }) } } catch (error) { lastError = error }
1001
+ }
1002
+ try { return { messageId: await replyText({ ...creds, messageId, text: String(message.text ?? '').slice(0, 3000) || `回复失败:${lastError?.message}` }) } } catch {}
1003
+ return {}
1004
+ }
1005
+
1006
+ const REACTIONS = { working: 'OnIt', done: 'DONE', failed: 'CrossMark' }
1007
+
1008
+ /**
1009
+ * A normalized Lark message (normalizeMessageEvent) as an InboundMessage.
1010
+ * Sender name/email are looked up (best effort) only for messages addressed to the bot.
1011
+ * @param creds - { appId, appSecret, domain, baseUrl? }
1012
+ */
1013
+ export async function toInboundMessage(m, creds, { channelId, lookup = true } = {}) {
1014
+ let user = {}
1015
+ if (lookup && m.mentionedBot && m.sender?.openId) { try { user = await getUserInfo({ ...creds, openId: m.sender.openId }) } catch {} }
1016
+ return {
1017
+ platform: 'lark',
1018
+ channelId,
1019
+ messageId: m.messageId,
1020
+ chatId: m.chatId,
1021
+ chatType: m.chatType === 'p2p' ? 'p2p' : 'group',
1022
+ text: m.text ?? '',
1023
+ mentionedBot: Boolean(m.mentionedBot),
1024
+ sender: { id: m.sender?.openId, ...(user.name ? { name: user.name } : {}), ...(user.email ? { email: user.email } : {}), ...(m.sender?.unionId ? { unionId: m.sender.unionId } : {}) },
1025
+ raw: m,
1026
+ reply: (message, opts = {}) => replyCard(creds, opts.replyTo ?? m.messageId, message),
1027
+ replyText: async (text) => { try { await replyText({ ...creds, messageId: m.messageId, text }) } catch {} },
1028
+ react: async (kind) => { await addReaction({ ...creds, messageId: m.messageId, emojiType: REACTIONS[kind] ?? kind }) },
1029
+ chatName: async () => (m.chatType === 'group' ? (await getChatInfo({ ...creds, chatId: m.chatId })).name : undefined),
1030
+ }
1031
+ }
1032
+
1033
+ /** A normalized card callback (normalizeCardActionEvent) as an InboundAction. */
1034
+ export async function toInboundAction(evt, creds, { channelId, lookupMs = 1200 } = {}) {
1035
+ let user = {}
1036
+ let timer
1037
+ try { user = await Promise.race([getUserInfo({ ...creds, openId: evt.operator.openId }), new Promise(r => { timer = setTimeout(() => r({}), lookupMs) })]) } catch {} finally { clearTimeout(timer) }
1038
+ const name = user.name ?? evt.operator.name
1039
+ return {
1040
+ platform: 'lark',
1041
+ channelId,
1042
+ operator: { id: evt.operator.openId, ...(name ? { name } : {}), ...(user.email ? { email: user.email } : {}) },
1043
+ chatId: evt.chatId,
1044
+ messageId: evt.messageId,
1045
+ value: evt.value,
1046
+ option: evt.option,
1047
+ raw: evt,
1048
+ reply: (message, opts = {}) => replyCard(creds, opts.replyTo ?? evt.messageId, message),
1049
+ replyText: async (text, opts = {}) => { try { await replyText({ ...creds, messageId: opts.replyTo ?? evt.messageId, text }) } catch {} },
1050
+ }
1051
+ }
1052
+
1053
+ function credsOf(channel, secrets, opts = {}) {
1054
+ return { appId: channel.appId, appSecret: secrets.appSecret, domain: channel.domain === 'lark' ? 'lark' : 'feishu', ...(opts.baseUrl ?? channel.baseUrl ? { baseUrl: opts.baseUrl ?? channel.baseUrl } : {}) }
1055
+ }
1056
+
1057
+ /**
1058
+ * Bot over the long connection for a lark_app channel.
1059
+ * handlers: { onMessage(InboundMessage), onAction?(InboundAction) → { toast } }
1060
+ */
1061
+ export function createBot(channel, secrets, handlers = {}, opts = {}) {
1062
+ const creds = credsOf(channel, secrets, opts)
1063
+ const logger = opts.logger ?? { debug() {}, info() {}, warn: (...a) => console.warn('nl2sql lark:', ...a), error: (...a) => console.warn('nl2sql lark:', ...a) }
1064
+ const bot = createLarkBot({
1065
+ ...creds,
1066
+ logger,
1067
+ onMessage: async (m) => {
1068
+ // Group messages not addressed to the bot are dropped early (no user lookup).
1069
+ if (m.chatType === 'group' && !m.mentionedBot) return
1070
+ await handlers.onMessage?.(await toInboundMessage(m, creds, { channelId: channel.id }))
1071
+ },
1072
+ onCardAction: handlers.onAction ? async (evt) => handlers.onAction(await toInboundAction(evt, creds, { channelId: channel.id })) : undefined,
1073
+ })
1074
+ return {
1075
+ start: () => bot.start(),
1076
+ stop: () => bot.stop(),
1077
+ status: () => bot.status(),
1078
+ info: () => { const i = bot.botInfo(); return i.openId || i.name ? { id: i.openId, name: i.name } : null },
1079
+ creds,
1080
+ }
1081
+ }
1082
+
1083
+ // ===========================================================================
1084
+ // Adapter
1085
+ // ===========================================================================
1086
+
1087
+ const DOMAIN_FIELD = { key: 'domain', label: { zh: '版本', en: 'Edition' }, type: 'select', options: [{ value: 'feishu', label: { zh: '飞书(中国)', en: 'Feishu (China)' } }, { value: 'lark', label: { zh: 'Lark(国际版)', en: 'Lark (international)' } }] }
1088
+
1089
+ const APP_SETUP = {
1090
+ zh: [
1091
+ '在开发者后台创建企业自建应用,记下 App ID / App Secret,并在这里添加为应用机器人渠道。',
1092
+ '「添加应用能力」开启机器人。',
1093
+ '「权限管理」开通:im:message、im:message.p2p_msg:readonly、im:message.group_at_msg:readonly、im:message:send_as_bot、im:resource、im:chat:readonly、contact:user.base:readonly(需要按邮箱识别成员时再加 contact:user.email:readonly)。',
1094
+ '在「机器人」中启用该渠道使长连接在线,然后到「事件与回调 → 事件配置」选择「使用长连接接收事件」并保存。',
1095
+ '添加事件「接收消息 v2.0(im.message.receive_v1)」,创建版本并发布。',
1096
+ '「事件与回调 → 回调配置」同样选择「使用长连接接收回调」,并添加回调「卡片回传交互(card.action.trigger)」,否则回答卡片上的「按维度拆分 / 为什么变化 / 明细 / 固定到看板」按钮点击无效。',
1097
+ '把机器人拉进群;群里需要 @机器人 才会响应。发送「帮助」查看命令(数据源、切换 <名称>、新对话)。',
1098
+ ],
1099
+ en: [
1100
+ 'Create a custom app, note App ID / Secret, and add it here as an app bot channel.',
1101
+ 'Enable the bot capability.',
1102
+ 'Grant im:message, im:message.p2p_msg:readonly, im:message.group_at_msg:readonly, im:message:send_as_bot, im:resource, im:chat:readonly, contact:user.base:readonly (plus contact:user.email:readonly to match by email).',
1103
+ 'Enable the bot for this channel so the long connection is online, then choose "Receive events through persistent connection" in Events & Callbacks.',
1104
+ 'Subscribe to im.message.receive_v1, create and publish a version.',
1105
+ 'In Events & Callbacks → Callback configuration, also choose the persistent connection and add the callback "card.action.trigger" (card interaction); otherwise the Breakdown / Why / Details / Pin buttons on answer cards do nothing.',
1106
+ 'Add the bot to groups; @ it to ask. Send "help" for commands.',
1107
+ ],
1108
+ }
1109
+
1110
+ const WEBHOOK_SETUP = {
1111
+ zh: [
1112
+ '在飞书群「设置 → 群机器人 → 添加机器人」中选择「自定义机器人」。',
1113
+ '复制 Webhook 地址填到这里;如开启了「签名校验」,把签名密钥一并填入。',
1114
+ '如设置了关键词校验,消息标题或正文需要包含该关键词。',
1115
+ ],
1116
+ en: [
1117
+ 'In the Lark group: Settings → Bots → Add bot → Custom bot.',
1118
+ 'Paste the webhook URL here; if signature verification is on, also paste the signing secret.',
1119
+ 'If keyword verification is on, titles or bodies must contain the keyword.',
1120
+ ],
1121
+ }
1122
+
1123
+ const str = (v, max = 300) => (v === null || v === undefined ? '' : String(v).trim().slice(0, max))
1124
+
1125
+ export const kinds = [
1126
+ {
1127
+ id: 'lark_webhook',
1128
+ platform: 'lark',
1129
+ label: { zh: '飞书群机器人(Webhook)', en: 'Lark group bot (webhook)' },
1130
+ description: { zh: '群自定义机器人,粘贴 Webhook 地址即可推送;不能上传图片,不能接收消息。', en: 'Custom group bot: paste the webhook URL to push; no image upload, no inbound messages.' },
1131
+ fields: [
1132
+ { key: 'webhookUrl', label: { zh: 'Webhook 地址', en: 'Webhook URL' }, type: 'secret', secret: true, required: true, placeholder: 'https://open.feishu.cn/open-apis/bot/v2/hook/…', pattern: WEBHOOK_RE.source },
1133
+ { key: 'signSecret', label: { zh: '签名密钥', en: 'Signing secret' }, type: 'secret', secret: true, help: { zh: '机器人开启「签名校验」时填写', en: 'Only when signature verification is enabled' } },
1134
+ ],
1135
+ capabilities: { push: true, markdown: 'full', charts: 'native', chartModes: ['native', 'table'], tables: 'native', tableRows: 50, files: false, buttons: false, bot: false, listTargets: false, mentionAll: true, maxMessageBytes: 28000 },
1136
+ setupSteps: WEBHOOK_SETUP,
1137
+ validate(input, existing) {
1138
+ const config = {}
1139
+ const secrets = { ...(input.secrets ?? {}) }
1140
+ const url = str(secrets.webhookUrl, 500)
1141
+ if (url) {
1142
+ if (!WEBHOOK_RE.test(url)) throw new ChannelError('channel/config', 'Webhook 地址格式不正确,应为 https://open.feishu.cn/open-apis/bot/v2/hook/… (Lark 国际版为 open.larksuite.com)', { field: 'webhookUrl' })
1143
+ secrets.webhookUrl = url
1144
+ config.domain = url.includes('larksuite') ? 'lark' : 'feishu'
1145
+ } else {
1146
+ config.domain = input.config?.domain === 'lark' ? 'lark' : existing?.config?.domain ?? 'feishu'
1147
+ }
1148
+ if (typeof secrets.signSecret === 'string') secrets.signSecret = secrets.signSecret.trim() || null
1149
+ if (!secrets.webhookUrl) throw new ChannelError('channel/config', '请填写 Webhook 地址', { field: 'webhookUrl' })
1150
+ return { config, secrets }
1151
+ },
1152
+ send: (channel, secrets, message) => sendMessage({ ...channel, type: 'lark_webhook' }, secrets, message),
1153
+ },
1154
+ {
1155
+ id: 'lark_app',
1156
+ platform: 'lark',
1157
+ label: { zh: '飞书应用机器人', en: 'Lark app bot' },
1158
+ description: { zh: '企业自建应用(App ID / Secret):推送到群、个人或邮箱,可上传图片;可开启机器人,在群里 @ 提问(长连接,无需公网地址),回答卡片带交互按钮。', en: 'Custom app (App ID / Secret): push to a chat, user or email, upload images; optional bot answering @ mentions over the long connection (no public URL) with interactive buttons.' },
1159
+ fields: [
1160
+ DOMAIN_FIELD,
1161
+ { key: 'appId', label: { zh: 'App ID', en: 'App ID' }, type: 'text', required: true, placeholder: 'cli_xxxxxxxxxxxxxxxx' },
1162
+ { key: 'appSecret', label: { zh: 'App Secret', en: 'App Secret' }, type: 'secret', secret: true, required: true },
1163
+ { key: 'receiveIdType', label: { zh: '接收者类型', en: 'Receiver type' }, type: 'select', options: [
1164
+ { value: 'chat_id', label: { zh: '群(chat_id)', en: 'Chat (chat_id)' } },
1165
+ { value: 'open_id', label: { zh: '用户(open_id)', en: 'User (open_id)' } },
1166
+ { value: 'user_id', label: { zh: '用户(user_id)', en: 'User (user_id)' } },
1167
+ { value: 'union_id', label: { zh: '用户(union_id)', en: 'User (union_id)' } },
1168
+ { value: 'email', label: { zh: '邮箱', en: 'Email' } },
1169
+ ] },
1170
+ { key: 'receiveId', label: { zh: '接收者 ID', en: 'Receiver ID' }, type: 'text', required: true, placeholder: 'oc_…', target: true, help: { zh: '推送目标;选择群时可点「列出群聊」', en: 'Push target; use "List chats" to pick a chat' } },
1171
+ ],
1172
+ capabilities: { push: true, markdown: 'full', charts: 'native', chartImages: true, chartModes: ['native', 'image', 'table'], tables: 'native', tableRows: 50, files: true, buttons: true, bot: true, listTargets: true, mentionAll: true, maxMessageBytes: 28000 },
1173
+ setupSteps: APP_SETUP,
1174
+ validate(input, existing) {
1175
+ const c = input.config ?? {}
1176
+ const config = {
1177
+ domain: c.domain === 'lark' ? 'lark' : c.domain === 'feishu' ? 'feishu' : existing?.config?.domain ?? 'feishu',
1178
+ appId: str(c.appId, 64),
1179
+ receiveIdType: RECEIVE_ID_TYPES.includes(c.receiveIdType) ? c.receiveIdType : c.receiveIdType ? (() => { throw new ChannelError('channel/config', `receiveIdType 必须是 ${RECEIVE_ID_TYPES.join(' / ')}`, { field: 'receiveIdType' }) })() : 'chat_id',
1180
+ receiveId: str(c.receiveId, 200),
1181
+ }
1182
+ if (!config.appId) throw new ChannelError('channel/config', '请填写 App ID', { field: 'appId' })
1183
+ if (!config.receiveId) throw new ChannelError('channel/config', '请填写接收者 ID', { field: 'receiveId' })
1184
+ const secrets = { ...(input.secrets ?? {}) }
1185
+ if (typeof secrets.appSecret === 'string') secrets.appSecret = secrets.appSecret.trim()
1186
+ if (!secrets.appSecret) throw new ChannelError('channel/config', '请填写 App Secret', { field: 'appSecret' })
1187
+ return { config, secrets }
1188
+ },
1189
+ send: (channel, secrets, message) => sendMessage({ ...channel, type: 'lark_app' }, secrets, message),
1190
+ /** Credentials check (tenant token + bot info); a test message follows when a receiver is set. */
1191
+ async test(channel, secrets) {
1192
+ await pushToken(channel, secrets)
1193
+ let bot = {}
1194
+ try { bot = await getBotInfo(credsOf(channel, secrets)) } catch {}
1195
+ return { ok: true, detail: `App 凭据有效${bot.name ? `(机器人:${bot.name})` : ''}`, send: Boolean(channel.receiveId) }
1196
+ },
1197
+ async listTargets(channel, secrets) {
1198
+ return (await listBotChats(channel, secrets)).map(c => ({ id: c.chatId, name: c.name, type: 'group', description: c.description }))
1199
+ },
1200
+ },
1201
+ ]
1202
+
1203
+ export default {
1204
+ id: 'lark',
1205
+ name: { zh: '飞书 / Lark', en: 'Lark / Feishu' },
1206
+ kinds,
1207
+ createBot,
1208
+ }