@hedeqiang/dsh-nl2sql 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -2
- package/README.en.md +2 -1
- package/README.md +2 -2
- package/channels/dingtalk.js +574 -0
- package/channels/http.js +63 -0
- package/channels/index.js +138 -0
- package/channels/lark.js +1180 -0
- package/channels/message.js +267 -0
- package/channels/telegram.js +663 -0
- package/channels/wecom.js +585 -0
- package/cordis.patch.yml +3 -3
- package/docs/CHANNELS.md +184 -0
- package/im.js +6 -509
- package/index.js +25 -17
- package/lark-bot.js +7 -604
- package/lib/client.js +1097 -185
- package/package.json +14 -9
- package/service/access.js +116 -22
- package/service/admin.js +29 -13
- package/service/bots.js +665 -0
- package/service/channels.js +363 -0
- package/service/datasources.js +31 -8
- package/service/push.js +5 -156
- package/service/shared.js +1 -1
- package/service/lark-actions.js +0 -223
- package/service/lark.js +0 -250
package/service/lark-actions.js
DELETED
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Nl2SqlService methods: interactive buttons on Lark bot answer cards.
|
|
3
|
-
* Mixed into the service prototype by index.js; `this` is the service.
|
|
4
|
-
*
|
|
5
|
-
* Buttons carry only an opaque token ({ a: <action>, t: <token> }); the token
|
|
6
|
-
* maps server-side to { resultId, sessionId, chatId, replyTo, depth, ownerRole }.
|
|
7
|
-
* Every click re-resolves the clicking operator and re-checks data source
|
|
8
|
-
* scope, row-level rules and role before anything runs.
|
|
9
|
-
*/
|
|
10
|
-
import { randomBytes } from 'node:crypto'
|
|
11
|
-
import { parseSelect } from '../analysis.js'
|
|
12
|
-
import { buildCard } from '../im.js'
|
|
13
|
-
import { getUserInfo, replyMessage, replyText } from '../lark-bot.js'
|
|
14
|
-
import { toUserError } from './shared.js'
|
|
15
|
-
|
|
16
|
-
export const ACTION_TOKEN_TTL_MS = 7 * 24 * 3600 * 1000
|
|
17
|
-
const ACTION_TOKEN_MAX = 5000
|
|
18
|
-
export const ACTION_RATE_MS = 2000
|
|
19
|
-
/** Cards produced by button actions get buttons up to this depth (0 = answer card). */
|
|
20
|
-
export const ACTION_MAX_DEPTH = 2
|
|
21
|
-
const ACTIONS = new Set(['drill', 'why', 'detail', 'pin'])
|
|
22
|
-
const ACTION_LABEL = { drill: '拆分', why: '为什么变化', detail: '明细', pin: '固定到看板' }
|
|
23
|
-
|
|
24
|
-
const toast = (type, content) => ({ toast: { type, content } })
|
|
25
|
-
|
|
26
|
-
function hasAggregate(r, dialect) {
|
|
27
|
-
const parsed = parseSelect(r.baseSql ?? r.sql, { dialect })
|
|
28
|
-
return Boolean(parsed?.select?.some(i => i.isAggregate))
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export const methods = {
|
|
32
|
-
// ------------------------------------------------------------- tokens
|
|
33
|
-
|
|
34
|
-
/** Store context for a card's buttons; returns the opaque token. */
|
|
35
|
-
createLarkActionToken(ctx) {
|
|
36
|
-
const token = randomBytes(9).toString('base64url')
|
|
37
|
-
const now = Date.now()
|
|
38
|
-
const body = { resultId: ctx.resultId, sessionId: ctx.sessionId ?? null, chatId: ctx.chatId ?? null, chatType: ctx.chatType === 'p2p' ? 'p2p' : 'group', replyTo: ctx.replyTo ?? null, depth: ctx.depth ?? 0, ownerRole: ctx.ownerRole ?? null }
|
|
39
|
-
this.larkActionTokens ??= new Map()
|
|
40
|
-
this.larkActionTokens.set(token, { ...body, created: now })
|
|
41
|
-
while (this.larkActionTokens.size > ACTION_TOKEN_MAX) this.larkActionTokens.delete(this.larkActionTokens.keys().next().value)
|
|
42
|
-
try {
|
|
43
|
-
this.store?.putActionToken(token, body, now)
|
|
44
|
-
if ((this.larkTokenWrites = (this.larkTokenWrites ?? 0) + 1) % 200 === 0) this.store?.pruneActionTokens(ACTION_TOKEN_TTL_MS, ACTION_TOKEN_MAX)
|
|
45
|
-
} catch {}
|
|
46
|
-
return token
|
|
47
|
-
},
|
|
48
|
-
|
|
49
|
-
/** Context for a token, or undefined when unknown / expired. */
|
|
50
|
-
lookupLarkActionToken(token, now = Date.now()) {
|
|
51
|
-
if (typeof token !== 'string' || !/^[\w-]{8,32}$/.test(token)) return undefined
|
|
52
|
-
let ctx = this.larkActionTokens?.get(token)
|
|
53
|
-
if (!ctx) { try { ctx = this.store?.getActionToken(token) } catch {} }
|
|
54
|
-
if (!ctx || now - ctx.created > ACTION_TOKEN_TTL_MS) return undefined
|
|
55
|
-
return ctx
|
|
56
|
-
},
|
|
57
|
-
|
|
58
|
-
// ------------------------------------------------------------- building
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Button spec (see im.js actionElement) for one result shown to `actor` in a
|
|
62
|
-
* chat, or undefined when nothing applies. Never throws.
|
|
63
|
-
*/
|
|
64
|
-
async larkCardActions(result, { actor, sessionId, chatId, chatType, replyTo, depth = 0 } = {}) {
|
|
65
|
-
try {
|
|
66
|
-
if (!result?.resultId || result.error || depth >= ACTION_MAX_DEPTH) return undefined
|
|
67
|
-
const ds = this.findDatasource(result.datasourceId)
|
|
68
|
-
if (!ds) return undefined
|
|
69
|
-
const dialect = ds.type ?? 'mysql'
|
|
70
|
-
const role = actor?.role ?? 'analyst'
|
|
71
|
-
const token = this.createLarkActionToken({ resultId: result.resultId, sessionId, chatId, chatType, replyTo, depth, ownerRole: role })
|
|
72
|
-
const aggregate = hasAggregate(result, dialect)
|
|
73
|
-
const buttons = []
|
|
74
|
-
let select
|
|
75
|
-
if (aggregate) {
|
|
76
|
-
let dims = []
|
|
77
|
-
try { dims = (await this.drillDimensions(result.resultId)).dimensions } catch {}
|
|
78
|
-
const xName = result.chart?.x
|
|
79
|
-
dims = dims.filter(d => d.column !== xName && d.column.split('.').pop() !== xName).slice(0, 6)
|
|
80
|
-
if (dims.length) select = { placeholder: '按维度拆分', value: { a: 'drill', t: token }, options: dims.map(d => ({ text: d.label && d.label !== d.column ? `${d.label}(${d.column})` : d.column, value: d.column })) }
|
|
81
|
-
}
|
|
82
|
-
const xi = result.chart?.x ? result.columns.findIndex(c => c.name === result.chart.x) : -1
|
|
83
|
-
const points = xi >= 0 ? result.rows.filter(r => r[xi] !== null && r[xi] !== undefined).length : 0
|
|
84
|
-
const timeLike = xi >= 0 && (result.columns[xi].kind === 'time' || ['line', 'area'].includes(result.chart?.type))
|
|
85
|
-
if (aggregate && timeLike && points >= 2) buttons.push({ text: '为什么变化', value: { a: 'why', t: token } })
|
|
86
|
-
if (xi >= 0 && points >= 1 && role !== 'viewer') buttons.push({ text: '明细', value: { a: 'detail', t: token } })
|
|
87
|
-
if (role === 'admin' || role === 'analyst') buttons.push({ text: '固定到看板', value: { a: 'pin', t: token } })
|
|
88
|
-
if (!select && !buttons.length) return undefined
|
|
89
|
-
return { ...(select ? { select } : {}), buttons }
|
|
90
|
-
} catch (error) {
|
|
91
|
-
console.warn('nl2sql lark: cannot build card actions: %s', error.message)
|
|
92
|
-
return undefined
|
|
93
|
-
}
|
|
94
|
-
},
|
|
95
|
-
|
|
96
|
-
/** Results with their button specs attached (for buildCard). */
|
|
97
|
-
async withLarkCardActions(results, ctx) {
|
|
98
|
-
const out = []
|
|
99
|
-
for (const r of results) {
|
|
100
|
-
const cardActions = await this.larkCardActions(r, ctx)
|
|
101
|
-
out.push(cardActions ? { ...r, cardActions } : r)
|
|
102
|
-
}
|
|
103
|
-
return out
|
|
104
|
-
},
|
|
105
|
-
|
|
106
|
-
/** Reply with a card, falling back to no charts, then no buttons, then text. */
|
|
107
|
-
async replyLarkCard(creds, messageId, message) {
|
|
108
|
-
const attempts = [{}, { charts: false }, { charts: false, actions: false }]
|
|
109
|
-
let lastError
|
|
110
|
-
for (const opts of attempts) {
|
|
111
|
-
try { return await replyMessage({ ...creds, messageId, card: buildCard(message, opts) }) } catch (error) { lastError = error }
|
|
112
|
-
}
|
|
113
|
-
try { return await replyText({ ...creds, messageId, text: String(message.text ?? '').slice(0, 3000) || `回复失败:${lastError?.message}` }) } catch {}
|
|
114
|
-
return undefined
|
|
115
|
-
},
|
|
116
|
-
|
|
117
|
-
// ------------------------------------------------------------- clicks
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* card.action.trigger (normalized by lark-bot.js). Authorizes synchronously
|
|
121
|
-
* and returns a toast within Feishu's 3 s window; the analysis runs in the
|
|
122
|
-
* background and replies in the chat with a new card.
|
|
123
|
-
*/
|
|
124
|
-
async handleLarkCardAction(evt) {
|
|
125
|
-
const creds = this.larkCreds
|
|
126
|
-
if (!creds || !evt?.operator?.openId) return undefined
|
|
127
|
-
const a = evt.value?.a
|
|
128
|
-
const ctx = this.lookupLarkActionToken(evt.value?.t)
|
|
129
|
-
const deny = (msg, detail) => { this.auditAdmin('larkbot.action.denied', `${evt.operator.openId} · ${a ?? '?'} · ${detail ?? msg}`); return toast('error', msg) }
|
|
130
|
-
// Per-operator rate limit.
|
|
131
|
-
this.larkActionRate ??= new Map()
|
|
132
|
-
const now = Date.now()
|
|
133
|
-
const last = this.larkActionRate.get(evt.operator.openId) ?? 0
|
|
134
|
-
if (now - last < ACTION_RATE_MS) return toast('warning', '操作太频繁,请稍后再试。')
|
|
135
|
-
this.larkActionRate.set(evt.operator.openId, now)
|
|
136
|
-
if (this.larkActionRate.size > 2000) for (const [k, t] of this.larkActionRate) if (now - t > ACTION_RATE_MS) this.larkActionRate.delete(k)
|
|
137
|
-
|
|
138
|
-
if (!ACTIONS.has(a) || !ctx) return deny('该按钮已过期或无效,请重新提问。', 'unknown/expired token')
|
|
139
|
-
if (ctx.chatId && evt.chatId && ctx.chatId !== evt.chatId) return deny('该按钮不属于当前聊天。', 'chat mismatch')
|
|
140
|
-
// Who clicked: resolved from our member list, never from the callback value.
|
|
141
|
-
let user = {}
|
|
142
|
-
let timer
|
|
143
|
-
try { user = await Promise.race([getUserInfo({ ...creds, openId: evt.operator.openId }), new Promise(r => { timer = setTimeout(() => r({}), 1200) })]) } catch {} finally { clearTimeout(timer) }
|
|
144
|
-
const chatId = ctx.chatId ?? evt.chatId
|
|
145
|
-
const resolved = this.resolveLarkActor({ openId: evt.operator.openId, email: user.email, name: user.name ?? evt.operator.name, chatId, chatType: ctx.chatType ?? 'group' })
|
|
146
|
-
if (resolved.refused) return deny(resolved.refused, 'actor refused')
|
|
147
|
-
const actor = { ...resolved.actor, kind: 'lark' }
|
|
148
|
-
const r = this.lookupResult(ctx.resultId)
|
|
149
|
-
if (!r) return deny('结果已过期,请重新提问。', 'result expired')
|
|
150
|
-
try { this.assertResultAccess(r, actor) } catch (error) { return deny(toUserError(error).message, `${actor.name}: ${error.message}`) }
|
|
151
|
-
if (actor.role === 'viewer') {
|
|
152
|
-
// Viewers may only act on governed results (produced under viewer rules, or a verified example / dashboard SQL).
|
|
153
|
-
const ds = this.findDatasource(r.datasourceId)
|
|
154
|
-
const governed = ctx.ownerRole === 'viewer' || (ds && this.isVerifiedExample(ds, r.baseSql ?? r.sql))
|
|
155
|
-
if (!governed || a === 'detail' || a === 'pin') return deny(a === 'pin' ? '只读成员不能固定到看板。' : a === 'detail' ? '只读成员不能查看明细数据。' : '只读成员只能分析指标库或已验证问答的结果。', `${actor.name}: viewer`)
|
|
156
|
-
}
|
|
157
|
-
if (a === 'pin' && actor.role !== 'admin' && actor.role !== 'analyst') return deny('只有管理员或分析师可以固定到看板。', `${actor.name}: role ${actor.role}`)
|
|
158
|
-
let dimension
|
|
159
|
-
if (a === 'drill') {
|
|
160
|
-
dimension = typeof evt.option === 'string' ? evt.option : undefined
|
|
161
|
-
let allowed = []
|
|
162
|
-
try { allowed = (await this.drillDimensions(r.resultId)).dimensions.map(d => d.column) } catch {}
|
|
163
|
-
if (!dimension || !allowed.includes(dimension)) return deny('请选择一个有效的拆分维度。', `invalid dimension ${String(dimension).slice(0, 40)}`)
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
if (a === 'pin') {
|
|
167
|
-
try {
|
|
168
|
-
const widget = await this.pinWidget({ title: r.title || '飞书结果', sql: r.baseSql ?? r.sql, datasourceId: r.datasourceId, chart: r.chart })
|
|
169
|
-
const board = this.currentDashboardObj()
|
|
170
|
-
this.auditAdmin('larkbot.pin', `${actor.name} · ${widget.title} → ${board.name}`, actor)
|
|
171
|
-
this.recordDelivery({ channel: { id: 'larkbot', name: `飞书机器人 · ${actor.name}` }, title: `固定:${widget.title}`.slice(0, 60), status: 'ok', results: 0, source: 'larkbot', sessionId: ctx.sessionId, elapsedMs: Date.now() - now })
|
|
172
|
-
await this.save()
|
|
173
|
-
return toast('success', `已固定到看板「${board.name}」`)
|
|
174
|
-
} catch (error) {
|
|
175
|
-
return toast('error', `固定失败:${toUserError(error).message}`.slice(0, 200))
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const job = this.runLarkCardAction({ a, ctx, r, actor, dimension, evt, creds })
|
|
180
|
-
this.larkActionJobs ??= new Set()
|
|
181
|
-
this.larkActionJobs.add(job)
|
|
182
|
-
job.finally(() => this.larkActionJobs.delete(job)).catch(() => {})
|
|
183
|
-
return toast('info', '正在分析…')
|
|
184
|
-
},
|
|
185
|
-
|
|
186
|
-
async runLarkCardAction({ a, ctx, r, actor, dimension, evt, creds }) {
|
|
187
|
-
const started = Date.now()
|
|
188
|
-
const replyTo = ctx.replyTo ?? evt.messageId
|
|
189
|
-
const label = a === 'drill' ? `拆分:${dimension}` : a === 'why' ? '为什么变化' : `明细:${r.chart?.x ?? ''}`
|
|
190
|
-
const footer = `数据问答 · ${r.datasourceName ?? ''} · ${actor.name}`
|
|
191
|
-
let status = 'ok'
|
|
192
|
-
let error
|
|
193
|
-
let resultsCount = 0
|
|
194
|
-
try {
|
|
195
|
-
let message
|
|
196
|
-
if (a === 'drill' || a === 'detail') {
|
|
197
|
-
let xValue
|
|
198
|
-
if (a === 'detail') {
|
|
199
|
-
const xi = r.columns.findIndex(c => c.name === r.chart?.x)
|
|
200
|
-
xValue = r.rows.map(row => row[xi]).filter(v => v !== null && v !== undefined).at(-1)
|
|
201
|
-
}
|
|
202
|
-
const out = await this.drill({ resultId: r.resultId, mode: a === 'drill' ? 'breakdown' : 'detail', dimension, xValue, sessionId: ctx.sessionId ?? undefined, actor, source: 'larkbot' })
|
|
203
|
-
const results = await this.withLarkCardActions([out], { actor, sessionId: ctx.sessionId, chatId: ctx.chatId ?? evt.chatId, chatType: ctx.chatType, replyTo, depth: (ctx.depth ?? 0) + 1 })
|
|
204
|
-
resultsCount = 1
|
|
205
|
-
message = { title: out.title || label, text: a === 'detail' ? `「${r.title || '结果'}」在 ${r.chart?.x}=${xValue} 的明细(最多 200 行)。` : `「${r.title || '结果'}」按 ${dimension} 拆分。`, results, footer }
|
|
206
|
-
} else {
|
|
207
|
-
const out = await this.explainChange({ resultId: r.resultId, sessionId: ctx.sessionId ?? undefined, actor })
|
|
208
|
-
const top = out.dimensions.find(d => d.attribution)
|
|
209
|
-
const rows = (top?.attribution.items ?? []).slice(0, 8)
|
|
210
|
-
const table = rows.length ? `\n\n| ${top.dimension} | ${out.baseline} | ${out.current} | 变化 |\n|---|---|---|---|\n${rows.map(i => `| ${i.value ?? 'NULL'} | ${i.baseline} | ${i.current} | ${i.delta >= 0 ? '+' : ''}${i.delta} |`).join('\n')}` : ''
|
|
211
|
-
message = { title: `为什么变化 · ${r.title || ''}`.slice(0, 80), text: `${out.summary}${table}`, results: [], footer }
|
|
212
|
-
}
|
|
213
|
-
await this.replyLarkCard(creds, replyTo, message)
|
|
214
|
-
} catch (e) {
|
|
215
|
-
status = 'error'
|
|
216
|
-
error = toUserError(e).message
|
|
217
|
-
try { await replyText({ ...creds, messageId: replyTo, text: `${label} 失败:${error}`.slice(0, 1000) }) } catch {}
|
|
218
|
-
}
|
|
219
|
-
this.auditAdmin('larkbot.action', `${actor.name} · ${label} · ${r.resultId}${error ? ` · ${error}` : ''}`, actor)
|
|
220
|
-
this.recordDelivery({ channel: { id: 'larkbot', name: `飞书机器人 · ${actor.name}` }, title: label.slice(0, 60), status, ...(error ? { error } : {}), results: resultsCount, source: 'larkbot', sessionId: ctx.sessionId, elapsedMs: Date.now() - started })
|
|
221
|
-
await this.save()
|
|
222
|
-
},
|
|
223
|
-
}
|
package/service/lark.js
DELETED
|
@@ -1,250 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Nl2SqlService methods: lark.
|
|
3
|
-
* Mixed into the service prototype by index.js; `this` is the service.
|
|
4
|
-
*/
|
|
5
|
-
import { addReaction, createLarkBot, getChatInfo, getUserInfo, replyText } from '../lark-bot.js'
|
|
6
|
-
import { PRESET_ID, UserError, hashText, oneOf, toUserError } from './shared.js'
|
|
7
|
-
|
|
8
|
-
export const methods = {
|
|
9
|
-
// ------------------------------------------------------------- Lark bot
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Inbound Lark bot: people @ the app bot in a group (or DM it); each chat
|
|
13
|
-
* gets its own nl2sql session bound to a data source the sender may use.
|
|
14
|
-
* Config: { enabled, channelId (lark_app channel providing credentials),
|
|
15
|
-
* replyMode: 'card', sessionPerChat: true, allowP2p: true }.
|
|
16
|
-
*/
|
|
17
|
-
larkBotConfig() {
|
|
18
|
-
return { enabled: false, channelId: null, allowP2p: true, sessionScope: 'chat', ...(this.state.larkBot ?? {}) }
|
|
19
|
-
},
|
|
20
|
-
|
|
21
|
-
larkBotStatus() {
|
|
22
|
-
const conf = this.larkBotConfig()
|
|
23
|
-
const status = this.larkBot?.status() ?? { state: conf.enabled ? 'stopped' : 'idle', since: Date.now() }
|
|
24
|
-
return { ...conf, status, bot: this.larkBot?.botInfo?.() ?? null, conversations: Object.values(this.state.larkSessions ?? {}).sort((a, b) => b.lastAt - a.lastAt).slice(0, 50) }
|
|
25
|
-
},
|
|
26
|
-
|
|
27
|
-
async saveLarkBot(input) {
|
|
28
|
-
const conf = this.larkBotConfig()
|
|
29
|
-
if (input.channelId !== undefined) {
|
|
30
|
-
const ch = this.state.channels.find(c => c.id === input.channelId)
|
|
31
|
-
if (input.channelId && (!ch || ch.type !== 'lark_app')) throw new UserError('nl2sql/invalid', '飞书机器人需要选择一个「应用机器人」类型的推送渠道(提供 App ID / Secret)')
|
|
32
|
-
conf.channelId = input.channelId || null
|
|
33
|
-
}
|
|
34
|
-
if (input.enabled !== undefined) conf.enabled = Boolean(input.enabled)
|
|
35
|
-
if (input.allowP2p !== undefined) conf.allowP2p = Boolean(input.allowP2p)
|
|
36
|
-
if (input.sessionScope !== undefined) conf.sessionScope = oneOf(input.sessionScope, ['chat', 'user'], 'sessionScope')
|
|
37
|
-
if (conf.enabled && !conf.channelId) throw new UserError('nl2sql/invalid', '请先选择应用机器人渠道')
|
|
38
|
-
this.state.larkBot = conf
|
|
39
|
-
this.auditAdmin('larkbot.save', `${conf.enabled ? '启用' : '停用'} · ${conf.channelId ?? ''}`)
|
|
40
|
-
await this.save()
|
|
41
|
-
await this.restartLarkBot()
|
|
42
|
-
return this.larkBotStatus()
|
|
43
|
-
},
|
|
44
|
-
|
|
45
|
-
async restartLarkBot() {
|
|
46
|
-
if (this.larkBot) { try { await this.larkBot.stop() } catch {} this.larkBot = undefined }
|
|
47
|
-
const conf = this.larkBotConfig()
|
|
48
|
-
if (!conf.enabled || this.disposed) return
|
|
49
|
-
const ch = this.state.channels.find(c => c.id === conf.channelId)
|
|
50
|
-
if (!ch) return
|
|
51
|
-
const secrets = await this.channelSecrets(ch)
|
|
52
|
-
this.larkCreds = { appId: ch.appId, appSecret: secrets.appSecret, domain: ch.domain === 'lark' ? 'lark' : 'feishu' }
|
|
53
|
-
this.larkBot = createLarkBot({
|
|
54
|
-
...this.larkCreds,
|
|
55
|
-
logger: { debug() {}, info() {}, warn: (...a) => console.warn('nl2sql lark:', ...a), error: (...a) => console.warn('nl2sql lark:', ...a) },
|
|
56
|
-
onStatus: () => {},
|
|
57
|
-
onMessage: (m) => { this.handleLarkMessage(m).catch(error => console.warn('nl2sql lark: handler failed: %s', error.message)) },
|
|
58
|
-
onCardAction: (evt) => this.handleLarkCardAction(evt),
|
|
59
|
-
})
|
|
60
|
-
await this.larkBot.start()
|
|
61
|
-
},
|
|
62
|
-
|
|
63
|
-
/** One inbound message → permission check → session prompt → reply card. */
|
|
64
|
-
async handleLarkMessage(m) {
|
|
65
|
-
const conf = this.larkBotConfig()
|
|
66
|
-
const creds = this.larkCreds
|
|
67
|
-
if (!creds) return
|
|
68
|
-
if (m.chatType === 'group' && !m.mentionedBot) return
|
|
69
|
-
if (m.chatType === 'p2p' && !conf.allowP2p) return
|
|
70
|
-
const text = (m.text ?? '').trim()
|
|
71
|
-
if (!text) return
|
|
72
|
-
const reply = (t) => replyText({ ...creds, messageId: m.messageId, text: t }).catch(() => {})
|
|
73
|
-
try {
|
|
74
|
-
await this.handleLarkMessageInner(m, text, conf, creds, reply)
|
|
75
|
-
} catch (error) {
|
|
76
|
-
console.warn('nl2sql lark: %s', error.message)
|
|
77
|
-
await reply(`处理失败:${toUserError(error).message}`)
|
|
78
|
-
}
|
|
79
|
-
},
|
|
80
|
-
|
|
81
|
-
async handleLarkMessageInner(m, text, conf, creds, reply) {
|
|
82
|
-
let user = {}
|
|
83
|
-
try { user = await getUserInfo({ ...creds, openId: m.sender.openId }) } catch {}
|
|
84
|
-
const resolved = this.resolveLarkActor({ openId: m.sender.openId, email: user.email, name: user.name, chatId: m.chatId, chatType: m.chatType })
|
|
85
|
-
if (resolved.refused) { await reply(resolved.refused); return }
|
|
86
|
-
const actor = resolved.actor
|
|
87
|
-
// Commands: 帮助 / 数据源 / 切换 <名称> / 新对话
|
|
88
|
-
const cmd = text.match(/^\/?(帮助|help|数据源|切换数据源|切换|新对话|reset)\s*(.*)$/i)
|
|
89
|
-
// Members with different scopes must not share a conversation: per-user sessions
|
|
90
|
-
// whenever the sender's effective scope or rules differ from the chat's.
|
|
91
|
-
const scopeSig = JSON.stringify([actor.datasourceIds, actor.role, actor.rowRules ?? []])
|
|
92
|
-
const shared = conf.sessionScope !== 'user'
|
|
93
|
-
let key = shared ? `${m.chatId}#${hashText(scopeSig)}` : `${m.chatId}:${m.sender.openId}`
|
|
94
|
-
this.state.larkSessions ??= {}
|
|
95
|
-
const conv = this.state.larkSessions[key] ?? { key, chatId: m.chatId, chatType: m.chatType, sessionId: null, datasourceId: actor.defaultDatasourceId, createdAt: Date.now() }
|
|
96
|
-
const allowedDs = this.state.datasources.filter(d => !Array.isArray(actor.datasourceIds) || actor.datasourceIds.includes(d.id))
|
|
97
|
-
if (conv.datasourceId && !allowedDs.some(d => d.id === conv.datasourceId)) conv.datasourceId = allowedDs[0]?.id ?? null
|
|
98
|
-
if (cmd) {
|
|
99
|
-
const verb = cmd[1].toLowerCase()
|
|
100
|
-
if (verb === '帮助' || verb === 'help') { await reply(`直接 @我 提问即可,例如「上周每天的订单量」。\n命令:数据源(查看可用数据源)、切换 <名称>、新对话。\n当前数据源:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}${actor.role === 'viewer' ? '\n你是只读成员:可以查询指标库中的指标、已验证的常见问题和看板。' : ''}`); return }
|
|
101
|
-
if (verb === '数据源') { await reply(`可用数据源:${allowedDs.map(d => d.name).join('、') || '无'}\n当前:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}`); return }
|
|
102
|
-
if (verb === '新对话' || verb === 'reset') { conv.sessionId = null; this.state.larkSessions[key] = conv; await this.save(); await reply('已开始新对话。'); return }
|
|
103
|
-
const target = allowedDs.find(d => d.name === cmd[2].trim() || d.id === cmd[2].trim())
|
|
104
|
-
if (!target) { await reply(`找不到可用的数据源「${cmd[2]}」。可用:${allowedDs.map(d => d.name).join('、')}`); return }
|
|
105
|
-
conv.datasourceId = target.id
|
|
106
|
-
if (conv.sessionId) { this.state.sessions[conv.sessionId] = target.id }
|
|
107
|
-
this.state.larkSessions[key] = conv
|
|
108
|
-
await this.save()
|
|
109
|
-
await reply(`已切换到数据源「${target.name}」。`)
|
|
110
|
-
return
|
|
111
|
-
}
|
|
112
|
-
const ds = this.findDatasource(conv.datasourceId) ?? allowedDs[0]
|
|
113
|
-
if (!ds) { await reply('没有可访问的数据源,请联系管理员。'); return }
|
|
114
|
-
this.assertAccess(actor, ds)
|
|
115
|
-
if (!this.ctx.get('sessionController')) { await reply('当前 Harness 不支持创建会话(缺少 sessionController)。'); return }
|
|
116
|
-
void addReaction({ ...creds, messageId: m.messageId, emojiType: 'OnIt' })
|
|
117
|
-
if (!conv.chatName && m.chatType === 'group') { try { conv.chatName = (await getChatInfo({ ...creds, chatId: m.chatId })).name } catch {} }
|
|
118
|
-
// One prompt at a time per conversation, so each question gets its own answer.
|
|
119
|
-
await this.serialize(`lark:${key}`, async () => {
|
|
120
|
-
const started = Date.now()
|
|
121
|
-
// Another message of this conversation may have created the session meanwhile.
|
|
122
|
-
const latest = this.state.larkSessions[key]
|
|
123
|
-
if (latest?.sessionId && !conv.sessionId) conv.sessionId = latest.sessionId
|
|
124
|
-
const prepare = async (sessionId) => {
|
|
125
|
-
conv.sessionId = sessionId
|
|
126
|
-
conv.datasourceId = ds.id
|
|
127
|
-
conv.lastAt = started
|
|
128
|
-
conv.lastQuestion = text.slice(0, 200)
|
|
129
|
-
conv.lastActor = actor.name
|
|
130
|
-
this.state.larkSessions[key] = conv
|
|
131
|
-
this.state.sessions[sessionId] = ds.id
|
|
132
|
-
this.state.sessionActors ??= {}
|
|
133
|
-
this.state.sessionActors[sessionId] = { id: actor.id, name: actor.name, kind: 'lark', datasourceIds: actor.datasourceIds, role: actor.role, rowRules: actor.rowRules ?? [] }
|
|
134
|
-
await this.save()
|
|
135
|
-
}
|
|
136
|
-
const prompt = `[飞书${m.chatType === 'group' ? `群「${conv.chatName ?? m.chatId}」` : '私聊'} · ${actor.name}${actor.role === 'viewer' ? ' · 只读成员' : ''}] ${text}\n\n(回答会以飞书卡片发回该聊天:请用简洁的中文 Markdown 写结论,必要时附上查询结果;不要调用 nl2sql_send,也不要向用户提问——有歧义时说明你的假设后直接回答。${actor.role === 'viewer' ? '该成员是只读角色:只能用 nl2sql_metric 查询指标库,或复用 nl2sql_knowledge 中已验证的样例 SQL;不能自由编写 SQL。' : ''})`
|
|
137
|
-
const turn = await this.runAgentTurn({ sessionId: conv.sessionId, title: `飞书 · ${conv.chatName ?? actor.name}`, requestId: `lark-${m.messageId}`, text: prompt, prepare, timeoutMs: 600000 })
|
|
138
|
-
let body = turn.text
|
|
139
|
-
if (turn.reason === 'timeout') body = `${body ? `${body}\n\n` : ''}<font color='grey'>(查询超过 10 分钟仍未完成,已停止。请缩小范围后重试。)</font>`
|
|
140
|
-
if (turn.reason === 'input-required') body = `${body ? `${body}\n\n` : ''}<font color='grey'>(需要在 Harness 中确认后才能继续,已取消本次回答。)</font>`
|
|
141
|
-
const results = turn.resultIds.map(id => this.lookupResult(id)).filter(Boolean).filter(r => { try { this.assertResultAccess(r, { ...actor, kind: 'lark' }); return true } catch { return false } }).slice(-4)
|
|
142
|
-
// Buttons (拆分 / 为什么变化 / 明细 / 固定到看板) carry opaque tokens only; see service/lark-actions.js.
|
|
143
|
-
const withActions = await this.withLarkCardActions(results, { actor, sessionId: conv.sessionId, chatId: m.chatId, chatType: m.chatType, replyTo: m.messageId, depth: 0 })
|
|
144
|
-
await this.replyLarkCard(creds, m.messageId, { title: text.length > 40 ? `${text.slice(0, 40)}…` : text, text: body || '(没有生成文字回答)', results: withActions, footer: `数据问答 · ${ds.name} · ${actor.name}` })
|
|
145
|
-
void addReaction({ ...creds, messageId: m.messageId, emojiType: turn.reason === 'idle' ? 'DONE' : 'CrossMark' })
|
|
146
|
-
this.recordDelivery({ channel: { id: 'larkbot', name: `飞书机器人 · ${conv.chatName ?? actor.name}` }, title: text.slice(0, 60), status: turn.reason === 'idle' ? 'ok' : 'error', ...(turn.reason === 'idle' ? {} : { error: turn.reason }), results: results.length, source: 'larkbot', sessionId: conv.sessionId, elapsedMs: Date.now() - started })
|
|
147
|
-
await this.save()
|
|
148
|
-
})
|
|
149
|
-
},
|
|
150
|
-
|
|
151
|
-
/** Run tasks with the same key one after another. */
|
|
152
|
-
serialize(key, task) {
|
|
153
|
-
this.queues ??= new Map()
|
|
154
|
-
const prev = this.queues.get(key) ?? Promise.resolve()
|
|
155
|
-
const next = prev.catch(() => {}).then(task)
|
|
156
|
-
this.queues.set(key, next)
|
|
157
|
-
next.finally(() => { if (this.queues.get(key) === next) this.queues.delete(key) }).catch(() => {})
|
|
158
|
-
return next
|
|
159
|
-
},
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Prompt an nl2sql session and collect exactly the answer of that prompt's
|
|
163
|
-
* turn. Creates (or recreates, if archived / missing) the session, handles
|
|
164
|
-
* timeouts and pending questions by cancelling the turn.
|
|
165
|
-
* @returns {{ sessionId, text, resultIds, reason: 'idle'|'timeout'|'input-required' }}
|
|
166
|
-
*/
|
|
167
|
-
async runAgentTurn({ sessionId, title, requestId, text, prepare, timeoutMs = 300000 }) {
|
|
168
|
-
const controller = this.ctx.get('sessionController')
|
|
169
|
-
const registry = this.ctx.get('workspaceRegistry')
|
|
170
|
-
const create = async () => {
|
|
171
|
-
const created = await controller.create({ agentPreset: PRESET_ID, ...(this.state.settings.botCwd ? { cwd: this.state.settings.botCwd } : {}) })
|
|
172
|
-
try { await controller.rename?.({ sessionId: created.sessionId, title }) } catch {}
|
|
173
|
-
return created.sessionId
|
|
174
|
-
}
|
|
175
|
-
let sid = sessionId
|
|
176
|
-
if (sid && (registry?.archivedSessionIds?.includes?.(sid))) sid = null
|
|
177
|
-
if (!sid) sid = await create()
|
|
178
|
-
await prepare?.(sid)
|
|
179
|
-
const send = (id) => controller.prompt({ requestId, sessionId: id, mode: 'queue', content: [{ type: 'text', text }] }, new AbortController().signal)
|
|
180
|
-
try {
|
|
181
|
-
await send(sid)
|
|
182
|
-
} catch (error) {
|
|
183
|
-
const code = error?.code ?? ''
|
|
184
|
-
if (!/not-found|archived|writer-held/.test(code) && !/not found|archived/i.test(error?.message ?? '')) throw error
|
|
185
|
-
sid = await create()
|
|
186
|
-
await prepare?.(sid)
|
|
187
|
-
await send(sid)
|
|
188
|
-
}
|
|
189
|
-
const waited = await controller.wait({ sessionId: sid, timeoutMs, maxMessages: 1 }, new AbortController().signal)
|
|
190
|
-
const reason = waited?.reason ?? 'idle'
|
|
191
|
-
if (reason !== 'idle') { try { controller.cancel({ sessionId: sid }) } catch {} }
|
|
192
|
-
const answer = await this.collectTurnByRequest(sid, requestId)
|
|
193
|
-
return { sessionId: sid, reason, ...answer }
|
|
194
|
-
},
|
|
195
|
-
|
|
196
|
-
/** Session events (live or persisted). */
|
|
197
|
-
async sessionEvents(sessionId) {
|
|
198
|
-
const live = this.ctx.get('agents')?.get(sessionId)?.session
|
|
199
|
-
const read = (session) => session.snapshotEvents()
|
|
200
|
-
if (live) return read(live)
|
|
201
|
-
const q = this.ctx.get('sessionQuery')
|
|
202
|
-
return q ? q.inspectSession(sessionId, read) : []
|
|
203
|
-
},
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Answer of the turn started by the user message carrying `requestId`:
|
|
207
|
-
* the last assistant text of that turn and the nl2sql resultIds it produced.
|
|
208
|
-
*/
|
|
209
|
-
async collectTurnByRequest(sessionId, requestId) {
|
|
210
|
-
const events = await this.sessionEvents(sessionId)
|
|
211
|
-
const at = events.findIndex(e => e.type === 'user/message' && (e.data?.source?.rpcId === requestId || e.data?.source?.requestId === requestId))
|
|
212
|
-
if (at < 0) return { text: '', resultIds: [] }
|
|
213
|
-
let turn
|
|
214
|
-
for (let i = at + 1; i < events.length; i++) {
|
|
215
|
-
const e = events[i]
|
|
216
|
-
if (e.type === 'turn/start') { turn = e.data.turn; break }
|
|
217
|
-
if (e.data?.turn !== undefined) { turn = e.data.turn; break }
|
|
218
|
-
}
|
|
219
|
-
if (turn === undefined) return { text: '', resultIds: [] }
|
|
220
|
-
let text = ''
|
|
221
|
-
const resultIds = []
|
|
222
|
-
for (const e of events.slice(at + 1)) {
|
|
223
|
-
if (e.data?.turn !== undefined && e.data.turn !== turn) { if (e.data.turn > turn) break; continue }
|
|
224
|
-
if (e.type === 'assistant/message') {
|
|
225
|
-
const t = (e.data.message?.content ?? []).filter(b => b.type === 'text').map(b => b.text).join('\n\n').trim()
|
|
226
|
-
if (t) text = t
|
|
227
|
-
}
|
|
228
|
-
if (e.type === 'tool/result') {
|
|
229
|
-
const meta = e.data.meta
|
|
230
|
-
if (meta?.resultId) resultIds.push(meta.resultId)
|
|
231
|
-
for (const r of meta?.results ?? []) if (r.resultId) resultIds.push(r.resultId)
|
|
232
|
-
}
|
|
233
|
-
if (e.type === 'turn/end' && e.data.turn === turn) break
|
|
234
|
-
}
|
|
235
|
-
return { text, resultIds: [...new Set(resultIds)] }
|
|
236
|
-
},
|
|
237
|
-
|
|
238
|
-
/** Back-compat for callers that only have the live session and a seq anchor. */
|
|
239
|
-
collectTurnAnswer(sessionId, afterSeq) {
|
|
240
|
-
const session = this.ctx.get('agents')?.get(sessionId)?.session
|
|
241
|
-
if (!session) return { text: '', resultIds: [] }
|
|
242
|
-
let text = ''
|
|
243
|
-
const resultIds = []
|
|
244
|
-
for (const e of session.snapshotEvents().filter(x => x.seq > afterSeq)) {
|
|
245
|
-
if (e.type === 'assistant/message') { const t = (e.data.message?.content ?? []).filter(b => b.type === 'text').map(b => b.text).join('\n\n').trim(); if (t) text = t }
|
|
246
|
-
if (e.type === 'tool/result') { const meta = e.data.meta; if (meta?.resultId) resultIds.push(meta.resultId); for (const r of meta?.results ?? []) if (r.resultId) resultIds.push(r.resultId) }
|
|
247
|
-
}
|
|
248
|
-
return { text, resultIds: [...new Set(resultIds)] }
|
|
249
|
-
},
|
|
250
|
-
}
|