@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.
- package/CHANGELOG.md +30 -2
- package/README.en.md +33 -5
- package/README.md +32 -12
- package/channels/dingtalk.js +574 -0
- package/channels/http.js +63 -0
- package/channels/index.js +138 -0
- package/channels/lark.js +1208 -0
- package/channels/message.js +284 -0
- package/channels/telegram.js +679 -0
- package/channels/wecom.js +585 -0
- package/cordis.patch.yml +3 -3
- package/docs/CHANNELS.md +184 -0
- package/im.js +6 -509
- package/index.js +27 -18
- package/lark-bot.js +7 -604
- package/lib/client.js +1162 -199
- package/package.json +14 -9
- package/service/access.js +116 -22
- package/service/admin.js +29 -13
- package/service/bots.js +665 -0
- package/service/channels.js +379 -0
- package/service/datasources.js +31 -8
- package/service/push.js +7 -158
- package/service/shared.js +1 -1
- package/service/lark-actions.js +0 -223
- package/service/lark.js +0 -250
|
@@ -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
|
+
}
|
package/service/datasources.js
CHANGED
|
@@ -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 { ...
|
|
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
|
-
|
|
49
|
-
|
|
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
|
|
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
|
|
242
|
-
for (const c of Object.values(this.state.
|
|
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:
|
|
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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|