@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/service/bots.js
ADDED
|
@@ -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
|
+
}
|