@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.
@@ -0,0 +1,363 @@
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 { renderChartPng } from '../channels/message.js'
15
+ import { getAdapter, kindOf, listCatalog, loadAdapters, requireKind, secretKeys } from '../channels/index.js'
16
+ import { UserError, newId, pick, requireText, upsert } from './shared.js'
17
+
18
+ const LARK_LEGACY = ['domain', 'appId', 'receiveIdType', 'receiveId']
19
+ const BOT_DEFAULTS = { enabled: false, allowP2p: true, sessionScope: 'chat' }
20
+
21
+ /** ChannelError (or legacy ImError) → UserError with the same code and message. */
22
+ export function channelUserError(error, code) {
23
+ if (!(error instanceof ChannelError || (error?.code && /^(im|channel)\//.test(error.code)))) return error
24
+ const e = new UserError(code ?? error.code, error.message)
25
+ if (error.field) e.field = error.field
26
+ return e
27
+ }
28
+
29
+ /** Chart modes a kind supports; the first one is the default. */
30
+ export function chartModesOf(kind) {
31
+ const caps = kind?.capabilities ?? {}
32
+ if (Array.isArray(caps.chartModes) && caps.chartModes.length) return caps.chartModes
33
+ if (caps.charts === 'native') return ['native', 'table']
34
+ if (caps.charts === 'image') return ['image', 'table']
35
+ return ['table']
36
+ }
37
+
38
+ /**
39
+ * Bring a stored channel record to the current shape (idempotent, in place):
40
+ * platform, config, secretsSet; Lark legacy fields mirrored both ways.
41
+ */
42
+ export function migrateChannelRecord(c) {
43
+ if (!c || typeof c !== 'object') return c
44
+ const found = kindOf(c.type)
45
+ const platform = found?.kind.platform ?? (String(c.type ?? '').startsWith('lark_') ? 'lark' : c.platform)
46
+ if (platform) c.platform = platform
47
+ if (!c.config || typeof c.config !== 'object') {
48
+ const keys = found ? (found.kind.fields ?? []).filter(f => !(f.secret || f.type === 'secret')).map(f => f.key) : LARK_LEGACY
49
+ c.config = {}
50
+ for (const k of keys) if (c[k] !== undefined) c.config[k] = c[k]
51
+ if (platform === 'lark' && c.domain && !c.config.domain) c.config.domain = c.domain
52
+ }
53
+ if (!c.secretsSet || typeof c.secretsSet !== 'object') {
54
+ c.secretsSet = {}
55
+ if (c.type === 'lark_webhook') { if (c.hasSecret) c.secretsSet.webhookUrl = true; if (c.hasSign) c.secretsSet.signSecret = true }
56
+ else if (c.type === 'lark_app') { if (c.hasSecret) c.secretsSet.appSecret = true }
57
+ }
58
+ if (platform === 'lark') {
59
+ for (const k of LARK_LEGACY) { if (c.config[k] !== undefined) c[k] = c.config[k]; else delete c[k] }
60
+ c.hasSecret = Boolean(c.type === 'lark_webhook' ? c.secretsSet.webhookUrl : c.secretsSet.appSecret)
61
+ c.hasSign = Boolean(c.secretsSet.signSecret)
62
+ }
63
+ return c
64
+ }
65
+
66
+ /** Legacy flat Lark payload ({ webhookUrl, signSecret, appId, appSecret, receiveId… }) → { config, secrets }. */
67
+ function splitPayload(input, kind) {
68
+ const fields = kind.fields ?? []
69
+ const secretSet = new Set(secretKeys(kind))
70
+ const config = input.config && typeof input.config === 'object' ? { ...input.config } : {}
71
+ const secrets = {}
72
+ const rawSecrets = input.secrets && typeof input.secrets === 'object' ? input.secrets : {}
73
+ for (const f of fields) {
74
+ if (secretSet.has(f.key)) {
75
+ const v = Object.hasOwn(rawSecrets, f.key) ? rawSecrets[f.key] : input[f.key]
76
+ if (v === null) secrets[f.key] = null
77
+ else if (typeof v === 'string' && v.trim() !== '') secrets[f.key] = v.trim()
78
+ // Legacy: an empty sign secret with clearSign removes it.
79
+ else if (typeof v === 'string' && f.key === 'signSecret' && input.clearSign) secrets[f.key] = null
80
+ } else if (!(f.key in config) && input[f.key] !== undefined) {
81
+ config[f.key] = input[f.key]
82
+ }
83
+ }
84
+ if (kind.platform === 'lark' && !('domain' in config) && input.domain !== undefined) config.domain = input.domain
85
+ return { config, secrets }
86
+ }
87
+
88
+ export const methods = {
89
+ // ---------------------------------------------------------- channels
90
+
91
+ publicChannel(c) {
92
+ migrateChannelRecord(c)
93
+ const found = kindOf(c.type)
94
+ const kind = found?.kind
95
+ const required = kind ? (kind.fields ?? []).filter(f => (f.secret || f.type === 'secret') && f.required).map(f => f.key) : []
96
+ const out = {
97
+ ...pick(c, ['id', 'name', 'type', 'platform', 'description', 'isDefault', 'createdAt', 'updatedAt', 'lastSentAt', 'lastError', 'chartMode']),
98
+ config: structuredClone(c.config ?? {}),
99
+ secretsSet: { ...(c.secretsSet ?? {}) },
100
+ hasSecret: c.platform === 'lark' ? Boolean(c.hasSecret) : required.every(k => c.secretsSet?.[k]),
101
+ capabilities: kind ? { ...kind.capabilities, chartModes: chartModesOf(kind) } : null,
102
+ ...(c.needsSecret ? { needsSecret: true } : {}),
103
+ ...(found ? {} : { unsupported: true }),
104
+ }
105
+ if (c.platform === 'lark') Object.assign(out, pick(c, LARK_LEGACY), { hasSign: Boolean(c.hasSign) })
106
+ if (kind?.capabilities?.bot) {
107
+ const bot = { ...BOT_DEFAULTS, ...(c.bot ?? {}) }
108
+ 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 }
109
+ }
110
+ return out
111
+ },
112
+
113
+ listChannels() {
114
+ return this.state.channels.map(c => this.publicChannel(c))
115
+ },
116
+
117
+ async channelCatalog({ locale } = {}) {
118
+ await loadAdapters()
119
+ return listCatalog(locale)
120
+ },
121
+
122
+ findChannel(ref) {
123
+ if (!ref) return this.state.channels.find(c => c.isDefault) ?? (this.state.channels.length === 1 ? this.state.channels[0] : undefined)
124
+ return this.state.channels.find(c => c.id === ref) ?? this.state.channels.find(c => c.name === ref)
125
+ },
126
+
127
+ requireChannel(ref) {
128
+ const c = this.findChannel(ref)
129
+ if (!c) throw new UserError('nl2sql/unknown-channel', ref ? `推送渠道不存在:${ref}` : `没有可用的推送渠道。${this.state.channels.length ? `可用渠道:${this.state.channels.map(x => x.name).join('、')}` : '请用户在「数据问答 → 推送」页面添加 IM 渠道(飞书 / Telegram / 企业微信 / 钉钉)。'}`)
130
+ return migrateChannelRecord(c)
131
+ },
132
+
133
+ channelSecretId(c) {
134
+ return `ch-${String(typeof c === 'string' ? c : c.id).replace(/^ch-/, '')}`
135
+ },
136
+
137
+ channelSecretKey(id) {
138
+ return this.credentialKey(this.channelSecretId(id))
139
+ },
140
+
141
+ async channelSecrets(c) {
142
+ return this.readSecrets(this.channelSecretId(c))
143
+ },
144
+
145
+ /** The channel as adapters see it: config fields at the top level too. */
146
+ adapterChannel(c) {
147
+ migrateChannelRecord(c)
148
+ return { ...c, ...(c.config ?? {}) }
149
+ },
150
+
151
+ /**
152
+ * Create or update a channel. Accepts the contract payload
153
+ * ({ id?, name, type, description?, isDefault?, chartMode?, config, secrets, bot? })
154
+ * and the legacy flat Lark payload (webhookUrl, signSecret, appId, appSecret, receiveId…).
155
+ * Secrets omitted or '' keep their stored value; null clears one.
156
+ */
157
+ async saveChannel(input = {}) {
158
+ await loadAdapters()
159
+ const existing = input.id ? this.state.channels.find(c => c.id === input.id) : undefined
160
+ if (input.id && !existing) throw new UserError('nl2sql/unknown-channel', '推送渠道不存在')
161
+ if (existing) migrateChannelRecord(existing)
162
+ const c = existing ? structuredClone(existing) : { id: newId('ch'), createdAt: Date.now() }
163
+ c.name = requireText(input.name ?? c.name, 'name', 64)
164
+ if (this.state.channels.some(x => x.name === c.name && x.id !== c.id)) throw new UserError('nl2sql/duplicate', `渠道名称已存在:${c.name}`)
165
+ const type = input.type ?? c.type ?? 'lark_webhook'
166
+ let found
167
+ try { found = requireKind(type) } catch (error) { throw new UserError('nl2sql/invalid', error.message) }
168
+ const { adapter, kind } = found
169
+ const typeChanged = Boolean(existing && existing.type !== type)
170
+ c.type = type
171
+ c.platform = adapter.id
172
+ c.description = String(input.description ?? c.description ?? '').slice(0, 300)
173
+ const { config: configIn, secrets: changed } = splitPayload(input, { ...kind, platform: adapter.id })
174
+ const stored = existing && !typeChanged ? await this.channelSecrets(existing) : {}
175
+ const keys = secretKeys(kind)
176
+ const merged = {}
177
+ for (const k of keys) {
178
+ if (Object.hasOwn(changed, k)) { if (changed[k] !== null) merged[k] = changed[k] } else if (stored[k] !== undefined) merged[k] = stored[k]
179
+ }
180
+ let validated
181
+ try {
182
+ validated = await kind.validate({ name: c.name, config: { ...(typeChanged ? {} : c.config ?? {}), ...configIn }, secrets: merged }, existing && !typeChanged ? { config: existing.config ?? {}, secrets: stored } : undefined)
183
+ } catch (error) {
184
+ throw error instanceof ChannelError ? channelUserError(error, 'nl2sql/invalid') : error
185
+ }
186
+ c.config = validated.config ?? {}
187
+ const finalSecrets = {}
188
+ for (const k of keys) if (validated.secrets?.[k] !== undefined && validated.secrets[k] !== null && validated.secrets[k] !== '') finalSecrets[k] = validated.secrets[k]
189
+ // Write only what changed; a type change also drops secrets of the old kind.
190
+ const patch = {}
191
+ const before = existing ? await this.channelSecrets(existing) : {}
192
+ for (const k of new Set([...Object.keys(before), ...keys])) {
193
+ const next = keys.includes(k) ? finalSecrets[k] : undefined
194
+ if (next !== before[k]) patch[k] = next === undefined ? null : next
195
+ }
196
+ if (Object.keys(patch).length) await this.writeSecrets(this.channelSecretId(c), patch)
197
+ c.secretsSet = Object.fromEntries(Object.keys(finalSecrets).map(k => [k, true]))
198
+ delete c.needsSecret
199
+ const modes = chartModesOf(kind)
200
+ c.chartMode = modes.includes(input.chartMode) ? input.chartMode : modes.includes(c.chartMode) ? c.chartMode : modes[0]
201
+ if (adapter.id !== 'lark') for (const k of [...LARK_LEGACY, 'hasSecret', 'hasSign']) delete c[k]
202
+ if (kind.capabilities?.bot) {
203
+ if (input.bot && typeof input.bot === 'object') c.bot = this.normalizeBotConfig(input.bot, c.bot)
204
+ } else {
205
+ delete c.bot
206
+ }
207
+ migrateChannelRecord(c)
208
+ if (input.isDefault || !this.state.channels.some(x => x.isDefault && x.id !== c.id)) {
209
+ for (const x of this.state.channels) x.isDefault = false
210
+ c.isDefault = true
211
+ }
212
+ c.updatedAt = Date.now()
213
+ this.state.channels = upsert(this.state.channels, c)
214
+ await this.save()
215
+ // Credentials or bot settings may have changed: restart (or stop) its bot.
216
+ 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))
217
+ return this.publicChannel(c)
218
+ },
219
+
220
+ async deleteChannel(id) {
221
+ const c = this.state.channels.find(x => x.id === id)
222
+ if (!c) return { deleted: id }
223
+ await this.stopBot(id)
224
+ this.state.channels = this.state.channels.filter(x => x.id !== id)
225
+ if (c.isDefault && this.state.channels[0]) this.state.channels[0].isDefault = true
226
+ for (const r of this.state.reports) if (r.channelId === id) delete r.channelId
227
+ try { await this.ctx.get('credentials')?.deleteRecord(this.channelSecretKey(id)) } catch {}
228
+ await this.save()
229
+ return { deleted: id }
230
+ },
231
+
232
+ /**
233
+ * Chats/groups a channel's bot can reach: a saved channel, or unsaved
234
+ * credentials from the form (nothing is stored). → [{ id, name, type }]
235
+ */
236
+ async listChannelTargets({ id, type, config, secrets, ...flat } = {}) {
237
+ await loadAdapters()
238
+ const saved = id ? this.state.channels.find(c => c.id === id) : undefined
239
+ if (id && !saved) throw new UserError('nl2sql/unknown-channel', '推送渠道不存在')
240
+ const found = kindOf(type ?? saved?.type ?? (flat.appId ? 'lark_app' : undefined))
241
+ if (!found) throw new UserError('nl2sql/invalid', `不支持的渠道类型:${type ?? saved?.type ?? '?'}`)
242
+ const { kind } = found
243
+ if (!kind.listTargets) throw new UserError('nl2sql/invalid', `「${kind.label?.zh ?? kind.id}」不支持列出群聊`)
244
+ const given = splitPayload({ ...flat, config, secrets }, { ...kind, platform: found.adapter.id })
245
+ const base = saved && saved.type === kind.id ? this.adapterChannel(saved) : { type: kind.id, platform: found.adapter.id, config: {} }
246
+ const channel = { ...base, ...given.config, config: { ...(base.config ?? {}), ...given.config } }
247
+ const storedSecrets = saved && saved.type === kind.id ? await this.channelSecrets(saved) : {}
248
+ const allSecrets = { ...storedSecrets }
249
+ for (const [k, v] of Object.entries(given.secrets)) if (v) allSecrets[k] = v
250
+ for (const f of (kind.fields ?? []).filter(x => (x.secret || x.type === 'secret') && x.required)) {
251
+ if (!allSecrets[f.key]) throw Object.assign(new UserError('nl2sql/invalid', `请先填写 ${f.label?.zh ?? f.key}`), { field: f.key })
252
+ }
253
+ try {
254
+ return await kind.listTargets(channel, allSecrets)
255
+ } catch (error) {
256
+ throw channelUserError(error)
257
+ }
258
+ },
259
+
260
+ /** Legacy: chats of a Lark app bot as [{ chatId, name, description }]. */
261
+ async listChannelChats({ id, appId, appSecret, domain } = {}) {
262
+ const saved = id ? this.state.channels.find(c => c.id === id) : undefined
263
+ if (saved && saved.type !== 'lark_app') throw new UserError('nl2sql/invalid', '只有应用机器人渠道可以列出群聊')
264
+ if (!saved) requireText(appId, 'appId', 64)
265
+ if (!appSecret && !saved) throw new UserError('nl2sql/invalid', '请先填写 App Secret')
266
+ const targets = await this.listChannelTargets({ id, type: 'lark_app', config: { ...(appId ? { appId } : {}), ...(domain ? { domain } : {}) }, secrets: appSecret ? { appSecret } : {} })
267
+ return targets.map(t => ({ chatId: t.id, name: t.name, description: t.description ?? '' }))
268
+ },
269
+
270
+ /**
271
+ * Check a channel: the kind's cheap auth check when it has one, plus a test
272
+ * message when it has none (or the check asks for one). → { ok, detail, … }
273
+ */
274
+ async testChannel(input) {
275
+ await loadAdapters()
276
+ const id = typeof input === 'string' ? input : input?.id
277
+ const c = this.requireChannel(id)
278
+ const found = kindOf(c.type)
279
+ if (!found) throw new UserError('nl2sql/invalid', `不支持的渠道类型:${c.type}`)
280
+ let checked
281
+ if (found.kind.test) {
282
+ try { checked = await found.kind.test(this.adapterChannel(c), await this.channelSecrets(c)) } catch (error) { throw channelUserError(error) }
283
+ if (checked && checked.ok === false) return { ok: false, detail: checked.detail ?? '' }
284
+ if (checked && checked.send === false) return { ok: true, detail: checked.detail ?? '' }
285
+ }
286
+ const sent = await this.sendToChannel({ channelId: c.id, title: '数据问答 · 推送测试', text: `这是一条测试消息。\n发送时间:${new Date().toLocaleString('zh-CN', { hour12: false })}`, source: 'test' })
287
+ return { ...sent, ok: true, detail: [checked?.detail, '测试消息已发送'].filter(Boolean).join(';') }
288
+ },
289
+
290
+ /**
291
+ * Send to an IM channel. Results are referenced by resultId (from this
292
+ * process' cache) or given inline; each is re-checked against privacy:
293
+ * masked values stay masked because results are stored masked.
294
+ */
295
+ async sendToChannel({ channelId, title, text, resultIds = [], results = [], includeSql = false, source = 'ui', sessionId, footer, chartMode, template, mentionAll }) {
296
+ await loadAdapters()
297
+ const c = this.requireChannel(channelId)
298
+ const resolved = []
299
+ const sender = this.actorFor(sessionId)
300
+ for (const id of resultIds.slice(0, 10)) {
301
+ const r = this.lookupResult(id)
302
+ if (!r) throw new UserError('nl2sql/result-expired', `结果 ${id} 已过期,请重新查询后再发送。`)
303
+ if (source === 'agent') this.assertResultAccess(r, sender)
304
+ resolved.push(r)
305
+ }
306
+ resolved.push(...results.slice(0, 10))
307
+ if (!text && resolved.length === 0) throw new UserError('nl2sql/invalid', '没有可发送的内容')
308
+ const found = kindOf(c.type)
309
+ if (!found) throw new UserError('nl2sql/invalid', `不支持的渠道类型:${c.type}(对应的渠道插件未加载)`)
310
+ const modes = chartModesOf(found.kind)
311
+ const body = text ? String(text).slice(0, 20000) : ''
312
+ const message = {
313
+ title: requireText(title || resolved[0]?.title || '数据问答', 'title', 100),
314
+ text: body,
315
+ chartMode: modes.includes(chartMode) ? chartMode : modes.includes(c.chartMode) ? c.chartMode : modes[0],
316
+ results: resolved.map(r => pick(r, ['title', 'columns', 'rows', 'rowCount', 'truncated', 'chart', 'sql', 'filters', 'error', ...(found.kind.capabilities?.buttons ? ['actions'] : [])])),
317
+ includeSql: Boolean(includeSql),
318
+ mentionAll: mentionAll ?? /<at\s+id\s*=\s*["']?all["']?\s*>/i.test(body),
319
+ ...(template ? { template } : {}),
320
+ footer: footer ?? `数据问答 · ${new Date().toLocaleString('zh-CN', { hour12: false })}${resolved[0]?.datasourceName ? ` · ${resolved[0].datasourceName}` : ''}`,
321
+ }
322
+ const started = Date.now()
323
+ try {
324
+ if (c.needsSecret) throw new ChannelError('channel/config', `渠道「${c.name}」需要重新填写密钥后才能使用`)
325
+ const sent = await found.kind.send(this.adapterChannel(c), await this.channelSecrets(c), message, { renderChartPng, logger: console, fetch: globalThis.fetch })
326
+ c.lastSentAt = Date.now()
327
+ delete c.lastError
328
+ this.recordDelivery({ channel: c, title: message.title, status: 'ok', results: resolved.length, images: sent?.images, source, sessionId, elapsedMs: Date.now() - started })
329
+ await this.save()
330
+ return { ok: true, channel: c.name, platform: c.platform, messageId: sent?.messageId ?? null, images: sent?.images ?? 0, results: resolved.length }
331
+ } catch (error) {
332
+ c.lastError = error.message
333
+ this.recordDelivery({ channel: c, title: message.title, status: 'error', error: error.message, results: resolved.length, source, sessionId, elapsedMs: Date.now() - started })
334
+ await this.save()
335
+ throw channelUserError(error)
336
+ }
337
+ },
338
+
339
+ recordDelivery({ channel, title, status, error, results, images, source, sessionId, elapsedMs }) {
340
+ 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, source, sessionId: sessionId ?? null, elapsedMs })
341
+ },
342
+
343
+ listDeliveries(limit = 100) {
344
+ return this.records.list('delivery', { limit: typeof limit === 'number' ? limit : limit?.limit ?? 100 })
345
+ },
346
+
347
+ /** Load optional adapters and migrate channel records (called once on start). */
348
+ async initChannels() {
349
+ await loadAdapters()
350
+ let changed = false
351
+ for (const c of this.state.channels ?? []) {
352
+ const before = JSON.stringify(c)
353
+ migrateChannelRecord(c)
354
+ if (JSON.stringify(c) !== before) changed = true
355
+ }
356
+ if (this.migrateLegacyBot()) changed = true
357
+ return changed
358
+ },
359
+
360
+ adapterFor(c) {
361
+ return getAdapter(c?.platform ?? kindOf(c?.type)?.adapter.id)
362
+ },
363
+ }