@hedeqiang/dsh-nl2sql 0.12.2 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }