@hedeqiang/dsh-nl2sql 0.0.0-stage → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/README.en.md +62 -0
- package/README.md +88 -2
- package/analysis.js +888 -0
- package/cordis.patch.yml +68 -0
- package/drivers.js +694 -0
- package/filters.js +237 -0
- package/icon.svg +13 -0
- package/im.js +511 -0
- package/index.js +650 -0
- package/lark-bot.js +606 -0
- package/lib/client.echarts.js +57 -0
- package/lib/client.js +4845 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +90 -4
- package/policy.js +362 -0
- package/service/access.js +145 -0
- package/service/admin.js +539 -0
- package/service/alerts.js +233 -0
- package/service/dashboards.js +359 -0
- package/service/datasources.js +392 -0
- package/service/evaluation.js +121 -0
- package/service/lark-actions.js +223 -0
- package/service/lark.js +250 -0
- package/service/push.js +314 -0
- package/service/query.js +411 -0
- package/service/schema.js +507 -0
- package/service/shared.js +424 -0
- package/service/suggestions.js +772 -0
- package/service/uploads.js +258 -0
- package/sql-guard.js +222 -0
- package/sqlite-driver.js +677 -0
- package/store.js +225 -0
- package/xlsx.js +1001 -0
package/lark-bot.js
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
// Lark / Feishu inbound messages over the official long connection (WebSocket)
|
|
2
|
+
// plus small fetch-based OpenAPI helpers for replying.
|
|
3
|
+
//
|
|
4
|
+
// Inbound: @larksuiteoapi/node-sdk WSClient + EventDispatcher subscribed to
|
|
5
|
+
// `im.message.receive_v1` and the card callback `card.action.trigger`. No public
|
|
6
|
+
// URL is needed; the app must be configured with "使用长连接接收事件" (and the
|
|
7
|
+
// callback "卡片回传交互" in long-connection mode) in the developer console.
|
|
8
|
+
//
|
|
9
|
+
// Outbound helpers use plain `fetch` (no SDK) so they can be tested against a
|
|
10
|
+
// mock server through the optional `baseUrl` parameter.
|
|
11
|
+
|
|
12
|
+
import { createHash } from 'node:crypto'
|
|
13
|
+
|
|
14
|
+
const HOSTS = {
|
|
15
|
+
feishu: 'https://open.feishu.cn',
|
|
16
|
+
lark: 'https://open.larksuite.com',
|
|
17
|
+
}
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 15000
|
|
19
|
+
// Feishu waits 3 s for a card callback response; answer well before that.
|
|
20
|
+
const CARD_ACTION_TIMEOUT_MS = 2500
|
|
21
|
+
const APP_ID_RE = /^cli_[0-9a-fA-F]{16}$/
|
|
22
|
+
// Lark codes meaning "the tenant token is invalid/expired" → drop cache, retry once.
|
|
23
|
+
const TOKEN_INVALID_CODES = new Set([99991661, 99991663, 99991664, 99991668])
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// HTTP helpers (plain fetch)
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export function resolveBaseUrl({ domain, baseUrl } = {}) {
|
|
30
|
+
if (baseUrl) return String(baseUrl).replace(/\/+$/, '')
|
|
31
|
+
return domain === 'lark' ? HOSTS.lark : HOSTS.feishu
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function larkError(path, body, status) {
|
|
35
|
+
const code = body && typeof body.code === 'number' ? body.code : undefined
|
|
36
|
+
const msg = (body && body.msg) || (status ? `HTTP ${status}` : 'unknown error')
|
|
37
|
+
const err = new Error(`Lark API ${path} failed: ${code !== undefined ? `code=${code} ` : ''}msg=${msg}`)
|
|
38
|
+
err.code = code
|
|
39
|
+
err.larkMsg = msg
|
|
40
|
+
err.status = status
|
|
41
|
+
return err
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function rawRequest(base, path, { method = 'GET', token, body, signal, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
45
|
+
const timeout = AbortSignal.timeout(timeoutMs)
|
|
46
|
+
const sig = signal ? AbortSignal.any([signal, timeout]) : timeout
|
|
47
|
+
const headers = { 'Content-Type': 'application/json; charset=utf-8' }
|
|
48
|
+
if (token) headers.Authorization = `Bearer ${token}`
|
|
49
|
+
let res
|
|
50
|
+
try {
|
|
51
|
+
res = await fetch(base + path, {
|
|
52
|
+
method,
|
|
53
|
+
headers,
|
|
54
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
55
|
+
signal: sig,
|
|
56
|
+
})
|
|
57
|
+
} catch (e) {
|
|
58
|
+
const reason = e?.name === 'TimeoutError' ? `timeout after ${timeoutMs}ms`
|
|
59
|
+
: e?.name === 'AbortError' ? 'aborted'
|
|
60
|
+
: (e?.cause?.code || e?.cause?.message || e?.message || String(e))
|
|
61
|
+
const err = new Error(`Lark API ${path} request failed: ${reason}`)
|
|
62
|
+
err.cause = e
|
|
63
|
+
throw err
|
|
64
|
+
}
|
|
65
|
+
const text = await res.text()
|
|
66
|
+
let json
|
|
67
|
+
try { json = text ? JSON.parse(text) : {} } catch { json = undefined }
|
|
68
|
+
if (!json || typeof json !== 'object') throw larkError(path, { msg: `non-JSON response (HTTP ${res.status})` }, res.status)
|
|
69
|
+
if (json.code !== 0) throw larkError(path, json, res.status)
|
|
70
|
+
return json
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const tokenCache = new Map() // key -> { token, expiresAt } | { pending: Promise }
|
|
74
|
+
|
|
75
|
+
function tokenKey(base, appId, appSecret) {
|
|
76
|
+
const h = createHash('sha256').update(String(appSecret)).digest('hex').slice(0, 16)
|
|
77
|
+
return `${base}|${appId}|${h}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* getTenantToken({ appId, appSecret, domain, baseUrl?, signal?, force? }) → Promise<string>
|
|
82
|
+
* tenant_access_token for an internal (self-built) app, cached in memory until
|
|
83
|
+
* `expire - 60s`. Concurrent calls share one request.
|
|
84
|
+
*/
|
|
85
|
+
export async function getTenantToken({ appId, appSecret, domain, baseUrl, signal, force = false } = {}) {
|
|
86
|
+
if (!appId || !appSecret) throw new Error('Lark appId and appSecret are required')
|
|
87
|
+
const base = resolveBaseUrl({ domain, baseUrl })
|
|
88
|
+
const key = tokenKey(base, appId, appSecret)
|
|
89
|
+
const hit = tokenCache.get(key)
|
|
90
|
+
if (!force && hit) {
|
|
91
|
+
if (hit.pending) return hit.pending
|
|
92
|
+
if (hit.expiresAt > Date.now()) return hit.token
|
|
93
|
+
}
|
|
94
|
+
const pending = (async () => {
|
|
95
|
+
const json = await rawRequest(base, '/open-apis/auth/v3/tenant_access_token/internal', {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
body: { app_id: appId, app_secret: appSecret },
|
|
98
|
+
signal,
|
|
99
|
+
})
|
|
100
|
+
const token = json.tenant_access_token
|
|
101
|
+
if (!token) throw larkError('/open-apis/auth/v3/tenant_access_token/internal', { msg: 'no tenant_access_token in response' })
|
|
102
|
+
const expireSec = Number(json.expire) || 7200
|
|
103
|
+
tokenCache.set(key, { token, expiresAt: Date.now() + Math.max(0, expireSec - 60) * 1000 })
|
|
104
|
+
return token
|
|
105
|
+
})()
|
|
106
|
+
tokenCache.set(key, { pending })
|
|
107
|
+
try {
|
|
108
|
+
return await pending
|
|
109
|
+
} catch (e) {
|
|
110
|
+
if (tokenCache.get(key)?.pending === pending) tokenCache.delete(key)
|
|
111
|
+
throw e
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Drop cached tenant tokens (all, or for one app). Mostly for tests. */
|
|
116
|
+
export function clearTenantTokenCache(appId) {
|
|
117
|
+
if (!appId) return tokenCache.clear()
|
|
118
|
+
for (const k of tokenCache.keys()) if (k.split('|')[1] === appId) tokenCache.delete(k)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Authenticated OpenAPI call; retries once with a fresh token on token-invalid codes. */
|
|
122
|
+
export async function larkApi(opts, path, { method = 'GET', body, signal } = {}) {
|
|
123
|
+
const base = resolveBaseUrl(opts)
|
|
124
|
+
const call = async (force) => {
|
|
125
|
+
const token = await getTenantToken({ ...opts, signal: signal || opts.signal, force })
|
|
126
|
+
return rawRequest(base, path, { method, token, body, signal: signal || opts.signal })
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
return await call(false)
|
|
130
|
+
} catch (e) {
|
|
131
|
+
if (TOKEN_INVALID_CODES.has(e.code)) return call(true)
|
|
132
|
+
throw e
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const enc = encodeURIComponent
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* replyMessage({ appId, appSecret, domain, baseUrl?, messageId, card, inThread = false, uuid? })
|
|
140
|
+
* → Promise<string> reply message_id. Throws Error (err.code = Lark code) on failure.
|
|
141
|
+
*/
|
|
142
|
+
export async function replyMessage({ messageId, card, inThread = false, uuid, ...opts }) {
|
|
143
|
+
if (!messageId) throw new Error('messageId is required')
|
|
144
|
+
const body = { msg_type: 'interactive', content: typeof card === 'string' ? card : JSON.stringify(card), reply_in_thread: !!inThread }
|
|
145
|
+
if (uuid) body.uuid = uuid
|
|
146
|
+
const json = await larkApi(opts, `/open-apis/im/v1/messages/${enc(messageId)}/reply`, { method: 'POST', body })
|
|
147
|
+
return json.data?.message_id
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** replyText({ …, messageId, text, inThread = false }) → Promise<string> reply message_id. */
|
|
151
|
+
export async function replyText({ messageId, text, inThread = false, uuid, ...opts }) {
|
|
152
|
+
if (!messageId) throw new Error('messageId is required')
|
|
153
|
+
const body = { msg_type: 'text', content: JSON.stringify({ text: String(text ?? '') }), reply_in_thread: !!inThread }
|
|
154
|
+
if (uuid) body.uuid = uuid
|
|
155
|
+
const json = await larkApi(opts, `/open-apis/im/v1/messages/${enc(messageId)}/reply`, { method: 'POST', body })
|
|
156
|
+
return json.data?.message_id
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* sendText({ …, chatId, text }) → Promise<string> message_id. Sends a new text
|
|
161
|
+
* message to a chat. If only `messageId` is given it behaves like replyText.
|
|
162
|
+
*/
|
|
163
|
+
export async function sendText({ chatId, messageId, text, uuid, ...opts }) {
|
|
164
|
+
if (!chatId && messageId) return replyText({ messageId, text, uuid, ...opts })
|
|
165
|
+
if (!chatId) throw new Error('chatId (or messageId) is required')
|
|
166
|
+
const body = { receive_id: chatId, msg_type: 'text', content: JSON.stringify({ text: String(text ?? '') }) }
|
|
167
|
+
if (uuid) body.uuid = uuid
|
|
168
|
+
const json = await larkApi(opts, '/open-apis/im/v1/messages?receive_id_type=chat_id', { method: 'POST', body })
|
|
169
|
+
return json.data?.message_id
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** sendCard({ …, chatId, card }) → Promise<string> message_id (new interactive message in a chat). */
|
|
173
|
+
export async function sendCard({ chatId, card, uuid, ...opts }) {
|
|
174
|
+
if (!chatId) throw new Error('chatId is required')
|
|
175
|
+
const body = { receive_id: chatId, msg_type: 'interactive', content: typeof card === 'string' ? card : JSON.stringify(card) }
|
|
176
|
+
if (uuid) body.uuid = uuid
|
|
177
|
+
const json = await larkApi(opts, '/open-apis/im/v1/messages?receive_id_type=chat_id', { method: 'POST', body })
|
|
178
|
+
return json.data?.message_id
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* updateCard({ …, messageId, card }) → Promise<void>. PATCH an interactive
|
|
183
|
+
* message previously sent by this bot (e.g. replace "正在查询…" with the result).
|
|
184
|
+
* The card must contain `config.update_multi: true` to be updatable for everyone.
|
|
185
|
+
*/
|
|
186
|
+
export async function updateCard({ messageId, card, ...opts }) {
|
|
187
|
+
if (!messageId) throw new Error('messageId is required')
|
|
188
|
+
await larkApi(opts, `/open-apis/im/v1/messages/${enc(messageId)}`, {
|
|
189
|
+
method: 'PATCH',
|
|
190
|
+
body: { content: typeof card === 'string' ? card : JSON.stringify(card) },
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** addReaction({ …, messageId, emojiType }) → Promise<string|null> reaction_id. Best effort, never throws. */
|
|
195
|
+
export async function addReaction({ messageId, emojiType, logger, ...opts }) {
|
|
196
|
+
try {
|
|
197
|
+
const json = await larkApi(opts, `/open-apis/im/v1/messages/${enc(messageId)}/reactions`, {
|
|
198
|
+
method: 'POST',
|
|
199
|
+
body: { reaction_type: { emoji_type: emojiType } },
|
|
200
|
+
})
|
|
201
|
+
return json.data?.reaction_id ?? null
|
|
202
|
+
} catch (e) {
|
|
203
|
+
logger?.debug?.(`[lark] addReaction ${emojiType} failed: ${e.message}`)
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** getChatInfo({ …, chatId }) → Promise<{ name? }>. Best effort ({} on failure). */
|
|
209
|
+
export async function getChatInfo({ chatId, logger, ...opts }) {
|
|
210
|
+
try {
|
|
211
|
+
const json = await larkApi(opts, `/open-apis/im/v1/chats/${enc(chatId)}`)
|
|
212
|
+
const d = json.data || {}
|
|
213
|
+
return d.name !== undefined ? { name: d.name } : {}
|
|
214
|
+
} catch (e) {
|
|
215
|
+
logger?.debug?.(`[lark] getChatInfo failed: ${e.message}`)
|
|
216
|
+
return {}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** getUserInfo({ …, openId }) → Promise<{ name?, email? }>. Needs contact permission; {} on failure. */
|
|
221
|
+
export async function getUserInfo({ openId, logger, ...opts }) {
|
|
222
|
+
try {
|
|
223
|
+
const json = await larkApi(opts, `/open-apis/contact/v3/users/${enc(openId)}?user_id_type=open_id`)
|
|
224
|
+
const u = json.data?.user || {}
|
|
225
|
+
const out = {}
|
|
226
|
+
if (u.name) out.name = u.name
|
|
227
|
+
if (u.email || u.enterprise_email) out.email = u.email || u.enterprise_email
|
|
228
|
+
return out
|
|
229
|
+
} catch (e) {
|
|
230
|
+
logger?.debug?.(`[lark] getUserInfo failed: ${e.message}`)
|
|
231
|
+
return {}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** getBotInfo({ … }) → Promise<{ openId, name }>. Throws on failure. */
|
|
236
|
+
export async function getBotInfo(opts) {
|
|
237
|
+
const json = await larkApi(opts, '/open-apis/bot/v3/info')
|
|
238
|
+
const bot = json.bot || json.data?.bot || {}
|
|
239
|
+
return { openId: bot.open_id, name: bot.app_name }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// Event normalization
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
const MENTION_RE = /@_user_\d+/g
|
|
247
|
+
|
|
248
|
+
function stripMentions(s) {
|
|
249
|
+
return String(s ?? '').replace(MENTION_RE, '').replace(/[ \t]{2,}/g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n[ \t]+/g, '\n').trim()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function postToText(content) {
|
|
253
|
+
if (!content || typeof content !== 'object') return ''
|
|
254
|
+
// Received posts are { title, content: [[el…]…] }; tolerate the send-side
|
|
255
|
+
// locale wrapper { zh_cn: { title, content } } too.
|
|
256
|
+
let post = content
|
|
257
|
+
if (!Array.isArray(post.content)) {
|
|
258
|
+
const inner = Object.values(content).find((v) => v && typeof v === 'object' && Array.isArray(v.content))
|
|
259
|
+
if (!inner) return ''
|
|
260
|
+
post = inner
|
|
261
|
+
}
|
|
262
|
+
const lines = []
|
|
263
|
+
if (post.title) lines.push(String(post.title))
|
|
264
|
+
for (const line of post.content) {
|
|
265
|
+
if (!Array.isArray(line)) continue
|
|
266
|
+
let s = ''
|
|
267
|
+
for (const el of line) {
|
|
268
|
+
if (!el || typeof el !== 'object') continue
|
|
269
|
+
if (el.tag === 'text' || el.tag === 'a' || el.tag === 'md' || el.tag === 'code_block') s += el.text ?? ''
|
|
270
|
+
else if (el.tag === 'at') s += el.text ?? el.user_id ?? '' // placeholder (@_user_N) is stripped below
|
|
271
|
+
}
|
|
272
|
+
lines.push(s)
|
|
273
|
+
}
|
|
274
|
+
return stripMentions(lines.join('\n')).replace(/\n{3,}/g, '\n\n')
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* normalizeMessageEvent(data, { botOpenId? }) → normalized message | null
|
|
279
|
+
* `data` may be the flattened object the SDK EventDispatcher passes to handlers
|
|
280
|
+
* ({ message, sender, event_id, … }) or a raw v2 envelope ({ schema, header, event }).
|
|
281
|
+
* Returns null for events that must be ignored (sent by a bot/app, malformed).
|
|
282
|
+
*/
|
|
283
|
+
export function normalizeMessageEvent(data, { botOpenId } = {}) {
|
|
284
|
+
const ev = data && data.event && !data.message ? data.event : data
|
|
285
|
+
const message = ev?.message
|
|
286
|
+
const sender = ev?.sender
|
|
287
|
+
if (!message || !message.message_id) return null
|
|
288
|
+
if (sender && (sender.sender_type === 'app' || sender.sender_type === 'bot')) return null
|
|
289
|
+
|
|
290
|
+
const mentions = (Array.isArray(message.mentions) ? message.mentions : []).map((m) => {
|
|
291
|
+
const openId = m?.id?.open_id
|
|
292
|
+
const isBot = botOpenId ? openId === botOpenId : m?.mentioned_type === 'bot'
|
|
293
|
+
return { key: m?.key, name: m?.name, openId, userId: m?.id?.user_id, unionId: m?.id?.union_id, isBot: !!isBot }
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
let content = {}
|
|
297
|
+
try { content = typeof message.content === 'string' ? JSON.parse(message.content) : (message.content || {}) } catch { content = {} }
|
|
298
|
+
|
|
299
|
+
const messageType = message.message_type
|
|
300
|
+
let text = ''
|
|
301
|
+
if (messageType === 'text') text = stripMentions(content.text)
|
|
302
|
+
else if (messageType === 'post') text = postToText(content)
|
|
303
|
+
|
|
304
|
+
const chatType = message.chat_type === 'p2p' ? 'p2p' : 'group'
|
|
305
|
+
const sid = sender?.sender_id || {}
|
|
306
|
+
const out = {
|
|
307
|
+
messageId: message.message_id,
|
|
308
|
+
chatId: message.chat_id,
|
|
309
|
+
chatType,
|
|
310
|
+
messageType,
|
|
311
|
+
text,
|
|
312
|
+
mentionedBot: chatType === 'p2p' || mentions.some((m) => m.isBot),
|
|
313
|
+
mentions,
|
|
314
|
+
sender: { openId: sid.open_id, userId: sid.user_id, unionId: sid.union_id },
|
|
315
|
+
rootId: message.root_id || undefined,
|
|
316
|
+
parentId: message.parent_id || undefined,
|
|
317
|
+
threadId: message.thread_id || undefined,
|
|
318
|
+
createTime: message.create_time ? Number(message.create_time) : undefined,
|
|
319
|
+
eventId: data?.event_id ?? data?.header?.event_id,
|
|
320
|
+
}
|
|
321
|
+
return out
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* normalizeCardActionEvent(data) → normalized card callback | null
|
|
326
|
+
* `card.action.trigger` (card JSON 2.0 callback) as delivered over the long
|
|
327
|
+
* connection: the SDK EventDispatcher flattens { schema, header, event } into
|
|
328
|
+
* one object; a raw envelope is accepted too. Message/chat ids live under
|
|
329
|
+
* `event.context` (older surfaces put them at the root).
|
|
330
|
+
* Everything here comes from the client and is untrusted: `value`, `option`
|
|
331
|
+
* and `formValue` must only be used as lookup keys, never for authorization.
|
|
332
|
+
*/
|
|
333
|
+
export function normalizeCardActionEvent(data) {
|
|
334
|
+
if (!data || typeof data !== 'object') return null
|
|
335
|
+
const ev = data.event && !data.action ? data.event : data
|
|
336
|
+
const header = data.header ?? data
|
|
337
|
+
const op = ev?.operator
|
|
338
|
+
const action = ev?.action
|
|
339
|
+
if (!op?.open_id || !action || typeof action !== 'object') return null
|
|
340
|
+
let value = action.value
|
|
341
|
+
if (typeof value === 'string') { try { value = JSON.parse(value) } catch { /* keep string */ } }
|
|
342
|
+
const ctx = ev.context ?? {}
|
|
343
|
+
return {
|
|
344
|
+
eventId: header.event_id ?? data.event_id,
|
|
345
|
+
operator: { openId: op.open_id, userId: op.user_id || undefined, unionId: op.union_id || undefined, name: op.name || undefined },
|
|
346
|
+
tag: action.tag ?? 'unknown',
|
|
347
|
+
name: action.name || undefined,
|
|
348
|
+
value: value && typeof value === 'object' && !Array.isArray(value) ? value : (value === undefined ? {} : { raw: value }),
|
|
349
|
+
option: typeof action.option === 'string' ? action.option : undefined,
|
|
350
|
+
options: Array.isArray(action.options) ? action.options.map(String) : undefined,
|
|
351
|
+
formValue: action.form_value && typeof action.form_value === 'object' ? action.form_value : undefined,
|
|
352
|
+
inputValue: typeof action.input_value === 'string' ? action.input_value : undefined,
|
|
353
|
+
messageId: ctx.open_message_id ?? ev.open_message_id,
|
|
354
|
+
chatId: ctx.open_chat_id ?? ev.open_chat_id,
|
|
355
|
+
host: ev.host,
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** createDedupe(max = 500) → (id) => boolean (true = first time seen, false = duplicate). LRU. */
|
|
360
|
+
export function createDedupe(max = 500) {
|
|
361
|
+
const seen = new Map()
|
|
362
|
+
return (id) => {
|
|
363
|
+
if (!id) return true
|
|
364
|
+
if (seen.has(id)) {
|
|
365
|
+
seen.delete(id)
|
|
366
|
+
seen.set(id, true)
|
|
367
|
+
return false
|
|
368
|
+
}
|
|
369
|
+
seen.set(id, true)
|
|
370
|
+
if (seen.size > max) seen.delete(seen.keys().next().value)
|
|
371
|
+
return true
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
// Bot (WebSocket long connection)
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
const LEVELS = { fatal: 0, error: 1, warn: 2, info: 3, debug: 4, trace: 5 }
|
|
380
|
+
|
|
381
|
+
function fmtArgs(args) {
|
|
382
|
+
const flat = args.length === 1 && Array.isArray(args[0]) ? args[0] : args
|
|
383
|
+
return flat.map((a) => (a instanceof Error ? a.message : typeof a === 'string' ? a : (() => { try { return JSON.stringify(a) } catch { return String(a) } })())).join(' ')
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let sdkPromise
|
|
387
|
+
function loadSdk() {
|
|
388
|
+
sdkPromise ??= import('@larksuiteoapi/node-sdk').then((m) => (m.WSClient ? m : m.default))
|
|
389
|
+
return sdkPromise
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* createLarkBot({ appId, appSecret, domain: 'feishu'|'lark', onMessage, onStatus, logger, logLevel = 'warn', baseUrl? })
|
|
394
|
+
* → { start(): Promise<void>, stop(): Promise<void>, status(): Status, botInfo(): { openId?, name? } }
|
|
395
|
+
*
|
|
396
|
+
* Status: { state: 'idle'|'connecting'|'connected'|'error'|'stopped', error?, since, lastEventAt? }
|
|
397
|
+
*
|
|
398
|
+
* start() never rejects: it resolves once the first connection attempt has
|
|
399
|
+
* settled (connected, or failed → state 'error'). The SDK keeps retrying
|
|
400
|
+
* transient (network) failures in the background; credential/config failures
|
|
401
|
+
* are terminal until start() is called again.
|
|
402
|
+
*/
|
|
403
|
+
export function createLarkBot({ appId, appSecret, domain = 'feishu', onMessage, onCardAction, onStatus, logger, logLevel = 'warn', baseUrl, cardActionTimeoutMs = CARD_ACTION_TIMEOUT_MS } = {}) {
|
|
404
|
+
const log = {
|
|
405
|
+
error: (m) => logger?.error?.(m),
|
|
406
|
+
warn: (m) => logger?.warn?.(m),
|
|
407
|
+
info: (m) => logger?.info?.(m),
|
|
408
|
+
debug: (m) => logger?.debug?.(m),
|
|
409
|
+
}
|
|
410
|
+
const http = { appId, appSecret, domain, baseUrl }
|
|
411
|
+
const dedupe = createDedupe(500)
|
|
412
|
+
const cardDedupe = createDedupe(500)
|
|
413
|
+
let st = { state: 'idle', since: Date.now() }
|
|
414
|
+
let lastEventAt
|
|
415
|
+
let wsClient = null
|
|
416
|
+
let abort = null
|
|
417
|
+
let gen = 0
|
|
418
|
+
let starting = null
|
|
419
|
+
let settleFirst = null
|
|
420
|
+
let bot = {}
|
|
421
|
+
|
|
422
|
+
function setStatus(state, error) {
|
|
423
|
+
if (st.state === state && st.error === error) return
|
|
424
|
+
st = { state, since: Date.now(), ...(error ? { error } : {}) }
|
|
425
|
+
try { onStatus?.(status()) } catch (e) { log.error(`[lark] onStatus threw: ${e?.message || e}`) }
|
|
426
|
+
}
|
|
427
|
+
function status() {
|
|
428
|
+
return { ...st, ...(lastEventAt ? { lastEventAt } : {}) }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const userLevel = LEVELS[logLevel] ?? LEVELS.warn
|
|
432
|
+
function sdkLogger(myGen) {
|
|
433
|
+
// The SDK calls logger.<level>(argsArray). Errors logged while (re)connecting
|
|
434
|
+
// are the only signal for retryable failures, so surface them in status.
|
|
435
|
+
const forward = (level) => (...args) => {
|
|
436
|
+
if (LEVELS[level] > userLevel && level !== 'error') return
|
|
437
|
+
const msg = `[lark-sdk] ${fmtArgs(args)}`
|
|
438
|
+
if (level === 'error') {
|
|
439
|
+
if (myGen === gen && (st.state === 'connecting' || st.state === 'error') && /\[ws\]/.test(msg) && !/discarded socket/.test(msg)) {
|
|
440
|
+
const reason = msg.replace(/^\[lark-sdk\]\s*\[ws\]\s*/, '')
|
|
441
|
+
if (reason !== 'connect failed' || !st.error) setStatus('error', `Lark long connection failed: ${reason} (retrying)`)
|
|
442
|
+
settleFirst?.()
|
|
443
|
+
}
|
|
444
|
+
if (userLevel >= LEVELS.error) log.error(msg)
|
|
445
|
+
} else if (level === 'warn') log.warn(msg)
|
|
446
|
+
else if (level === 'info') log.info(msg)
|
|
447
|
+
else log.debug(msg)
|
|
448
|
+
}
|
|
449
|
+
return { error: forward('error'), warn: forward('warn'), info: forward('info'), debug: forward('debug'), trace: forward('trace') }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function handleEvent(data) {
|
|
453
|
+
// Must return quickly: the SDK acks the frame after this resolves.
|
|
454
|
+
try {
|
|
455
|
+
lastEventAt = Date.now()
|
|
456
|
+
const msg = normalizeMessageEvent(data, { botOpenId: bot.openId })
|
|
457
|
+
if (!msg) return
|
|
458
|
+
if (!dedupe(msg.messageId)) {
|
|
459
|
+
log.debug(`[lark] duplicate event for ${msg.messageId} ignored`)
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
if (!onMessage) return
|
|
463
|
+
Promise.resolve()
|
|
464
|
+
.then(() => onMessage(msg))
|
|
465
|
+
.catch((e) => log.error(`[lark] onMessage handler failed: ${e?.stack || e?.message || e}`))
|
|
466
|
+
} catch (e) {
|
|
467
|
+
log.error(`[lark] failed to handle im.message.receive_v1: ${e?.message || e}`)
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* card.action.trigger: the value returned here is what the SDK sends back
|
|
473
|
+
* as the callback response (WSClient.handleEventData base64-encodes the
|
|
474
|
+
* handler result into the ack frame). Feishu needs it within 3 s, so the
|
|
475
|
+
* handler is raced against a timeout and falls back to a neutral toast.
|
|
476
|
+
*/
|
|
477
|
+
async function handleCardAction(data) {
|
|
478
|
+
lastEventAt = Date.now()
|
|
479
|
+
let evt
|
|
480
|
+
try { evt = normalizeCardActionEvent(data) } catch (e) { log.error(`[lark] bad card.action.trigger: ${e?.message || e}`) }
|
|
481
|
+
if (!evt) return undefined
|
|
482
|
+
if (evt.eventId && !cardDedupe(evt.eventId)) return { toast: { type: 'info', content: '已收到,正在处理…' } }
|
|
483
|
+
if (!onCardAction) return undefined
|
|
484
|
+
let timer
|
|
485
|
+
try {
|
|
486
|
+
const res = await Promise.race([
|
|
487
|
+
Promise.resolve().then(() => onCardAction(evt)),
|
|
488
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve({ toast: { type: 'info', content: '已收到,正在处理…' } }), cardActionTimeoutMs) }),
|
|
489
|
+
])
|
|
490
|
+
return res ?? undefined
|
|
491
|
+
} catch (e) {
|
|
492
|
+
log.error(`[lark] onCardAction handler failed: ${e?.stack || e?.message || e}`)
|
|
493
|
+
return { toast: { type: 'error', content: '处理失败,请稍后重试' } }
|
|
494
|
+
} finally {
|
|
495
|
+
clearTimeout(timer)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function doStart(myGen) {
|
|
500
|
+
setStatus('connecting')
|
|
501
|
+
if (!appId || !appSecret) return setStatus('error', 'Lark App ID and App Secret are required')
|
|
502
|
+
if (!APP_ID_RE.test(appId)) return setStatus('error', `Invalid Lark App ID "${appId}" (expected cli_ followed by 16 hex chars)`)
|
|
503
|
+
abort = new AbortController()
|
|
504
|
+
const signal = abort.signal
|
|
505
|
+
try {
|
|
506
|
+
// Validates credentials with a readable error before opening the socket.
|
|
507
|
+
await getTenantToken({ ...http, signal })
|
|
508
|
+
} catch (e) {
|
|
509
|
+
if (myGen !== gen) return
|
|
510
|
+
return setStatus('error', `Lark credentials check failed: ${e.message}`)
|
|
511
|
+
}
|
|
512
|
+
if (myGen !== gen) return
|
|
513
|
+
try {
|
|
514
|
+
bot = await getBotInfo({ ...http, signal })
|
|
515
|
+
if (!bot.openId) log.warn('[lark] bot info has no open_id; @bot detection falls back to mentioned_type')
|
|
516
|
+
} catch (e) {
|
|
517
|
+
if (myGen !== gen) return
|
|
518
|
+
log.warn(`[lark] could not fetch bot info (is the bot capability enabled?): ${e.message}`)
|
|
519
|
+
}
|
|
520
|
+
if (myGen !== gen) return
|
|
521
|
+
|
|
522
|
+
let lark
|
|
523
|
+
try {
|
|
524
|
+
lark = await loadSdk()
|
|
525
|
+
} catch (e) {
|
|
526
|
+
return setStatus('error', `Failed to load @larksuiteoapi/node-sdk: ${e?.message || e}`)
|
|
527
|
+
}
|
|
528
|
+
if (myGen !== gen) return
|
|
529
|
+
|
|
530
|
+
// The SDK must always emit errors to us (they drive status); we filter by userLevel.
|
|
531
|
+
const level = Math.max(userLevel, LEVELS.error)
|
|
532
|
+
const sdkLog = sdkLogger(myGen)
|
|
533
|
+
const firstSettled = new Promise((resolve) => { settleFirst = resolve })
|
|
534
|
+
let client
|
|
535
|
+
try {
|
|
536
|
+
const params = {
|
|
537
|
+
appId,
|
|
538
|
+
appSecret,
|
|
539
|
+
domain: baseUrl || (domain === 'lark' ? lark.Domain.Lark : lark.Domain.Feishu),
|
|
540
|
+
loggerLevel: level,
|
|
541
|
+
logger: sdkLog,
|
|
542
|
+
autoReconnect: true,
|
|
543
|
+
handshakeTimeoutMs: DEFAULT_TIMEOUT_MS,
|
|
544
|
+
source: 'dsh-nl2sql',
|
|
545
|
+
onReady: () => { if (myGen === gen) { setStatus('connected'); settleFirst?.() } },
|
|
546
|
+
onReconnecting: () => { if (myGen === gen) setStatus('connecting') },
|
|
547
|
+
onReconnected: () => { if (myGen === gen) setStatus('connected') },
|
|
548
|
+
onError: (err) => { if (myGen === gen) { setStatus('error', `Lark long connection failed: ${err?.message || err}`); settleFirst?.() } },
|
|
549
|
+
}
|
|
550
|
+
client = new lark.WSClient(params)
|
|
551
|
+
const dispatcher = new lark.EventDispatcher({ loggerLevel: level, logger: sdkLog }).register({
|
|
552
|
+
'im.message.receive_v1': (data) => { handleEvent(data) },
|
|
553
|
+
// Card callbacks arrive over the same long connection as event frames
|
|
554
|
+
// whose header.event_type is card.action.trigger (requires subscribing to
|
|
555
|
+
// the callback in the developer console, long-connection mode).
|
|
556
|
+
'card.action.trigger': (data) => handleCardAction(data),
|
|
557
|
+
})
|
|
558
|
+
wsClient = client
|
|
559
|
+
await client.start({ eventDispatcher: dispatcher })
|
|
560
|
+
} catch (e) {
|
|
561
|
+
if (client) { try { client.close({ force: true }) } catch {} }
|
|
562
|
+
if (wsClient === client) wsClient = null
|
|
563
|
+
if (myGen === gen) setStatus('error', `Failed to start Lark long connection: ${e?.message || e}`)
|
|
564
|
+
return
|
|
565
|
+
}
|
|
566
|
+
await firstSettled
|
|
567
|
+
settleFirst = null
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function start() {
|
|
571
|
+
if (starting) return starting
|
|
572
|
+
if (wsClient && (st.state === 'connected' || st.state === 'connecting')) return Promise.resolve()
|
|
573
|
+
if (wsClient) closeClient()
|
|
574
|
+
const myGen = ++gen
|
|
575
|
+
starting = doStart(myGen)
|
|
576
|
+
.catch((e) => { if (myGen === gen) setStatus('error', `Lark bot start failed: ${e?.message || e}`) })
|
|
577
|
+
.finally(() => { if (myGen === gen) starting = null })
|
|
578
|
+
return starting
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function closeClient() {
|
|
582
|
+
const c = wsClient
|
|
583
|
+
wsClient = null
|
|
584
|
+
if (c) {
|
|
585
|
+
// close({ force: true }) sets the SDK's `closed` flag, bumps its reconnect
|
|
586
|
+
// generation, clears ping/reconnect/liveness timers and the fragment-cache
|
|
587
|
+
// interval, and terminates the socket immediately (no 30s close handshake).
|
|
588
|
+
try { c.close({ force: true }) } catch (e) { log.debug(`[lark] close failed: ${e?.message || e}`) }
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function stop() {
|
|
593
|
+
gen++
|
|
594
|
+
starting = null
|
|
595
|
+
abort?.abort()
|
|
596
|
+
abort = null
|
|
597
|
+
settleFirst?.()
|
|
598
|
+
settleFirst = null
|
|
599
|
+
closeClient()
|
|
600
|
+
setStatus('stopped')
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return { start, stop, status, botInfo: () => ({ ...bot }) }
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export default createLarkBot
|