@hedeqiang/dsh-nl2sql 0.10.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,665 @@
1
+ /**
2
+ * Nl2SqlService methods: inbound IM bots (see docs/CHANNELS.md).
3
+ * Mixed into the service prototype by index.js; `this` is the service.
4
+ *
5
+ * Every channel whose kind has `capabilities.bot` may carry
6
+ * `channel.bot = { enabled, allowP2p, sessionScope: 'chat'|'user' }`. Enabled
7
+ * bots run their adapter's `createBot(channel, secrets, { onMessage, onAction })`.
8
+ * This module owns lifecycle, conversations (`state.botConversations`, keyed
9
+ * `<channelId>:<chatId>#<scopeHash>` per chat or `<channelId>:<chatId>:<userId>`
10
+ * per user), the per-conversation queue, the agent turn, actor resolution,
11
+ * interactive actions (opaque tokens, rate limit, re-authorization), delivery
12
+ * log and audit.
13
+ *
14
+ * Buttons carry only an opaque token ({ a: <action>, t: <token> }); the token
15
+ * maps server-side to { resultId, sessionId, channelId, platform, chatId,
16
+ * replyTo, depth, ownerRole }. Every click re-resolves the clicking operator and
17
+ * re-checks data source scope, row-level rules and role before anything runs.
18
+ */
19
+ import { randomBytes } from 'node:crypto'
20
+ import { parseSelect } from '../analysis.js'
21
+ import { getAdapter, kindOf, loadAdapters } from '../channels/index.js'
22
+ import { toInboundAction, toInboundMessage } from '../channels/lark.js'
23
+ import { PLATFORM_LABEL } from './access.js'
24
+ import { PRESET_ID, UserError, hashText, oneOf, toUserError } from './shared.js'
25
+
26
+ export const ACTION_TOKEN_TTL_MS = 7 * 24 * 3600 * 1000
27
+ const ACTION_TOKEN_MAX = 5000
28
+ export const ACTION_RATE_MS = 2000
29
+ /** Messages produced by actions get buttons up to this depth (0 = answer message). */
30
+ export const ACTION_MAX_DEPTH = 2
31
+ const ACTIONS = new Set(['drill', 'why', 'detail', 'pin'])
32
+ const CONVERSATIONS_MAX = 500
33
+
34
+ const toast = (type, content) => ({ toast: { type, content } })
35
+ const label = (platform) => PLATFORM_LABEL[platform] ?? platform ?? 'IM'
36
+ const SOURCE = (platform) => `${platform ?? 'im'}bot`
37
+
38
+ function hasAggregate(r, dialect) {
39
+ const parsed = parseSelect(r.baseSql ?? r.sql, { dialect })
40
+ return Boolean(parsed?.select?.some(i => i.isAggregate))
41
+ }
42
+
43
+ export const methods = {
44
+ // ------------------------------------------------------------- config
45
+
46
+ normalizeBotConfig(input = {}, prev = {}) {
47
+ const conf = { enabled: false, allowP2p: true, sessionScope: 'chat', ...(prev ?? {}) }
48
+ if (input.enabled !== undefined) conf.enabled = Boolean(input.enabled)
49
+ if (input.allowP2p !== undefined) conf.allowP2p = Boolean(input.allowP2p)
50
+ if (input.sessionScope !== undefined) conf.sessionScope = oneOf(input.sessionScope, ['chat', 'user'], 'sessionScope')
51
+ return { enabled: conf.enabled, allowP2p: conf.allowP2p, sessionScope: conf.sessionScope }
52
+ },
53
+
54
+ /** Channels whose kind can run a bot. */
55
+ botChannels() {
56
+ return this.state.channels.filter(c => kindOf(c.type)?.kind.capabilities?.bot)
57
+ },
58
+
59
+ /** Bot settings of a channel (or the legacy / default settings for unknown ids). */
60
+ botConfigFor(channelId) {
61
+ const c = channelId ? this.state.channels.find(x => x.id === channelId) : undefined
62
+ if (c?.bot) return this.normalizeBotConfig({}, c.bot)
63
+ if (this.state.larkBot) return this.normalizeBotConfig({}, this.state.larkBot)
64
+ return this.normalizeBotConfig({}, {})
65
+ },
66
+
67
+ /**
68
+ * One-time migration (idempotent): legacy `state.larkBot` → `channel.bot` on
69
+ * the referenced channel, `state.larkSessions` → `state.botConversations`.
70
+ */
71
+ migrateLegacyBot() {
72
+ let changed = false
73
+ const legacy = this.state.larkBot
74
+ let legacyChannelId = legacy?.channelId ?? null
75
+ if (legacy && typeof legacy === 'object') {
76
+ const c = legacyChannelId ? this.state.channels.find(x => x.id === legacyChannelId) : undefined
77
+ if (c && c.type === 'lark_app' && !c.bot) c.bot = this.normalizeBotConfig(legacy, {})
78
+ changed = true
79
+ }
80
+ if ('larkBot' in this.state) { delete this.state.larkBot; changed = true }
81
+ const old = Object.getOwnPropertyDescriptor(this.state, 'larkSessions')
82
+ if (old && 'value' in old) {
83
+ legacyChannelId ??= this.state.channels.find(c => c.type === 'lark_app' && c.bot)?.id ?? 'lark'
84
+ this.state.botConversations ??= {}
85
+ for (const conv of Object.values(old.value ?? {})) {
86
+ if (!conv || typeof conv !== 'object') continue
87
+ const key = `${legacyChannelId}:${conv.key ?? conv.chatId}`
88
+ if (!this.state.botConversations[key]) this.state.botConversations[key] = { ...conv, key, channelId: legacyChannelId, platform: 'lark' }
89
+ }
90
+ delete this.state.larkSessions
91
+ changed = true
92
+ }
93
+ this.state.botConversations ??= {}
94
+ // `state.larkSessions` stays readable (not persisted) for older callers.
95
+ if (!Object.getOwnPropertyDescriptor(this.state, 'larkSessions')) {
96
+ Object.defineProperty(this.state, 'larkSessions', { get: () => this.state.botConversations, enumerable: false, configurable: true })
97
+ }
98
+ return changed
99
+ },
100
+
101
+ // ------------------------------------------------------------- lifecycle
102
+
103
+ botRuntimeStatus(c) {
104
+ const running = this.bots?.get(c.id)
105
+ if (running) return running.bot.status()
106
+ return { state: c.bot?.enabled ? 'stopped' : 'idle', since: Date.now() }
107
+ },
108
+
109
+ conversationsFor(channelId, limit = 50) {
110
+ return Object.values(this.state.botConversations ?? {}).filter(v => !channelId || v.channelId === channelId).sort((a, b) => (b.lastAt ?? 0) - (a.lastAt ?? 0)).slice(0, limit)
111
+ },
112
+
113
+ /** Status of every bot-capable channel. */
114
+ async botStatus() {
115
+ await loadAdapters()
116
+ return this.botChannels().map(c => this.botStatusFor(c))
117
+ },
118
+
119
+ botStatusFor(c) {
120
+ const conf = this.normalizeBotConfig({}, c.bot)
121
+ return {
122
+ channelId: c.id, channelName: c.name, platform: c.platform ?? kindOf(c.type)?.adapter.id, type: c.type,
123
+ ...conf,
124
+ status: this.botRuntimeStatus(c),
125
+ info: this.bots?.get(c.id)?.bot.info?.() ?? null,
126
+ conversations: this.conversationsFor(c.id),
127
+ }
128
+ },
129
+
130
+ async saveBot({ channelId, ...input } = {}) {
131
+ await loadAdapters()
132
+ const c = this.state.channels.find(x => x.id === channelId)
133
+ if (!c) throw new UserError('nl2sql/unknown-channel', '推送渠道不存在')
134
+ if (!kindOf(c.type)?.kind.capabilities?.bot) throw new UserError('nl2sql/invalid', `渠道「${c.name}」不支持机器人`)
135
+ c.bot = this.normalizeBotConfig(input, c.bot)
136
+ this.auditAdmin('bot.save', `${c.name} · ${c.bot.enabled ? '启用' : '停用'}`)
137
+ await this.save()
138
+ await this.restartBot({ channelId: c.id })
139
+ return this.botStatusFor(c)
140
+ },
141
+
142
+ async stopBot(channelId) {
143
+ const running = this.bots?.get(channelId)
144
+ if (!running) return
145
+ this.bots.delete(channelId)
146
+ try { await running.bot.stop() } catch {}
147
+ },
148
+
149
+ async stopAllBots() {
150
+ await Promise.allSettled([...(this.bots?.keys() ?? [])].map(id => this.stopBot(id)))
151
+ },
152
+
153
+ /** (Re)start the bot of one channel; stops it when disabled. Returns its status. */
154
+ async restartBot({ channelId } = {}) {
155
+ await loadAdapters()
156
+ this.bots ??= new Map()
157
+ await this.stopBot(channelId)
158
+ const c = this.state.channels.find(x => x.id === channelId)
159
+ if (!c) return undefined
160
+ const found = kindOf(c.type)
161
+ const adapter = found ? getAdapter(found.adapter.id) : undefined
162
+ if (!c.bot?.enabled || this.disposed || !found?.kind.capabilities?.bot || !adapter?.createBot || c.needsSecret) return this.botStatusFor(c)
163
+ const secrets = await this.channelSecrets(c)
164
+ const bot = adapter.createBot(this.adapterChannel(c), secrets, {
165
+ onMessage: (m) => this.handleBotMessage({ ...m, channelId: c.id, platform: m.platform ?? adapter.id }),
166
+ onAction: (a) => this.handleBotAction({ ...a, channelId: c.id, platform: a.platform ?? adapter.id }),
167
+ }, { logger: { debug() {}, info() {}, warn: (...a) => console.warn(`nl2sql ${adapter.id}:`, ...a), error: (...a) => console.warn(`nl2sql ${adapter.id}:`, ...a) } })
168
+ this.bots.set(c.id, { bot, platform: adapter.id })
169
+ if (adapter.id === 'lark' && bot.creds && this.larkBotChannel()?.id === c.id) this.larkCreds = bot.creds
170
+ try { await bot.start() } catch (error) { console.warn('nl2sql bot: %s start failed: %s', c.name, error.message) }
171
+ return this.botStatusFor(c)
172
+ },
173
+
174
+ /** Start every enabled bot (on apply). */
175
+ async startBots() {
176
+ await loadAdapters()
177
+ for (const c of this.botChannels()) {
178
+ if (!c.bot?.enabled) continue
179
+ await this.restartBot({ channelId: c.id }).catch(error => console.warn('nl2sql bot: %s: %s', c.name, error.message))
180
+ }
181
+ },
182
+
183
+ // ------------------------------------------------------------- legacy Lark RPCs
184
+
185
+ /** The Lark app channel the legacy single-bot RPCs operate on. */
186
+ larkBotChannel() {
187
+ const apps = this.state.channels.filter(c => c.type === 'lark_app')
188
+ return apps.find(c => c.bot?.enabled) ?? apps.find(c => c.bot) ?? undefined
189
+ },
190
+
191
+ larkBotConfig() {
192
+ if (this.state.larkBot) return { enabled: false, channelId: null, allowP2p: true, sessionScope: 'chat', ...this.state.larkBot }
193
+ const c = this.larkBotChannel()
194
+ return { ...this.normalizeBotConfig({}, c?.bot), channelId: c?.id ?? null }
195
+ },
196
+
197
+ larkBotStatus() {
198
+ const conf = this.larkBotConfig()
199
+ const c = conf.channelId ? this.state.channels.find(x => x.id === conf.channelId) : undefined
200
+ const info = c ? this.bots?.get(c.id)?.bot.info?.() : null
201
+ return {
202
+ ...conf,
203
+ status: c ? this.botRuntimeStatus(c) : { state: conf.enabled ? 'stopped' : 'idle', since: Date.now() },
204
+ bot: info ? { openId: info.id, name: info.name } : null,
205
+ conversations: this.conversationsFor(undefined).filter(v => (v.platform ?? 'lark') === 'lark'),
206
+ }
207
+ },
208
+
209
+ async saveLarkBot(input = {}) {
210
+ const conf = this.larkBotConfig()
211
+ let target = conf.channelId
212
+ if (input.channelId !== undefined) {
213
+ const ch = this.state.channels.find(c => c.id === input.channelId)
214
+ if (input.channelId && (!ch || ch.type !== 'lark_app')) throw new UserError('nl2sql/invalid', '飞书机器人需要选择一个「应用机器人」类型的推送渠道(提供 App ID / Secret)')
215
+ target = input.channelId || null
216
+ }
217
+ const next = this.normalizeBotConfig(input, conf)
218
+ if (next.enabled && !target) throw new UserError('nl2sql/invalid', '请先选择应用机器人渠道')
219
+ delete this.state.larkBot
220
+ // One legacy Lark bot: moving it disables it on the previous channel.
221
+ const restart = new Set()
222
+ for (const c of this.state.channels) {
223
+ if (c.type !== 'lark_app') continue
224
+ if (c.id === target) { c.bot = next; restart.add(c.id) } else if (c.bot && conf.channelId === c.id) { c.bot = { ...c.bot, enabled: false }; restart.add(c.id) }
225
+ }
226
+ this.auditAdmin('larkbot.save', `${next.enabled ? '启用' : '停用'} · ${target ?? ''}`)
227
+ await this.save()
228
+ for (const id of restart) await this.restartBot({ channelId: id })
229
+ return this.larkBotStatus()
230
+ },
231
+
232
+ async restartLarkBot() {
233
+ const c = this.larkBotChannel()
234
+ if (c) await this.restartBot({ channelId: c.id })
235
+ },
236
+
237
+ /** Legacy entry: a normalized Lark message (lark-bot.js shape) handled with `this.larkCreds`. */
238
+ async handleLarkMessage(m) {
239
+ const creds = this.larkCreds
240
+ if (!creds) return
241
+ if (m.chatType === 'group' && !m.mentionedBot) return
242
+ const inbound = await toInboundMessage(m, creds, { channelId: this.larkBotChannel()?.id })
243
+ return this.handleBotMessage(inbound)
244
+ },
245
+
246
+ /** Legacy signature (m: normalized Lark message, creds: Lark credentials). */
247
+ async handleLarkMessageInner(m, text, conf, creds, reply) {
248
+ const inbound = await toInboundMessage(m, creds, { channelId: this.larkBotChannel()?.id })
249
+ return this.handleBotMessageInner(inbound, text, conf, reply)
250
+ },
251
+
252
+ /** Legacy entry: a normalized Lark card callback handled with `this.larkCreds`. */
253
+ async handleLarkCardAction(evt) {
254
+ const creds = this.larkCreds
255
+ if (!creds || !evt?.operator?.openId) return undefined
256
+ return this.handleBotAction(await toInboundAction(evt, creds, { channelId: this.larkBotChannel()?.id }))
257
+ },
258
+
259
+ // ------------------------------------------------------------- messages
260
+
261
+ /** One inbound message → permission check → session prompt → reply. */
262
+ async handleBotMessage(m) {
263
+ const conf = this.botConfigFor(m.channelId)
264
+ if (m.chatType === 'group' && !m.mentionedBot) return
265
+ if (m.chatType === 'p2p' && !conf.allowP2p) return
266
+ const text = String(m.text ?? '').trim()
267
+ if (!text) return
268
+ const reply = (t) => Promise.resolve(m.replyText?.(t)).catch(() => {})
269
+ try {
270
+ await this.handleBotMessageInner(m, text, conf, reply)
271
+ } catch (error) {
272
+ console.warn('nl2sql bot: %s', error.message)
273
+ await reply(`处理失败:${toUserError(error).message}`)
274
+ }
275
+ },
276
+
277
+ async handleBotMessageInner(m, text, conf, reply) {
278
+ const platform = m.platform ?? 'lark'
279
+ const name = label(platform)
280
+ const userId = m.sender?.id != null ? String(m.sender.id) : undefined
281
+ const resolved = this.resolveActor({ platform, userId, email: m.sender?.email, name: m.sender?.name, chatId: m.chatId != null ? String(m.chatId) : m.chatId, chatType: m.chatType })
282
+ if (resolved.refused) { await reply(resolved.refused); return }
283
+ const actor = resolved.actor
284
+ // Commands: 帮助 / 数据源 / 切换 <名称> / 新对话
285
+ const cmd = text.match(/^\/?(帮助|help|数据源|切换数据源|切换|新对话|reset|start)\s*(.*)$/i)
286
+ // Members with different scopes must not share a conversation: per-user sessions
287
+ // whenever the sender's effective scope or rules differ from the chat's.
288
+ const scopeSig = JSON.stringify([actor.datasourceIds, actor.role, actor.rowRules ?? []])
289
+ const shared = conf.sessionScope !== 'user'
290
+ const prefix = m.channelId ?? platform
291
+ const key = shared ? `${prefix}:${m.chatId}#${hashText(scopeSig)}` : `${prefix}:${m.chatId}:${userId}`
292
+ this.state.botConversations ??= {}
293
+ const convs = this.state.botConversations
294
+ const conv = convs[key] ?? { key, channelId: m.channelId ?? null, platform, chatId: m.chatId, chatType: m.chatType, sessionId: null, datasourceId: actor.defaultDatasourceId, createdAt: Date.now() }
295
+ const allowedDs = this.state.datasources.filter(d => !Array.isArray(actor.datasourceIds) || actor.datasourceIds.includes(d.id))
296
+ if (conv.datasourceId && !allowedDs.some(d => d.id === conv.datasourceId)) conv.datasourceId = allowedDs[0]?.id ?? null
297
+ if (cmd) {
298
+ const verb = cmd[1].toLowerCase()
299
+ if (verb === '帮助' || verb === 'help' || verb === 'start') { await reply(`直接${m.chatType === 'group' ? ' @我 ' : ''}提问即可,例如「上周每天的订单量」。\n命令:数据源(查看可用数据源)、切换 <名称>、新对话。\n当前数据源:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}${actor.role === 'viewer' ? '\n你是只读成员:可以查询指标库中的指标、已验证的常见问题和看板。' : ''}`); return }
300
+ if (verb === '数据源') { await reply(`可用数据源:${allowedDs.map(d => d.name).join('、') || '无'}\n当前:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}`); return }
301
+ if (verb === '新对话' || verb === 'reset') { conv.sessionId = null; convs[key] = conv; await this.save(); await reply('已开始新对话。'); return }
302
+ const target = allowedDs.find(d => d.name === cmd[2].trim() || d.id === cmd[2].trim())
303
+ if (!target) { await reply(`找不到可用的数据源「${cmd[2]}」。可用:${allowedDs.map(d => d.name).join('、')}`); return }
304
+ conv.datasourceId = target.id
305
+ if (conv.sessionId) { this.state.sessions[conv.sessionId] = target.id }
306
+ convs[key] = conv
307
+ await this.save()
308
+ await reply(`已切换到数据源「${target.name}」。`)
309
+ return
310
+ }
311
+ const ds = this.findDatasource(conv.datasourceId) ?? allowedDs[0]
312
+ if (!ds) { await reply('没有可访问的数据源,请联系管理员。'); return }
313
+ this.assertAccess(actor, ds)
314
+ if (!this.ctx.get('sessionController')) { await reply('当前 Harness 不支持创建会话(缺少 sessionController)。'); return }
315
+ void Promise.resolve(m.react?.('working')).catch(() => {})
316
+ if (!conv.chatName && m.chatType === 'group') {
317
+ try { conv.chatName = typeof m.chatName === 'function' ? await m.chatName() : m.chatName ?? undefined } catch {}
318
+ if (!conv.chatName) delete conv.chatName
319
+ }
320
+ const channel = m.channelId ? this.state.channels.find(c => c.id === m.channelId) : undefined
321
+ const buttons = channel ? Boolean(kindOf(channel.type)?.kind.capabilities?.buttons) : platform === 'lark'
322
+ // One prompt at a time per conversation, so each question gets its own answer.
323
+ await this.serialize(`bot:${key}`, async () => {
324
+ const started = Date.now()
325
+ // Another message of this conversation may have created the session meanwhile.
326
+ const latest = convs[key]
327
+ if (latest?.sessionId && !conv.sessionId) conv.sessionId = latest.sessionId
328
+ const prepare = async (sessionId) => {
329
+ conv.sessionId = sessionId
330
+ conv.datasourceId = ds.id
331
+ conv.lastAt = started
332
+ conv.lastQuestion = text.slice(0, 200)
333
+ conv.lastActor = actor.name
334
+ convs[key] = conv
335
+ this.state.sessions[sessionId] = ds.id
336
+ this.state.sessionActors ??= {}
337
+ this.state.sessionActors[sessionId] = { id: actor.id, name: actor.name, kind: platform, platform, datasourceIds: actor.datasourceIds, role: actor.role, rowRules: actor.rowRules ?? [] }
338
+ await this.save()
339
+ }
340
+ const where = m.chatType === 'group' ? `群「${conv.chatName ?? m.chatId}」` : '私聊'
341
+ const prompt = `[${name}${where} · ${actor.name}${actor.role === 'viewer' ? ' · 只读成员' : ''}] ${text}\n\n(回答会以${name}消息发回该聊天:请用简洁的中文 Markdown 写结论,必要时附上查询结果;不要调用 nl2sql_send,也不要向用户提问——有歧义时说明你的假设后直接回答。${actor.role === 'viewer' ? '该成员是只读角色:只能用 nl2sql_metric 查询指标库,或复用 nl2sql_knowledge 中已验证的样例 SQL;不能自由编写 SQL。' : ''})`
342
+ const requestId = platform === 'lark' ? `lark-${m.messageId}` : `${platform}-${m.chatId}-${m.messageId}`
343
+ const turn = await this.runAgentTurn({ sessionId: conv.sessionId, title: `${name} · ${conv.chatName ?? actor.name}`, requestId, text: prompt, prepare, timeoutMs: 600000 })
344
+ let body = turn.text
345
+ if (turn.reason === 'timeout') body = `${body ? `${body}\n\n` : ''}<font color='grey'>(查询超过 10 分钟仍未完成,已停止。请缩小范围后重试。)</font>`
346
+ if (turn.reason === 'input-required') body = `${body ? `${body}\n\n` : ''}<font color='grey'>(需要在 Harness 中确认后才能继续,已取消本次回答。)</font>`
347
+ const results = turn.resultIds.map(id => this.lookupResult(id)).filter(Boolean).filter(r => { try { this.assertResultAccess(r, { ...actor, kind: platform }); return true } catch { return false } }).slice(-4)
348
+ // Buttons (拆分 / 为什么变化 / 明细 / 固定到看板) carry opaque tokens only.
349
+ const shown = buttons ? await this.withResultActions(results, { actor, sessionId: conv.sessionId, channelId: m.channelId, platform, chatId: m.chatId, chatType: m.chatType, replyTo: m.messageId, depth: 0 }) : results
350
+ let status = turn.reason === 'idle' ? 'ok' : 'error'
351
+ let error = turn.reason === 'idle' ? undefined : turn.reason
352
+ try {
353
+ await m.reply({ title: text.length > 40 ? `${text.slice(0, 40)}…` : text, text: body || '(没有生成文字回答)', results: shown, footer: `数据问答 · ${ds.name} · ${actor.name}` })
354
+ } catch (e) {
355
+ status = 'error'
356
+ error = e.message
357
+ await reply(String(body || e.message).slice(0, 3000))
358
+ }
359
+ void Promise.resolve(m.react?.(status === 'ok' ? 'done' : 'failed')).catch(() => {})
360
+ this.recordDelivery({ channel: { id: m.channelId ?? SOURCE(platform), name: `${name}机器人 · ${conv.chatName ?? actor.name}`, platform }, title: text.slice(0, 60), status, ...(error ? { error } : {}), results: results.length, source: SOURCE(platform), sessionId: conv.sessionId, elapsedMs: Date.now() - started })
361
+ await this.save()
362
+ })
363
+ },
364
+
365
+ /** Run tasks with the same key one after another. */
366
+ serialize(key, task) {
367
+ this.queues ??= new Map()
368
+ const prev = this.queues.get(key) ?? Promise.resolve()
369
+ const next = prev.catch(() => {}).then(task)
370
+ this.queues.set(key, next)
371
+ next.finally(() => { if (this.queues.get(key) === next) this.queues.delete(key) }).catch(() => {})
372
+ return next
373
+ },
374
+
375
+ /** Bound the conversation map (most recent first). */
376
+ pruneBotConversations() {
377
+ const convs = Object.values(this.state.botConversations ?? {}).sort((a, b) => (b.lastAt ?? 0) - (a.lastAt ?? 0))
378
+ if (convs.length > CONVERSATIONS_MAX) for (const c of convs.slice(CONVERSATIONS_MAX)) delete this.state.botConversations[c.key]
379
+ },
380
+
381
+ /**
382
+ * Prompt an nl2sql session and collect exactly the answer of that prompt's
383
+ * turn. Creates (or recreates, if archived / missing) the session, handles
384
+ * timeouts and pending questions by cancelling the turn.
385
+ * @returns {{ sessionId, text, resultIds, reason: 'idle'|'timeout'|'input-required' }}
386
+ */
387
+ async runAgentTurn({ sessionId, title, requestId, text, prepare, timeoutMs = 300000 }) {
388
+ const controller = this.ctx.get('sessionController')
389
+ const registry = this.ctx.get('workspaceRegistry')
390
+ const create = async () => {
391
+ const created = await controller.create({ agentPreset: PRESET_ID, ...(this.state.settings.botCwd ? { cwd: this.state.settings.botCwd } : {}) })
392
+ try { await controller.rename?.({ sessionId: created.sessionId, title }) } catch {}
393
+ return created.sessionId
394
+ }
395
+ let sid = sessionId
396
+ if (sid && (registry?.archivedSessionIds?.includes?.(sid))) sid = null
397
+ if (!sid) sid = await create()
398
+ await prepare?.(sid)
399
+ const send = (id) => controller.prompt({ requestId, sessionId: id, mode: 'queue', content: [{ type: 'text', text }] }, new AbortController().signal)
400
+ try {
401
+ await send(sid)
402
+ } catch (error) {
403
+ const code = error?.code ?? ''
404
+ if (!/not-found|archived|writer-held/.test(code) && !/not found|archived/i.test(error?.message ?? '')) throw error
405
+ sid = await create()
406
+ await prepare?.(sid)
407
+ await send(sid)
408
+ }
409
+ const waited = await controller.wait({ sessionId: sid, timeoutMs, maxMessages: 1 }, new AbortController().signal)
410
+ const reason = waited?.reason ?? 'idle'
411
+ if (reason !== 'idle') { try { controller.cancel({ sessionId: sid }) } catch {} }
412
+ const answer = await this.collectTurnByRequest(sid, requestId)
413
+ return { sessionId: sid, reason, ...answer }
414
+ },
415
+
416
+ /** Session events (live or persisted). */
417
+ async sessionEvents(sessionId) {
418
+ const live = this.ctx.get('agents')?.get(sessionId)?.session
419
+ const read = (session) => session.snapshotEvents()
420
+ if (live) return read(live)
421
+ const q = this.ctx.get('sessionQuery')
422
+ return q ? q.inspectSession(sessionId, read) : []
423
+ },
424
+
425
+ /**
426
+ * Answer of the turn started by the user message carrying `requestId`:
427
+ * the last assistant text of that turn and the nl2sql resultIds it produced.
428
+ */
429
+ async collectTurnByRequest(sessionId, requestId) {
430
+ const events = await this.sessionEvents(sessionId)
431
+ const at = events.findIndex(e => e.type === 'user/message' && (e.data?.source?.rpcId === requestId || e.data?.source?.requestId === requestId))
432
+ if (at < 0) return { text: '', resultIds: [] }
433
+ let turn
434
+ for (let i = at + 1; i < events.length; i++) {
435
+ const e = events[i]
436
+ if (e.type === 'turn/start') { turn = e.data.turn; break }
437
+ if (e.data?.turn !== undefined) { turn = e.data.turn; break }
438
+ }
439
+ if (turn === undefined) return { text: '', resultIds: [] }
440
+ let text = ''
441
+ const resultIds = []
442
+ for (const e of events.slice(at + 1)) {
443
+ if (e.data?.turn !== undefined && e.data.turn !== turn) { if (e.data.turn > turn) break; continue }
444
+ if (e.type === 'assistant/message') {
445
+ const t = (e.data.message?.content ?? []).filter(b => b.type === 'text').map(b => b.text).join('\n\n').trim()
446
+ if (t) text = t
447
+ }
448
+ if (e.type === 'tool/result') {
449
+ const meta = e.data.meta
450
+ if (meta?.resultId) resultIds.push(meta.resultId)
451
+ for (const r of meta?.results ?? []) if (r.resultId) resultIds.push(r.resultId)
452
+ }
453
+ if (e.type === 'turn/end' && e.data.turn === turn) break
454
+ }
455
+ return { text, resultIds: [...new Set(resultIds)] }
456
+ },
457
+
458
+ /** Back-compat for callers that only have the live session and a seq anchor. */
459
+ collectTurnAnswer(sessionId, afterSeq) {
460
+ const session = this.ctx.get('agents')?.get(sessionId)?.session
461
+ if (!session) return { text: '', resultIds: [] }
462
+ let text = ''
463
+ const resultIds = []
464
+ for (const e of session.snapshotEvents().filter(x => x.seq > afterSeq)) {
465
+ 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 }
466
+ 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) }
467
+ }
468
+ return { text, resultIds: [...new Set(resultIds)] }
469
+ },
470
+
471
+ // ------------------------------------------------------------- action tokens
472
+
473
+ /** In-memory token cache (legacy name kept for callers/tests). */
474
+ get larkActionTokens() {
475
+ return this.actionTokens
476
+ },
477
+
478
+ /** Store context for a message's buttons; returns the opaque token. */
479
+ createActionToken(ctx) {
480
+ const token = randomBytes(9).toString('base64url')
481
+ const now = Date.now()
482
+ const body = { resultId: ctx.resultId, sessionId: ctx.sessionId ?? null, channelId: ctx.channelId ?? null, platform: ctx.platform ?? 'lark', chatId: ctx.chatId ?? null, chatType: ctx.chatType === 'p2p' ? 'p2p' : 'group', replyTo: ctx.replyTo ?? null, depth: ctx.depth ?? 0, ownerRole: ctx.ownerRole ?? null }
483
+ this.actionTokens ??= new Map()
484
+ this.actionTokens.set(token, { ...body, created: now })
485
+ while (this.actionTokens.size > ACTION_TOKEN_MAX) this.actionTokens.delete(this.actionTokens.keys().next().value)
486
+ try {
487
+ this.store?.putActionToken(token, body, now)
488
+ if ((this.actionTokenWrites = (this.actionTokenWrites ?? 0) + 1) % 200 === 0) this.store?.pruneActionTokens(ACTION_TOKEN_TTL_MS, ACTION_TOKEN_MAX)
489
+ } catch {}
490
+ return token
491
+ },
492
+
493
+ /** Context for a token, or undefined when unknown / expired. */
494
+ lookupActionToken(token, now = Date.now()) {
495
+ if (typeof token !== 'string' || !/^[\w-]{8,32}$/.test(token)) return undefined
496
+ let ctx = this.actionTokens?.get(token)
497
+ if (!ctx) { try { ctx = this.store?.getActionToken(token) } catch {} }
498
+ if (!ctx || now - ctx.created > ACTION_TOKEN_TTL_MS) return undefined
499
+ return ctx
500
+ },
501
+
502
+ createLarkActionToken(ctx) { return this.createActionToken({ platform: 'lark', ...ctx }) },
503
+ lookupLarkActionToken(token, now) { return this.lookupActionToken(token, now) },
504
+
505
+ // ------------------------------------------------------------- building actions
506
+
507
+ /**
508
+ * ActionSpec for one result shown to `actor` in a chat, or undefined when
509
+ * nothing applies. Never throws.
510
+ */
511
+ async resultActions(result, { actor, sessionId, channelId, platform, chatId, chatType, replyTo, depth = 0 } = {}) {
512
+ try {
513
+ if (!result?.resultId || result.error || depth >= ACTION_MAX_DEPTH) return undefined
514
+ const ds = this.findDatasource(result.datasourceId)
515
+ if (!ds) return undefined
516
+ const dialect = ds.type ?? 'mysql'
517
+ const role = actor?.role ?? 'analyst'
518
+ const token = this.createActionToken({ resultId: result.resultId, sessionId, channelId, platform, chatId, chatType, replyTo, depth, ownerRole: role })
519
+ const aggregate = hasAggregate(result, dialect)
520
+ const buttons = []
521
+ let select
522
+ if (aggregate) {
523
+ let dims = []
524
+ try { dims = (await this.drillDimensions(result.resultId)).dimensions } catch {}
525
+ const xName = result.chart?.x
526
+ dims = dims.filter(d => d.column !== xName && d.column.split('.').pop() !== xName).slice(0, 6)
527
+ 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 })) }
528
+ }
529
+ const xi = result.chart?.x ? result.columns.findIndex(c => c.name === result.chart.x) : -1
530
+ const points = xi >= 0 ? result.rows.filter(r => r[xi] !== null && r[xi] !== undefined).length : 0
531
+ const timeLike = xi >= 0 && (result.columns[xi].kind === 'time' || ['line', 'area'].includes(result.chart?.type))
532
+ if (aggregate && timeLike && points >= 2) buttons.push({ text: '为什么变化', value: { a: 'why', t: token } })
533
+ if (xi >= 0 && points >= 1 && role !== 'viewer') buttons.push({ text: '明细', value: { a: 'detail', t: token } })
534
+ if (role === 'admin' || role === 'analyst') buttons.push({ text: '固定到看板', value: { a: 'pin', t: token } })
535
+ if (!select && !buttons.length) return undefined
536
+ return { ...(select ? { select } : {}), buttons }
537
+ } catch (error) {
538
+ console.warn('nl2sql bot: cannot build actions: %s', error.message)
539
+ return undefined
540
+ }
541
+ },
542
+
543
+ /** Results with their ActionSpec attached as `actions`. */
544
+ async withResultActions(results, ctx) {
545
+ const out = []
546
+ for (const r of results) {
547
+ const actions = await this.resultActions(r, ctx)
548
+ out.push(actions ? { ...r, actions } : r)
549
+ }
550
+ return out
551
+ },
552
+
553
+ larkCardActions(result, ctx) { return this.resultActions(result, { platform: 'lark', ...ctx }) },
554
+ withLarkCardActions(results, ctx) { return this.withResultActions(results, { platform: 'lark', ...ctx }) },
555
+
556
+ // ------------------------------------------------------------- clicks
557
+
558
+ /**
559
+ * An InboundAction. Authorizes synchronously and returns a toast quickly
560
+ * (Lark waits at most 3 s); the analysis runs in the background and replies
561
+ * in the chat with a new message.
562
+ */
563
+ async handleBotAction(a) {
564
+ if (!a?.operator?.id) return undefined
565
+ const platform = a.platform ?? 'lark'
566
+ const operatorId = String(a.operator.id)
567
+ const verb = a.value?.a
568
+ const ctx = this.lookupActionToken(a.value?.t)
569
+ const deny = (msg, detail) => { this.auditAdmin(`${SOURCE(platform)}.action.denied`, `${operatorId} · ${verb ?? '?'} · ${detail ?? msg}`); return toast('error', msg) }
570
+ // Per-operator rate limit.
571
+ this.actionRate ??= new Map()
572
+ const now = Date.now()
573
+ const rateKey = `${platform}:${operatorId}`
574
+ const last = this.actionRate.get(rateKey) ?? 0
575
+ if (now - last < ACTION_RATE_MS) return toast('warning', '操作太频繁,请稍后再试。')
576
+ this.actionRate.set(rateKey, now)
577
+ if (this.actionRate.size > 2000) for (const [k, t] of this.actionRate) if (now - t > ACTION_RATE_MS) this.actionRate.delete(k)
578
+
579
+ if (!ACTIONS.has(verb) || !ctx) return deny('该按钮已过期或无效,请重新提问。', 'unknown/expired token')
580
+ if ((ctx.platform ?? 'lark') !== platform || (ctx.channelId && a.channelId && ctx.channelId !== a.channelId)) return deny('该按钮不属于当前机器人。', 'channel mismatch')
581
+ if (ctx.chatId && a.chatId && String(ctx.chatId) !== String(a.chatId)) return deny('该按钮不属于当前聊天。', 'chat mismatch')
582
+ // Who clicked: resolved from our member list, never from the callback value.
583
+ const chatId = ctx.chatId ?? a.chatId
584
+ const resolved = this.resolveActor({ platform, userId: operatorId, email: a.operator.email, name: a.operator.name, chatId: chatId != null ? String(chatId) : chatId, chatType: ctx.chatType ?? 'group' })
585
+ if (resolved.refused) return deny(resolved.refused, 'actor refused')
586
+ const actor = { ...resolved.actor, kind: platform }
587
+ const r = this.lookupResult(ctx.resultId)
588
+ if (!r) return deny('结果已过期,请重新提问。', 'result expired')
589
+ try { this.assertResultAccess(r, actor) } catch (error) { return deny(toUserError(error).message, `${actor.name}: ${error.message}`) }
590
+ if (actor.role === 'viewer') {
591
+ // Viewers may only act on governed results (produced under viewer rules, or a verified example / dashboard SQL).
592
+ const ds = this.findDatasource(r.datasourceId)
593
+ const governed = ctx.ownerRole === 'viewer' || (ds && this.isVerifiedExample(ds, r.baseSql ?? r.sql))
594
+ if (!governed || verb === 'detail' || verb === 'pin') return deny(verb === 'pin' ? '只读成员不能固定到看板。' : verb === 'detail' ? '只读成员不能查看明细数据。' : '只读成员只能分析指标库或已验证问答的结果。', `${actor.name}: viewer`)
595
+ }
596
+ if (verb === 'pin' && actor.role !== 'admin' && actor.role !== 'analyst') return deny('只有管理员或分析师可以固定到看板。', `${actor.name}: role ${actor.role}`)
597
+ let dimension
598
+ if (verb === 'drill') {
599
+ dimension = typeof a.option === 'string' ? a.option : undefined
600
+ let allowed = []
601
+ try { allowed = (await this.drillDimensions(r.resultId)).dimensions.map(d => d.column) } catch {}
602
+ if (!dimension || !allowed.includes(dimension)) return deny('请选择一个有效的拆分维度。', `invalid dimension ${String(dimension).slice(0, 40)}`)
603
+ }
604
+ const deliveryChannel = { id: a.channelId ?? SOURCE(platform), name: `${label(platform)}机器人 · ${actor.name}`, platform }
605
+
606
+ if (verb === 'pin') {
607
+ try {
608
+ const widget = await this.pinWidget({ title: r.title || `${label(platform)}结果`, sql: r.baseSql ?? r.sql, datasourceId: r.datasourceId, chart: r.chart })
609
+ const board = this.currentDashboardObj()
610
+ this.auditAdmin(`${SOURCE(platform)}.pin`, `${actor.name} · ${widget.title} → ${board.name}`, actor)
611
+ this.recordDelivery({ channel: deliveryChannel, title: `固定:${widget.title}`.slice(0, 60), status: 'ok', results: 0, source: SOURCE(platform), sessionId: ctx.sessionId, elapsedMs: Date.now() - now })
612
+ await this.save()
613
+ return toast('success', `已固定到看板「${board.name}」`)
614
+ } catch (error) {
615
+ return toast('error', `固定失败:${toUserError(error).message}`.slice(0, 200))
616
+ }
617
+ }
618
+
619
+ const job = this.runBotAction({ verb, ctx, r, actor, dimension, a, deliveryChannel })
620
+ this.botActionJobs ??= new Set()
621
+ this.botActionJobs.add(job)
622
+ job.finally(() => this.botActionJobs.delete(job)).catch(() => {})
623
+ return toast('info', '正在分析…')
624
+ },
625
+
626
+ async runBotAction({ verb, ctx, r, actor, dimension, a, deliveryChannel }) {
627
+ const platform = a.platform ?? 'lark'
628
+ const started = Date.now()
629
+ const replyTo = ctx.replyTo ?? a.messageId
630
+ const what = verb === 'drill' ? `拆分:${dimension}` : verb === 'why' ? '为什么变化' : `明细:${r.chart?.x ?? ''}`
631
+ const footer = `数据问答 · ${r.datasourceName ?? ''} · ${actor.name}`
632
+ let status = 'ok'
633
+ let error
634
+ let resultsCount = 0
635
+ try {
636
+ let message
637
+ if (verb === 'drill' || verb === 'detail') {
638
+ let xValue
639
+ if (verb === 'detail') {
640
+ const xi = r.columns.findIndex(c => c.name === r.chart?.x)
641
+ xValue = r.rows.map(row => row[xi]).filter(v => v !== null && v !== undefined).at(-1)
642
+ }
643
+ const out = await this.drill({ resultId: r.resultId, mode: verb === 'drill' ? 'breakdown' : 'detail', dimension, xValue, sessionId: ctx.sessionId ?? undefined, actor, source: SOURCE(platform) })
644
+ const results = await this.withResultActions([out], { actor, sessionId: ctx.sessionId, channelId: ctx.channelId ?? a.channelId, platform, chatId: ctx.chatId ?? a.chatId, chatType: ctx.chatType, replyTo, depth: (ctx.depth ?? 0) + 1 })
645
+ resultsCount = 1
646
+ message = { title: out.title || what, text: verb === 'detail' ? `「${r.title || '结果'}」在 ${r.chart?.x}=${xValue} 的明细(最多 200 行)。` : `「${r.title || '结果'}」按 ${dimension} 拆分。`, results, footer }
647
+ } else {
648
+ const out = await this.explainChange({ resultId: r.resultId, sessionId: ctx.sessionId ?? undefined, actor })
649
+ const top = out.dimensions.find(d => d.attribution)
650
+ const rows = (top?.attribution.items ?? []).slice(0, 8)
651
+ 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')}` : ''
652
+ message = { title: `为什么变化 · ${r.title || ''}`.slice(0, 80), text: `${out.summary}${table}`, results: [], footer }
653
+ }
654
+ await a.reply(message, { replyTo })
655
+ } catch (e) {
656
+ status = 'error'
657
+ error = toUserError(e).message
658
+ const text = `${what} 失败:${error}`.slice(0, 1000)
659
+ try { if (a.replyText) await a.replyText(text, { replyTo }); else await a.reply({ title: what, text }, { replyTo }) } catch {}
660
+ }
661
+ this.auditAdmin(`${SOURCE(platform)}.action`, `${actor.name} · ${what} · ${r.resultId}${error ? ` · ${error}` : ''}`, actor)
662
+ this.recordDelivery({ channel: deliveryChannel, title: what.slice(0, 60), status, ...(error ? { error } : {}), results: resultsCount, source: SOURCE(platform), sessionId: ctx.sessionId, elapsedMs: Date.now() - started })
663
+ await this.save()
664
+ },
665
+ }