@hedeqiang/dsh-nl2sql 0.0.0-stage → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/README.en.md +63 -0
- package/README.md +88 -2
- package/analysis.js +888 -0
- package/channels/dingtalk.js +574 -0
- package/channels/http.js +63 -0
- package/channels/index.js +138 -0
- package/channels/lark.js +1180 -0
- package/channels/message.js +267 -0
- package/channels/telegram.js +663 -0
- package/channels/wecom.js +585 -0
- package/cordis.patch.yml +68 -0
- package/docs/CHANNELS.md +184 -0
- package/drivers.js +694 -0
- package/filters.js +237 -0
- package/icon.svg +13 -0
- package/im.js +8 -0
- package/index.js +658 -0
- package/lark-bot.js +9 -0
- package/lib/client.echarts.js +57 -0
- package/lib/client.js +5757 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +95 -4
- package/policy.js +362 -0
- package/service/access.js +239 -0
- package/service/admin.js +555 -0
- package/service/alerts.js +233 -0
- package/service/bots.js +665 -0
- package/service/channels.js +363 -0
- package/service/dashboards.js +359 -0
- package/service/datasources.js +415 -0
- package/service/evaluation.js +121 -0
- package/service/push.js +163 -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/channels/lark.js
ADDED
|
@@ -0,0 +1,1180 @@
|
|
|
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, num, 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
|
+
// ===========================================================================
|
|
681
|
+
// Cards (JSON 2.0)
|
|
682
|
+
// ===========================================================================
|
|
683
|
+
|
|
684
|
+
const CARD_LIMIT = 28000
|
|
685
|
+
|
|
686
|
+
function tableElement(result, maxRows) {
|
|
687
|
+
const cols = result.columns.slice(0, 8)
|
|
688
|
+
return {
|
|
689
|
+
tag: 'table',
|
|
690
|
+
page_size: Math.min(10, Math.max(1, Math.min(maxRows, result.rows.length))),
|
|
691
|
+
row_height: 'low',
|
|
692
|
+
header_style: { background_style: 'grey', bold: true, text_size: 'normal' },
|
|
693
|
+
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' })),
|
|
694
|
+
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])]))),
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* A native card chart (VChart spec) for a result. Works for webhook and app
|
|
700
|
+
* bots alike and stays interactive in the client.
|
|
701
|
+
*/
|
|
702
|
+
export function chartElement(result, { maxPoints = 200 } = {}) {
|
|
703
|
+
const chart = result.chart ?? {}
|
|
704
|
+
const idx = (name) => result.columns.findIndex(c => c.name === name)
|
|
705
|
+
const xi = idx(chart.x)
|
|
706
|
+
const ys = (chart.y ?? []).map(idx).filter(i => i >= 0)
|
|
707
|
+
const si = chart.series ? idx(chart.series) : -1
|
|
708
|
+
if (xi < 0 || !ys.length) return undefined
|
|
709
|
+
const rows = result.rows.slice(0, maxPoints)
|
|
710
|
+
const label = (v) => (v === null || v === undefined ? 'NULL' : String(v))
|
|
711
|
+
let spec
|
|
712
|
+
if (chart.type === 'pie' || chart.type === 'funnel') {
|
|
713
|
+
const values = rows.slice(0, 20).map(r => ({ type: label(r[xi]), value: num(r[ys[0]]) }))
|
|
714
|
+
spec = chart.type === 'pie'
|
|
715
|
+
? { type: 'pie', data: { values }, valueField: 'value', categoryField: 'type', outerRadius: 0.85, innerRadius: 0.45, label: { visible: true }, legends: { visible: true, orient: 'bottom' } }
|
|
716
|
+
: { type: 'funnel', data: { values }, categoryField: 'type', valueField: 'value', isTransform: true, label: { visible: true }, transformLabel: { visible: true }, outerLabel: { visible: false } }
|
|
717
|
+
} else if (chart.type === 'scatter') {
|
|
718
|
+
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 } }] }
|
|
719
|
+
} else {
|
|
720
|
+
// Long format: one row per (x, series) so one spec covers multi-measure and series charts.
|
|
721
|
+
const values = []
|
|
722
|
+
if (si >= 0) {
|
|
723
|
+
for (const r of rows) values.push({ x: label(r[xi]), s: label(r[si]), v: num(r[ys[0]]) })
|
|
724
|
+
} else {
|
|
725
|
+
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]) })
|
|
726
|
+
}
|
|
727
|
+
const multi = si >= 0 || ys.length > 1
|
|
728
|
+
const type = chart.type === 'line' ? 'line' : chart.type === 'area' ? 'area' : 'bar'
|
|
729
|
+
const horizontal = chart.type === 'barh'
|
|
730
|
+
// Second axis when magnitudes differ by more than 10x (two-measure line/area/bar).
|
|
731
|
+
if (!horizontal && chart.type !== 'stacked' && si < 0 && ys.length === 2) {
|
|
732
|
+
const peaks = ys.map(i => Math.max(0, ...rows.map(r => Math.abs(num(r[i]) ?? 0))))
|
|
733
|
+
const [hi, lo] = peaks[0] >= peaks[1] ? [0, 1] : [1, 0]
|
|
734
|
+
if (peaks[lo] > 0 && peaks[hi] / peaks[lo] > 10) {
|
|
735
|
+
const name = (k) => result.columns[ys[k]].name
|
|
736
|
+
spec = {
|
|
737
|
+
type: 'common',
|
|
738
|
+
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) })) }],
|
|
739
|
+
series: [
|
|
740
|
+
{ type: type === 'bar' ? 'bar' : type, dataIndex: 0, xField: 'x', yField: 'v', seriesField: 's' },
|
|
741
|
+
{ type: 'line', dataIndex: 1, xField: 'x', yField: 'v', seriesField: 's' },
|
|
742
|
+
],
|
|
743
|
+
axes: [{ orient: 'left', seriesIndex: [0] }, { orient: 'right', seriesIndex: [1], grid: { visible: false } }, { orient: 'bottom', type: 'band' }],
|
|
744
|
+
legends: { visible: true, orient: 'bottom' },
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
spec ??= horizontal
|
|
749
|
+
? { type: 'bar', direction: 'horizontal', data: { values }, xField: 'v', yField: multi ? ['x', 's'] : 'x', ...(multi ? { seriesField: 's' } : {}), axes: [{ orient: 'left', type: 'band', inverse: true }] }
|
|
750
|
+
: {
|
|
751
|
+
type, data: { values },
|
|
752
|
+
xField: type === 'bar' && multi && chart.type !== 'stacked' ? ['x', 's'] : 'x',
|
|
753
|
+
yField: 'v',
|
|
754
|
+
...(multi ? { seriesField: 's', legends: { visible: true, orient: 'bottom' } } : {}),
|
|
755
|
+
...(chart.type === 'stacked' ? { stack: true } : {}),
|
|
756
|
+
...(type !== 'bar' ? { point: { visible: rows.length <= 31 } } : {}),
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return { tag: 'chart', aspect_ratio: chart.type === 'pie' || chart.type === 'funnel' ? '4:3' : '16:9', chart_spec: { ...spec, title: { visible: false } } }
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Split Markdown into card elements: text stays Markdown; large tables become table components. */
|
|
763
|
+
export function markdownElements(md) {
|
|
764
|
+
const text = String(md ?? '').replace(/\r\n?/g, '\n').trim()
|
|
765
|
+
if (!text) return []
|
|
766
|
+
const out = []
|
|
767
|
+
let last = 0
|
|
768
|
+
let mdTables = 0
|
|
769
|
+
// Feishu markdown tables show at most five rows per page and four tables per
|
|
770
|
+
// component; bigger tables become table components instead.
|
|
771
|
+
for (const m of text.matchAll(MD_TABLE)) {
|
|
772
|
+
const block = m[2]
|
|
773
|
+
const start = m.index + m[1].length
|
|
774
|
+
const table = parseMdTable(block)
|
|
775
|
+
if (table.rows.length <= 5 && mdTables < 4 && table.columns.length <= 8) { mdTables++; continue }
|
|
776
|
+
const before = text.slice(last, start).trim()
|
|
777
|
+
if (before) out.push({ tag: 'markdown', content: before })
|
|
778
|
+
out.push(tableElement(table, 50))
|
|
779
|
+
last = start + block.length
|
|
780
|
+
}
|
|
781
|
+
const rest = text.slice(last).trim()
|
|
782
|
+
if (rest) {
|
|
783
|
+
// A component holds at most ~10k characters comfortably; split long text on paragraph boundaries.
|
|
784
|
+
let chunk = ''
|
|
785
|
+
for (const para of rest.split(/\n{2,}/)) {
|
|
786
|
+
if (chunk && chunk.length + para.length > 6000) { out.push({ tag: 'markdown', content: chunk }); chunk = '' }
|
|
787
|
+
chunk = chunk ? `${chunk}\n\n${para}` : para
|
|
788
|
+
}
|
|
789
|
+
if (chunk) out.push({ tag: 'markdown', content: chunk })
|
|
790
|
+
}
|
|
791
|
+
return out
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Interactive controls under one result (card JSON 2.0, callback behaviors).
|
|
796
|
+
* spec (ActionSpec): { buttons?: [{ text, value, type? }], select?: { placeholder, value, options: [{ text, value }] } }
|
|
797
|
+
* `value` objects are echoed back verbatim in card.action.trigger: keep them
|
|
798
|
+
* opaque (a short token) — the receiver must not trust them.
|
|
799
|
+
*/
|
|
800
|
+
export function actionElement(spec) {
|
|
801
|
+
const items = []
|
|
802
|
+
const plain = (s, n = 40) => ({ tag: 'plain_text', content: String(s ?? '').slice(0, n) })
|
|
803
|
+
if (spec?.select?.options?.length) {
|
|
804
|
+
items.push({
|
|
805
|
+
tag: 'select_static',
|
|
806
|
+
placeholder: plain(spec.select.placeholder),
|
|
807
|
+
width: 'default',
|
|
808
|
+
options: spec.select.options.slice(0, 10).map(o => ({ text: plain(o.text, 60), value: String(o.value).slice(0, 64) })),
|
|
809
|
+
behaviors: [{ type: 'callback', value: spec.select.value }],
|
|
810
|
+
})
|
|
811
|
+
}
|
|
812
|
+
for (const b of spec?.buttons ?? []) {
|
|
813
|
+
items.push({ tag: 'button', text: plain(b.text, 20), type: b.type ?? 'default', size: 'small', behaviors: [{ type: 'callback', value: b.value }] })
|
|
814
|
+
}
|
|
815
|
+
if (!items.length) return undefined
|
|
816
|
+
return { tag: 'column_set', flex_mode: 'flow', horizontal_spacing: 'small', columns: items.map(el => ({ tag: 'column', width: 'auto', vertical_align: 'center', elements: [el] })) }
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Build a JSON 2.0 card from an OutboundMessage.
|
|
821
|
+
* Results may carry `imageKey` (uploaded PNG) and `actions` (ActionSpec; legacy name `cardActions`).
|
|
822
|
+
*/
|
|
823
|
+
export function buildCard(message, { maxRows = 10, charts = true, actions = true } = {}) {
|
|
824
|
+
const elements = [...markdownElements(message.text)]
|
|
825
|
+
let tables = elements.filter(e => e.tag === 'table').length
|
|
826
|
+
let chartCount = 0
|
|
827
|
+
for (const r of message.results ?? []) {
|
|
828
|
+
if (elements.length) elements.push({ tag: 'hr' })
|
|
829
|
+
if (r.error) {
|
|
830
|
+
elements.push({ tag: 'markdown', content: `**${r.title || '查询'}**\n<font color='red'>${r.error}</font>` })
|
|
831
|
+
continue
|
|
832
|
+
}
|
|
833
|
+
elements.push({ tag: 'markdown', content: [`**${r.title || '查询结果'}**`, `<font color='grey'>${resultMeta(r)}</font>`].join(' ') })
|
|
834
|
+
const nativeChart = charts && chartCount < 5 && chartable(r) ? chartElement(r) : undefined
|
|
835
|
+
if (r.rows.length === 0) {
|
|
836
|
+
elements.push({ tag: 'markdown', content: '<font color=\'grey\'>无数据</font>' })
|
|
837
|
+
} else if (isKpi(r)) {
|
|
838
|
+
elements.push({ tag: 'markdown', content: kpiText(r) })
|
|
839
|
+
} else if (nativeChart) {
|
|
840
|
+
elements.push(nativeChart)
|
|
841
|
+
chartCount++
|
|
842
|
+
} else if (r.imageKey) {
|
|
843
|
+
elements.push({ tag: 'img', img_key: r.imageKey, alt: { tag: 'plain_text', content: r.title || 'chart' }, scale_type: 'fit_horizontal', preview: true })
|
|
844
|
+
} else if (tables < 5) {
|
|
845
|
+
elements.push(tableElement(r, maxRows))
|
|
846
|
+
tables++
|
|
847
|
+
} else {
|
|
848
|
+
elements.push({ tag: 'markdown', content: r.rows.slice(0, 5).map(row => row.slice(0, 6).map(v => cellText(v)).join(' | ')).join('\n') })
|
|
849
|
+
}
|
|
850
|
+
const spec = r.actions ?? r.cardActions
|
|
851
|
+
if (spec && actions) {
|
|
852
|
+
const el = actionElement(spec)
|
|
853
|
+
if (el) elements.push(el)
|
|
854
|
+
}
|
|
855
|
+
if (r.sql && message.includeSql) elements.push({ tag: 'markdown', content: `\`\`\`sql\n${r.sql.slice(0, 1500)}\n\`\`\`` })
|
|
856
|
+
}
|
|
857
|
+
if (message.footer) elements.push({ tag: 'markdown', content: `<font color='grey'>${message.footer}</font>`, text_size: 'notation' })
|
|
858
|
+
const card = {
|
|
859
|
+
schema: '2.0',
|
|
860
|
+
config: { wide_screen_mode: true, summary: { content: String(message.title).slice(0, 60) } },
|
|
861
|
+
header: { template: message.template ?? 'blue', title: { tag: 'plain_text', content: String(message.title).slice(0, 100) } },
|
|
862
|
+
body: { elements },
|
|
863
|
+
}
|
|
864
|
+
// Card bodies are limited to ~30 KB: shrink tables/charts until it fits.
|
|
865
|
+
const size = Buffer.byteLength(JSON.stringify(card))
|
|
866
|
+
if (size > CARD_LIMIT && maxRows > 3) return buildCard(message, { maxRows: Math.max(3, Math.floor(maxRows / 2)), charts, actions })
|
|
867
|
+
if (size > CARD_LIMIT && charts) return buildCard(message, { maxRows, charts: false, actions })
|
|
868
|
+
if (size > CARD_LIMIT) {
|
|
869
|
+
card.body.elements = elements.filter(e => e.tag !== 'table')
|
|
870
|
+
card.body.elements.push({ tag: 'markdown', content: '<font color=\'grey\'>(数据过多,表格已省略)</font>' })
|
|
871
|
+
}
|
|
872
|
+
return card
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** JSON 1.0 fallback: lark_md only, for bots/clients rejecting 2.0 components. */
|
|
876
|
+
export function legacyCard(card) {
|
|
877
|
+
const md = card.body.elements.map(e => {
|
|
878
|
+
if (e.tag === 'markdown') return e.content
|
|
879
|
+
if (e.tag === 'hr') return '---'
|
|
880
|
+
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')
|
|
881
|
+
if (e.tag === 'chart') {
|
|
882
|
+
const v = e.chart_spec.data?.values ?? e.chart_spec.data?.[0]?.values ?? []
|
|
883
|
+
return v.slice(0, 10).map(x => Object.values(x).map(y => cellText(y)).join(' | ')).join('\n')
|
|
884
|
+
}
|
|
885
|
+
return ''
|
|
886
|
+
}).filter(Boolean).join('\n\n')
|
|
887
|
+
return { config: { wide_screen_mode: true }, header: card.header, elements: [{ tag: 'markdown', content: md.slice(0, 20000) }] }
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// ===========================================================================
|
|
891
|
+
// Send
|
|
892
|
+
// ===========================================================================
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Send an OutboundMessage to a Lark channel.
|
|
896
|
+
* @param channel - { type: 'lark_webhook'|'lark_app', domain, appId, receiveIdType, receiveId, baseUrl? }
|
|
897
|
+
* @param secrets - { webhookUrl, signSecret, appSecret }
|
|
898
|
+
* @returns { messageId?, images }
|
|
899
|
+
*/
|
|
900
|
+
export async function sendMessage(channel, secrets, message) {
|
|
901
|
+
let images = 0
|
|
902
|
+
const results = []
|
|
903
|
+
// Native card charts are the default. Images are only uploaded when the
|
|
904
|
+
// caller asks for them (app bots; e.g. clients that cannot render charts).
|
|
905
|
+
for (const r of message.results ?? []) {
|
|
906
|
+
if (channel.type === 'lark_app' && message.chartMode === 'image' && chartable(r)) {
|
|
907
|
+
try {
|
|
908
|
+
const png = renderChartPng(r)
|
|
909
|
+
results.push({ ...r, imageKey: await uploadImage(channel, secrets, png), chart: { ...r.chart, nativeDisabled: true } })
|
|
910
|
+
images++
|
|
911
|
+
continue
|
|
912
|
+
} catch (error) {
|
|
913
|
+
if (error instanceof ChannelError && /99991672|234007/.test(error.code)) throw error
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
results.push(r)
|
|
917
|
+
}
|
|
918
|
+
const card = buildCard({ ...message, results }, { charts: message.chartMode !== 'image' && message.chartMode !== 'table' })
|
|
919
|
+
if (channel.type === 'lark_webhook') {
|
|
920
|
+
if (!secrets.webhookUrl) throw new ChannelError('im/config', '缺少 Webhook 地址')
|
|
921
|
+
const url = secrets.webhookUrl
|
|
922
|
+
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')
|
|
923
|
+
const send = async (c) => {
|
|
924
|
+
const timestamp = String(Math.floor(Date.now() / 1000))
|
|
925
|
+
const body = { msg_type: 'interactive', card: c, ...(secrets.signSecret ? { timestamp, sign: larkSign(secrets.signSecret, timestamp) } : {}) }
|
|
926
|
+
return postJson(url, body)
|
|
927
|
+
}
|
|
928
|
+
const fatal = [19021, 19022, 19024, 11232]
|
|
929
|
+
const codeOf = (json) => json?.code ?? json?.StatusCode
|
|
930
|
+
let { status, json } = await send(card)
|
|
931
|
+
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 })))
|
|
932
|
+
if (codeOf(json) !== 0 && !fatal.includes(codeOf(json))) ({ status, json } = await send(legacyCard(card)))
|
|
933
|
+
if (codeOf(json) !== 0) throw larkPushError(json, status)
|
|
934
|
+
return { images }
|
|
935
|
+
}
|
|
936
|
+
if (channel.type === 'lark_app') {
|
|
937
|
+
if (!channel.receiveId) throw new ChannelError('im/config', '缺少接收者 ID')
|
|
938
|
+
const token = await pushToken(channel, secrets)
|
|
939
|
+
const type = RECEIVE_ID_TYPES.includes(channel.receiveIdType) ? channel.receiveIdType : 'chat_id'
|
|
940
|
+
const url = `${hostFor(channel)}/open-apis/im/v1/messages?receive_id_type=${type}`
|
|
941
|
+
const send = (c) => postJson(url, { receive_id: channel.receiveId, msg_type: 'interactive', content: JSON.stringify(c) }, { authorization: `Bearer ${token}` })
|
|
942
|
+
const fatal = [230002, 230001, 230013, 99991672, 99991663]
|
|
943
|
+
let { status, json } = await send(card)
|
|
944
|
+
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 })))
|
|
945
|
+
if (json.code !== 0 && !fatal.includes(json.code)) ({ status, json } = await send(legacyCard(card)))
|
|
946
|
+
if (json.code !== 0) throw larkPushError(json, status)
|
|
947
|
+
return { messageId: json.data?.message_id, images }
|
|
948
|
+
}
|
|
949
|
+
throw new ChannelError('im/config', `不支持的渠道类型:${channel.type}`)
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/** Chats the app bot belongs to: [{ chatId, name, description }] (legacy shape). */
|
|
953
|
+
export async function listBotChats(channel, secrets) {
|
|
954
|
+
const token = await pushToken(channel, secrets)
|
|
955
|
+
const { status, json } = await request(`${hostFor(channel)}/open-apis/im/v1/chats?page_size=100`, { headers: { authorization: `Bearer ${token}` }, label: '飞书' })
|
|
956
|
+
if (json.code !== 0) throw larkPushError(json, status)
|
|
957
|
+
return (json.data?.items ?? []).map(c => ({ chatId: c.chat_id, name: c.name, description: c.description ?? '' }))
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// ===========================================================================
|
|
961
|
+
// Bot: normalized Lark events → InboundMessage / InboundAction
|
|
962
|
+
// ===========================================================================
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Reply to a message with a card, falling back to no charts, then no buttons,
|
|
966
|
+
* then plain text. Returns { messageId? } (never throws).
|
|
967
|
+
*/
|
|
968
|
+
export async function replyCard(creds, messageId, message) {
|
|
969
|
+
const attempts = [{}, { charts: false }, { charts: false, actions: false }]
|
|
970
|
+
let lastError
|
|
971
|
+
for (const opts of attempts) {
|
|
972
|
+
try { return { messageId: await replyMessage({ ...creds, messageId, card: buildCard(message, opts) }) } } catch (error) { lastError = error }
|
|
973
|
+
}
|
|
974
|
+
try { return { messageId: await replyText({ ...creds, messageId, text: String(message.text ?? '').slice(0, 3000) || `回复失败:${lastError?.message}` }) } } catch {}
|
|
975
|
+
return {}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const REACTIONS = { working: 'OnIt', done: 'DONE', failed: 'CrossMark' }
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* A normalized Lark message (normalizeMessageEvent) as an InboundMessage.
|
|
982
|
+
* Sender name/email are looked up (best effort) only for messages addressed to the bot.
|
|
983
|
+
* @param creds - { appId, appSecret, domain, baseUrl? }
|
|
984
|
+
*/
|
|
985
|
+
export async function toInboundMessage(m, creds, { channelId, lookup = true } = {}) {
|
|
986
|
+
let user = {}
|
|
987
|
+
if (lookup && m.mentionedBot && m.sender?.openId) { try { user = await getUserInfo({ ...creds, openId: m.sender.openId }) } catch {} }
|
|
988
|
+
return {
|
|
989
|
+
platform: 'lark',
|
|
990
|
+
channelId,
|
|
991
|
+
messageId: m.messageId,
|
|
992
|
+
chatId: m.chatId,
|
|
993
|
+
chatType: m.chatType === 'p2p' ? 'p2p' : 'group',
|
|
994
|
+
text: m.text ?? '',
|
|
995
|
+
mentionedBot: Boolean(m.mentionedBot),
|
|
996
|
+
sender: { id: m.sender?.openId, ...(user.name ? { name: user.name } : {}), ...(user.email ? { email: user.email } : {}), ...(m.sender?.unionId ? { unionId: m.sender.unionId } : {}) },
|
|
997
|
+
raw: m,
|
|
998
|
+
reply: (message, opts = {}) => replyCard(creds, opts.replyTo ?? m.messageId, message),
|
|
999
|
+
replyText: async (text) => { try { await replyText({ ...creds, messageId: m.messageId, text }) } catch {} },
|
|
1000
|
+
react: async (kind) => { await addReaction({ ...creds, messageId: m.messageId, emojiType: REACTIONS[kind] ?? kind }) },
|
|
1001
|
+
chatName: async () => (m.chatType === 'group' ? (await getChatInfo({ ...creds, chatId: m.chatId })).name : undefined),
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** A normalized card callback (normalizeCardActionEvent) as an InboundAction. */
|
|
1006
|
+
export async function toInboundAction(evt, creds, { channelId, lookupMs = 1200 } = {}) {
|
|
1007
|
+
let user = {}
|
|
1008
|
+
let timer
|
|
1009
|
+
try { user = await Promise.race([getUserInfo({ ...creds, openId: evt.operator.openId }), new Promise(r => { timer = setTimeout(() => r({}), lookupMs) })]) } catch {} finally { clearTimeout(timer) }
|
|
1010
|
+
const name = user.name ?? evt.operator.name
|
|
1011
|
+
return {
|
|
1012
|
+
platform: 'lark',
|
|
1013
|
+
channelId,
|
|
1014
|
+
operator: { id: evt.operator.openId, ...(name ? { name } : {}), ...(user.email ? { email: user.email } : {}) },
|
|
1015
|
+
chatId: evt.chatId,
|
|
1016
|
+
messageId: evt.messageId,
|
|
1017
|
+
value: evt.value,
|
|
1018
|
+
option: evt.option,
|
|
1019
|
+
raw: evt,
|
|
1020
|
+
reply: (message, opts = {}) => replyCard(creds, opts.replyTo ?? evt.messageId, message),
|
|
1021
|
+
replyText: async (text, opts = {}) => { try { await replyText({ ...creds, messageId: opts.replyTo ?? evt.messageId, text }) } catch {} },
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function credsOf(channel, secrets, opts = {}) {
|
|
1026
|
+
return { appId: channel.appId, appSecret: secrets.appSecret, domain: channel.domain === 'lark' ? 'lark' : 'feishu', ...(opts.baseUrl ?? channel.baseUrl ? { baseUrl: opts.baseUrl ?? channel.baseUrl } : {}) }
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Bot over the long connection for a lark_app channel.
|
|
1031
|
+
* handlers: { onMessage(InboundMessage), onAction?(InboundAction) → { toast } }
|
|
1032
|
+
*/
|
|
1033
|
+
export function createBot(channel, secrets, handlers = {}, opts = {}) {
|
|
1034
|
+
const creds = credsOf(channel, secrets, opts)
|
|
1035
|
+
const logger = opts.logger ?? { debug() {}, info() {}, warn: (...a) => console.warn('nl2sql lark:', ...a), error: (...a) => console.warn('nl2sql lark:', ...a) }
|
|
1036
|
+
const bot = createLarkBot({
|
|
1037
|
+
...creds,
|
|
1038
|
+
logger,
|
|
1039
|
+
onMessage: async (m) => {
|
|
1040
|
+
// Group messages not addressed to the bot are dropped early (no user lookup).
|
|
1041
|
+
if (m.chatType === 'group' && !m.mentionedBot) return
|
|
1042
|
+
await handlers.onMessage?.(await toInboundMessage(m, creds, { channelId: channel.id }))
|
|
1043
|
+
},
|
|
1044
|
+
onCardAction: handlers.onAction ? async (evt) => handlers.onAction(await toInboundAction(evt, creds, { channelId: channel.id })) : undefined,
|
|
1045
|
+
})
|
|
1046
|
+
return {
|
|
1047
|
+
start: () => bot.start(),
|
|
1048
|
+
stop: () => bot.stop(),
|
|
1049
|
+
status: () => bot.status(),
|
|
1050
|
+
info: () => { const i = bot.botInfo(); return i.openId || i.name ? { id: i.openId, name: i.name } : null },
|
|
1051
|
+
creds,
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// ===========================================================================
|
|
1056
|
+
// Adapter
|
|
1057
|
+
// ===========================================================================
|
|
1058
|
+
|
|
1059
|
+
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)' } }] }
|
|
1060
|
+
|
|
1061
|
+
const APP_SETUP = {
|
|
1062
|
+
zh: [
|
|
1063
|
+
'在开发者后台创建企业自建应用,记下 App ID / App Secret,并在这里添加为应用机器人渠道。',
|
|
1064
|
+
'「添加应用能力」开启机器人。',
|
|
1065
|
+
'「权限管理」开通: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)。',
|
|
1066
|
+
'在「机器人」中启用该渠道使长连接在线,然后到「事件与回调 → 事件配置」选择「使用长连接接收事件」并保存。',
|
|
1067
|
+
'添加事件「接收消息 v2.0(im.message.receive_v1)」,创建版本并发布。',
|
|
1068
|
+
'「事件与回调 → 回调配置」同样选择「使用长连接接收回调」,并添加回调「卡片回传交互(card.action.trigger)」,否则回答卡片上的「按维度拆分 / 为什么变化 / 明细 / 固定到看板」按钮点击无效。',
|
|
1069
|
+
'把机器人拉进群;群里需要 @机器人 才会响应。发送「帮助」查看命令(数据源、切换 <名称>、新对话)。',
|
|
1070
|
+
],
|
|
1071
|
+
en: [
|
|
1072
|
+
'Create a custom app, note App ID / Secret, and add it here as an app bot channel.',
|
|
1073
|
+
'Enable the bot capability.',
|
|
1074
|
+
'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).',
|
|
1075
|
+
'Enable the bot for this channel so the long connection is online, then choose "Receive events through persistent connection" in Events & Callbacks.',
|
|
1076
|
+
'Subscribe to im.message.receive_v1, create and publish a version.',
|
|
1077
|
+
'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.',
|
|
1078
|
+
'Add the bot to groups; @ it to ask. Send "help" for commands.',
|
|
1079
|
+
],
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const WEBHOOK_SETUP = {
|
|
1083
|
+
zh: [
|
|
1084
|
+
'在飞书群「设置 → 群机器人 → 添加机器人」中选择「自定义机器人」。',
|
|
1085
|
+
'复制 Webhook 地址填到这里;如开启了「签名校验」,把签名密钥一并填入。',
|
|
1086
|
+
'如设置了关键词校验,消息标题或正文需要包含该关键词。',
|
|
1087
|
+
],
|
|
1088
|
+
en: [
|
|
1089
|
+
'In the Lark group: Settings → Bots → Add bot → Custom bot.',
|
|
1090
|
+
'Paste the webhook URL here; if signature verification is on, also paste the signing secret.',
|
|
1091
|
+
'If keyword verification is on, titles or bodies must contain the keyword.',
|
|
1092
|
+
],
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const str = (v, max = 300) => (v === null || v === undefined ? '' : String(v).trim().slice(0, max))
|
|
1096
|
+
|
|
1097
|
+
export const kinds = [
|
|
1098
|
+
{
|
|
1099
|
+
id: 'lark_webhook',
|
|
1100
|
+
platform: 'lark',
|
|
1101
|
+
label: { zh: '飞书群机器人(Webhook)', en: 'Lark group bot (webhook)' },
|
|
1102
|
+
description: { zh: '群自定义机器人,粘贴 Webhook 地址即可推送;不能上传图片,不能接收消息。', en: 'Custom group bot: paste the webhook URL to push; no image upload, no inbound messages.' },
|
|
1103
|
+
fields: [
|
|
1104
|
+
{ 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 },
|
|
1105
|
+
{ key: 'signSecret', label: { zh: '签名密钥', en: 'Signing secret' }, type: 'secret', secret: true, help: { zh: '机器人开启「签名校验」时填写', en: 'Only when signature verification is enabled' } },
|
|
1106
|
+
],
|
|
1107
|
+
capabilities: { push: true, markdown: 'full', charts: 'native', chartModes: ['native', 'table'], tables: 'native', buttons: false, bot: false, listTargets: false, mentionAll: true, maxMessageBytes: 28000 },
|
|
1108
|
+
setupSteps: WEBHOOK_SETUP,
|
|
1109
|
+
validate(input, existing) {
|
|
1110
|
+
const config = {}
|
|
1111
|
+
const secrets = { ...(input.secrets ?? {}) }
|
|
1112
|
+
const url = str(secrets.webhookUrl, 500)
|
|
1113
|
+
if (url) {
|
|
1114
|
+
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' })
|
|
1115
|
+
secrets.webhookUrl = url
|
|
1116
|
+
config.domain = url.includes('larksuite') ? 'lark' : 'feishu'
|
|
1117
|
+
} else {
|
|
1118
|
+
config.domain = input.config?.domain === 'lark' ? 'lark' : existing?.config?.domain ?? 'feishu'
|
|
1119
|
+
}
|
|
1120
|
+
if (typeof secrets.signSecret === 'string') secrets.signSecret = secrets.signSecret.trim() || null
|
|
1121
|
+
if (!secrets.webhookUrl) throw new ChannelError('channel/config', '请填写 Webhook 地址', { field: 'webhookUrl' })
|
|
1122
|
+
return { config, secrets }
|
|
1123
|
+
},
|
|
1124
|
+
send: (channel, secrets, message) => sendMessage({ ...channel, type: 'lark_webhook' }, secrets, message),
|
|
1125
|
+
},
|
|
1126
|
+
{
|
|
1127
|
+
id: 'lark_app',
|
|
1128
|
+
platform: 'lark',
|
|
1129
|
+
label: { zh: '飞书应用机器人', en: 'Lark app bot' },
|
|
1130
|
+
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.' },
|
|
1131
|
+
fields: [
|
|
1132
|
+
DOMAIN_FIELD,
|
|
1133
|
+
{ key: 'appId', label: { zh: 'App ID', en: 'App ID' }, type: 'text', required: true, placeholder: 'cli_xxxxxxxxxxxxxxxx' },
|
|
1134
|
+
{ key: 'appSecret', label: { zh: 'App Secret', en: 'App Secret' }, type: 'secret', secret: true, required: true },
|
|
1135
|
+
{ key: 'receiveIdType', label: { zh: '接收者类型', en: 'Receiver type' }, type: 'select', options: [
|
|
1136
|
+
{ value: 'chat_id', label: { zh: '群(chat_id)', en: 'Chat (chat_id)' } },
|
|
1137
|
+
{ value: 'open_id', label: { zh: '用户(open_id)', en: 'User (open_id)' } },
|
|
1138
|
+
{ value: 'user_id', label: { zh: '用户(user_id)', en: 'User (user_id)' } },
|
|
1139
|
+
{ value: 'union_id', label: { zh: '用户(union_id)', en: 'User (union_id)' } },
|
|
1140
|
+
{ value: 'email', label: { zh: '邮箱', en: 'Email' } },
|
|
1141
|
+
] },
|
|
1142
|
+
{ 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' } },
|
|
1143
|
+
],
|
|
1144
|
+
capabilities: { push: true, markdown: 'full', charts: 'native', chartImages: true, chartModes: ['native', 'image', 'table'], tables: 'native', buttons: true, bot: true, listTargets: true, mentionAll: true, maxMessageBytes: 28000 },
|
|
1145
|
+
setupSteps: APP_SETUP,
|
|
1146
|
+
validate(input, existing) {
|
|
1147
|
+
const c = input.config ?? {}
|
|
1148
|
+
const config = {
|
|
1149
|
+
domain: c.domain === 'lark' ? 'lark' : c.domain === 'feishu' ? 'feishu' : existing?.config?.domain ?? 'feishu',
|
|
1150
|
+
appId: str(c.appId, 64),
|
|
1151
|
+
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',
|
|
1152
|
+
receiveId: str(c.receiveId, 200),
|
|
1153
|
+
}
|
|
1154
|
+
if (!config.appId) throw new ChannelError('channel/config', '请填写 App ID', { field: 'appId' })
|
|
1155
|
+
if (!config.receiveId) throw new ChannelError('channel/config', '请填写接收者 ID', { field: 'receiveId' })
|
|
1156
|
+
const secrets = { ...(input.secrets ?? {}) }
|
|
1157
|
+
if (typeof secrets.appSecret === 'string') secrets.appSecret = secrets.appSecret.trim()
|
|
1158
|
+
if (!secrets.appSecret) throw new ChannelError('channel/config', '请填写 App Secret', { field: 'appSecret' })
|
|
1159
|
+
return { config, secrets }
|
|
1160
|
+
},
|
|
1161
|
+
send: (channel, secrets, message) => sendMessage({ ...channel, type: 'lark_app' }, secrets, message),
|
|
1162
|
+
/** Credentials check (tenant token + bot info); a test message follows when a receiver is set. */
|
|
1163
|
+
async test(channel, secrets) {
|
|
1164
|
+
await pushToken(channel, secrets)
|
|
1165
|
+
let bot = {}
|
|
1166
|
+
try { bot = await getBotInfo(credsOf(channel, secrets)) } catch {}
|
|
1167
|
+
return { ok: true, detail: `App 凭据有效${bot.name ? `(机器人:${bot.name})` : ''}`, send: Boolean(channel.receiveId) }
|
|
1168
|
+
},
|
|
1169
|
+
async listTargets(channel, secrets) {
|
|
1170
|
+
return (await listBotChats(channel, secrets)).map(c => ({ id: c.chatId, name: c.name, type: 'group', description: c.description }))
|
|
1171
|
+
},
|
|
1172
|
+
},
|
|
1173
|
+
]
|
|
1174
|
+
|
|
1175
|
+
export default {
|
|
1176
|
+
id: 'lark',
|
|
1177
|
+
name: { zh: '飞书 / Lark', en: 'Lark / Feishu' },
|
|
1178
|
+
kinds,
|
|
1179
|
+
createBot,
|
|
1180
|
+
}
|