@mzzsfy/dsh-usage-panel 0.4.3
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/README.md +132 -0
- package/cordis.patch.yml +8 -0
- package/package.json +49 -0
- package/src/client.js +1424 -0
- package/src/history.mjs +117 -0
- package/src/historyStore.mjs +61 -0
- package/src/index.js +777 -0
- package/src/notify.mjs +424 -0
- package/src/parsers.mjs +327 -0
- package/src/poller.mjs +60 -0
- package/test/client-id.test.mjs +16 -0
- package/test/history.test.mjs +170 -0
- package/test/historyStore.test.mjs +107 -0
- package/test/notify-poll.test.mjs +31 -0
- package/test/notify.route.test.mjs +447 -0
- package/test/notify.test.mjs +498 -0
- package/test/parity.test.mjs +119 -0
- package/test/parsers.test.mjs +286 -0
- package/test/poller.test.mjs +88 -0
- package/test/route.test.mjs +171 -0
- package/test/switch-guard.test.mjs +32 -0
package/src/notify.mjs
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
// 通知纯逻辑层:规则合并 / 沿触发评估 / 投影 / 认领 / 配置校验。
|
|
2
|
+
// 无 IO 与 npm 依赖,host 半区与单测共用;时间经参数注入。
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_QUOTA_THRESHOLD_PCT = 90
|
|
5
|
+
export const PROJECTION_CAPACITY = 20
|
|
6
|
+
export const PROJECTION_TTL_MS = 60 * 1000
|
|
7
|
+
export const CLAIM_LOCK_TTL_MS = 30 * 1000
|
|
8
|
+
export const WEBHOOK_TIMEOUT_MS = 10 * 1000
|
|
9
|
+
|
|
10
|
+
export const KIND_QUOTA = 'quota'
|
|
11
|
+
export const KIND_BALANCE = 'balance'
|
|
12
|
+
export const KIND_RESET = 'reset'
|
|
13
|
+
// 全局通知默认值:默认关闭,余额阈值未配置即不评估。
|
|
14
|
+
export function defaultNotifySettings() {
|
|
15
|
+
return {
|
|
16
|
+
enabled: false,
|
|
17
|
+
quotaThresholdPct: DEFAULT_QUOTA_THRESHOLD_PCT,
|
|
18
|
+
balanceThreshold: null,
|
|
19
|
+
resetNotice: true,
|
|
20
|
+
toast: true,
|
|
21
|
+
webhookUrl: '',
|
|
22
|
+
imTargets: [],
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 账号可覆盖的字段:通道与总开关全局统一,账号仅覆盖规则本体。
|
|
27
|
+
const ACCOUNT_OVERRIDE_KEYS = ['quotaThresholdPct', 'balanceThreshold', 'resetNotice']
|
|
28
|
+
|
|
29
|
+
// 字段级合并:账号 notify 仅覆盖其设置的键,其余继承全局。
|
|
30
|
+
export function mergeAccountOverride(globalSettings, accountNotify) {
|
|
31
|
+
const source = accountNotify !== null && typeof accountNotify === 'object' ? accountNotify : {}
|
|
32
|
+
const merged = {}
|
|
33
|
+
for (const key of ACCOUNT_OVERRIDE_KEYS) merged[key] = globalSettings[key]
|
|
34
|
+
for (const key of ACCOUNT_OVERRIDE_KEYS) {
|
|
35
|
+
if (source[key] !== undefined) merged[key] = source[key]
|
|
36
|
+
}
|
|
37
|
+
return merged
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 账号沿触发状态:窗口按 label 建基线,余额默认武装;plain object 便于 JSON 持久化。
|
|
41
|
+
export function createNotifyState() {
|
|
42
|
+
return { windows: {}, balanceArmed: true }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 沿触发状态读侧归一:形态异常回新状态,窗口条目仅保留三字段防脏数据扩散。
|
|
46
|
+
export function normalizeNotifyState(raw) {
|
|
47
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return createNotifyState()
|
|
48
|
+
const state = createNotifyState()
|
|
49
|
+
state.balanceArmed = raw.balanceArmed !== false
|
|
50
|
+
const windows = typeof raw.windows === 'object' && raw.windows !== null && !Array.isArray(raw.windows) ? raw.windows : {}
|
|
51
|
+
for (const label of Object.keys(windows)) {
|
|
52
|
+
const entry = windows[label]
|
|
53
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue
|
|
54
|
+
state.windows[label] = {
|
|
55
|
+
resetsAt: typeof entry.resetsAt === 'string' ? entry.resetsAt : null,
|
|
56
|
+
// peak=null 是"无峰值"语义,不得经 Number(null) 伪装成 0
|
|
57
|
+
peak: entry.peak === null || entry.peak === undefined ? null : (Number.isFinite(Number(entry.peak)) ? Number(entry.peak) : null),
|
|
58
|
+
armed: entry.armed !== false,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return state
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const PERCENT_BASE = 100
|
|
65
|
+
|
|
66
|
+
// 沿触发评估:刷新读数越过逻辑点(阈值穿越/窗口重置)时产出事件,返回新状态不改入参。
|
|
67
|
+
// 仅在 last.ok 时由 host 调用;reading 缺失按无读数处理。
|
|
68
|
+
export function evaluateAccount({ account, rule, state, seq, ts }) {
|
|
69
|
+
const events = []
|
|
70
|
+
const reading = account.last && account.last.reading ? account.last.reading : null
|
|
71
|
+
const next = createNotifyState()
|
|
72
|
+
next.balanceArmed = state.balanceArmed !== false
|
|
73
|
+
if (reading === null) return { events, state: next }
|
|
74
|
+
if (reading.kind === 'quota') evaluateQuota({ account, rule, state, reading, events, next })
|
|
75
|
+
if (reading.kind === 'balance') evaluateBalance({ account, rule, state, reading, events, next })
|
|
76
|
+
return { events: events.map((event, index) => buildNotifyEvent(event, seq + index, ts)), state: next }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 窗口评估顺序:重置检测(重建基线并 re-arm)-> 峰值更新 -> 阈值判断。
|
|
80
|
+
// 基线以旧状态为底合并:上游偶发少返回某窗口时保留其基线,重现不误判新窗口。
|
|
81
|
+
function evaluateQuota({ account, rule, state, reading, events, next }) {
|
|
82
|
+
next.windows = { ...state.windows }
|
|
83
|
+
for (const window of reading.windows || []) {
|
|
84
|
+
const label = String(window.label || '')
|
|
85
|
+
if (label.length === 0) continue
|
|
86
|
+
const utilization = Number(window.utilization)
|
|
87
|
+
const resetsAt = typeof window.resetsAt === 'string' ? window.resetsAt : null
|
|
88
|
+
const prev = state.windows[label] || null
|
|
89
|
+
let armed = prev === null ? true : prev.armed !== false
|
|
90
|
+
let peak = prev === null ? null : prev.peak
|
|
91
|
+
// 窗口轮转:两侧 resetsAt 均有效且值不同;上一窗口峰值随 reset 事件上报
|
|
92
|
+
if (prev !== null && resetsAt !== null && prev.resetsAt !== null && prev.resetsAt !== resetsAt) {
|
|
93
|
+
if (rule.resetNotice !== false && prev.peak !== null) {
|
|
94
|
+
events.push({
|
|
95
|
+
kind: KIND_RESET,
|
|
96
|
+
accountId: account.id,
|
|
97
|
+
accountName: account.name,
|
|
98
|
+
label,
|
|
99
|
+
detail: { peak: prev.peak },
|
|
100
|
+
text: '[dsh] ' + account.name + ' ' + label + '窗口已重置,上一窗口峰值用量 ' + Math.round(prev.peak) + '%',
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
armed = true
|
|
104
|
+
peak = null
|
|
105
|
+
}
|
|
106
|
+
if (Number.isFinite(utilization)) {
|
|
107
|
+
peak = peak === null ? utilization : Math.max(peak, utilization)
|
|
108
|
+
if (utilization >= rule.quotaThresholdPct && armed) {
|
|
109
|
+
armed = false
|
|
110
|
+
events.push({
|
|
111
|
+
kind: KIND_QUOTA,
|
|
112
|
+
accountId: account.id,
|
|
113
|
+
accountName: account.name,
|
|
114
|
+
label,
|
|
115
|
+
detail: { value: utilization, threshold: rule.quotaThresholdPct },
|
|
116
|
+
text: '[dsh] ' + account.name + ' ' + label + '窗口用量达 ' + Math.round(utilization) + '%(阈值 ' + rule.quotaThresholdPct + '%)',
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
next.windows[label] = { resetsAt, peak, armed }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 可用余额口径:remaining 优先,缺失(null/undefined 同视)回落 total
|
|
125
|
+
// (deepseek 无 remaining,total 即余额);null 不得经 Number() 伪装成 0。
|
|
126
|
+
function availableOf(entry) {
|
|
127
|
+
const remaining = entry.remaining === null || entry.remaining === undefined ? NaN : Number(entry.remaining)
|
|
128
|
+
if (Number.isFinite(remaining)) return remaining
|
|
129
|
+
const total = entry.total === null || entry.total === undefined ? NaN : Number(entry.total)
|
|
130
|
+
return Number.isFinite(total) ? total : null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 余额评估:首个 entry 口径,下穿触发解除武装,回升到阈值上方恢复武装。
|
|
134
|
+
function evaluateBalance({ account, rule, state, reading, events, next }) {
|
|
135
|
+
const entry = (reading.entries || []).find((item) => availableOf(item) !== null)
|
|
136
|
+
if (entry === undefined || rule.balanceThreshold === null || rule.balanceThreshold === undefined) return
|
|
137
|
+
const available = availableOf(entry)
|
|
138
|
+
const threshold = Number(rule.balanceThreshold)
|
|
139
|
+
let armed = state.balanceArmed !== false
|
|
140
|
+
if (armed && available <= threshold) {
|
|
141
|
+
armed = false
|
|
142
|
+
const currency = String(entry.currency || '')
|
|
143
|
+
events.push({
|
|
144
|
+
kind: KIND_BALANCE,
|
|
145
|
+
accountId: account.id,
|
|
146
|
+
accountName: account.name,
|
|
147
|
+
label: null,
|
|
148
|
+
detail: { value: available, threshold, currency },
|
|
149
|
+
text: '[dsh] ' + account.name + ' 余额 ' + available + ' ' + currency + ',低于阈值 ' + threshold + ' ' + currency,
|
|
150
|
+
})
|
|
151
|
+
} else if (available > threshold) {
|
|
152
|
+
armed = true
|
|
153
|
+
}
|
|
154
|
+
next.balanceArmed = armed
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 事件构造唯一入口:评估产出与测试事件共用,防事件形态平行漂移。
|
|
158
|
+
export function buildNotifyEvent(event, seq, ts) {
|
|
159
|
+
return { ...event, id: 'un-' + ts.toString(36) + '-' + String(seq) + '-' + event.kind, ts }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---- IM 目标与配置校验 ----
|
|
163
|
+
|
|
164
|
+
// ID 规格与 dsh-im delivery-service 的 BOT_ID/TARGET_ID 一致,写侧拦截防落库后投递静默失败。
|
|
165
|
+
const IM_TARGETS_MAX = 16
|
|
166
|
+
const IM_BOT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
|
|
167
|
+
const IM_TARGET_ID_PATTERN = /^[A-Za-z0-9._:@-]{1,128}$/
|
|
168
|
+
|
|
169
|
+
// imTargets 读侧归一化:非数组回空,剔除形态非法项,仅保留两字段。
|
|
170
|
+
export function normalizeImTargets(raw) {
|
|
171
|
+
if (!Array.isArray(raw)) return []
|
|
172
|
+
return raw
|
|
173
|
+
.filter((item) => item !== null && typeof item === 'object' && !Array.isArray(item)
|
|
174
|
+
&& typeof item.botId === 'string' && item.botId.length > 0
|
|
175
|
+
&& typeof item.targetId === 'string' && item.targetId.length > 0)
|
|
176
|
+
.map(({ botId, targetId }) => ({ botId, targetId }))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 目标列表操作:与 client.js LOGIC 段同形,parity 测试锁定不漂移。
|
|
180
|
+
// botId/targetId 字符集均不含 '/',拼接键无歧义;与 dsh-im delivery-service 共用 ID 规格。
|
|
181
|
+
export function imTargetKey(item) {
|
|
182
|
+
return item.botId + '/' + item.targetId
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 勾选幂等:同一 botId+targetId 只保留一份;勾选追加到尾部,取消即移除。
|
|
186
|
+
export function toggleImTargetList(list, botId, targetId, checked) {
|
|
187
|
+
const wanted = { botId, targetId }
|
|
188
|
+
const rest = list.filter((item) => imTargetKey(item) !== imTargetKey(wanted))
|
|
189
|
+
return checked ? rest.concat([wanted]) : rest
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function removeImTargetFromList(list, botId, targetId) {
|
|
193
|
+
return list.filter((item) => imTargetKey(item) !== botId + '/' + targetId)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 取消注册:移除该 bot 全部目标。
|
|
197
|
+
export function unregisterImBotList(list, botId) {
|
|
198
|
+
return list.filter((item) => item.botId !== botId)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 已绑 bot:按首次绑定顺序去重。
|
|
202
|
+
export function imBoundBotIds(list) {
|
|
203
|
+
const botIds = []
|
|
204
|
+
for (const item of list) {
|
|
205
|
+
if (!botIds.includes(item.botId)) botIds.push(item.botId)
|
|
206
|
+
}
|
|
207
|
+
return botIds
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const WEBHOOK_SCHEMES = ['https:', 'http:']
|
|
211
|
+
|
|
212
|
+
function isValidWebhookUrl(value) {
|
|
213
|
+
if (typeof value !== 'string') return false
|
|
214
|
+
const trimmed = value.trim()
|
|
215
|
+
if (trimmed.length === 0) return true
|
|
216
|
+
try {
|
|
217
|
+
return WEBHOOK_SCHEMES.indexOf(new URL(trimmed).protocol) >= 0
|
|
218
|
+
} catch {
|
|
219
|
+
return false
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 全局通知配置写侧校验:顶层键白名单,值域内即归一通过。
|
|
224
|
+
export function validateNotifyPatch(patch) {
|
|
225
|
+
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) return { ok: false, reason: '补丁须为对象' }
|
|
226
|
+
const known = ['enabled', 'quotaThresholdPct', 'balanceThreshold', 'resetNotice', 'toast', 'webhookUrl', 'imTargets']
|
|
227
|
+
for (const key of Object.keys(patch)) {
|
|
228
|
+
if (known.indexOf(key) < 0) return { ok: false, reason: '未知配置项: ' + key }
|
|
229
|
+
}
|
|
230
|
+
const next = {}
|
|
231
|
+
if ('enabled' in patch) {
|
|
232
|
+
if (typeof patch.enabled !== 'boolean') return { ok: false, reason: 'enabled 须为布尔' }
|
|
233
|
+
next.enabled = patch.enabled
|
|
234
|
+
}
|
|
235
|
+
if ('quotaThresholdPct' in patch) {
|
|
236
|
+
const pct = patch.quotaThresholdPct
|
|
237
|
+
if (typeof pct !== 'number' || !Number.isFinite(pct) || pct <= 0 || pct > PERCENT_BASE) return { ok: false, reason: 'quotaThresholdPct 须为 (0,100] 内数值' }
|
|
238
|
+
next.quotaThresholdPct = pct
|
|
239
|
+
}
|
|
240
|
+
if ('balanceThreshold' in patch) {
|
|
241
|
+
const value = patch.balanceThreshold
|
|
242
|
+
if (value !== null && (typeof value !== 'number' || !Number.isFinite(value) || value < 0)) return { ok: false, reason: 'balanceThreshold 须为非负数值或 null' }
|
|
243
|
+
next.balanceThreshold = value
|
|
244
|
+
}
|
|
245
|
+
if ('resetNotice' in patch) {
|
|
246
|
+
if (typeof patch.resetNotice !== 'boolean') return { ok: false, reason: 'resetNotice 须为布尔' }
|
|
247
|
+
next.resetNotice = patch.resetNotice
|
|
248
|
+
}
|
|
249
|
+
if ('toast' in patch) {
|
|
250
|
+
if (typeof patch.toast !== 'boolean') return { ok: false, reason: 'toast 须为布尔' }
|
|
251
|
+
next.toast = patch.toast
|
|
252
|
+
}
|
|
253
|
+
if ('webhookUrl' in patch) {
|
|
254
|
+
if (!isValidWebhookUrl(patch.webhookUrl)) return { ok: false, reason: 'webhookUrl 须为 http(s) URL 或空串' }
|
|
255
|
+
next.webhookUrl = String(patch.webhookUrl).trim()
|
|
256
|
+
}
|
|
257
|
+
if ('imTargets' in patch) {
|
|
258
|
+
const list = patch.imTargets
|
|
259
|
+
if (!Array.isArray(list)) return { ok: false, reason: 'imTargets 须为数组' }
|
|
260
|
+
if (list.length > IM_TARGETS_MAX) return { ok: false, reason: 'imTargets 超过上限' }
|
|
261
|
+
const seen = new Set()
|
|
262
|
+
const targets = []
|
|
263
|
+
for (const item of list) {
|
|
264
|
+
const normalized = normalizeImTargets([item])[0]
|
|
265
|
+
if (normalized === undefined) return { ok: false, reason: 'imTargets 项须为含 botId 与 targetId 的对象' }
|
|
266
|
+
// 字符集规格与 dsh-im delivery-service 一致:落库前拦截,防投递静默失败
|
|
267
|
+
if (!IM_BOT_ID_PATTERN.test(normalized.botId)) return { ok: false, reason: 'botId 格式非法' }
|
|
268
|
+
if (!IM_TARGET_ID_PATTERN.test(normalized.targetId)) return { ok: false, reason: 'targetId 格式非法' }
|
|
269
|
+
const key = normalized.botId + '/' + normalized.targetId
|
|
270
|
+
if (seen.has(key)) return { ok: false, reason: 'imTargets 存在重复项' }
|
|
271
|
+
seen.add(key)
|
|
272
|
+
targets.push(normalized)
|
|
273
|
+
}
|
|
274
|
+
next.imTargets = targets
|
|
275
|
+
}
|
|
276
|
+
return { ok: true, patch: next }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 账号 notify 覆盖归一:仅保留三字段中值域合法的键,值域与全局校验一致。
|
|
280
|
+
export function normalizeAccountNotify(raw) {
|
|
281
|
+
const source = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
|
|
282
|
+
const result = {}
|
|
283
|
+
if ('quotaThresholdPct' in source) {
|
|
284
|
+
const pct = source.quotaThresholdPct
|
|
285
|
+
if (typeof pct === 'number' && Number.isFinite(pct) && pct > 0 && pct <= PERCENT_BASE) result.quotaThresholdPct = pct
|
|
286
|
+
}
|
|
287
|
+
if ('balanceThreshold' in source) {
|
|
288
|
+
const value = source.balanceThreshold
|
|
289
|
+
if (value === null || (typeof value === 'number' && Number.isFinite(value) && value >= 0)) result.balanceThreshold = value
|
|
290
|
+
}
|
|
291
|
+
if ('resetNotice' in source && typeof source.resetNotice === 'boolean') result.resetNotice = source.resetNotice
|
|
292
|
+
return result
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---- 投影 / 认领 / webhook(turn-notify 同构语义) ----
|
|
296
|
+
|
|
297
|
+
// 通知投影:环形容量 + 读取时过期清理;now 注入便于测试。
|
|
298
|
+
// version 单调递增支撑长轮询:push 与 bump 各递增一次并唤醒全部等待者,
|
|
299
|
+
// wait 在无更新版本时挂起至唤醒或超时,超时与 dispose 均以当前版本收尾不报错。
|
|
300
|
+
export function createProjection({ capacity = PROJECTION_CAPACITY, ttlMs = PROJECTION_TTL_MS, now = Date.now }) {
|
|
301
|
+
const ring = []
|
|
302
|
+
let version = 0
|
|
303
|
+
const waiters = new Set()
|
|
304
|
+
const settle = (waiter) => {
|
|
305
|
+
clearTimeout(waiter.timer)
|
|
306
|
+
waiters.delete(waiter)
|
|
307
|
+
waiter.resolve(version)
|
|
308
|
+
}
|
|
309
|
+
const wakeAll = () => { for (const waiter of [...waiters]) settle(waiter) }
|
|
310
|
+
return {
|
|
311
|
+
push(unit) {
|
|
312
|
+
const current = now()
|
|
313
|
+
while (ring.length > 0 && current - ring[0].ts > ttlMs) ring.shift()
|
|
314
|
+
ring.push(unit)
|
|
315
|
+
while (ring.length > capacity) ring.shift()
|
|
316
|
+
version += 1
|
|
317
|
+
wakeAll()
|
|
318
|
+
},
|
|
319
|
+
bump() {
|
|
320
|
+
version += 1
|
|
321
|
+
wakeAll()
|
|
322
|
+
},
|
|
323
|
+
version: () => version,
|
|
324
|
+
wait(cursor, timeoutMs) {
|
|
325
|
+
if (version > cursor) return Promise.resolve(version)
|
|
326
|
+
return new Promise((resolve) => {
|
|
327
|
+
const waiter = { resolve, timer: null }
|
|
328
|
+
waiter.timer = setTimeout(() => settle(waiter), timeoutMs)
|
|
329
|
+
waiters.add(waiter)
|
|
330
|
+
})
|
|
331
|
+
},
|
|
332
|
+
dispose: wakeAll,
|
|
333
|
+
list() {
|
|
334
|
+
const current = now()
|
|
335
|
+
while (ring.length > 0 && current - ring[0].ts > ttlMs) ring.shift()
|
|
336
|
+
return ring.slice()
|
|
337
|
+
},
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// 认领决策(读阶段):done 标记终态只补已读;有效他锁跳过;过期锁接管;无锁认领。
|
|
342
|
+
// 值非法视为无锁接管;锁归属自己视为继续(重试补完成标记)。
|
|
343
|
+
export function decideClaim({ stored, done, now, windowId, lockTtlMs = CLAIM_LOCK_TTL_MS }) {
|
|
344
|
+
if (done !== null && done !== undefined) return 'done'
|
|
345
|
+
if (stored === null || stored === undefined) return 'claim'
|
|
346
|
+
let lock = null
|
|
347
|
+
try {
|
|
348
|
+
lock = JSON.parse(stored)
|
|
349
|
+
} catch {
|
|
350
|
+
lock = null
|
|
351
|
+
}
|
|
352
|
+
if (lock === null || typeof lock !== 'object' || typeof lock.at !== 'number' || typeof lock.wid !== 'string') return 'takeover'
|
|
353
|
+
if (now - lock.at >= lockTtlMs) return 'takeover'
|
|
354
|
+
return lock.wid === windowId ? 'claim' : 'skip'
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 通知事件字段到 webhook 结构化字段的一比一映射(text 随行)。
|
|
358
|
+
export function buildWebhookPayload(unit) {
|
|
359
|
+
return {
|
|
360
|
+
text: unit.text,
|
|
361
|
+
event: unit.id,
|
|
362
|
+
kind: unit.kind,
|
|
363
|
+
account: unit.accountName,
|
|
364
|
+
accountId: unit.accountId,
|
|
365
|
+
label: unit.label,
|
|
366
|
+
detail: unit.detail,
|
|
367
|
+
ts: unit.ts,
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// webhook 直发:未配置跳过;任何失败不抛出(fire-and-forget,不重试)。
|
|
372
|
+
// 返回真实投递结果供测试按钮呈现,真实通知路径以 void 忽略。
|
|
373
|
+
export async function sendWebhook({ url, payload, fetchImpl = fetch }) {
|
|
374
|
+
if (typeof url !== 'string' || url.trim().length === 0) return { ok: false, detail: '未配置 webhook' }
|
|
375
|
+
try {
|
|
376
|
+
const response = await fetchImpl(url.trim(), {
|
|
377
|
+
method: 'POST',
|
|
378
|
+
headers: { 'content-type': 'application/json' },
|
|
379
|
+
body: JSON.stringify(payload),
|
|
380
|
+
signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
|
|
381
|
+
})
|
|
382
|
+
return { ok: response.ok, detail: 'HTTP ' + response.status }
|
|
383
|
+
} catch (error) {
|
|
384
|
+
return { ok: false, detail: error && error.message ? error.message : String(error) }
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// settings 读数归一:字段类型异常回退默认值,读侧宽松与写侧校验宽松度一致。
|
|
389
|
+
export function resolvedNotifySettings(raw) {
|
|
390
|
+
const source = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
|
|
391
|
+
const fallback = defaultNotifySettings()
|
|
392
|
+
const bool = (value, fallbackValue) => (typeof value === 'boolean' ? value : fallbackValue)
|
|
393
|
+
const pct = Number(source.quotaThresholdPct)
|
|
394
|
+
const balance = source.balanceThreshold === null || source.balanceThreshold === undefined
|
|
395
|
+
? null
|
|
396
|
+
: Number(source.balanceThreshold)
|
|
397
|
+
return {
|
|
398
|
+
enabled: bool(source.enabled, fallback.enabled),
|
|
399
|
+
quotaThresholdPct: Number.isFinite(pct) && pct > 0 && pct <= PERCENT_BASE ? pct : fallback.quotaThresholdPct,
|
|
400
|
+
balanceThreshold: Number.isFinite(balance) && balance >= 0 ? balance : null,
|
|
401
|
+
resetNotice: bool(source.resetNotice, fallback.resetNotice),
|
|
402
|
+
toast: bool(source.toast, fallback.toast),
|
|
403
|
+
webhookUrl: typeof source.webhookUrl === 'string' ? source.webhookUrl : '',
|
|
404
|
+
imTargets: normalizeImTargets(source.imTargets),
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// 面板可见配置:webhookUrl 属凭据不出主机,仅回是否已配置。
|
|
409
|
+
export function publicNotify(resolved) {
|
|
410
|
+
return {
|
|
411
|
+
enabled: resolved.enabled,
|
|
412
|
+
quotaThresholdPct: resolved.quotaThresholdPct,
|
|
413
|
+
balanceThreshold: resolved.balanceThreshold,
|
|
414
|
+
resetNotice: resolved.resetNotice,
|
|
415
|
+
toast: resolved.toast,
|
|
416
|
+
imTargets: resolved.imTargets,
|
|
417
|
+
webhookConfigured: resolved.webhookUrl.trim().length > 0,
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// botId 写侧校验:与 dsh-im delivery-service 的 BOT_ID 规格一致。
|
|
422
|
+
export function isValidImBotId(value) {
|
|
423
|
+
return typeof value === 'string' && IM_BOT_ID_PATTERN.test(value)
|
|
424
|
+
}
|