@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
|
@@ -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
|
+
}
|