meodp 0.0.9 → 0.2.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/README.md +230 -21
- package/bin/index.mjs +10 -2
- package/dist/check/cli.cjs +257 -0
- package/dist/check/cli.d.cts +5 -0
- package/dist/check/cli.d.mts +5 -0
- package/dist/check/cli.d.ts +5 -0
- package/dist/check/cli.mjs +249 -0
- package/dist/check/index.cjs +29 -0
- package/dist/check/index.d.cts +44 -0
- package/dist/check/index.d.mts +44 -0
- package/dist/check/index.d.ts +44 -0
- package/dist/check/index.mjs +13 -0
- package/dist/cli/index.cjs +2 -2
- package/dist/cli/index.d.cts +1 -1
- package/dist/cli/index.d.mts +1 -1
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.mjs +2 -2
- package/dist/cli/main.cjs +272 -0
- package/dist/cli/main.d.cts +4 -0
- package/dist/cli/main.d.mts +4 -0
- package/dist/cli/main.d.ts +4 -0
- package/dist/cli/main.mjs +266 -0
- package/dist/config/index.cjs +7 -0
- package/dist/config/index.d.cts +47 -0
- package/dist/config/index.d.mts +47 -0
- package/dist/config/index.d.ts +47 -0
- package/dist/config/index.mjs +5 -0
- package/dist/notify/cli.cjs +107 -0
- package/dist/notify/cli.d.cts +3 -0
- package/dist/notify/cli.d.mts +3 -0
- package/dist/notify/cli.d.ts +3 -0
- package/dist/notify/cli.mjs +101 -0
- package/dist/notify/email.cjs +38 -0
- package/dist/notify/email.d.cts +29 -0
- package/dist/notify/email.d.mts +29 -0
- package/dist/notify/email.d.ts +29 -0
- package/dist/notify/email.mjs +35 -0
- package/dist/notify/feishu.cjs +162 -0
- package/dist/notify/feishu.d.cts +74 -0
- package/dist/notify/feishu.d.mts +74 -0
- package/dist/notify/feishu.d.ts +74 -0
- package/dist/notify/feishu.mjs +157 -0
- package/dist/notify/index.cjs +64 -0
- package/dist/notify/index.d.cts +27 -0
- package/dist/notify/index.d.mts +27 -0
- package/dist/notify/index.d.ts +27 -0
- package/dist/notify/index.mjs +62 -0
- package/dist/shared/meodp.2b00b0ae.cjs +228 -0
- package/dist/shared/meodp.4f1da070.d.cts +41 -0
- package/dist/shared/meodp.63483e20.cjs +30 -0
- package/dist/shared/meodp.80ff918d.mjs +465 -0
- package/dist/shared/meodp.8369c4c1.d.ts +41 -0
- package/dist/shared/meodp.93e06e00.d.cts +64 -0
- package/dist/shared/meodp.93e06e00.d.mts +64 -0
- package/dist/shared/meodp.93e06e00.d.ts +64 -0
- package/dist/shared/meodp.9c706389.mjs +220 -0
- package/dist/shared/meodp.a704f8db.d.mts +41 -0
- package/dist/shared/meodp.c4c70565.cjs +477 -0
- package/dist/shared/meodp.f96d0510.mjs +24 -0
- package/package.json +104 -31
- package/bin/index.ts +0 -6
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { R as ReporterConfig, a as ReportVerificationOptions } from '../shared/meodp.8369c4c1.js';
|
|
2
|
+
import { S as SitemapOptions } from '../shared/meodp.93e06e00.js';
|
|
3
|
+
import { EmailOptions } from '../notify/email.js';
|
|
4
|
+
import { FeishuOptions } from '../notify/feishu.js';
|
|
5
|
+
import { NotificationOptions } from '../notify/index.js';
|
|
6
|
+
|
|
7
|
+
interface ScanConfig extends Omit<SitemapOptions, 'previousReport' | 'onResult'> {
|
|
8
|
+
input?: string;
|
|
9
|
+
reporter?: ReporterConfig;
|
|
10
|
+
output?: string;
|
|
11
|
+
history?: string;
|
|
12
|
+
/** Seed missing history from a committed snapshot. */
|
|
13
|
+
historySeed?: string;
|
|
14
|
+
observerMismatch?: 'error' | 'reset';
|
|
15
|
+
site?: string;
|
|
16
|
+
failOn?: 'unavailable' | 'review' | 'none';
|
|
17
|
+
}
|
|
18
|
+
interface NotificationConfig extends Omit<NotificationOptions, 'previousReport' | 'mode'> {
|
|
19
|
+
input: string;
|
|
20
|
+
previousReport?: string;
|
|
21
|
+
timeZone?: string;
|
|
22
|
+
maxItems?: number;
|
|
23
|
+
feishu?: FeishuOptions & {
|
|
24
|
+
mode?: NotificationOptions['mode'];
|
|
25
|
+
};
|
|
26
|
+
email?: EmailOptions & {
|
|
27
|
+
mode?: NotificationOptions['mode'];
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
interface MeodpConfig {
|
|
31
|
+
/** Shared reporters; command sections may override this selection. */
|
|
32
|
+
reporter?: ReporterConfig;
|
|
33
|
+
check?: ScanConfig;
|
|
34
|
+
sitemap?: ScanConfig;
|
|
35
|
+
report?: {
|
|
36
|
+
reporter?: ReporterConfig;
|
|
37
|
+
input?: string;
|
|
38
|
+
output?: string;
|
|
39
|
+
dataUrl?: string;
|
|
40
|
+
verify?: ReportVerificationOptions;
|
|
41
|
+
};
|
|
42
|
+
notify?: NotificationConfig;
|
|
43
|
+
}
|
|
44
|
+
/** Lightweight configuration entry; importing it does not load browser or mail clients. */
|
|
45
|
+
declare function defineConfig<const T extends MeodpConfig>(config: T): T;
|
|
46
|
+
|
|
47
|
+
export { type MeodpConfig, type NotificationConfig, type ScanConfig, defineConfig };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const process = require('node:process');
|
|
4
|
+
const node_util = require('node:util');
|
|
5
|
+
const report = require('../shared/meodp.2b00b0ae.cjs');
|
|
6
|
+
const load = require('../shared/meodp.63483e20.cjs');
|
|
7
|
+
const notify_feishu = require('./feishu.cjs');
|
|
8
|
+
const notify_index = require('./index.cjs');
|
|
9
|
+
require('node:crypto');
|
|
10
|
+
require('node:fs/promises');
|
|
11
|
+
require('node:path');
|
|
12
|
+
require('c12');
|
|
13
|
+
|
|
14
|
+
function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; }
|
|
15
|
+
|
|
16
|
+
const process__default = /*#__PURE__*/_interopDefaultCompat(process);
|
|
17
|
+
|
|
18
|
+
async function runNotifyCli(args = process__default.argv.slice(3)) {
|
|
19
|
+
try {
|
|
20
|
+
const { values } = node_util.parseArgs({ args, options: {
|
|
21
|
+
"config": { type: "string" },
|
|
22
|
+
"channel": { type: "string" },
|
|
23
|
+
"mode": { type: "string" },
|
|
24
|
+
"dry-run": { type: "boolean" },
|
|
25
|
+
"test": { type: "boolean" },
|
|
26
|
+
"help": { type: "boolean", short: "h" }
|
|
27
|
+
} });
|
|
28
|
+
if (values.help) {
|
|
29
|
+
console.log(`Usage: meodp notify [--channel feishu|email] [--mode off|changes|weekly] [--dry-run] [--test]
|
|
30
|
+
|
|
31
|
+
Read saved reports and send configured notifications. No link scans run.
|
|
32
|
+
--config <file> loads meodp.config.ts by default.
|
|
33
|
+
--dry-run renders content without credentials or delivery; disabled channels stay disabled.
|
|
34
|
+
--test explicitly sends a test using an optional saved snapshot, even if its mode is off.
|
|
35
|
+
Exit codes: 0 = sent, previewed or skipped; 2 = configuration or delivery error.`);
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
if (values.channel && !["feishu", "email"].includes(values.channel))
|
|
39
|
+
throw new Error("--channel must be feishu or email.");
|
|
40
|
+
const project = await load.loadProjectConfig(values.config);
|
|
41
|
+
const config = project.config.notify;
|
|
42
|
+
if (!config)
|
|
43
|
+
throw new Error("Configure notify in meodp.config.ts before sending notifications.");
|
|
44
|
+
const channels = values.channel ? [values.channel] : ["feishu", "email"].filter((name) => name in config);
|
|
45
|
+
if (values.test && channels.length > 1)
|
|
46
|
+
throw new Error("Choose --channel when testing a config with multiple notification channels.");
|
|
47
|
+
if (!channels.length)
|
|
48
|
+
throw new Error("Configure at least one notification channel.");
|
|
49
|
+
for (const channel of channels) {
|
|
50
|
+
const delivery = channel === "feishu" ? config.feishu : config.email;
|
|
51
|
+
if (!delivery)
|
|
52
|
+
throw new Error(`Configure notify.${channel} before using this channel.`);
|
|
53
|
+
const mode = values.mode ?? delivery.mode ?? "off";
|
|
54
|
+
if (!["off", "changes", "weekly"].includes(mode))
|
|
55
|
+
throw new Error("Notification mode must be off, changes, or weekly.");
|
|
56
|
+
if (mode === "off" && !values.test) {
|
|
57
|
+
console.log(`${channel}: notifications disabled.`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const report$1 = await report.readReport(project.path(config.input));
|
|
61
|
+
const previous = config.previousReport ? await report.readReport(project.path(config.previousReport)) : void 0;
|
|
62
|
+
const options = { ...config, previousReport: previous, mode };
|
|
63
|
+
let message;
|
|
64
|
+
if (values.test) {
|
|
65
|
+
const snapshot = report$1 ?? previous;
|
|
66
|
+
message = {
|
|
67
|
+
...snapshot ? notify_index.createNotification(snapshot, { ...options, previousReport: void 0, mode: "weekly" }) : {},
|
|
68
|
+
subject: `[${config.title || "MEODP"}] \u901A\u77E5\u94FE\u8DEF\u9A8C\u8BC1`,
|
|
69
|
+
test: true,
|
|
70
|
+
text: `\u8FD9\u662F\u4E00\u6761\u901A\u77E5\u5361\u7247\u6D4B\u8BD5\u3002
|
|
71
|
+
\u53D1\u9001\u65F6\u95F4\uFF1A${( new Date()).toISOString()}
|
|
72
|
+
\u4E0B\u65B9\u5982\u6709\u6570\u636E\uFF0C\u4F7F\u7528\u7684\u662F\u5DF2\u4FDD\u5B58\u7684\u5386\u53F2\u5FEB\u7167\uFF0C\u4E0D\u4EE3\u8868\u65B0\u68C0\u6D4B\u7ED3\u679C\u6216\u516C\u5F00\u62A5\u544A\u5DF2\u66F4\u65B0\u3002`
|
|
73
|
+
};
|
|
74
|
+
} else {
|
|
75
|
+
if (!report$1)
|
|
76
|
+
throw new Error("Missing completed report; run meodp check first.");
|
|
77
|
+
message = notify_index.createNotification(report$1, options);
|
|
78
|
+
}
|
|
79
|
+
if (!message) {
|
|
80
|
+
console.log(`${channel}: no important changes; notification skipped.`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (channel === "feishu") {
|
|
84
|
+
const feishu = { reportUrl: config.reportUrl, runUrl: config.runUrl, timeZone: config.timeZone, maxItems: config.maxItems, ...config.feishu };
|
|
85
|
+
if (values["dry-run"])
|
|
86
|
+
console.log(JSON.stringify(notify_feishu.createFeishuCard(message, feishu), null, 2));
|
|
87
|
+
else
|
|
88
|
+
await notify_feishu.sendFeishuNotification(message, feishu);
|
|
89
|
+
} else if (values["dry-run"]) {
|
|
90
|
+
console.log(`${message.subject}
|
|
91
|
+
|
|
92
|
+
${message.text}`);
|
|
93
|
+
} else {
|
|
94
|
+
const { sendEmailNotification } = await import('./email.cjs');
|
|
95
|
+
await sendEmailNotification(message, config.email);
|
|
96
|
+
}
|
|
97
|
+
if (!values["dry-run"])
|
|
98
|
+
console.log(`${channel}: notification accepted.`);
|
|
99
|
+
}
|
|
100
|
+
return 0;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.error(`meodp notify: ${error instanceof Error ? error.message : String(error)}`);
|
|
103
|
+
return 2;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
exports.runNotifyCli = runNotifyCli;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { r as readReport } from '../shared/meodp.9c706389.mjs';
|
|
4
|
+
import { l as loadProjectConfig } from '../shared/meodp.f96d0510.mjs';
|
|
5
|
+
import { createFeishuCard, sendFeishuNotification } from './feishu.mjs';
|
|
6
|
+
import { createNotification } from './index.mjs';
|
|
7
|
+
import 'node:crypto';
|
|
8
|
+
import 'node:fs/promises';
|
|
9
|
+
import 'node:path';
|
|
10
|
+
import 'c12';
|
|
11
|
+
|
|
12
|
+
async function runNotifyCli(args = process.argv.slice(3)) {
|
|
13
|
+
try {
|
|
14
|
+
const { values } = parseArgs({ args, options: {
|
|
15
|
+
"config": { type: "string" },
|
|
16
|
+
"channel": { type: "string" },
|
|
17
|
+
"mode": { type: "string" },
|
|
18
|
+
"dry-run": { type: "boolean" },
|
|
19
|
+
"test": { type: "boolean" },
|
|
20
|
+
"help": { type: "boolean", short: "h" }
|
|
21
|
+
} });
|
|
22
|
+
if (values.help) {
|
|
23
|
+
console.log(`Usage: meodp notify [--channel feishu|email] [--mode off|changes|weekly] [--dry-run] [--test]
|
|
24
|
+
|
|
25
|
+
Read saved reports and send configured notifications. No link scans run.
|
|
26
|
+
--config <file> loads meodp.config.ts by default.
|
|
27
|
+
--dry-run renders content without credentials or delivery; disabled channels stay disabled.
|
|
28
|
+
--test explicitly sends a test using an optional saved snapshot, even if its mode is off.
|
|
29
|
+
Exit codes: 0 = sent, previewed or skipped; 2 = configuration or delivery error.`);
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
if (values.channel && !["feishu", "email"].includes(values.channel))
|
|
33
|
+
throw new Error("--channel must be feishu or email.");
|
|
34
|
+
const project = await loadProjectConfig(values.config);
|
|
35
|
+
const config = project.config.notify;
|
|
36
|
+
if (!config)
|
|
37
|
+
throw new Error("Configure notify in meodp.config.ts before sending notifications.");
|
|
38
|
+
const channels = values.channel ? [values.channel] : ["feishu", "email"].filter((name) => name in config);
|
|
39
|
+
if (values.test && channels.length > 1)
|
|
40
|
+
throw new Error("Choose --channel when testing a config with multiple notification channels.");
|
|
41
|
+
if (!channels.length)
|
|
42
|
+
throw new Error("Configure at least one notification channel.");
|
|
43
|
+
for (const channel of channels) {
|
|
44
|
+
const delivery = channel === "feishu" ? config.feishu : config.email;
|
|
45
|
+
if (!delivery)
|
|
46
|
+
throw new Error(`Configure notify.${channel} before using this channel.`);
|
|
47
|
+
const mode = values.mode ?? delivery.mode ?? "off";
|
|
48
|
+
if (!["off", "changes", "weekly"].includes(mode))
|
|
49
|
+
throw new Error("Notification mode must be off, changes, or weekly.");
|
|
50
|
+
if (mode === "off" && !values.test) {
|
|
51
|
+
console.log(`${channel}: notifications disabled.`);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const report = await readReport(project.path(config.input));
|
|
55
|
+
const previous = config.previousReport ? await readReport(project.path(config.previousReport)) : void 0;
|
|
56
|
+
const options = { ...config, previousReport: previous, mode };
|
|
57
|
+
let message;
|
|
58
|
+
if (values.test) {
|
|
59
|
+
const snapshot = report ?? previous;
|
|
60
|
+
message = {
|
|
61
|
+
...snapshot ? createNotification(snapshot, { ...options, previousReport: void 0, mode: "weekly" }) : {},
|
|
62
|
+
subject: `[${config.title || "MEODP"}] \u901A\u77E5\u94FE\u8DEF\u9A8C\u8BC1`,
|
|
63
|
+
test: true,
|
|
64
|
+
text: `\u8FD9\u662F\u4E00\u6761\u901A\u77E5\u5361\u7247\u6D4B\u8BD5\u3002
|
|
65
|
+
\u53D1\u9001\u65F6\u95F4\uFF1A${( new Date()).toISOString()}
|
|
66
|
+
\u4E0B\u65B9\u5982\u6709\u6570\u636E\uFF0C\u4F7F\u7528\u7684\u662F\u5DF2\u4FDD\u5B58\u7684\u5386\u53F2\u5FEB\u7167\uFF0C\u4E0D\u4EE3\u8868\u65B0\u68C0\u6D4B\u7ED3\u679C\u6216\u516C\u5F00\u62A5\u544A\u5DF2\u66F4\u65B0\u3002`
|
|
67
|
+
};
|
|
68
|
+
} else {
|
|
69
|
+
if (!report)
|
|
70
|
+
throw new Error("Missing completed report; run meodp check first.");
|
|
71
|
+
message = createNotification(report, options);
|
|
72
|
+
}
|
|
73
|
+
if (!message) {
|
|
74
|
+
console.log(`${channel}: no important changes; notification skipped.`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (channel === "feishu") {
|
|
78
|
+
const feishu = { reportUrl: config.reportUrl, runUrl: config.runUrl, timeZone: config.timeZone, maxItems: config.maxItems, ...config.feishu };
|
|
79
|
+
if (values["dry-run"])
|
|
80
|
+
console.log(JSON.stringify(createFeishuCard(message, feishu), null, 2));
|
|
81
|
+
else
|
|
82
|
+
await sendFeishuNotification(message, feishu);
|
|
83
|
+
} else if (values["dry-run"]) {
|
|
84
|
+
console.log(`${message.subject}
|
|
85
|
+
|
|
86
|
+
${message.text}`);
|
|
87
|
+
} else {
|
|
88
|
+
const { sendEmailNotification } = await import('./email.mjs');
|
|
89
|
+
await sendEmailNotification(message, config.email);
|
|
90
|
+
}
|
|
91
|
+
if (!values["dry-run"])
|
|
92
|
+
console.log(`${channel}: notification accepted.`);
|
|
93
|
+
}
|
|
94
|
+
return 0;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error(`meodp notify: ${error instanceof Error ? error.message : String(error)}`);
|
|
97
|
+
return 2;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export { runNotifyCli };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function smtpOptions(options) {
|
|
4
|
+
for (const key of ["host", "user", "password", "from", "to"]) {
|
|
5
|
+
if (!options[key]?.trim())
|
|
6
|
+
throw new Error(`Missing email.${key}; configure SMTP before enabling email.`);
|
|
7
|
+
}
|
|
8
|
+
const port = options.port ?? 465;
|
|
9
|
+
if (![465, 587].includes(port))
|
|
10
|
+
throw new Error("Email port must be 465 (TLS) or 587 (STARTTLS).");
|
|
11
|
+
return {
|
|
12
|
+
host: options.host,
|
|
13
|
+
port,
|
|
14
|
+
secure: port === 465,
|
|
15
|
+
requireTLS: true,
|
|
16
|
+
auth: { user: options.user, pass: options.password },
|
|
17
|
+
connectionTimeout: 15e3,
|
|
18
|
+
greetingTimeout: 15e3,
|
|
19
|
+
socketTimeout: 3e4,
|
|
20
|
+
disableFileAccess: true,
|
|
21
|
+
disableUrlAccess: true
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
async function sendEmailNotification(message, options) {
|
|
25
|
+
const connection = smtpOptions(options);
|
|
26
|
+
const { default: nodemailer } = await import('nodemailer');
|
|
27
|
+
const transport = nodemailer.createTransport(connection);
|
|
28
|
+
try {
|
|
29
|
+
const result = await transport.sendMail({ subject: message.subject, text: message.text, from: options.from, to: options.to });
|
|
30
|
+
if (result.rejected.length)
|
|
31
|
+
throw new Error("SMTP rejected one or more recipients.");
|
|
32
|
+
} finally {
|
|
33
|
+
transport.close();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
exports.sendEmailNotification = sendEmailNotification;
|
|
38
|
+
exports.smtpOptions = smtpOptions;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { LinkNotification } from './index.cjs';
|
|
2
|
+
import '../shared/meodp.93e06e00.cjs';
|
|
3
|
+
|
|
4
|
+
interface EmailOptions {
|
|
5
|
+
host?: string;
|
|
6
|
+
port?: number;
|
|
7
|
+
user?: string;
|
|
8
|
+
password?: string;
|
|
9
|
+
from?: string;
|
|
10
|
+
to?: string;
|
|
11
|
+
}
|
|
12
|
+
declare function smtpOptions(options: EmailOptions): {
|
|
13
|
+
host: string | undefined;
|
|
14
|
+
port: number;
|
|
15
|
+
secure: boolean;
|
|
16
|
+
requireTLS: boolean;
|
|
17
|
+
auth: {
|
|
18
|
+
user: string | undefined;
|
|
19
|
+
pass: string | undefined;
|
|
20
|
+
};
|
|
21
|
+
connectionTimeout: number;
|
|
22
|
+
greetingTimeout: number;
|
|
23
|
+
socketTimeout: number;
|
|
24
|
+
disableFileAccess: boolean;
|
|
25
|
+
disableUrlAccess: boolean;
|
|
26
|
+
};
|
|
27
|
+
declare function sendEmailNotification(message: LinkNotification, options: EmailOptions): Promise<void>;
|
|
28
|
+
|
|
29
|
+
export { type EmailOptions, sendEmailNotification, smtpOptions };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { LinkNotification } from './index.mjs';
|
|
2
|
+
import '../shared/meodp.93e06e00.mjs';
|
|
3
|
+
|
|
4
|
+
interface EmailOptions {
|
|
5
|
+
host?: string;
|
|
6
|
+
port?: number;
|
|
7
|
+
user?: string;
|
|
8
|
+
password?: string;
|
|
9
|
+
from?: string;
|
|
10
|
+
to?: string;
|
|
11
|
+
}
|
|
12
|
+
declare function smtpOptions(options: EmailOptions): {
|
|
13
|
+
host: string | undefined;
|
|
14
|
+
port: number;
|
|
15
|
+
secure: boolean;
|
|
16
|
+
requireTLS: boolean;
|
|
17
|
+
auth: {
|
|
18
|
+
user: string | undefined;
|
|
19
|
+
pass: string | undefined;
|
|
20
|
+
};
|
|
21
|
+
connectionTimeout: number;
|
|
22
|
+
greetingTimeout: number;
|
|
23
|
+
socketTimeout: number;
|
|
24
|
+
disableFileAccess: boolean;
|
|
25
|
+
disableUrlAccess: boolean;
|
|
26
|
+
};
|
|
27
|
+
declare function sendEmailNotification(message: LinkNotification, options: EmailOptions): Promise<void>;
|
|
28
|
+
|
|
29
|
+
export { type EmailOptions, sendEmailNotification, smtpOptions };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { LinkNotification } from './index.js';
|
|
2
|
+
import '../shared/meodp.93e06e00.js';
|
|
3
|
+
|
|
4
|
+
interface EmailOptions {
|
|
5
|
+
host?: string;
|
|
6
|
+
port?: number;
|
|
7
|
+
user?: string;
|
|
8
|
+
password?: string;
|
|
9
|
+
from?: string;
|
|
10
|
+
to?: string;
|
|
11
|
+
}
|
|
12
|
+
declare function smtpOptions(options: EmailOptions): {
|
|
13
|
+
host: string | undefined;
|
|
14
|
+
port: number;
|
|
15
|
+
secure: boolean;
|
|
16
|
+
requireTLS: boolean;
|
|
17
|
+
auth: {
|
|
18
|
+
user: string | undefined;
|
|
19
|
+
pass: string | undefined;
|
|
20
|
+
};
|
|
21
|
+
connectionTimeout: number;
|
|
22
|
+
greetingTimeout: number;
|
|
23
|
+
socketTimeout: number;
|
|
24
|
+
disableFileAccess: boolean;
|
|
25
|
+
disableUrlAccess: boolean;
|
|
26
|
+
};
|
|
27
|
+
declare function sendEmailNotification(message: LinkNotification, options: EmailOptions): Promise<void>;
|
|
28
|
+
|
|
29
|
+
export { type EmailOptions, sendEmailNotification, smtpOptions };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
function smtpOptions(options) {
|
|
2
|
+
for (const key of ["host", "user", "password", "from", "to"]) {
|
|
3
|
+
if (!options[key]?.trim())
|
|
4
|
+
throw new Error(`Missing email.${key}; configure SMTP before enabling email.`);
|
|
5
|
+
}
|
|
6
|
+
const port = options.port ?? 465;
|
|
7
|
+
if (![465, 587].includes(port))
|
|
8
|
+
throw new Error("Email port must be 465 (TLS) or 587 (STARTTLS).");
|
|
9
|
+
return {
|
|
10
|
+
host: options.host,
|
|
11
|
+
port,
|
|
12
|
+
secure: port === 465,
|
|
13
|
+
requireTLS: true,
|
|
14
|
+
auth: { user: options.user, pass: options.password },
|
|
15
|
+
connectionTimeout: 15e3,
|
|
16
|
+
greetingTimeout: 15e3,
|
|
17
|
+
socketTimeout: 3e4,
|
|
18
|
+
disableFileAccess: true,
|
|
19
|
+
disableUrlAccess: true
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
async function sendEmailNotification(message, options) {
|
|
23
|
+
const connection = smtpOptions(options);
|
|
24
|
+
const { default: nodemailer } = await import('nodemailer');
|
|
25
|
+
const transport = nodemailer.createTransport(connection);
|
|
26
|
+
try {
|
|
27
|
+
const result = await transport.sendMail({ subject: message.subject, text: message.text, from: options.from, to: options.to });
|
|
28
|
+
if (result.rejected.length)
|
|
29
|
+
throw new Error("SMTP rejected one or more recipients.");
|
|
30
|
+
} finally {
|
|
31
|
+
transport.close();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export { sendEmailNotification, smtpOptions };
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const node_crypto = require('node:crypto');
|
|
4
|
+
|
|
5
|
+
function truncate(text, limit) {
|
|
6
|
+
const characters = Array.from(String(text));
|
|
7
|
+
return characters.length > limit ? `${characters.slice(0, limit).join("")}
|
|
8
|
+
\u2026\u5B8C\u6574\u5185\u5BB9\u8BF7\u67E5\u770B\u62A5\u544A\u3002` : characters.join("");
|
|
9
|
+
}
|
|
10
|
+
function safeLink(url) {
|
|
11
|
+
let parsed;
|
|
12
|
+
try {
|
|
13
|
+
parsed = new URL(url);
|
|
14
|
+
} catch {
|
|
15
|
+
throw new Error("Notification links must use HTTP(S) without credentials.");
|
|
16
|
+
}
|
|
17
|
+
if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password)
|
|
18
|
+
throw new Error("Notification links must use HTTP(S) without credentials.");
|
|
19
|
+
return parsed.href;
|
|
20
|
+
}
|
|
21
|
+
function signFeishu(timestamp, secret) {
|
|
22
|
+
return node_crypto.createHmac("sha256", `${timestamp}
|
|
23
|
+
${secret}`).digest("base64");
|
|
24
|
+
}
|
|
25
|
+
function createFeishuCard(message, { keyword = "", reportUrl, runUrl, reportLabel = "\u67E5\u770B\u5B8C\u6574\u62A5\u544A", timeZone = "UTC", maxItems = 6 } = {}) {
|
|
26
|
+
if (!Number.isSafeInteger(maxItems) || maxItems < 1 || maxItems > 6)
|
|
27
|
+
throw new Error("maxItems must be between 1 and 6.");
|
|
28
|
+
const text = (content) => ({ tag: "plain_text", content });
|
|
29
|
+
const elements = [];
|
|
30
|
+
if (message.test)
|
|
31
|
+
elements.push({ tag: "div", text: text(message.text) });
|
|
32
|
+
if (message.report) {
|
|
33
|
+
const { total, reachable, restricted, unavailable } = message.report.summary;
|
|
34
|
+
elements.push({
|
|
35
|
+
tag: "div",
|
|
36
|
+
fields: [["\u{1F310} \u68C0\u6D4B\u7AD9\u70B9", total], ["\u2705 \u53EF\u8BBF\u95EE", reachable], ["\u{1F7E0} \u8BBF\u95EE\u53D7\u9650", restricted], ["\u{1F534} \u672C\u6B21\u4E0D\u53EF\u8BBF\u95EE", unavailable]].map(([label, value]) => ({ is_short: true, text: text(`${label}
|
|
37
|
+
${value}`) }))
|
|
38
|
+
});
|
|
39
|
+
const date = new Date(message.report.completedAt);
|
|
40
|
+
const time = Number.isNaN(date.valueOf()) ? message.report.completedAt : new Intl.DateTimeFormat("zh-CN", {
|
|
41
|
+
timeZone,
|
|
42
|
+
dateStyle: "medium",
|
|
43
|
+
timeStyle: "short",
|
|
44
|
+
hour12: false
|
|
45
|
+
}).format(date);
|
|
46
|
+
elements.push({ tag: "note", elements: [text(`${message.test ? "\u793A\u4F8B\u5FEB\u7167" : "\u68C0\u6D4B\u5B8C\u6210"}\uFF1A${time}\uFF08${timeZone}\uFF09
|
|
47
|
+
\u68C0\u6D4B\u73AF\u5883\uFF1A${truncate(message.report.observer, 80)}`)] });
|
|
48
|
+
if (message.entries?.length) {
|
|
49
|
+
elements.push({ tag: "hr" }, { tag: "markdown", content: "**\u72B6\u6001\u6458\u8981**" });
|
|
50
|
+
for (const { item, reason } of message.entries.slice(0, maxItems)) {
|
|
51
|
+
elements.push({ tag: "div", text: text(`${reason} \xB7 ${truncate(item.name || item.url, 60)}
|
|
52
|
+
${truncate(item.url, 160)}
|
|
53
|
+
HTTP / \u539F\u56E0\uFF1A${truncate(item.httpStatus ?? item.reason ?? "\u2014", 80)} \xB7 \u8FDE\u7EED\u5931\u8D25 ${item.consecutiveFailures} \u6B21`) });
|
|
54
|
+
}
|
|
55
|
+
if (message.entries.length > maxItems)
|
|
56
|
+
elements.push({ tag: "note", elements: [text(`\u53E6\u6709 ${message.entries.length - maxItems} \u9879\uFF0C\u70B9\u51FB\u4E0B\u65B9\u67E5\u770B\u5B8C\u6574\u62A5\u544A\u3002`)] });
|
|
57
|
+
} else {
|
|
58
|
+
elements.push({ tag: "div", text: text("\u672C\u6B21\u6CA1\u6709\u9700\u8981\u5173\u6CE8\u7684\u72B6\u6001\u53D8\u5316\u3002") });
|
|
59
|
+
}
|
|
60
|
+
} else if (!message.test) {
|
|
61
|
+
elements.push({ tag: "div", text: text(truncate(message.text, 2500)) });
|
|
62
|
+
}
|
|
63
|
+
const actions = [];
|
|
64
|
+
for (const [label, url] of [[reportLabel, reportUrl], ["\u67E5\u770B\u672C\u6B21\u8FD0\u884C", runUrl]]) {
|
|
65
|
+
if (url)
|
|
66
|
+
actions.push({ tag: "button", text: text(label), type: actions.length ? "default" : "primary", url: safeLink(url) });
|
|
67
|
+
}
|
|
68
|
+
if (actions.length)
|
|
69
|
+
elements.push({ tag: "action", actions });
|
|
70
|
+
elements.push({ tag: "note", elements: [text("\u8BBF\u95EE\u53D7\u9650\u4E0D\u7B49\u4E8E\u5931\u6548\uFF1B\u8FDE\u7EED\u5931\u8D25\u6B21\u6570\u4E0D\u4EE3\u8868\u671F\u95F4\u6301\u7EED\u5B95\u673A\u3002")] });
|
|
71
|
+
const template = message.test ? "blue" : message.report?.summary?.unavailable ? "orange" : message.report?.summary?.restricted ? "yellow" : "green";
|
|
72
|
+
return {
|
|
73
|
+
config: { wide_screen_mode: true, enable_forward: false },
|
|
74
|
+
header: { template, title: text(`${keyword ? `${truncate(keyword, 64)} \xB7 ` : ""}${truncate(message.subject, 100)}`) },
|
|
75
|
+
elements
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function createFeishuPayload(message, options = {}) {
|
|
79
|
+
const payload = { msg_type: "interactive", card: createFeishuCard(message, options) };
|
|
80
|
+
if (options.secret) {
|
|
81
|
+
payload.timestamp = String(options.timestamp ?? Math.floor(Date.now() / 1e3));
|
|
82
|
+
payload.sign = signFeishu(payload.timestamp, options.secret);
|
|
83
|
+
}
|
|
84
|
+
return payload;
|
|
85
|
+
}
|
|
86
|
+
async function requestFeishu(url, init, fetcher, phase) {
|
|
87
|
+
let response;
|
|
88
|
+
try {
|
|
89
|
+
response = await fetcher(url, { ...init, redirect: "error", signal: AbortSignal.timeout(15e3) });
|
|
90
|
+
} catch {
|
|
91
|
+
throw new Error(`Feishu ${phase} request failed or timed out; check delivery before retrying.`);
|
|
92
|
+
}
|
|
93
|
+
if (!response.ok)
|
|
94
|
+
throw new Error(`Feishu ${phase} returned HTTP ${response.status}.`);
|
|
95
|
+
let result;
|
|
96
|
+
try {
|
|
97
|
+
result = await response.json();
|
|
98
|
+
} catch {
|
|
99
|
+
throw new Error(`Feishu ${phase} returned an invalid response; check delivery before retrying.`);
|
|
100
|
+
}
|
|
101
|
+
if (!result || typeof result !== "object")
|
|
102
|
+
throw new Error(`Feishu ${phase} returned an invalid response; check delivery before retrying.`);
|
|
103
|
+
const code = ("code" in result ? result.code : void 0) ?? ("StatusCode" in result ? result.StatusCode : void 0);
|
|
104
|
+
if (code !== 0)
|
|
105
|
+
throw new Error(`Feishu ${phase} rejected the request (code ${Number.isInteger(code) ? code : "unknown"}). Check the bot configuration and permissions.`);
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
async function sendFeishuNotification(message, options, fetcher = fetch) {
|
|
109
|
+
const transport = options.transport || "webhook";
|
|
110
|
+
if (!["app", "webhook"].includes(transport))
|
|
111
|
+
throw new Error("feishu.transport must be app or webhook.");
|
|
112
|
+
if (transport === "app") {
|
|
113
|
+
for (const [name, value] of [["feishu.appId", options.appId], ["feishu.appSecret", options.appSecret], ["feishu.receiveId", options.receiveId]]) {
|
|
114
|
+
if (!value?.trim())
|
|
115
|
+
throw new Error(`Configure ${name} in the notification configuration.`);
|
|
116
|
+
}
|
|
117
|
+
const receiveIdType = options.receiveIdType || "open_id";
|
|
118
|
+
if (!["open_id", "user_id", "union_id", "email"].includes(receiveIdType))
|
|
119
|
+
throw new Error("feishu.receiveIdType must be open_id, user_id, union_id, or email for direct messages.");
|
|
120
|
+
const card = createFeishuCard(message, options);
|
|
121
|
+
const auth = await requestFeishu("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "Content-Type": "application/json" },
|
|
124
|
+
body: JSON.stringify({ app_id: options.appId, app_secret: options.appSecret })
|
|
125
|
+
}, fetcher, "authentication");
|
|
126
|
+
if (!("tenant_access_token" in auth) || typeof auth.tenant_access_token !== "string" || !auth.tenant_access_token)
|
|
127
|
+
throw new Error("Feishu authentication returned no tenant access token.");
|
|
128
|
+
const content = JSON.stringify(card);
|
|
129
|
+
const uuid = node_crypto.createHash("sha256").update(`${options.appId}
|
|
130
|
+
${receiveIdType}
|
|
131
|
+
${options.receiveId}
|
|
132
|
+
${content}`).digest("hex").slice(0, 40);
|
|
133
|
+
const result = await requestFeishu(`https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${receiveIdType}`, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${auth.tenant_access_token}` },
|
|
136
|
+
body: JSON.stringify({ receive_id: options.receiveId, msg_type: "interactive", content, uuid })
|
|
137
|
+
}, fetcher, "direct message");
|
|
138
|
+
if (!("data" in result) || !result.data || typeof result.data !== "object" || !("message_id" in result.data) || typeof result.data.message_id !== "string" || !result.data.message_id) {
|
|
139
|
+
throw new Error("Feishu returned no message ID; check delivery before retrying.");
|
|
140
|
+
}
|
|
141
|
+
return { messageId: result.data.message_id };
|
|
142
|
+
}
|
|
143
|
+
let webhook;
|
|
144
|
+
try {
|
|
145
|
+
webhook = new URL(options.webhook || "");
|
|
146
|
+
} catch {
|
|
147
|
+
throw new Error("Configure feishu.webhook in the notification configuration.");
|
|
148
|
+
}
|
|
149
|
+
if (webhook.protocol !== "https:" || !["open.feishu.cn", "open.larksuite.com"].includes(webhook.hostname) || !/^\/open-apis\/bot\/v2\/hook\/[a-z0-9-]+$/i.test(webhook.pathname) || webhook.username || webhook.password || webhook.search || webhook.hash || webhook.port) {
|
|
150
|
+
throw new Error("feishu.webhook must be an official Feishu/Lark custom bot webhook.");
|
|
151
|
+
}
|
|
152
|
+
await requestFeishu(webhook.href, {
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers: { "Content-Type": "application/json" },
|
|
155
|
+
body: JSON.stringify(createFeishuPayload(message, options))
|
|
156
|
+
}, fetcher, "webhook");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
exports.createFeishuCard = createFeishuCard;
|
|
160
|
+
exports.createFeishuPayload = createFeishuPayload;
|
|
161
|
+
exports.sendFeishuNotification = sendFeishuNotification;
|
|
162
|
+
exports.signFeishu = signFeishu;
|