devassist-agent 1.0.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.
- package/LICENSE +21 -0
- package/README.md +467 -0
- package/bin/devassist.js +220 -0
- package/package.json +44 -0
- package/src/ai/adapter.js +464 -0
- package/src/ai/providers/claude.js +80 -0
- package/src/ai/providers/hunyuan.js +87 -0
- package/src/ai/providers/openai.js +74 -0
- package/src/ai/providers/qwen.js +81 -0
- package/src/cli/commands/ai.js +944 -0
- package/src/cli/commands/ask.js +79 -0
- package/src/cli/commands/backup.js +30 -0
- package/src/cli/commands/check.js +327 -0
- package/src/cli/commands/clean.js +130 -0
- package/src/cli/commands/comparison-report.js +326 -0
- package/src/cli/commands/convention.js +91 -0
- package/src/cli/commands/debt.js +49 -0
- package/src/cli/commands/deploy.js +88 -0
- package/src/cli/commands/diff.js +193 -0
- package/src/cli/commands/doctor.js +186 -0
- package/src/cli/commands/fix.js +195 -0
- package/src/cli/commands/init.js +431 -0
- package/src/cli/commands/inject.js +254 -0
- package/src/cli/commands/report.js +310 -0
- package/src/cli/commands/restore.js +78 -0
- package/src/cli/commands/schema.js +93 -0
- package/src/cli/commands/watch.js +212 -0
- package/src/cli/shared/code-context.js +51 -0
- package/src/cli/shared/config-loader.js +89 -0
- package/src/cli/shared/file-collector.js +116 -0
- package/src/cli/shared/inline-ignore.js +142 -0
- package/src/cli/shared/watch-list.js +281 -0
- package/src/core/event-bus.js +83 -0
- package/src/core/fsm.js +103 -0
- package/src/core/logger.js +54 -0
- package/src/core/rule-engine.js +117 -0
- package/src/index.js +64 -0
- package/src/modules/dev-time/arch-risk-assessor.js +250 -0
- package/src/modules/dev-time/code-quality-guard.js +1340 -0
- package/src/modules/dev-time/convention-store.js +201 -0
- package/src/modules/dev-time/impact-analyzer.js +292 -0
- package/src/modules/dev-time/pre-deploy-guard.js +284 -0
- package/src/modules/dev-time/schema-registry.js +284 -0
- package/src/modules/dev-time/tech-debt-tracker.js +225 -0
- package/src/modules/dev-time/version-manager.js +280 -0
- package/src/modules/runtime/channel-agent.js +404 -0
- package/src/modules/runtime/channel-middleware.js +316 -0
- package/src/modules/runtime/index.js +64 -0
- package/src/modules/runtime/infrastructure-guard.js +582 -0
- package/src/modules/runtime/notification-center.js +443 -0
- package/src/modules/runtime/schema-gatekeeper.js +664 -0
- package/src/modules/runtime/tool-registry.js +329 -0
- package/templates/ci/github-actions.yml +60 -0
- package/templates/ci/gitlab-ci.yml +30 -0
- package/templates/ci/pre-commit-hook.sh +18 -0
- package/templates/git-hooks/pre-commit +61 -0
- package/tests/run-all.js +434 -0
- package/tests/test-layer2.js +461 -0
- package/tests/test-new-rules.js +157 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NotificationCenter - Multi-channel notification system
|
|
3
|
+
*
|
|
4
|
+
* Design from 通知与健康检查基础设施_完整报告.md:
|
|
5
|
+
* - 4 channels: log, email, webhook, dingtalk
|
|
6
|
+
* - Priority-based routing (INFO, WARN, ERROR, CRITICAL)
|
|
7
|
+
* - Rate limiting per channel (prevents alert storms)
|
|
8
|
+
* - Deduplication by content signature
|
|
9
|
+
*
|
|
10
|
+
* Integrates with InfrastructureGuard via EventBus:
|
|
11
|
+
* guard:alert -> NotificationCenter.send()
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { bus } = require('../../core/event-bus');
|
|
15
|
+
const { Logger } = require('../../core/logger');
|
|
16
|
+
|
|
17
|
+
const log = new Logger('NotificationCenter');
|
|
18
|
+
|
|
19
|
+
const LEVELS = {
|
|
20
|
+
INFO: { value: 0, label: 'INFO', color: '\x1b[36m' },
|
|
21
|
+
WARN: { value: 1, label: 'WARN', color: '\x1b[33m' },
|
|
22
|
+
ERROR: { value: 2, label: 'ERROR', color: '\x1b[31m' },
|
|
23
|
+
CRITICAL: { value: 3, label: 'CRITICAL', color: '\x1b[35m' },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// ============ Channels ============
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Base Channel class.
|
|
30
|
+
*/
|
|
31
|
+
class Channel {
|
|
32
|
+
constructor(name, opts = {}) {
|
|
33
|
+
this.name = name;
|
|
34
|
+
this.minLevel = opts.minLevel || LEVELS.INFO;
|
|
35
|
+
this.rateLimitWindow = opts.rateLimitWindow || 60 * 1000; // 1 min
|
|
36
|
+
this.rateLimitMax = opts.rateLimitMax || 50; // 50 per window
|
|
37
|
+
this.enabled = opts.enabled !== false;
|
|
38
|
+
|
|
39
|
+
this._sentCount = 0;
|
|
40
|
+
this._rateBuckets = new Map(); // signature -> timestamps[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Send a notification. Override in subclass.
|
|
45
|
+
*/
|
|
46
|
+
async send(notification) {
|
|
47
|
+
throw new Error('Channel.send() must be overridden');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check rate limit for a given signature.
|
|
52
|
+
*/
|
|
53
|
+
_checkRateLimit(signature) {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
const bucket = this._rateBuckets.get(signature) || [];
|
|
56
|
+
const valid = bucket.filter(t => now - t < this.rateLimitWindow);
|
|
57
|
+
|
|
58
|
+
if (valid.length >= this.rateLimitMax) {
|
|
59
|
+
return false; // Rate limited
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
valid.push(now);
|
|
63
|
+
this._rateBuckets.set(signature, valid);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Clean up old rate limit entries.
|
|
69
|
+
*/
|
|
70
|
+
_cleanup() {
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
for (const [sig, times] of this._rateBuckets) {
|
|
73
|
+
const valid = times.filter(t => now - t < this.rateLimitWindow);
|
|
74
|
+
if (valid.length === 0) {
|
|
75
|
+
this._rateBuckets.delete(sig);
|
|
76
|
+
} else {
|
|
77
|
+
this._rateBuckets.set(sig, valid);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get stats() {
|
|
83
|
+
return { sent: this._sentCount, enabled: this.enabled };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Log Channel — writes to console/file logger.
|
|
89
|
+
*/
|
|
90
|
+
class LogChannel extends Channel {
|
|
91
|
+
constructor(opts = {}) {
|
|
92
|
+
super('log', { minLevel: LEVELS.INFO, rateLimitMax: 200, ...opts });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async send(notification) {
|
|
96
|
+
const level = LEVELS[notification.level] || LEVELS.INFO;
|
|
97
|
+
const color = level.color;
|
|
98
|
+
const reset = '\x1b[0m';
|
|
99
|
+
const ts = new Date(notification.timestamp).toISOString();
|
|
100
|
+
|
|
101
|
+
const line = `${color}[${ts}] ${level.label}${reset} [${notification.source}] ${notification.title}`;
|
|
102
|
+
|
|
103
|
+
if (notification.data) {
|
|
104
|
+
console.log(line);
|
|
105
|
+
console.log(` Details: ${JSON.stringify(notification.data, null, 2).split('\n').join('\n ')}`);
|
|
106
|
+
} else {
|
|
107
|
+
console.log(line);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (notification.message) {
|
|
111
|
+
console.log(` ${notification.message}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this._sentCount++;
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Email Channel — sends email notifications (stub — implement with nodemailer).
|
|
121
|
+
*/
|
|
122
|
+
class EmailChannel extends Channel {
|
|
123
|
+
constructor(opts = {}) {
|
|
124
|
+
super('email', { minLevel: LEVELS.ERROR, rateLimitMax: 20, ...opts });
|
|
125
|
+
this.to = opts.to || [];
|
|
126
|
+
this.from = opts.from || 'devassist@localhost';
|
|
127
|
+
this.smtpConfig = opts.smtpConfig || null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async send(notification) {
|
|
131
|
+
if (!this.smtpConfig || this.to.length === 0) {
|
|
132
|
+
log.debug('Email channel not configured, skipping');
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// In production, use nodemailer here:
|
|
137
|
+
// const transporter = nodemailer.createTransport(this.smtpConfig);
|
|
138
|
+
// await transporter.sendMail({ from, to, subject, text });
|
|
139
|
+
|
|
140
|
+
log.info(`[EMAIL] 收件人:${this.to.join(',')} | 主题:[${notification.level}] ${notification.title}`);
|
|
141
|
+
this._sentCount++;
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Webhook Channel — sends HTTP POST to a URL.
|
|
148
|
+
*/
|
|
149
|
+
class WebhookChannel extends Channel {
|
|
150
|
+
constructor(opts = {}) {
|
|
151
|
+
super('webhook', { minLevel: LEVELS.WARN, rateLimitMax: 30, ...opts });
|
|
152
|
+
this.url = opts.url || null;
|
|
153
|
+
this.headers = opts.headers || { 'Content-Type': 'application/json' };
|
|
154
|
+
this.timeout = opts.timeout || 5000;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async send(notification) {
|
|
158
|
+
if (!this.url) {
|
|
159
|
+
log.debug('Webhook URL not configured, skipping');
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const body = JSON.stringify({
|
|
164
|
+
level: notification.level,
|
|
165
|
+
title: notification.title,
|
|
166
|
+
message: notification.message,
|
|
167
|
+
source: notification.source,
|
|
168
|
+
data: notification.data,
|
|
169
|
+
timestamp: notification.timestamp,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const res = await fetch(this.url, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: this.headers,
|
|
176
|
+
body,
|
|
177
|
+
signal: AbortSignal.timeout ? AbortSignal.timeout(this.timeout) : undefined,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
if (!res.ok) {
|
|
181
|
+
log.warn(`Webhook 返回 ${res.status}:${res.statusText}`);
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
this._sentCount++;
|
|
186
|
+
return true;
|
|
187
|
+
} catch (err) {
|
|
188
|
+
log.error(`Webhook 发送失败:${err.message}`);
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* DingTalk Channel — sends to DingTalk group robot.
|
|
196
|
+
*/
|
|
197
|
+
class DingTalkChannel extends Channel {
|
|
198
|
+
constructor(opts = {}) {
|
|
199
|
+
super('dingtalk', { minLevel: LEVELS.WARN, rateLimitMax: 20, ...opts });
|
|
200
|
+
this.webhookUrl = opts.webhookUrl || null;
|
|
201
|
+
this.secret = opts.secret || null; // Sign key for security
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async send(notification) {
|
|
205
|
+
if (!this.webhookUrl) {
|
|
206
|
+
log.debug('DingTalk webhook not configured, skipping');
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const level = LEVELS[notification.level] || LEVELS.INFO;
|
|
211
|
+
const title = `[${level.label}] ${notification.title}`;
|
|
212
|
+
let text = `### ${title}\n\n`;
|
|
213
|
+
text += `**Source:** ${notification.source}\n\n`;
|
|
214
|
+
if (notification.message) {
|
|
215
|
+
text += `**Message:** ${notification.message}\n\n`;
|
|
216
|
+
}
|
|
217
|
+
if (notification.data) {
|
|
218
|
+
text += `**Details:**\n\n\`\`\`json\n${JSON.stringify(notification.data, null, 2)}\n\`\`\`\n`;
|
|
219
|
+
}
|
|
220
|
+
text += `**Time:** ${new Date(notification.timestamp).toLocaleString('zh-CN')}`;
|
|
221
|
+
|
|
222
|
+
const body = {
|
|
223
|
+
msgtype: 'markdown',
|
|
224
|
+
markdown: { title, text },
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// Sign the request if secret is configured
|
|
228
|
+
let url = this.webhookUrl;
|
|
229
|
+
if (this.secret) {
|
|
230
|
+
const timestamp = Date.now();
|
|
231
|
+
const stringToSign = `${timestamp}\n${this.secret}`;
|
|
232
|
+
const crypto = require('crypto');
|
|
233
|
+
const sign = crypto
|
|
234
|
+
.createHmac('sha256', this.secret)
|
|
235
|
+
.update(stringToSign)
|
|
236
|
+
.digest('base64');
|
|
237
|
+
url += `×tamp=${timestamp}&sign=${encodeURIComponent(sign)}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const res = await fetch(url, {
|
|
242
|
+
method: 'POST',
|
|
243
|
+
headers: { 'Content-Type': 'application/json' },
|
|
244
|
+
body: JSON.stringify(body),
|
|
245
|
+
signal: AbortSignal.timeout ? AbortSignal.timeout(5000) : undefined,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const j = await res.json().catch(() => ({}));
|
|
249
|
+
if (j.errcode && j.errcode !== 0) {
|
|
250
|
+
log.warn(`钉钉错误:${j.errmsg}`);
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
this._sentCount++;
|
|
255
|
+
return true;
|
|
256
|
+
} catch (err) {
|
|
257
|
+
log.error(`钉钉发送失败:${err.message}`);
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ============ NotificationCenter ============
|
|
264
|
+
|
|
265
|
+
class NotificationCenter {
|
|
266
|
+
constructor() {
|
|
267
|
+
this._channels = new Map();
|
|
268
|
+
this._dedupCache = new Map(); // signature -> last sent timestamp
|
|
269
|
+
this._dedupTTL = 5 * 60 * 1000; // 5 minutes
|
|
270
|
+
this._queue = [];
|
|
271
|
+
this._processing = false;
|
|
272
|
+
this._stats = { totalSent: 0, totalDeduped: 0, totalRateLimited: 0 };
|
|
273
|
+
|
|
274
|
+
// Register default log channel
|
|
275
|
+
this.registerChannel(new LogChannel());
|
|
276
|
+
|
|
277
|
+
// Auto-subscribe to guard:alert events
|
|
278
|
+
bus.on('guard:alert', async (alert) => {
|
|
279
|
+
await this.send({
|
|
280
|
+
level: alert.severity === 'critical' ? 'CRITICAL' : 'WARN',
|
|
281
|
+
title: `Health Alert: ${alert.probe}`,
|
|
282
|
+
message: alert.message,
|
|
283
|
+
source: 'InfrastructureGuard',
|
|
284
|
+
data: alert.details,
|
|
285
|
+
timestamp: Date.now(),
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Register a notification channel.
|
|
292
|
+
*/
|
|
293
|
+
registerChannel(channel) {
|
|
294
|
+
if (!(channel instanceof Channel)) {
|
|
295
|
+
throw new Error('Must be a Channel instance');
|
|
296
|
+
}
|
|
297
|
+
this._channels.set(channel.name, channel);
|
|
298
|
+
log.info(`通道已注册:${channel.name}(最低级别:${channel.minLevel.label})`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Remove a channel.
|
|
303
|
+
*/
|
|
304
|
+
unregisterChannel(name) {
|
|
305
|
+
this._channels.delete(name);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Send a notification to all eligible channels.
|
|
310
|
+
*
|
|
311
|
+
* @param {object} notification - { level, title, message, source, data, timestamp }
|
|
312
|
+
*/
|
|
313
|
+
async send(notification) {
|
|
314
|
+
// Normalize
|
|
315
|
+
const level = typeof notification.level === 'string'
|
|
316
|
+
? LEVELS[notification.level]
|
|
317
|
+
: notification.level;
|
|
318
|
+
|
|
319
|
+
if (!level) {
|
|
320
|
+
log.warn(`未知通知级别:${notification.level}`);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
notification = {
|
|
325
|
+
...notification,
|
|
326
|
+
level: level.label,
|
|
327
|
+
levelValue: level.value,
|
|
328
|
+
timestamp: notification.timestamp || Date.now(),
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
// Deduplication — skip if same content was sent recently
|
|
332
|
+
const signature = this._makeSignature(notification);
|
|
333
|
+
const lastSent = this._dedupCache.get(signature);
|
|
334
|
+
if (lastSent && Date.now() - lastSent < this._dedupTTL) {
|
|
335
|
+
this._stats.totalDeduped++;
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Send to all eligible channels
|
|
340
|
+
const promises = [];
|
|
341
|
+
for (const channel of this._channels.values()) {
|
|
342
|
+
if (!channel.enabled) continue;
|
|
343
|
+
if (level.value < channel.minLevel.value) continue;
|
|
344
|
+
|
|
345
|
+
// Rate limit check
|
|
346
|
+
if (!channel._checkRateLimit(signature)) {
|
|
347
|
+
this._stats.totalRateLimited++;
|
|
348
|
+
log.warn(`通道 '${channel.name}' 被速率限制:${notification.title}`);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
promises.push(
|
|
353
|
+
channel.send(notification).catch(err => {
|
|
354
|
+
log.error(`通道 '${channel.name}' 发送错误:${err.message}`);
|
|
355
|
+
})
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
await Promise.all(promises);
|
|
360
|
+
|
|
361
|
+
// Update dedup cache
|
|
362
|
+
this._dedupCache.set(signature, notification.timestamp);
|
|
363
|
+
this._stats.totalSent++;
|
|
364
|
+
|
|
365
|
+
// Emit event for monitoring
|
|
366
|
+
bus.emit('notification:sent', {
|
|
367
|
+
level: notification.level,
|
|
368
|
+
title: notification.title,
|
|
369
|
+
source: notification.source,
|
|
370
|
+
channels: Array.from(this._channels.keys()),
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Convenience methods for each level.
|
|
376
|
+
*/
|
|
377
|
+
async info(title, message, source = 'system', data = null) {
|
|
378
|
+
return this.send({ level: 'INFO', title, message, source, data });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async warn(title, message, source = 'system', data = null) {
|
|
382
|
+
return this.send({ level: 'WARN', title, message, source, data });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async error(title, message, source = 'system', data = null) {
|
|
386
|
+
return this.send({ level: 'ERROR', title, message, source, data });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async critical(title, message, source = 'system', data = null) {
|
|
390
|
+
return this.send({ level: 'CRITICAL', title, message, source, data });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Create a dedup signature from notification content.
|
|
395
|
+
*/
|
|
396
|
+
_makeSignature(notification) {
|
|
397
|
+
const parts = [
|
|
398
|
+
notification.level,
|
|
399
|
+
notification.title,
|
|
400
|
+
notification.source,
|
|
401
|
+
notification.data ? JSON.stringify(notification.data).substring(0, 200) : '',
|
|
402
|
+
];
|
|
403
|
+
return parts.join('|');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Clean up old dedup entries.
|
|
408
|
+
*/
|
|
409
|
+
cleanup() {
|
|
410
|
+
const now = Date.now();
|
|
411
|
+
for (const [sig, ts] of this._dedupCache) {
|
|
412
|
+
if (now - ts > this._dedupTTL) {
|
|
413
|
+
this._dedupCache.delete(sig);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
for (const channel of this._channels.values()) {
|
|
417
|
+
channel._cleanup();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Get stats.
|
|
423
|
+
*/
|
|
424
|
+
getStats() {
|
|
425
|
+
const channels = {};
|
|
426
|
+
for (const [name, channel] of this._channels) {
|
|
427
|
+
channels[name] = channel.stats;
|
|
428
|
+
}
|
|
429
|
+
return { ...this._stats, channels };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Get registered channels.
|
|
434
|
+
*/
|
|
435
|
+
getChannels() {
|
|
436
|
+
return Array.from(this._channels.keys());
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Singleton
|
|
441
|
+
const center = new NotificationCenter();
|
|
442
|
+
|
|
443
|
+
module.exports = { NotificationCenter, center, Channel, LogChannel, EmailChannel, WebhookChannel, DingTalkChannel, LEVELS };
|