@hedeqiang/dsh-nl2sql 0.12.1 → 0.13.0

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,563 @@
1
+ /**
2
+ * Feature: subscriptions and alerts created from IM chats (bot sessions).
3
+ *
4
+ * A bot conversation (state.botConversations[*] = { sessionId, channelId, chatId,
5
+ * chatType, platform, chatName }) lets chat members say 「每天 9 点把这个发到群里」
6
+ * or 「超过 X 提醒我」. The agent calls:
7
+ * - nl2sql_subscribe → state.chatSubscriptions[] (own scheduler job `jobs_subscriptions`)
8
+ * - nl2sql_alert_create → a normal alert (saveAlert) marked `chatTarget` so the alert
9
+ * engine delivers to the originating chat and runs as the creator
10
+ * - nl2sql_subscriptions → list / pause / resume / cancel what this chat created
11
+ *
12
+ * Delivery goes to the originating chat through the channel's own adapter with a copy of
13
+ * the channel whose push target is replaced by the chat id (adapters are not modified).
14
+ * Every run re-resolves the creator (resolveActor): removed / disabled members, members
15
+ * who lost the role or the data source are skipped, the item is suspended (status +
16
+ * lastError) and the chat is told once.
17
+ */
18
+ import { needsAttachment, renderChartPng } from '../channels/message.js'
19
+ import { ChannelError } from '../channels/http.js'
20
+ import { kindOf, loadAdapters } from '../channels/index.js'
21
+ import { writeXlsx } from '../xlsx.js'
22
+ import { chartModesOf, clip, channelUserError } from './channels.js'
23
+ import { UserError, newId, pick } from './shared.js'
24
+
25
+ const PERIODS = ['daily', 'weekly', 'monthly']
26
+ const GRAINS = ['hour', 'day', 'week', 'month', 'year']
27
+ const RANGES = ['today', 'yesterday', 'last7d', 'last30d', 'last90d', 'thisMonth', 'lastMonth', 'thisYear']
28
+ const MAX_PER_CHAT = 30
29
+ const CREATOR_ROLES = ['admin', 'analyst']
30
+
31
+ /**
32
+ * Per-chat target fields of a channel kind, or undefined when the kind cannot
33
+ * address an arbitrary chat. Known kinds are explicit; other kinds (third-party
34
+ * adapters) use the field marked `target: true`.
35
+ */
36
+ export function chatTargetPatch(kind, chatId, { chatType, userId } = {}) {
37
+ const id = String(chatId)
38
+ switch (kind?.id) {
39
+ case 'lark_app': return { receiveIdType: 'chat_id', receiveId: id }
40
+ case 'telegram_bot': return { chatId: id }
41
+ case 'dingtalk_app': return chatType === 'p2p' ? (userId ? { openConversationId: '', userId: String(userId) } : undefined) : { openConversationId: id, userId: '' }
42
+ case 'wecom_aibot': return { chatId: id, chatType: chatType === 'p2p' ? 'single' : 'group' }
43
+ case 'lark_webhook': case 'wecom_webhook': case 'dingtalk_webhook': return undefined
44
+ default: {
45
+ const f = (kind?.fields ?? []).find(x => x.target && !(x.secret || x.type === 'secret'))
46
+ return f ? { [f.key]: id } : undefined
47
+ }
48
+ }
49
+ }
50
+
51
+ /** Most recent schedule slot (ms) at or before `now`, or undefined. */
52
+ export function lastSubscriptionSlot(sub, now = new Date()) {
53
+ const [hh, mm] = String(sub.time ?? '09:00').split(':').map(Number)
54
+ const base = new Date(now)
55
+ for (let k = 0; k <= 62; k++) {
56
+ const d = new Date(base.getFullYear(), base.getMonth(), base.getDate() - k, hh, mm, 0, 0)
57
+ if (d.getHours() !== hh || d.getMinutes() !== mm) continue // DST gap
58
+ if (d.getTime() > base.getTime()) continue
59
+ if (sub.period === 'weekly') {
60
+ const wd = d.getDay() === 0 ? 7 : d.getDay()
61
+ if (!(sub.weekdays ?? []).includes(wd)) continue
62
+ } else if (sub.period === 'monthly') {
63
+ const dim = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()
64
+ if (d.getDate() !== Math.min(sub.monthDay ?? 1, dim)) continue
65
+ }
66
+ return d.getTime()
67
+ }
68
+ return undefined
69
+ }
70
+
71
+ /** Next slot after `now` (for display). */
72
+ export function nextSubscriptionSlot(sub, now = new Date()) {
73
+ for (let k = 1; k <= 400; k++) {
74
+ const probe = new Date(now.getTime() + k * 86400000)
75
+ const s = lastSubscriptionSlot(sub, probe)
76
+ if (s !== undefined && s > now.getTime()) {
77
+ // The earliest slot after now: walk back while an earlier slot is still after now.
78
+ let best = s
79
+ for (let j = k - 1; j >= 0; j--) { const e = lastSubscriptionSlot(sub, new Date(now.getTime() + j * 86400000 + 86399999)); if (e !== undefined && e > now.getTime() && e < best) best = e }
80
+ return best
81
+ }
82
+ }
83
+ return undefined
84
+ }
85
+
86
+ function scheduleText(sub) {
87
+ const w = ['一', '二', '三', '四', '五', '六', '日']
88
+ if (sub.period === 'weekly') return `每周${(sub.weekdays ?? []).map(d => w[d - 1]).join('、')} ${sub.time}`
89
+ if (sub.period === 'monthly') return `每月 ${sub.monthDay} 日 ${sub.time}`
90
+ return `每天 ${sub.time}`
91
+ }
92
+
93
+ function hhmm(v) {
94
+ const m = /^(\d{1,2}):(\d{2})$/.exec(String(v ?? '').trim())
95
+ if (!m || Number(m[1]) > 23 || Number(m[2]) > 59) throw new UserError('nl2sql/invalid', '时间格式应为 HH:MM,例如 09:00')
96
+ return `${m[1].padStart(2, '0')}:${m[2]}`
97
+ }
98
+
99
+ export const methods = {
100
+ // ---------------------------------------------------------------- chat context
101
+
102
+ /** The bot conversation a session belongs to, or undefined (page / local sessions). */
103
+ chatForSession(sessionId) {
104
+ if (!sessionId) return undefined
105
+ return Object.values(this.state.botConversations ?? {}).filter(c => c && c.sessionId === sessionId && c.channelId && c.chatId != null).sort((a, b) => (b.lastAt ?? 0) - (a.lastAt ?? 0))[0]
106
+ },
107
+
108
+ /** Conversation + live actor of a bot session; throws a clear message elsewhere. */
109
+ requireChatContext(sessionId) {
110
+ const conv = this.chatForSession(sessionId)
111
+ if (!conv) throw new UserError('nl2sql/not-chat', '群订阅和群告警只能在 IM 机器人的聊天中创建。页面用户请在「数据问答 → 管理 → 告警与报告」中设置定时报告和告警。')
112
+ const sa = this.state.sessionActors?.[sessionId]
113
+ const platform = conv.platform ?? sa?.platform ?? 'lark'
114
+ let userId
115
+ let memberId
116
+ const member = sa?.id ? (this.state.users ?? []).find(u => u.id === sa.id) : undefined
117
+ if (member) {
118
+ memberId = member.id
119
+ userId = (member.identities ?? []).find(i => i.platform === platform)?.id
120
+ } else if (sa?.id && String(sa.id).startsWith(`${platform}:`)) {
121
+ userId = String(sa.id).slice(platform.length + 1)
122
+ }
123
+ const createdBy = { platform, userId: userId ?? null, name: sa?.name ?? userId ?? '?', memberId: memberId ?? null, email: member?.email }
124
+ const resolved = this.resolveCreator(createdBy, conv)
125
+ return { conv, platform, createdBy, ...resolved }
126
+ },
127
+
128
+ /** Re-resolve a creator now → { actor } | { refused }. */
129
+ resolveCreator(createdBy, chat) {
130
+ if (!createdBy?.userId && !createdBy?.email) return { refused: '无法确认创建者身份' }
131
+ const resolved = this.resolveActor({ platform: createdBy.platform, userId: createdBy.userId ?? undefined, email: createdBy.email, name: createdBy.name, chatId: chat?.chatId != null ? String(chat.chatId) : undefined, chatType: chat?.chatType ?? 'group' })
132
+ if (resolved.refused) return resolved
133
+ if (createdBy.memberId && resolved.actor.id !== createdBy.memberId) return { refused: `成员「${createdBy.name}」已被移除或停用` }
134
+ return resolved
135
+ },
136
+
137
+ /** The channel kind of a chat and its target patch; throws when the kind has no per-chat target. */
138
+ chatDeliveryTarget(channelId, chatId, opts = {}) {
139
+ const c = this.state.channels.find(x => x.id === channelId)
140
+ if (!c) throw new UserError('nl2sql/unknown-channel', '该聊天所属的机器人渠道已被删除')
141
+ const found = kindOf(c.type)
142
+ if (!found) throw new UserError('nl2sql/invalid', `不支持的渠道类型:${c.type}(对应的渠道插件未加载)`)
143
+ const patch = chatTargetPatch(found.kind, chatId, opts)
144
+ if (!patch) throw new UserError('nl2sql/invalid', `「${found.kind.label?.zh ?? found.kind.id}」渠道不能按聊天主动推送,无法在这里订阅或设置告警`)
145
+ return { c, kind: found.kind, patch }
146
+ },
147
+
148
+ /**
149
+ * Send an OutboundMessage-like `{ title, text, results?, footer?, mentionAll? }` to one chat
150
+ * of a bot channel: the kind's send() with a channel copy targeting `chatId`.
151
+ */
152
+ async deliverToChat(channelId, chatId, message, { chatType, userId, source = 'subscription', attachFull } = {}) {
153
+ await loadAdapters()
154
+ const { c, kind, patch } = this.chatDeliveryTarget(channelId, chatId, { chatType, userId })
155
+ const base = this.adapterChannel(c)
156
+ const channel = { ...base, ...patch, config: { ...(c.config ?? {}), ...patch } }
157
+ const modes = chartModesOf(kind)
158
+ const caps = kind.capabilities ?? {}
159
+ const results = (message.results ?? []).slice(0, 10)
160
+ const body = message.text ? String(message.text).slice(0, 20000) : ''
161
+ const out = {
162
+ title: clip(String(message.title || results[0]?.title || '').trim() || '数据问答', 100),
163
+ text: body,
164
+ chartMode: modes.includes(c.chartMode) ? c.chartMode : modes[0],
165
+ results: results.map(r => ({ ...pick(r, ['title', 'columns', 'rows', 'rowCount', 'truncated', 'chart', 'sql', 'filters', 'error']), ...(r.title ? { title: clip(String(r.title), 100) } : {}) })),
166
+ includeSql: false,
167
+ mentionAll: Boolean(message.mentionAll ?? /<at\s+id\s*=\s*["']?all["']?\s*>/i.test(body)),
168
+ footer: message.footer ?? `数据问答 · ${new Date().toLocaleString('zh-CN', { hour12: false })}`,
169
+ }
170
+ if (caps.files && (attachFull ?? c.attachFull ?? true)) {
171
+ const shown = caps.tableRows ?? 10
172
+ const idx = out.results.map((r, i) => (needsAttachment(r, shown) ? i : -1)).filter(i => i >= 0)
173
+ if (idx.length) {
174
+ const buffer = writeXlsx(idx.map(i => ({ name: out.results[i].title || `结果${i + 1}`, columns: out.results[i].columns, rows: out.results[i].rows })), { creator: 'DeepSeek Harness 数据问答' })
175
+ const safe = String(out.title).replace(/[\\/:*?"<>|\s]+/g, '_').slice(0, 60) || '数据'
176
+ out.attachment = { buffer, fileName: `${safe}_${new Date().toISOString().slice(0, 10)}.xlsx`, resultIndexes: idx }
177
+ }
178
+ }
179
+ const conv = Object.values(this.state.botConversations ?? {}).find(x => x?.channelId === channelId && String(x.chatId) === String(chatId))
180
+ const rec = { id: c.id, name: `${c.name} · ${conv?.chatName ?? chatId}`, platform: c.platform }
181
+ const started = Date.now()
182
+ try {
183
+ if (c.needsSecret) throw new ChannelError('channel/config', `渠道「${c.name}」需要重新填写密钥后才能使用`)
184
+ const sent = await kind.send(channel, await this.channelSecrets(c), out, { renderChartPng, logger: console, fetch: globalThis.fetch })
185
+ this.recordDelivery({ channel: rec, title: out.title, status: 'ok', results: out.results.length, images: sent?.images, files: sent?.files, ...(sent?.warning ? { error: sent.warning } : {}), source, elapsedMs: Date.now() - started })
186
+ return { ok: true, messageId: sent?.messageId ?? null, files: sent?.files ?? 0 }
187
+ } catch (error) {
188
+ this.recordDelivery({ channel: rec, title: out.title, status: 'error', error: error.message, results: out.results.length, source, elapsedMs: Date.now() - started })
189
+ throw channelUserError(error)
190
+ }
191
+ },
192
+
193
+ /** A previous result of this session usable by `actor` → its reusable definition. */
194
+ chatSourceFromResult(sessionId, resultId, actor) {
195
+ const r = this.lookupResult(resultId)
196
+ if (!r) throw new UserError('nl2sql/result-expired', `结果 ${resultId} 已过期,请重新查询后再订阅`)
197
+ const ofSession = this.records.list('history', { ref: r.datasourceId, since: (r.createdAt ?? 0) - 60000, limit: 5, filter: h => h.resultId === resultId })
198
+ if (ofSession.length && !ofSession.some(h => h.sessionId === sessionId)) throw new UserError('nl2sql/access-denied', '只能订阅本聊天中查询过的结果')
199
+ this.assertResultAccess(r, actor)
200
+ return { datasourceId: r.datasourceId, sql: r.baseSql ?? r.sql, chart: r.chart, title: r.title }
201
+ },
202
+
203
+ chatItemsOf(channelId, chatId) {
204
+ const same = (x) => x?.channelId === channelId && String(x.chatId) === String(chatId)
205
+ return {
206
+ subscriptions: (this.state.chatSubscriptions ?? []).filter(s => same(s)),
207
+ alerts: (this.state.alerts ?? []).filter(a => same(a.chatTarget)),
208
+ }
209
+ },
210
+
211
+ // ---------------------------------------------------------------- create
212
+
213
+ async createChatSubscription(sessionId, input = {}) {
214
+ const ctx = this.requireChatContext(sessionId)
215
+ if (ctx.refused) throw new UserError('nl2sql/access-denied', ctx.refused)
216
+ const actor = ctx.actor
217
+ if (!CREATOR_ROLES.includes(actor.role)) throw new UserError('nl2sql/viewer', '只有管理员或分析师可以创建订阅;只读成员可以用「我的订阅」查看本群的订阅。')
218
+ const { conv } = ctx
219
+ this.chatDeliveryTarget(conv.channelId, conv.chatId, { chatType: conv.chatType, userId: ctx.createdBy.userId })
220
+ if (this.chatItemsOf(conv.channelId, conv.chatId).subscriptions.length >= MAX_PER_CHAT) throw new UserError('nl2sql/invalid', `每个聊天最多 ${MAX_PER_CHAT} 个订阅,请先取消不需要的订阅`)
221
+ const sub = { id: newId('sub'), createdAt: Date.now(), status: 'active', channelId: conv.channelId, chatId: String(conv.chatId), chatType: conv.chatType ?? 'group', platform: ctx.platform, chatName: conv.chatName ?? null, createdBy: ctx.createdBy }
222
+ if (input.resultId) {
223
+ Object.assign(sub, { source: 'sql' }, this.chatSourceFromResult(sessionId, input.resultId, { ...actor, kind: ctx.platform }))
224
+ } else if (input.metric) {
225
+ const ds = this.datasourceForSession(sessionId, input.datasource)
226
+ this.assertAccess(actor, ds)
227
+ const m = this.findMetric(ds, input.metric)
228
+ if (!m) throw new UserError('nl2sql/unknown-metric', `指标库中没有「${input.metric}」`)
229
+ Object.assign(sub, { source: 'metric', datasourceId: ds.id, metric: m.name, grain: input.grain && GRAINS.includes(input.grain) ? input.grain : undefined, range: input.range && RANGES.includes(input.range) ? input.range : (input.grain ? 'last30d' : 'yesterday'), title: m.name })
230
+ } else {
231
+ throw new UserError('nl2sql/invalid', '请提供要订阅的 resultId(本聊天中之前的查询结果)或指标名 metric')
232
+ }
233
+ const ds = this.requireDatasource(sub.datasourceId)
234
+ this.assertAccess(actor, ds)
235
+ sub.title = String(input.title || sub.title || '数据订阅').slice(0, 80)
236
+ sub.period = PERIODS.includes(input.period) ? input.period : 'daily'
237
+ sub.time = hhmm(input.time ?? '09:00')
238
+ if (sub.period === 'weekly') {
239
+ sub.weekdays = [...new Set((Array.isArray(input.weekdays) ? input.weekdays : [1]).map(Number).filter(d => d >= 1 && d <= 7))].sort()
240
+ if (!sub.weekdays.length) throw new UserError('nl2sql/invalid', 'weekdays 应为 1(周一)到 7(周日)')
241
+ }
242
+ if (sub.period === 'monthly') {
243
+ sub.monthDay = Math.trunc(Number(input.monthDay ?? 1))
244
+ if (!(sub.monthDay >= 1 && sub.monthDay <= 31)) throw new UserError('nl2sql/invalid', 'monthDay 应为 1–31')
245
+ }
246
+ this.state.chatSubscriptions = [...(this.state.chatSubscriptions ?? []), sub]
247
+ this.auditAdmin('sub.create', `${sub.title} · ${scheduleText(sub)} → ${sub.chatName ?? sub.chatId}`, actor)
248
+ await this.save()
249
+ return this.publicSubscription(sub)
250
+ },
251
+
252
+ async createChatAlert(sessionId, input = {}) {
253
+ const ctx = this.requireChatContext(sessionId)
254
+ if (ctx.refused) throw new UserError('nl2sql/access-denied', ctx.refused)
255
+ const actor = ctx.actor
256
+ if (!CREATOR_ROLES.includes(actor.role)) throw new UserError('nl2sql/viewer', '只有管理员或分析师可以设置告警;只读成员可以用「我的订阅」查看本群的告警。')
257
+ const { conv } = ctx
258
+ this.chatDeliveryTarget(conv.channelId, conv.chatId, { chatType: conv.chatType, userId: ctx.createdBy.userId })
259
+ if (this.chatItemsOf(conv.channelId, conv.chatId).alerts.length >= MAX_PER_CHAT) throw new UserError('nl2sql/invalid', `每个聊天最多 ${MAX_PER_CHAT} 个告警`)
260
+ const def = {}
261
+ if (input.resultId) {
262
+ const s = this.chatSourceFromResult(sessionId, input.resultId, { ...actor, kind: ctx.platform })
263
+ Object.assign(def, { source: 'sql', datasourceId: s.datasourceId, sql: s.sql, name: input.name || s.title })
264
+ } else if (input.metric) {
265
+ const ds = this.datasourceForSession(sessionId, input.datasource)
266
+ Object.assign(def, { source: 'metric', datasourceId: ds.id, metric: input.metric, grain: input.grain ?? 'day', name: input.name || input.metric })
267
+ } else {
268
+ throw new UserError('nl2sql/invalid', '请提供要监控的 resultId(本聊天中之前的查询结果)或指标名 metric')
269
+ }
270
+ this.assertAccess(actor, this.requireDatasource(def.datasourceId))
271
+ const cond = input.condition ?? {}
272
+ const condition = { type: cond.type ?? 'threshold' }
273
+ if (condition.type === 'threshold') Object.assign(condition, { op: cond.op ?? '>', value: Number(cond.value ?? 0) })
274
+ if (condition.type === 'change') Object.assign(condition, { direction: cond.direction ?? 'any', pct: Number(cond.pct ?? 20) })
275
+ if (condition.type === 'anomaly') Object.assign(condition, { z: Number(cond.z ?? 3) })
276
+ const saved = await this.saveAlert({
277
+ ...def, name: String(def.name || '群告警').slice(0, 60), condition,
278
+ every: input.every ?? 'hour', at: input.at, cooldownMin: input.cooldownMin ?? 60, channelId: null, mention: false, enabled: true,
279
+ })
280
+ const stored = (this.state.alerts ?? []).find(a => a.id === saved.id) ?? saved
281
+ stored.chatTarget = { channelId: conv.channelId, chatId: String(conv.chatId), chatType: conv.chatType ?? 'group', platform: ctx.platform, chatName: conv.chatName ?? null, mentionUser: Boolean(input.mentionMe) }
282
+ stored.createdBy = ctx.createdBy
283
+ this.auditAdmin('sub.alert.create', `${stored.name} → ${conv.chatName ?? conv.chatId}`, actor)
284
+ await this.save()
285
+ return this.publicChatAlert(stored)
286
+ },
287
+
288
+ // ---------------------------------------------------------------- views
289
+
290
+ publicSubscription(s) {
291
+ const ch = this.state.channels.find(c => c.id === s.channelId)
292
+ return {
293
+ kind: 'subscription', ...s, schedule: scheduleText(s),
294
+ channelName: ch?.name ?? null, datasourceName: this.findDatasource(s.datasourceId)?.name ?? s.datasourceId,
295
+ nextAt: s.status === 'active' ? (nextSubscriptionSlot(s) ?? null) : null,
296
+ }
297
+ },
298
+
299
+ publicChatAlert(a) {
300
+ const ch = this.state.channels.find(c => c.id === a.chatTarget?.channelId)
301
+ const cond = a.condition ?? {}
302
+ const condText = cond.type === 'threshold' ? `${cond.op} ${cond.value}` : cond.type === 'change' ? `变化 ${cond.direction ?? 'any'} ≥ ${cond.pct}%` : cond.type === 'anomaly' ? `异常 Z ≥ ${cond.z ?? 3}` : cond.type
303
+ return {
304
+ kind: 'alert', id: a.id, title: a.name, channelId: a.chatTarget?.channelId, chatId: a.chatTarget?.chatId, chatName: a.chatTarget?.chatName ?? null, platform: a.chatTarget?.platform,
305
+ createdBy: a.createdBy ?? null, createdAt: a.createdAt, datasourceId: a.datasourceId, datasourceName: this.findDatasource(a.datasourceId)?.name ?? a.datasourceId,
306
+ schedule: { '15m': '每 15 分钟检查', hour: '每小时检查', day: `每天 ${a.at ?? '09:00'} 检查` }[a.every] ?? a.every,
307
+ condition: a.condition, conditionText: condText, metric: a.metric ?? null,
308
+ status: a.chatStatus === 'suspended' ? 'suspended' : a.enabled ? 'active' : 'paused', state: a.state,
309
+ lastDeliveredAt: a.lastFiredAt ?? null, lastRunAt: a.lastCheckedAt ?? null, lastError: a.lastError ?? null, channelName: ch?.name ?? null,
310
+ }
311
+ },
312
+
313
+ /** Page: everything created from chats, grouped by chat. */
314
+ listChatSubscriptions() {
315
+ const groups = new Map()
316
+ const add = (item) => {
317
+ const key = `${item.channelId}:${item.chatId}`
318
+ const g = groups.get(key) ?? { key, channelId: item.channelId, chatId: item.chatId, chatName: item.chatName, platform: item.platform, channelName: item.channelName, items: [] }
319
+ if (!g.chatName && item.chatName) g.chatName = item.chatName
320
+ g.items.push(item)
321
+ groups.set(key, g)
322
+ }
323
+ for (const s of this.state.chatSubscriptions ?? []) add(this.publicSubscription(s))
324
+ for (const a of this.state.alerts ?? []) if (a.chatTarget) add(this.publicChatAlert(a))
325
+ return [...groups.values()]
326
+ },
327
+
328
+ /** pause | resume | delete one chat subscription or chat-created alert. */
329
+ async setChatSubscription({ id, action } = {}, actor) {
330
+ const sub = (this.state.chatSubscriptions ?? []).find(s => s.id === id)
331
+ const alert = sub ? undefined : (this.state.alerts ?? []).find(a => a.id === id && a.chatTarget)
332
+ if (!sub && !alert) throw new UserError('nl2sql/invalid', '订阅或告警不存在')
333
+ if (!['pause', 'resume', 'delete'].includes(action)) throw new UserError('nl2sql/invalid', 'action 应为 pause / resume / delete')
334
+ if (sub) {
335
+ if (action === 'delete') this.state.chatSubscriptions = this.state.chatSubscriptions.filter(s => s.id !== id)
336
+ else if (action === 'pause') sub.status = 'paused'
337
+ else { sub.status = 'active'; delete sub.lastError; delete sub.notifiedAt; sub.lastSlot = Math.max(sub.lastSlot ?? 0, lastSubscriptionSlot(sub, new Date()) ?? 0) }
338
+ this.auditAdmin(`sub.${action}`, sub.title, actor)
339
+ await this.save()
340
+ return action === 'delete' ? { deleted: id } : this.publicSubscription(sub)
341
+ }
342
+ if (action === 'delete') { this.auditAdmin('sub.alert.delete', alert.name, actor); return this.deleteAlert(id) }
343
+ alert.enabled = action === 'resume'
344
+ if (action === 'resume') { delete alert.chatStatus; delete alert.lastError; delete alert.chatNotifiedAt }
345
+ this.auditAdmin(`sub.alert.${action}`, alert.name, actor)
346
+ await this.save()
347
+ this.scheduleAlerts()
348
+ return this.publicChatAlert(alert)
349
+ },
350
+
351
+ // ---------------------------------------------------------------- run
352
+
353
+ /** Tell a chat (once per suspension) that one of its items stopped. */
354
+ async notifyChatOnce(item, target, text) {
355
+ if (item.notifiedAt) return
356
+ item.notifiedAt = Date.now()
357
+ try { await this.deliverToChat(target.channelId, target.chatId, { title: '⏸ 订阅已暂停', text }, { chatType: target.chatType, userId: item.createdBy?.userId, source: 'subscription' }) } catch (error) { console.warn('nl2sql sub: notify failed: %s', error.message) }
358
+ },
359
+
360
+ /** Actor allowed to run an item now, or { refused } with the reason. */
361
+ creatorCheck(createdBy, target, datasourceId) {
362
+ const r = this.resolveCreator(createdBy, target)
363
+ if (r.refused) return r
364
+ if (!CREATOR_ROLES.includes(r.actor.role)) return { refused: `成员「${createdBy.name}」已不是管理员或分析师` }
365
+ const ds = this.findDatasource(datasourceId)
366
+ if (!ds) return { refused: '数据源已被删除' }
367
+ try { this.assertAccess(r.actor, ds) } catch (error) { return { refused: error.message } }
368
+ return { actor: { ...r.actor, kind: createdBy.platform } }
369
+ },
370
+
371
+ async runSubscription(id, { now = new Date(), slot } = {}) {
372
+ const sub = (this.state.chatSubscriptions ?? []).find(s => s.id === id)
373
+ if (!sub) throw new UserError('nl2sql/invalid', '订阅不存在')
374
+ sub.lastSlot = slot ?? lastSubscriptionSlot(sub, now) ?? now.getTime()
375
+ sub.lastRunAt = Date.now()
376
+ const check = this.creatorCheck(sub.createdBy, sub, sub.datasourceId)
377
+ if (check.refused) {
378
+ sub.status = 'suspended'
379
+ sub.lastError = check.refused
380
+ await this.notifyChatOnce(sub, sub, `订阅「${sub.title}」已暂停:${check.refused}。请管理员在「数据问答 → 管理 → 群订阅」中处理。`)
381
+ await this.save()
382
+ return { skipped: true, reason: check.refused }
383
+ }
384
+ const actor = check.actor
385
+ try {
386
+ const result = sub.source === 'metric'
387
+ ? await this.queryMetric({ datasourceId: sub.datasourceId, metric: sub.metric, grain: sub.grain, range: sub.range, title: sub.title, source: 'subscription', actor })
388
+ : await this.runQuery({ sql: sub.sql, datasourceId: sub.datasourceId, title: sub.title, chart: sub.chart, source: 'subscription', actor, confirmScan: true })
389
+ await this.deliverToChat(sub.channelId, sub.chatId, {
390
+ title: `📬 ${sub.title}`,
391
+ text: `<font color='grey'>${scheduleText(sub)} · 数据源:${result.datasourceName ?? ''} · 订阅人:${sub.createdBy?.name ?? ''}</font>`,
392
+ results: [result],
393
+ footer: `数据问答 · 群订阅 · ${new Date().toLocaleString('zh-CN', { hour12: false })}`,
394
+ }, { chatType: sub.chatType, userId: sub.createdBy?.userId, source: 'subscription' })
395
+ sub.lastDeliveredAt = Date.now()
396
+ delete sub.lastError
397
+ await this.save()
398
+ return { delivered: true, resultId: result.resultId }
399
+ } catch (error) {
400
+ sub.lastError = error.message
401
+ await this.save()
402
+ throw error
403
+ }
404
+ },
405
+
406
+ jobs_subscriptions(now = new Date()) {
407
+ return (this.state.chatSubscriptions ?? []).filter(s => s.status === 'active').map(s => ({
408
+ id: `sub:${s.id}`,
409
+ due: (n) => {
410
+ const slot = lastSubscriptionSlot(s, n)
411
+ return slot !== undefined && slot >= (s.createdAt ?? 0) && slot > (s.lastSlot ?? 0)
412
+ },
413
+ run: () => this.runSubscription(s.id, { now, slot: lastSubscriptionSlot(s, now) }),
414
+ }))
415
+ },
416
+
417
+ // ---------------------------------------------------------------- alert engine hooks
418
+
419
+ /**
420
+ * Called by checkAlertInner for alerts with `chatTarget`: the creator's actor now.
421
+ * Throws (after suspending the alert and telling the chat once) when the creator
422
+ * lost access.
423
+ */
424
+ async chatAlertActor(a) {
425
+ const check = this.creatorCheck(a.createdBy ?? {}, a.chatTarget, a.datasourceId)
426
+ if (!check.refused) return check.actor
427
+ const cur = (this.state.alerts ?? []).find(x => x.id === a.id)
428
+ if (cur) {
429
+ cur.enabled = false
430
+ cur.chatStatus = 'suspended'
431
+ cur.lastError = check.refused
432
+ if (!cur.chatNotifiedAt) {
433
+ cur.chatNotifiedAt = Date.now()
434
+ try { await this.deliverToChat(cur.chatTarget.channelId, cur.chatTarget.chatId, { title: '⏸ 告警已暂停', text: `告警「${cur.name}」已暂停:${check.refused}。请管理员在「数据问答 → 管理 → 群订阅」中处理。` }, { chatType: cur.chatTarget.chatType, userId: cur.createdBy?.userId, source: 'alert' }) } catch (error) { console.warn('nl2sql sub: notify failed: %s', error.message) }
435
+ }
436
+ await this.save()
437
+ }
438
+ throw new UserError('nl2sql/access-denied', check.refused)
439
+ },
440
+
441
+ /** Push of a chat-created alert: same message as a channel push, to the chat. */
442
+ async deliverAlertToChat(a, { title, text, resultIds = [] }) {
443
+ const t = a.chatTarget
444
+ const who = t.mentionUser && a.createdBy?.userId ? `<at id=${a.createdBy.userId}>${a.createdBy.name ?? ''}</at> ` : ''
445
+ const results = resultIds.map(id => this.lookupResult(id)).filter(Boolean)
446
+ return this.deliverToChat(t.channelId, t.chatId, { title, text: `${who}${text ?? ''}`, results }, { chatType: t.chatType, userId: a.createdBy?.userId, source: 'alert' })
447
+ },
448
+ }
449
+
450
+ // ------------------------------------------------------------------ tools
451
+
452
+ function registerTools(ctx, service, { textBlock }) {
453
+ const fail = (fn) => async (args, exec) => {
454
+ try { return await fn(args ?? {}, exec) } catch (error) { throw error instanceof UserError ? new Error(error.message) : error }
455
+ }
456
+ const out = { schema: { type: 'object' }, render: (_args, value) => textBlock(JSON.stringify(value, null, 1)) }
457
+ const short = (x) => ({ id: x.id, kind: x.kind, title: x.title, schedule: x.schedule, ...(x.conditionText ? { condition: x.conditionText } : {}), datasource: x.datasourceName, status: x.status, createdBy: x.createdBy?.name, ...(x.lastError ? { lastError: x.lastError } : {}) })
458
+ ctx.tools.register({
459
+ name: 'nl2sql_subscribe',
460
+ description: 'Only in IM bot chats: subscribe the CURRENT chat to a scheduled delivery of a result ("每天 9 点把这个发到群里", "每周一早上推送周报"). Source: resultId of a previous query in this chat (its SQL, chart and title are re-run at each delivery) or a metric name from the metric library (+grain/range, relative ranges stay relative). Schedule: time HH:MM local, period daily | weekly (weekdays 1=Mon…7=Sun) | monthly (monthDay). Only admin/analyst members may create. Refused outside bot chats.',
461
+ parameters: {
462
+ type: 'object',
463
+ properties: {
464
+ resultId: { type: 'string', description: 'resultId of a previous nl2sql_query / nl2sql_metric result in this chat.' },
465
+ metric: { type: 'string', description: 'Metric name from the metric library (instead of resultId).' },
466
+ grain: { type: 'string', enum: GRAINS, description: 'Metric time bucket.' },
467
+ range: { type: 'string', enum: RANGES, description: 'Metric relative time range (default yesterday, or last30d with grain).' },
468
+ datasource: { type: 'string', description: 'Data source id or name for metric; default: this chat\'s.' },
469
+ title: { type: 'string', description: 'Short subscription title in the user\'s language.' },
470
+ time: { type: 'string', description: 'Delivery time HH:MM (24h, local).' },
471
+ period: { type: 'string', enum: PERIODS },
472
+ weekdays: { type: 'array', items: { type: 'integer' }, description: 'weekly: days 1=Mon … 7=Sun.' },
473
+ monthDay: { type: 'integer', description: 'monthly: day of month 1–31 (clamped to month length).' },
474
+ },
475
+ required: ['time', 'period'],
476
+ additionalProperties: false,
477
+ },
478
+ output: out,
479
+ presentCall: (args) => ({ card: 'generic', title: `订阅:${args?.title ?? args?.metric ?? ''}`, kind: 'edit' }),
480
+ execute: fail(async (args, exec) => {
481
+ const s = await service.createChatSubscription(exec.agent?.id, args)
482
+ return { created: short(s), note: `已订阅,${s.schedule} 推送到本聊天。` }
483
+ }),
484
+ })
485
+ ctx.tools.register({
486
+ name: 'nl2sql_alert_create',
487
+ description: 'Only in IM bot chats: create a data alert delivered to the CURRENT chat ("超过 X 提醒我", "下跌 20% 告诉我"). Source: resultId of a previous time-series query in this chat (first column time, last numeric column value) or a metric name (+grain). Condition: threshold {op, value} | change {direction up/down/any, pct} | anomaly {z}. every: 15m | hour | day (+at HH:MM). mentionMe=true @-mentions the asker. Only admin/analyst members may create.',
488
+ parameters: {
489
+ type: 'object',
490
+ properties: {
491
+ resultId: { type: 'string', description: 'resultId of a previous result in this chat.' },
492
+ metric: { type: 'string', description: 'Metric name (instead of resultId).' },
493
+ grain: { type: 'string', enum: ['hour', 'day', 'week', 'month'] },
494
+ datasource: { type: 'string', description: 'Data source id or name for metric.' },
495
+ name: { type: 'string', description: 'Short alert name.' },
496
+ condition: {
497
+ type: 'object',
498
+ properties: {
499
+ type: { type: 'string', enum: ['threshold', 'change', 'anomaly'] },
500
+ op: { type: 'string', enum: ['>', '>=', '<', '<=', '==', '!='] },
501
+ value: { type: 'number' },
502
+ direction: { type: 'string', enum: ['up', 'down', 'any'] },
503
+ pct: { type: 'number' },
504
+ z: { type: 'number' },
505
+ },
506
+ required: ['type'],
507
+ additionalProperties: false,
508
+ },
509
+ every: { type: 'string', enum: ['15m', 'hour', 'day'] },
510
+ at: { type: 'string', description: 'every=day: check time HH:MM.' },
511
+ cooldownMin: { type: 'integer', description: 'Minutes between repeated notifications (default 60).' },
512
+ mentionMe: { type: 'boolean', description: '@-mention the asker when it fires.' },
513
+ },
514
+ required: ['condition'],
515
+ additionalProperties: false,
516
+ },
517
+ output: out,
518
+ presentCall: (args) => ({ card: 'generic', title: `告警:${args?.name ?? args?.metric ?? ''}`, kind: 'edit' }),
519
+ execute: fail(async (args, exec) => {
520
+ const a = await service.createChatAlert(exec.agent?.id, args)
521
+ return { created: short(a), note: `已设置告警(${a.schedule}),触发时发到本聊天。` }
522
+ }),
523
+ })
524
+ ctx.tools.register({
525
+ name: 'nl2sql_subscriptions',
526
+ description: 'Only in IM bot chats: list the subscriptions and alerts created from the CURRENT chat ("我的订阅", "有哪些告警"), or pause / resume / cancel one by id ("取消订阅", "暂停这个告警"). Viewers may list only.',
527
+ parameters: {
528
+ type: 'object',
529
+ properties: {
530
+ action: { type: 'string', enum: ['list', 'pause', 'resume', 'cancel'] },
531
+ id: { type: 'string', description: 'Subscription or alert id (from list) for pause / resume / cancel.' },
532
+ },
533
+ required: ['action'],
534
+ additionalProperties: false,
535
+ },
536
+ output: out,
537
+ isConcurrencySafe: (args) => args?.action === 'list',
538
+ presentCall: (args) => ({ card: 'generic', title: args?.action === 'list' ? '本群订阅' : `订阅 · ${args?.action ?? ''}`, kind: args?.action === 'list' ? 'read' : 'edit' }),
539
+ execute: fail(async (args, exec) => {
540
+ const ctxc = service.requireChatContext(exec.agent?.id)
541
+ const { conv } = ctxc
542
+ const mine = service.chatItemsOf(conv.channelId, conv.chatId)
543
+ const items = [...mine.subscriptions.map(s => service.publicSubscription(s)), ...mine.alerts.map(a => service.publicChatAlert(a))]
544
+ if (args.action === 'list') return { chat: conv.chatName ?? conv.chatId, items: items.map(short) }
545
+ if (ctxc.refused) throw new UserError('nl2sql/access-denied', ctxc.refused)
546
+ if (!CREATOR_ROLES.includes(ctxc.actor.role)) throw new UserError('nl2sql/viewer', '只读成员只能查看订阅,不能暂停或取消。')
547
+ if (!args.id || !items.some(x => x.id === args.id)) throw new UserError('nl2sql/invalid', `本聊天没有 id 为「${args.id ?? ''}」的订阅或告警,请先用 action=list 查看`)
548
+ const r = await service.setChatSubscription({ id: args.id, action: args.action === 'cancel' ? 'delete' : args.action }, ctxc.actor)
549
+ return { done: args.action, id: args.id, ...(r.deleted ? {} : { status: r.status }) }
550
+ }),
551
+ })
552
+ }
553
+
554
+ export const FEATURE = {
555
+ name: 'subscriptions',
556
+ methods,
557
+ rpc: (service) => ({
558
+ listChatSubscriptions: () => service.listChatSubscriptions(),
559
+ setChatSubscription: (p) => service.setChatSubscription(p ?? {}),
560
+ runChatSubscription: (p) => service.runSubscription(p?.id),
561
+ }),
562
+ tools: registerTools,
563
+ }
@@ -202,8 +202,11 @@ export const methods = {
202
202
  fileName = fileName || staged.fileName
203
203
  raw = staged.raw
204
204
  }
205
+ fileName = String(fileName ?? '').trim().slice(0, 255)
206
+ if (!raw && !fileName) throw new UserError('nl2sql/invalid', '缺少文件名')
205
207
  let ds = datasourceId ? this.requireDatasource(datasourceId) : undefined
206
- if (!ds) ds = await this.saveDatasource({ type: 'sqlite', name: newName || String(fileName ?? '本地文件').replace(/\.[^.]+$/, '').slice(0, 60), description: `上传的文件:${fileName}` })
208
+ // The description is left empty for the user to fill in (it is shown to the model and in bot catalogs).
209
+ if (!ds) ds = await this.saveDatasource({ type: 'sqlite', name: newName || (fileName || 'uploaded-file').replace(/\.[^.]+$/, '').slice(0, 60) })
207
210
  if (ds.type !== 'sqlite') throw new UserError('nl2sql/invalid', '只能导入到「本地文件」类型的数据源')
208
211
  const ds2 = this.requireDatasource(ds.id)
209
212
  if (!raw) {
package/sql-guard.js CHANGED
@@ -34,7 +34,7 @@ const FORBIDDEN = [
34
34
  * Per-dialect lexical rules. Getting these exactly right matters: if the guard
35
35
  * thinks a quote is escaped when the server does not (or vice versa), text the
36
36
  * server executes would be hidden inside a "string".
37
- * mysql: `-- ` needs whitespace after it, `#` comments, backslash escapes in '…' and "…", `…` identifiers
37
+ * mysql: `--` starts a comment only before a char <= 0x20 or end of input, `#` comments, backslash escapes in '…' and "…", `…` identifiers
38
38
  * postgres: any `--` comments, no `#`, backslash escapes only inside E'…', $tag$…$tag$ dollar quoting, "…" identifiers
39
39
  * sqlite: any `--` comments, no `#`, no backslash escapes, "…" / `…` / […] identifiers
40
40
  * elasticsearch: any `--` comments, no `#`, no backslash escapes, "…" identifiers
@@ -52,6 +52,35 @@ export function dialectRules(dialect = 'mysql') {
52
52
  }
53
53
  }
54
54
 
55
+ /**
56
+ * MySQL starts a `--` comment only when the next character is a control
57
+ * character or space (code <= 0x20) or the input ends. Unicode spaces such as
58
+ * U+00A0 do NOT start a comment (JS `\s` would wrongly say they do).
59
+ */
60
+ export function dashDashCommentFollower(ch) {
61
+ return ch === undefined || ch.charCodeAt(0) <= 0x20
62
+ }
63
+
64
+ /**
65
+ * Characters refused outside string literals / quoted identifiers / comments:
66
+ * C0 controls other than \t \n \r, DEL, and any non-ASCII whitespace or
67
+ * format character. Servers disagree with each other (and with JS) about these,
68
+ * so they are a lexer-confusion hazard and never needed in real SQL.
69
+ */
70
+ const SUSPICIOUS_CODE_CHAR = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x80-\x9F\u00A0\u1680\u180E\u2000-\u200F\u2028-\u202F\u205F-\u2064\u3000\uFEFF]/
71
+
72
+ /** First suspicious character in the code (non-literal) parts of the SQL, or undefined. */
73
+ export function suspiciousCodeChar(sql, { dialect = 'mysql' } = {}) {
74
+ const { segments } = lexSql(sql, { dialect })
75
+ for (const s of segments) {
76
+ // Comments are checked too: a control character right after `--` is exactly the ambiguity being refused.
77
+ if (s.kind === 'string' || s.kind === 'ident') continue
78
+ const m = SUSPICIOUS_CODE_CHAR.exec(s.text)
79
+ if (m) return m[0]
80
+ }
81
+ return undefined
82
+ }
83
+
55
84
  /**
56
85
  * Tokenize SQL into code / string / identifier / comment segments using the
57
86
  * dialect's lexical rules. Unterminated strings, identifiers and comments are
@@ -72,7 +101,7 @@ export function lexSql(sql, { dialect = 'mysql' } = {}) {
72
101
  const c = sql[i]
73
102
  const n = sql[i + 1]
74
103
  // -- comments
75
- if (c === '-' && n === '-' && (!r.dashDashNeedsSpace || sql[i + 2] === undefined || /\s/.test(sql[i + 2]))) {
104
+ if (c === '-' && n === '-' && (!r.dashDashNeedsSpace || dashDashCommentFollower(sql[i + 2]))) {
76
105
  const start = i
77
106
  while (i < sql.length && sql[i] !== '\n') i++
78
107
  push('comment', start, i)
@@ -167,6 +196,8 @@ export function admitSql(input, { dialect = 'mysql' } = {}) {
167
196
  const { code, executableComment, unterminated } = stripSql(sql, { dialect })
168
197
  if (executableComment) throw new Error('MySQL executable comments (/*! ... */) are not allowed')
169
198
  if (unterminated) throw new Error('unterminated string, identifier or comment')
199
+ const bad = suspiciousCodeChar(sql, { dialect })
200
+ if (bad) throw new Error(`control or non-ASCII whitespace character U+${bad.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')} is only allowed inside string literals or quoted identifiers`)
170
201
  if (code.includes(';')) throw new Error('only one statement may be executed at a time')
171
202
  const head = (code.trim().match(/^\(*\s*([A-Za-z]+)/)?.[1] ?? '').toUpperCase()
172
203
  const allowed = ALLOWED_HEADS[dialect] ?? ALLOWED_HEADS.mysql