@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,379 @@
1
+ /**
2
+ * Nl2SqlService methods: IM channels (see docs/CHANNELS.md).
3
+ * Mixed into the service prototype by index.js; `this` is the service.
4
+ *
5
+ * A channel record (state.channels[]):
6
+ * { id, name, type, platform, description, isDefault, chartMode,
7
+ * config: { non-secret fields of the kind }, secretsSet: { <secretKey>: true },
8
+ * bot?: { enabled, allowP2p, sessionScope }, needsSecret?, createdAt, updatedAt, lastSentAt?, lastError? }
9
+ * Lark channels also keep their legacy top-level fields (domain, appId,
10
+ * receiveIdType, receiveId, hasSecret, hasSign) so older pages and exports work.
11
+ * Secrets live in one credential record per channel: `nl2sql/ch-<id>`.
12
+ */
13
+ import { ChannelError } from '../channels/http.js'
14
+ import { needsAttachment, renderChartPng } from '../channels/message.js'
15
+ import { writeXlsx } from '../xlsx.js'
16
+ import { getAdapter, kindOf, listCatalog, loadAdapters, requireKind, secretKeys } from '../channels/index.js'
17
+ import { UserError, newId, pick, requireText, upsert } from './shared.js'
18
+
19
+ const LARK_LEGACY = ['domain', 'appId', 'receiveIdType', 'receiveId']
20
+ const BOT_DEFAULTS = { enabled: false, allowP2p: true, sessionScope: 'chat' }
21
+
22
+ /** ChannelError (or legacy ImError) → UserError with the same code and message. */
23
+ export function channelUserError(error, code) {
24
+ if (!(error instanceof ChannelError || (error?.code && /^(im|channel)\//.test(error.code)))) return error
25
+ const e = new UserError(code ?? error.code, error.message)
26
+ if (error.field) e.field = error.field
27
+ return e
28
+ }
29
+
30
+ /** Chart modes a kind supports; the first one is the default. */
31
+ export function chartModesOf(kind) {
32
+ const caps = kind?.capabilities ?? {}
33
+ if (Array.isArray(caps.chartModes) && caps.chartModes.length) return caps.chartModes
34
+ if (caps.charts === 'native') return ['native', 'table']
35
+ if (caps.charts === 'image') return ['image', 'table']
36
+ return ['table']
37
+ }
38
+
39
+ /**
40
+ * Bring a stored channel record to the current shape (idempotent, in place):
41
+ * platform, config, secretsSet; Lark legacy fields mirrored both ways.
42
+ */
43
+ export function migrateChannelRecord(c) {
44
+ if (!c || typeof c !== 'object') return c
45
+ const found = kindOf(c.type)
46
+ const platform = found?.kind.platform ?? (String(c.type ?? '').startsWith('lark_') ? 'lark' : c.platform)
47
+ if (platform) c.platform = platform
48
+ if (!c.config || typeof c.config !== 'object') {
49
+ const keys = found ? (found.kind.fields ?? []).filter(f => !(f.secret || f.type === 'secret')).map(f => f.key) : LARK_LEGACY
50
+ c.config = {}
51
+ for (const k of keys) if (c[k] !== undefined) c.config[k] = c[k]
52
+ if (platform === 'lark' && c.domain && !c.config.domain) c.config.domain = c.domain
53
+ }
54
+ if (!c.secretsSet || typeof c.secretsSet !== 'object') {
55
+ c.secretsSet = {}
56
+ if (c.type === 'lark_webhook') { if (c.hasSecret) c.secretsSet.webhookUrl = true; if (c.hasSign) c.secretsSet.signSecret = true }
57
+ else if (c.type === 'lark_app') { if (c.hasSecret) c.secretsSet.appSecret = true }
58
+ }
59
+ if (platform === 'lark') {
60
+ for (const k of LARK_LEGACY) { if (c.config[k] !== undefined) c[k] = c.config[k]; else delete c[k] }
61
+ c.hasSecret = Boolean(c.type === 'lark_webhook' ? c.secretsSet.webhookUrl : c.secretsSet.appSecret)
62
+ c.hasSign = Boolean(c.secretsSet.signSecret)
63
+ }
64
+ return c
65
+ }
66
+
67
+ /** Legacy flat Lark payload ({ webhookUrl, signSecret, appId, appSecret, receiveId… }) → { config, secrets }. */
68
+ function splitPayload(input, kind) {
69
+ const fields = kind.fields ?? []
70
+ const secretSet = new Set(secretKeys(kind))
71
+ const config = input.config && typeof input.config === 'object' ? { ...input.config } : {}
72
+ const secrets = {}
73
+ const rawSecrets = input.secrets && typeof input.secrets === 'object' ? input.secrets : {}
74
+ for (const f of fields) {
75
+ if (secretSet.has(f.key)) {
76
+ const v = Object.hasOwn(rawSecrets, f.key) ? rawSecrets[f.key] : input[f.key]
77
+ if (v === null) secrets[f.key] = null
78
+ else if (typeof v === 'string' && v.trim() !== '') secrets[f.key] = v.trim()
79
+ // Legacy: an empty sign secret with clearSign removes it.
80
+ else if (typeof v === 'string' && f.key === 'signSecret' && input.clearSign) secrets[f.key] = null
81
+ } else if (!(f.key in config) && input[f.key] !== undefined) {
82
+ config[f.key] = input[f.key]
83
+ }
84
+ }
85
+ if (kind.platform === 'lark' && !('domain' in config) && input.domain !== undefined) config.domain = input.domain
86
+ return { config, secrets }
87
+ }
88
+
89
+ export const methods = {
90
+ // ---------------------------------------------------------- channels
91
+
92
+ publicChannel(c) {
93
+ migrateChannelRecord(c)
94
+ const found = kindOf(c.type)
95
+ const kind = found?.kind
96
+ const required = kind ? (kind.fields ?? []).filter(f => (f.secret || f.type === 'secret') && f.required).map(f => f.key) : []
97
+ const out = {
98
+ ...pick(c, ['id', 'name', 'type', 'platform', 'description', 'isDefault', 'createdAt', 'updatedAt', 'lastSentAt', 'lastError', 'chartMode']),
99
+ config: structuredClone(c.config ?? {}),
100
+ secretsSet: { ...(c.secretsSet ?? {}) },
101
+ hasSecret: c.platform === 'lark' ? Boolean(c.hasSecret) : required.every(k => c.secretsSet?.[k]),
102
+ capabilities: kind ? { ...kind.capabilities, chartModes: chartModesOf(kind) } : null,
103
+ ...(c.needsSecret ? { needsSecret: true } : {}),
104
+ ...(found ? {} : { unsupported: true }),
105
+ }
106
+ if (c.platform === 'lark') Object.assign(out, pick(c, LARK_LEGACY), { hasSign: Boolean(c.hasSign) })
107
+ if (kind?.capabilities?.bot) {
108
+ const bot = { ...BOT_DEFAULTS, ...(c.bot ?? {}) }
109
+ out.bot = { enabled: Boolean(bot.enabled), allowP2p: Boolean(bot.allowP2p), sessionScope: bot.sessionScope, status: this.botRuntimeStatus(c), info: this.bots?.get(c.id)?.bot.info?.() ?? null }
110
+ }
111
+ return out
112
+ },
113
+
114
+ listChannels() {
115
+ return this.state.channels.map(c => this.publicChannel(c))
116
+ },
117
+
118
+ async channelCatalog({ locale } = {}) {
119
+ await loadAdapters()
120
+ return listCatalog(locale)
121
+ },
122
+
123
+ findChannel(ref) {
124
+ if (!ref) return this.state.channels.find(c => c.isDefault) ?? (this.state.channels.length === 1 ? this.state.channels[0] : undefined)
125
+ return this.state.channels.find(c => c.id === ref) ?? this.state.channels.find(c => c.name === ref)
126
+ },
127
+
128
+ requireChannel(ref) {
129
+ const c = this.findChannel(ref)
130
+ if (!c) throw new UserError('nl2sql/unknown-channel', ref ? `推送渠道不存在:${ref}` : `没有可用的推送渠道。${this.state.channels.length ? `可用渠道:${this.state.channels.map(x => x.name).join('、')}` : '请用户在「数据问答 → 推送」页面添加 IM 渠道(飞书 / Telegram / 企业微信 / 钉钉)。'}`)
131
+ return migrateChannelRecord(c)
132
+ },
133
+
134
+ channelSecretId(c) {
135
+ return `ch-${String(typeof c === 'string' ? c : c.id).replace(/^ch-/, '')}`
136
+ },
137
+
138
+ channelSecretKey(id) {
139
+ return this.credentialKey(this.channelSecretId(id))
140
+ },
141
+
142
+ async channelSecrets(c) {
143
+ return this.readSecrets(this.channelSecretId(c))
144
+ },
145
+
146
+ /** The channel as adapters see it: config fields at the top level too. */
147
+ adapterChannel(c) {
148
+ migrateChannelRecord(c)
149
+ return { ...c, ...(c.config ?? {}) }
150
+ },
151
+
152
+ /**
153
+ * Create or update a channel. Accepts the contract payload
154
+ * ({ id?, name, type, description?, isDefault?, chartMode?, config, secrets, bot? })
155
+ * and the legacy flat Lark payload (webhookUrl, signSecret, appId, appSecret, receiveId…).
156
+ * Secrets omitted or '' keep their stored value; null clears one.
157
+ */
158
+ async saveChannel(input = {}) {
159
+ await loadAdapters()
160
+ const existing = input.id ? this.state.channels.find(c => c.id === input.id) : undefined
161
+ if (input.id && !existing) throw new UserError('nl2sql/unknown-channel', '推送渠道不存在')
162
+ if (existing) migrateChannelRecord(existing)
163
+ const c = existing ? structuredClone(existing) : { id: newId('ch'), createdAt: Date.now() }
164
+ c.name = requireText(input.name ?? c.name, 'name', 64)
165
+ if (this.state.channels.some(x => x.name === c.name && x.id !== c.id)) throw new UserError('nl2sql/duplicate', `渠道名称已存在:${c.name}`)
166
+ const type = input.type ?? c.type ?? 'lark_webhook'
167
+ let found
168
+ try { found = requireKind(type) } catch (error) { throw new UserError('nl2sql/invalid', error.message) }
169
+ const { adapter, kind } = found
170
+ const typeChanged = Boolean(existing && existing.type !== type)
171
+ c.type = type
172
+ c.platform = adapter.id
173
+ c.description = String(input.description ?? c.description ?? '').slice(0, 300)
174
+ const { config: configIn, secrets: changed } = splitPayload(input, { ...kind, platform: adapter.id })
175
+ const stored = existing && !typeChanged ? await this.channelSecrets(existing) : {}
176
+ const keys = secretKeys(kind)
177
+ const merged = {}
178
+ for (const k of keys) {
179
+ if (Object.hasOwn(changed, k)) { if (changed[k] !== null) merged[k] = changed[k] } else if (stored[k] !== undefined) merged[k] = stored[k]
180
+ }
181
+ let validated
182
+ try {
183
+ validated = await kind.validate({ name: c.name, config: { ...(typeChanged ? {} : c.config ?? {}), ...configIn }, secrets: merged }, existing && !typeChanged ? { config: existing.config ?? {}, secrets: stored } : undefined)
184
+ } catch (error) {
185
+ throw error instanceof ChannelError ? channelUserError(error, 'nl2sql/invalid') : error
186
+ }
187
+ c.config = validated.config ?? {}
188
+ const finalSecrets = {}
189
+ for (const k of keys) if (validated.secrets?.[k] !== undefined && validated.secrets[k] !== null && validated.secrets[k] !== '') finalSecrets[k] = validated.secrets[k]
190
+ // Write only what changed; a type change also drops secrets of the old kind.
191
+ const patch = {}
192
+ const before = existing ? await this.channelSecrets(existing) : {}
193
+ for (const k of new Set([...Object.keys(before), ...keys])) {
194
+ const next = keys.includes(k) ? finalSecrets[k] : undefined
195
+ if (next !== before[k]) patch[k] = next === undefined ? null : next
196
+ }
197
+ if (Object.keys(patch).length) await this.writeSecrets(this.channelSecretId(c), patch)
198
+ c.secretsSet = Object.fromEntries(Object.keys(finalSecrets).map(k => [k, true]))
199
+ delete c.needsSecret
200
+ const modes = chartModesOf(kind)
201
+ c.chartMode = modes.includes(input.chartMode) ? input.chartMode : modes.includes(c.chartMode) ? c.chartMode : modes[0]
202
+ if (adapter.id !== 'lark') for (const k of [...LARK_LEGACY, 'hasSecret', 'hasSign']) delete c[k]
203
+ if (kind.capabilities?.bot) {
204
+ if (input.bot && typeof input.bot === 'object') c.bot = this.normalizeBotConfig(input.bot, c.bot)
205
+ } else {
206
+ delete c.bot
207
+ }
208
+ migrateChannelRecord(c)
209
+ if (input.isDefault || !this.state.channels.some(x => x.isDefault && x.id !== c.id)) {
210
+ for (const x of this.state.channels) x.isDefault = false
211
+ c.isDefault = true
212
+ }
213
+ c.updatedAt = Date.now()
214
+ this.state.channels = upsert(this.state.channels, c)
215
+ await this.save()
216
+ // Credentials or bot settings may have changed: restart (or stop) its bot.
217
+ if (this.bots?.has(c.id) || c.bot?.enabled) await this.restartBot({ channelId: c.id }).catch(error => console.warn('nl2sql bot: restart failed: %s', error.message))
218
+ return this.publicChannel(c)
219
+ },
220
+
221
+ async deleteChannel(id) {
222
+ const c = this.state.channels.find(x => x.id === id)
223
+ if (!c) return { deleted: id }
224
+ await this.stopBot(id)
225
+ this.state.channels = this.state.channels.filter(x => x.id !== id)
226
+ if (c.isDefault && this.state.channels[0]) this.state.channels[0].isDefault = true
227
+ for (const r of this.state.reports) if (r.channelId === id) delete r.channelId
228
+ try { await this.ctx.get('credentials')?.deleteRecord(this.channelSecretKey(id)) } catch {}
229
+ await this.save()
230
+ return { deleted: id }
231
+ },
232
+
233
+ /**
234
+ * Chats/groups a channel's bot can reach: a saved channel, or unsaved
235
+ * credentials from the form (nothing is stored). → [{ id, name, type }]
236
+ */
237
+ async listChannelTargets({ id, type, config, secrets, ...flat } = {}) {
238
+ await loadAdapters()
239
+ const saved = id ? this.state.channels.find(c => c.id === id) : undefined
240
+ if (id && !saved) throw new UserError('nl2sql/unknown-channel', '推送渠道不存在')
241
+ const found = kindOf(type ?? saved?.type ?? (flat.appId ? 'lark_app' : undefined))
242
+ if (!found) throw new UserError('nl2sql/invalid', `不支持的渠道类型:${type ?? saved?.type ?? '?'}`)
243
+ const { kind } = found
244
+ if (!kind.listTargets) throw new UserError('nl2sql/invalid', `「${kind.label?.zh ?? kind.id}」不支持列出群聊`)
245
+ const given = splitPayload({ ...flat, config, secrets }, { ...kind, platform: found.adapter.id })
246
+ const base = saved && saved.type === kind.id ? this.adapterChannel(saved) : { type: kind.id, platform: found.adapter.id, config: {} }
247
+ const channel = { ...base, ...given.config, config: { ...(base.config ?? {}), ...given.config } }
248
+ const storedSecrets = saved && saved.type === kind.id ? await this.channelSecrets(saved) : {}
249
+ const allSecrets = { ...storedSecrets }
250
+ for (const [k, v] of Object.entries(given.secrets)) if (v) allSecrets[k] = v
251
+ for (const f of (kind.fields ?? []).filter(x => (x.secret || x.type === 'secret') && x.required)) {
252
+ if (!allSecrets[f.key]) throw Object.assign(new UserError('nl2sql/invalid', `请先填写 ${f.label?.zh ?? f.key}`), { field: f.key })
253
+ }
254
+ try {
255
+ return await kind.listTargets(channel, allSecrets)
256
+ } catch (error) {
257
+ throw channelUserError(error)
258
+ }
259
+ },
260
+
261
+ /** Legacy: chats of a Lark app bot as [{ chatId, name, description }]. */
262
+ async listChannelChats({ id, appId, appSecret, domain } = {}) {
263
+ const saved = id ? this.state.channels.find(c => c.id === id) : undefined
264
+ if (saved && saved.type !== 'lark_app') throw new UserError('nl2sql/invalid', '只有应用机器人渠道可以列出群聊')
265
+ if (!saved) requireText(appId, 'appId', 64)
266
+ if (!appSecret && !saved) throw new UserError('nl2sql/invalid', '请先填写 App Secret')
267
+ const targets = await this.listChannelTargets({ id, type: 'lark_app', config: { ...(appId ? { appId } : {}), ...(domain ? { domain } : {}) }, secrets: appSecret ? { appSecret } : {} })
268
+ return targets.map(t => ({ chatId: t.id, name: t.name, description: t.description ?? '' }))
269
+ },
270
+
271
+ /**
272
+ * Check a channel: the kind's cheap auth check when it has one, plus a test
273
+ * message when it has none (or the check asks for one). → { ok, detail, … }
274
+ */
275
+ async testChannel(input) {
276
+ await loadAdapters()
277
+ const id = typeof input === 'string' ? input : input?.id
278
+ const c = this.requireChannel(id)
279
+ const found = kindOf(c.type)
280
+ if (!found) throw new UserError('nl2sql/invalid', `不支持的渠道类型:${c.type}`)
281
+ let checked
282
+ if (found.kind.test) {
283
+ try { checked = await found.kind.test(this.adapterChannel(c), await this.channelSecrets(c)) } catch (error) { throw channelUserError(error) }
284
+ if (checked && checked.ok === false) return { ok: false, detail: checked.detail ?? '' }
285
+ if (checked && checked.send === false) return { ok: true, detail: checked.detail ?? '' }
286
+ }
287
+ const sent = await this.sendToChannel({ channelId: c.id, title: '数据问答 · 推送测试', text: `这是一条测试消息。\n发送时间:${new Date().toLocaleString('zh-CN', { hour12: false })}`, source: 'test' })
288
+ return { ...sent, ok: true, detail: [checked?.detail, '测试消息已发送'].filter(Boolean).join(';') }
289
+ },
290
+
291
+ /**
292
+ * Send to an IM channel. Results are referenced by resultId (from this
293
+ * process' cache) or given inline; each is re-checked against privacy:
294
+ * masked values stay masked because results are stored masked.
295
+ */
296
+ async sendToChannel({ channelId, title, text, resultIds = [], results = [], includeSql = false, source = 'ui', sessionId, footer, chartMode, template, mentionAll, attachFull }) {
297
+ await loadAdapters()
298
+ const c = this.requireChannel(channelId)
299
+ const resolved = []
300
+ const sender = this.actorFor(sessionId)
301
+ for (const id of resultIds.slice(0, 10)) {
302
+ const r = this.lookupResult(id)
303
+ if (!r) throw new UserError('nl2sql/result-expired', `结果 ${id} 已过期,请重新查询后再发送。`)
304
+ if (source === 'agent') this.assertResultAccess(r, sender)
305
+ resolved.push(r)
306
+ }
307
+ resolved.push(...results.slice(0, 10))
308
+ if (!text && resolved.length === 0) throw new UserError('nl2sql/invalid', '没有可发送的内容')
309
+ const found = kindOf(c.type)
310
+ if (!found) throw new UserError('nl2sql/invalid', `不支持的渠道类型:${c.type}(对应的渠道插件未加载)`)
311
+ const modes = chartModesOf(found.kind)
312
+ const body = text ? String(text).slice(0, 20000) : ''
313
+ const message = {
314
+ title: requireText(title || resolved[0]?.title || '数据问答', 'title', 100),
315
+ text: body,
316
+ chartMode: modes.includes(chartMode) ? chartMode : modes.includes(c.chartMode) ? c.chartMode : modes[0],
317
+ results: resolved.map(r => pick(r, ['title', 'columns', 'rows', 'rowCount', 'truncated', 'chart', 'sql', 'filters', 'error', ...(found.kind.capabilities?.buttons ? ['actions'] : [])])),
318
+ includeSql: Boolean(includeSql),
319
+ mentionAll: mentionAll ?? /<at\s+id\s*=\s*["']?all["']?\s*>/i.test(body),
320
+ ...(template ? { template } : {}),
321
+ footer: footer ?? `数据问答 · ${new Date().toLocaleString('zh-CN', { hour12: false })}${resolved[0]?.datasourceName ? ` · ${resolved[0].datasourceName}` : ''}`,
322
+ }
323
+ // Results with more rows than the message can show: attach the full data as Excel
324
+ // where the platform can send files (default on; per send `attachFull: false` to skip).
325
+ const caps = found.kind.capabilities ?? {}
326
+ const shown = caps.tableRows ?? 10
327
+ const wantAttach = attachFull ?? c.attachFull ?? true
328
+ if (caps.files && wantAttach) {
329
+ const idx = message.results.map((r, i) => (needsAttachment(r, shown) ? i : -1)).filter(i => i >= 0)
330
+ if (idx.length) {
331
+ const sheets = idx.map(i => ({ name: (message.results[i].title || `结果${i + 1}`).slice(0, 31), columns: message.results[i].columns, rows: message.results[i].rows }))
332
+ if (includeSql) sheets.push({ name: 'SQL', columns: [{ name: '结果', kind: 'text' }, { name: 'SQL', kind: 'text' }], rows: idx.map(i => [message.results[i].title ?? '', message.results[i].sql ?? '']) })
333
+ const buffer = writeXlsx(sheets, { creator: 'DeepSeek Harness 数据问答' })
334
+ const safe = String(message.title).replace(/[\\/:*?"<>|\s]+/g, '_').slice(0, 60) || '数据'
335
+ message.attachment = { buffer, fileName: `${safe}_${new Date().toISOString().slice(0, 10)}.xlsx`, resultIndexes: idx }
336
+ }
337
+ }
338
+ const started = Date.now()
339
+ try {
340
+ if (c.needsSecret) throw new ChannelError('channel/config', `渠道「${c.name}」需要重新填写密钥后才能使用`)
341
+ const sent = await found.kind.send(this.adapterChannel(c), await this.channelSecrets(c), message, { renderChartPng, logger: console, fetch: globalThis.fetch })
342
+ c.lastSentAt = Date.now()
343
+ delete c.lastError
344
+ this.recordDelivery({ channel: c, title: message.title, status: 'ok', results: resolved.length, images: sent?.images, files: sent?.files, ...(sent?.warning ? { error: sent.warning } : {}), source, sessionId, elapsedMs: Date.now() - started })
345
+ await this.save()
346
+ return { ok: true, channel: c.name, platform: c.platform, messageId: sent?.messageId ?? null, images: sent?.images ?? 0, files: sent?.files ?? 0, ...(sent?.warning ? { warning: sent.warning } : {}), results: resolved.length }
347
+ } catch (error) {
348
+ c.lastError = error.message
349
+ this.recordDelivery({ channel: c, title: message.title, status: 'error', error: error.message, results: resolved.length, source, sessionId, elapsedMs: Date.now() - started })
350
+ await this.save()
351
+ throw channelUserError(error)
352
+ }
353
+ },
354
+
355
+ recordDelivery({ channel, title, status, error, results, images, files, source, sessionId, elapsedMs }) {
356
+ this.records.put('delivery', { id: newId('dl'), at: Date.now(), channelId: channel.id, channelName: channel.name, ...(channel.platform ? { platform: channel.platform } : {}), title, status, ...(error ? { error: String(error).slice(0, 500) } : {}), results, images: images ?? 0, files: files ?? 0, source, sessionId: sessionId ?? null, elapsedMs })
357
+ },
358
+
359
+ listDeliveries(limit = 100) {
360
+ return this.records.list('delivery', { limit: typeof limit === 'number' ? limit : limit?.limit ?? 100 })
361
+ },
362
+
363
+ /** Load optional adapters and migrate channel records (called once on start). */
364
+ async initChannels() {
365
+ await loadAdapters()
366
+ let changed = false
367
+ for (const c of this.state.channels ?? []) {
368
+ const before = JSON.stringify(c)
369
+ migrateChannelRecord(c)
370
+ if (JSON.stringify(c) !== before) changed = true
371
+ }
372
+ if (this.migrateLegacyBot()) changed = true
373
+ return changed
374
+ },
375
+
376
+ adapterFor(c) {
377
+ return getAdapter(c?.platform ?? kindOf(c?.type)?.adapter.id)
378
+ },
379
+ }
@@ -13,21 +13,41 @@ export const methods = {
13
13
 
14
14
  /** Current settings plus `defaults` (for "restore defaults" in the page). */
15
15
  getSettings() {
16
- return { ...structuredClone(this.state.settings), defaults: structuredClone(DEFAULT_SETTINGS) }
16
+ return { ...this.publicSettings(), defaults: { ...structuredClone(DEFAULT_SETTINGS), larkDefaultRole: DEFAULT_SETTINGS.botDefaultRole } }
17
+ },
18
+
19
+ /** Settings with the bot defaults under both names (botDefault* and the legacy larkDefault* aliases). */
20
+ publicSettings() {
21
+ const s = structuredClone(this.state.settings)
22
+ s.botDefaultRole = s.botDefaultRole ?? s.larkDefaultRole ?? 'none'
23
+ s.larkDefaultRole = s.botDefaultRole
24
+ const ids = s.botDefaultDatasourceIds !== undefined ? s.botDefaultDatasourceIds : s.larkDefaultDatasourceIds
25
+ if (ids !== undefined) { s.botDefaultDatasourceIds = ids; s.larkDefaultDatasourceIds = ids } else { delete s.botDefaultDatasourceIds; delete s.larkDefaultDatasourceIds }
26
+ return s
27
+ },
28
+
29
+ /** Move larkDefaultRole / larkDefaultDatasourceIds to the bot* keys (idempotent). */
30
+ migrateSettings() {
31
+ const s = this.state.settings
32
+ let changed = false
33
+ if (s.larkDefaultRole !== undefined) { if (s.botDefaultRole === undefined || s.botDefaultRole === DEFAULT_SETTINGS.botDefaultRole) s.botDefaultRole = s.larkDefaultRole; delete s.larkDefaultRole; changed = true }
34
+ if (s.larkDefaultDatasourceIds !== undefined) { if (s.botDefaultDatasourceIds === undefined) s.botDefaultDatasourceIds = s.larkDefaultDatasourceIds; delete s.larkDefaultDatasourceIds; changed = true }
35
+ return changed
17
36
  },
18
37
 
19
38
  /** Reset the given setting keys (or all) to their defaults. */
20
39
  async resetSettings({ keys } = {}) {
21
- const list = Array.isArray(keys) && keys.length ? keys : Object.keys(DEFAULT_SETTINGS)
40
+ const list = (Array.isArray(keys) && keys.length ? keys : Object.keys(DEFAULT_SETTINGS)).map(k => (k === 'larkDefaultRole' ? 'botDefaultRole' : k))
22
41
  const unknown = list.filter(k => !Object.hasOwn(DEFAULT_SETTINGS, k))
23
42
  if (unknown.length) throw new UserError('nl2sql/invalid', `未知的设置项:${unknown.join(', ')}`)
24
43
  const s = { ...this.state.settings }
25
44
  for (const k of list) s[k] = structuredClone(DEFAULT_SETTINGS[k])
26
45
  if (!Array.isArray(keys) || !keys.length) {
27
46
  // Full reset also clears the optional settings that have no default value.
28
- for (const k of ['larkDefaultDatasourceIds', 'schemaNotifyChannel', 'schemaCheckDaily']) delete s[k]
47
+ for (const k of ['botDefaultDatasourceIds', 'larkDefaultDatasourceIds', 'schemaNotifyChannel', 'schemaCheckDaily']) delete s[k]
29
48
  }
30
49
  this.state.settings = s
50
+ delete s.larkDefaultRole
31
51
  this.records.trim()
32
52
  this.auditAdmin('settings.reset', list.join(', '))
33
53
  await this.save()
@@ -45,8 +65,11 @@ export const methods = {
45
65
  if (input.scanGuardRows !== undefined) s.scanGuardRows = clampInt(input.scanGuardRows, 0, 1e12, 'scanGuardRows')
46
66
  if (input.cacheTtlSec !== undefined) s.cacheTtlSec = clampInt(input.cacheTtlSec, 0, 86400, 'cacheTtlSec')
47
67
  if (input.schemaWatch !== undefined) s.schemaWatch = Boolean(input.schemaWatch)
48
- if (input.larkDefaultRole !== undefined) s.larkDefaultRole = oneOf(input.larkDefaultRole, ['none', 'viewer', 'analyst'], 'larkDefaultRole')
49
- if (input.larkDefaultDatasourceIds !== undefined) s.larkDefaultDatasourceIds = input.larkDefaultDatasourceIds === null ? null : (input.larkDefaultDatasourceIds ?? []).filter(id => this.findDatasource(id))
68
+ // Bot defaults: botDefault* (new) or larkDefault* (legacy alias); stored under the new keys.
69
+ const role = input.botDefaultRole ?? input.larkDefaultRole
70
+ if (role !== undefined) { s.botDefaultRole = oneOf(role, ['none', 'viewer', 'analyst'], 'botDefaultRole'); delete s.larkDefaultRole }
71
+ const dsIds = input.botDefaultDatasourceIds !== undefined ? input.botDefaultDatasourceIds : input.larkDefaultDatasourceIds
72
+ if (dsIds !== undefined) { s.botDefaultDatasourceIds = dsIds === null ? null : (dsIds ?? []).filter(id => this.findDatasource(id)); delete s.larkDefaultDatasourceIds }
50
73
  if (input.schemaNotifyChannel !== undefined) s.schemaNotifyChannel = this.state.channels.some(c => c.id === input.schemaNotifyChannel) ? input.schemaNotifyChannel : ''
51
74
  if (input.schemaCheckDaily !== undefined) s.schemaCheckDaily = /^\d{2}:\d{2}$/.test(String(input.schemaCheckDaily)) ? input.schemaCheckDaily : ''
52
75
  if (input.schemaOverviewMax !== undefined) s.schemaOverviewMax = clampInt(input.schemaOverviewMax, 1, 100000, 'schemaOverviewMax')
@@ -68,7 +91,7 @@ export const methods = {
68
91
  this.state.settings = s
69
92
  if (lowered) this.records.trim()
70
93
  await this.save()
71
- return structuredClone(s)
94
+ return this.publicSettings()
72
95
  },
73
96
 
74
97
  settingsFor(ds) {
@@ -238,8 +261,8 @@ export const methods = {
238
261
  delete this.state.evals?.[ds.id]
239
262
  this.state.alerts = (this.state.alerts ?? []).filter(a => a.datasourceId !== ds.id)
240
263
  for (const u of this.state.users ?? []) if (Array.isArray(u.datasourceIds)) u.datasourceIds = u.datasourceIds.filter(x => x !== ds.id)
241
- if (Array.isArray(this.state.settings.larkDefaultDatasourceIds)) this.state.settings.larkDefaultDatasourceIds = this.state.settings.larkDefaultDatasourceIds.filter(x => x !== ds.id)
242
- for (const c of Object.values(this.state.larkSessions ?? {})) if (c.datasourceId === ds.id) c.datasourceId = null
264
+ for (const k of ['botDefaultDatasourceIds', 'larkDefaultDatasourceIds']) if (Array.isArray(this.state.settings[k])) this.state.settings[k] = this.state.settings[k].filter(x => x !== ds.id)
265
+ for (const c of Object.values(this.state.botConversations ?? {})) if (c.datasourceId === ds.id) c.datasourceId = null
243
266
  try { const { rm } = await import('node:fs/promises'); await rm(join(this.dir, `schema-${ds.id}.json`), { force: true }) } catch {}
244
267
  this.schemas.delete(ds.id)
245
268
  if (ds.type === 'sqlite' && ds.file?.startsWith(this.dir)) { try { const { rm } = await import('node:fs/promises'); await rm(ds.file, { force: true }); await rm(`${ds.file}-wal`, { force: true }); await rm(`${ds.file}-shm`, { force: true }) } catch {} }
package/service/push.js CHANGED
@@ -1,151 +1,13 @@
1
1
  /**
2
- * Nl2SqlService methods: push.
2
+ * Nl2SqlService methods: sending assistant replies and dashboards to IM
3
+ * channels, dashboard snapshots, and result feedback. Channel management and
4
+ * delivery live in service/channels.js.
3
5
  * Mixed into the service prototype by index.js; `this` is the service.
4
6
  */
5
- import { ImError, RECEIVE_ID_TYPES, listBotChats, sendMessage } from '../im.js'
6
- import { CREDENTIAL_SCOPE, UserError, newId, oneOf, pick, requireText, upsert } from './shared.js'
7
+ import { UserError, newId } from './shared.js'
7
8
 
8
9
  export const methods = {
9
- // ---------------------------------------------------------- IM channels
10
-
11
- publicChannel(c) {
12
- return { ...pick(c, ['id', 'name', 'type', 'domain', 'appId', 'receiveIdType', 'receiveId', 'description', 'isDefault', 'createdAt', 'updatedAt', 'lastSentAt', 'lastError', 'chartMode']), hasSecret: Boolean(c.hasSecret), hasSign: Boolean(c.hasSign), ...(c.needsSecret ? { needsSecret: true } : {}) }
13
- },
14
-
15
- listChannels() {
16
- return this.state.channels.map(c => this.publicChannel(c))
17
- },
18
-
19
- findChannel(ref) {
20
- if (!ref) return this.state.channels.find(c => c.isDefault) ?? (this.state.channels.length === 1 ? this.state.channels[0] : undefined)
21
- return this.state.channels.find(c => c.id === ref) ?? this.state.channels.find(c => c.name === ref)
22
- },
23
-
24
- requireChannel(ref) {
25
- const c = this.findChannel(ref)
26
- if (!c) throw new UserError('nl2sql/unknown-channel', ref ? `推送渠道不存在:${ref}` : `没有可用的推送渠道。${this.state.channels.length ? `可用渠道:${this.state.channels.map(x => x.name).join('、')}` : '请用户在「数据问答 → 推送」页面添加飞书/Lark 渠道。'}`)
27
- return c
28
- },
29
-
30
- channelSecretKey(id) {
31
- return `${CREDENTIAL_SCOPE}/${id}`
32
- },
33
-
34
- async saveChannel(input) {
35
- const existing = input.id ? this.state.channels.find(c => c.id === input.id) : undefined
36
- if (input.id && !existing) throw new UserError('nl2sql/unknown-channel', '推送渠道不存在')
37
- const c = existing ? { ...existing } : { id: newId('ch'), createdAt: Date.now() }
38
- c.name = requireText(input.name ?? c.name, 'name', 64)
39
- if (this.state.channels.some(x => x.name === c.name && x.id !== c.id)) throw new UserError('nl2sql/duplicate', `渠道名称已存在:${c.name}`)
40
- c.type = oneOf(input.type ?? c.type ?? 'lark_webhook', ['lark_webhook', 'lark_app'], 'type')
41
- c.domain = input.domain === 'lark' ? 'lark' : input.domain === 'feishu' ? 'feishu' : (c.domain ?? 'feishu')
42
- c.description = String(input.description ?? c.description ?? '').slice(0, 300)
43
- c.chartMode = ['native', 'image', 'table'].includes(input.chartMode) ? input.chartMode : (c.chartMode ?? 'native')
44
- if (c.chartMode === 'image' && c.type !== 'lark_app') c.chartMode = 'native'
45
- if (c.type === 'lark_app') {
46
- c.appId = requireText(input.appId ?? c.appId, 'appId', 64)
47
- c.receiveIdType = oneOf(input.receiveIdType ?? c.receiveIdType ?? 'chat_id', RECEIVE_ID_TYPES, 'receiveIdType')
48
- c.receiveId = requireText(input.receiveId ?? c.receiveId, 'receiveId', 200)
49
- } else {
50
- delete c.appId; delete c.receiveIdType; delete c.receiveId
51
- }
52
- const patch = {}
53
- if (c.type === 'lark_webhook') {
54
- if (typeof input.webhookUrl === 'string' && input.webhookUrl.trim()) {
55
- const url = input.webhookUrl.trim()
56
- if (!/^https:\/\/open\.(feishu\.cn|larksuite\.com)\/open-apis\/bot\/v2\/hook\/[\w-]+$/.test(url)) throw new UserError('nl2sql/invalid', 'Webhook 地址格式不正确,应为 https://open.feishu.cn/open-apis/bot/v2/hook/… (Lark 国际版为 open.larksuite.com)')
57
- patch.webhookUrl = url
58
- c.domain = url.includes('larksuite') ? 'lark' : 'feishu'
59
- }
60
- if (typeof input.signSecret === 'string' && (input.signSecret !== '' || input.clearSign)) patch.signSecret = input.signSecret.trim()
61
- } else if (typeof input.appSecret === 'string' && input.appSecret !== '') {
62
- patch.appSecret = input.appSecret.trim()
63
- }
64
- const id = `ch-${c.id.replace(/^ch-/, '')}`
65
- const secrets = Object.keys(patch).length ? await this.writeSecrets(id, patch) : await this.readSecrets(id)
66
- c.hasSecret = Boolean(c.type === 'lark_webhook' ? secrets.webhookUrl : secrets.appSecret)
67
- c.hasSign = Boolean(secrets.signSecret)
68
- if (!c.hasSecret) throw new UserError('nl2sql/invalid', c.type === 'lark_webhook' ? '请填写 Webhook 地址' : '请填写 App Secret')
69
- delete c.needsSecret
70
- if (input.isDefault || !this.state.channels.some(x => x.isDefault && x.id !== c.id)) {
71
- for (const x of this.state.channels) x.isDefault = false
72
- c.isDefault = true
73
- }
74
- c.updatedAt = Date.now()
75
- this.state.channels = upsert(this.state.channels, c)
76
- await this.save()
77
- return this.publicChannel(c)
78
- },
79
-
80
- async deleteChannel(id) {
81
- const c = this.state.channels.find(x => x.id === id)
82
- if (!c) return { deleted: id }
83
- this.state.channels = this.state.channels.filter(x => x.id !== id)
84
- if (c.isDefault && this.state.channels[0]) this.state.channels[0].isDefault = true
85
- for (const r of this.state.reports) if (r.channelId === id) delete r.channelId
86
- try { await this.ctx.get('credentials')?.deleteRecord(this.credentialKey(`ch-${id.replace(/^ch-/, '')}`)) } catch {}
87
- await this.save()
88
- return { deleted: id }
89
- },
90
-
91
- async channelSecrets(c) {
92
- return this.readSecrets(`ch-${c.id.replace(/^ch-/, '')}`)
93
- },
94
-
95
- /** Chats of an app bot: a saved channel, or unsaved credentials from the form (nothing is stored). */
96
- async listChannelChats({ id, appId, appSecret, domain }) {
97
- const saved = id ? this.state.channels.find(c => c.id === id) : undefined
98
- const c = saved ?? { type: 'lark_app', appId: requireText(appId, 'appId', 64), domain: domain === 'lark' ? 'lark' : 'feishu' }
99
- if (c.type !== 'lark_app') throw new UserError('nl2sql/invalid', '只有应用机器人渠道可以列出群聊')
100
- const secrets = appSecret ? { appSecret } : saved ? await this.channelSecrets(saved) : {}
101
- if (!secrets.appSecret) throw new UserError('nl2sql/invalid', '请先填写 App Secret')
102
- try {
103
- return await listBotChats({ ...c, ...(appId ? { appId } : {}) }, secrets)
104
- } catch (error) {
105
- throw error instanceof ImError ? new UserError(error.code, error.message) : error
106
- }
107
- },
108
-
109
- /**
110
- * Send to an IM channel. Results are referenced by resultId (from this
111
- * process' cache) or given inline; each is re-checked against privacy:
112
- * masked values stay masked because results are stored masked.
113
- */
114
- async sendToChannel({ channelId, title, text, resultIds = [], results = [], includeSql = false, source = 'ui', sessionId, footer, chartMode }) {
115
- const c = this.requireChannel(channelId)
116
- const resolved = []
117
- const sender = this.actorFor(sessionId)
118
- for (const id of resultIds.slice(0, 10)) {
119
- const r = this.lookupResult(id)
120
- if (!r) throw new UserError('nl2sql/result-expired', `结果 ${id} 已过期,请重新查询后再发送。`)
121
- if (source === 'agent') this.assertResultAccess(r, sender)
122
- resolved.push(r)
123
- }
124
- resolved.push(...results.slice(0, 10))
125
- if (!text && resolved.length === 0) throw new UserError('nl2sql/invalid', '没有可发送的内容')
126
- const message = {
127
- title: requireText(title || resolved[0]?.title || '数据问答', 'title', 100),
128
- text: text ? String(text).slice(0, 20000) : '',
129
- chartMode: ['native', 'image', 'table'].includes(chartMode) ? chartMode : (c.chartMode ?? 'native'),
130
- results: resolved.map(r => pick(r, ['title', 'columns', 'rows', 'rowCount', 'truncated', 'chart', 'sql', 'filters', 'error'])),
131
- includeSql: Boolean(includeSql),
132
- footer: footer ?? `数据问答 · ${new Date().toLocaleString('zh-CN', { hour12: false })}${resolved[0]?.datasourceName ? ` · ${resolved[0].datasourceName}` : ''}`,
133
- }
134
- const started = Date.now()
135
- try {
136
- const sent = await sendMessage(c, await this.channelSecrets(c), message)
137
- c.lastSentAt = Date.now()
138
- delete c.lastError
139
- this.recordDelivery({ channel: c, title: message.title, status: 'ok', results: resolved.length, images: sent.images, source, sessionId, elapsedMs: Date.now() - started })
140
- await this.save()
141
- return { ok: true, channel: c.name, messageId: sent.messageId ?? null, images: sent.images, results: resolved.length }
142
- } catch (error) {
143
- c.lastError = error.message
144
- this.recordDelivery({ channel: c, title: message.title, status: 'error', error: error.message, results: resolved.length, source, sessionId, elapsedMs: Date.now() - started })
145
- await this.save()
146
- throw error instanceof ImError ? new UserError(error.code, error.message) : error
147
- }
148
- },
10
+ // ------------------------------------------- assistant replies → IM
149
11
 
150
12
  /** Text of one assistant message of a session, for "send this reply to IM". */
151
13
  async assistantMessageText(sessionId, messageId) {
@@ -201,21 +63,8 @@ export const methods = {
201
63
  return { text, resultIds, titles: resultIds.map(id => this.lookupResult(id)?.title ?? '') }
202
64
  },
203
65
 
204
- recordDelivery({ channel, title, status, error, results, images, source, sessionId, elapsedMs }) {
205
- this.records.put('delivery', { id: newId('dl'), at: Date.now(), channelId: channel.id, channelName: channel.name, title, status, ...(error ? { error: String(error).slice(0, 500) } : {}), results, images: images ?? 0, source, sessionId: sessionId ?? null, elapsedMs })
206
-
207
- },
208
-
209
- listDeliveries(limit = 100) {
210
- return this.records.list('delivery', { limit })
211
- },
212
-
213
- async testChannel(id) {
214
- return this.sendToChannel({ channelId: id, title: '数据问答 · 推送测试', text: `这是一条测试消息。\n发送时间:${new Date().toLocaleString('zh-CN', { hour12: false })}`, source: 'test' })
215
- },
216
-
217
66
  /** Send the current dashboard (with saved global filters) to a channel. */
218
- async sendDashboard({ channelId, title, filters, dashboardId, chartMode }) {
67
+ async sendDashboard({ channelId, title, filters, dashboardId, chartMode, attachFull }) {
219
68
  const results = []
220
69
  const d = dashboardId ? this.requireDashboard(dashboardId) : this.currentDashboardObj()
221
70
  for (const w of d.widgets.slice(0, 10)) {
@@ -227,7 +76,7 @@ export const methods = {
227
76
  }
228
77
  }
229
78
  if (!results.length) throw new UserError('nl2sql/invalid', '看板为空')
230
- const sent = await this.sendToChannel({ channelId, title: title || d.name || '数据看板', results, source: 'dashboard', chartMode })
79
+ const sent = await this.sendToChannel({ channelId, title: title || d.name || '数据看板', results, source: 'dashboard', chartMode, attachFull })
231
80
  await this.saveDashboardSnapshot(d, results, 'push')
232
81
  return sent
233
82
  },
package/service/shared.js CHANGED
@@ -24,7 +24,7 @@ export const DEFAULT_SETTINGS = {
24
24
  scanGuardRows: 1000000, // EXPLAIN rows above which a full scan needs confirmation (0 = off)
25
25
  cacheTtlSec: 300, // identical query reuse window for dashboards / reruns (0 = off)
26
26
  schemaWatch: true, // detect schema changes on re-analysis
27
- larkDefaultRole: 'none', // role for unknown Lark users: none | viewer | analyst
27
+ botDefaultRole: 'none', // role for unknown IM bot users: none | viewer | analyst (legacy name: larkDefaultRole)
28
28
  schemaOverviewMax: 80, // above this many tables the schema overview lists names only, plus the most relevant tables in full
29
29
  schemaOverviewTop: 15, // tables described in full in a large-schema overview
30
30
  retention: { history: 20000, feedback: 5000, delivery: 5000, alertEvent: 5000, admin: 5000, evalRun: 200, schemaChange: 1000 }, // records kept per log kind