qq-bot-roost 1.0.0 → 1.0.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.
package/README.md CHANGED
@@ -8,9 +8,9 @@
8
8
 
9
9
  <img src="https://img.shields.io/badge/Node.js-22.13%2B-339933?logo=node.js&logoColor=white" alt="Node.js 22.13+">
10
10
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="零依赖">
11
- <img src="https://img.shields.io/badge/tests-737%20passed-brightgreen" alt="测试 737 项">
11
+ <img src="https://img.shields.io/badge/tests-771%20passed-brightgreen" alt="测试 771 项">
12
12
  <img src="https://img.shields.io/badge/plugins-JS%20%7C%20Python%20%7C%20Go%20%7C%20any-blue" alt="多语言插件">
13
- <img src="https://img.shields.io/badge/version-1.0.0-blueviolet" alt="版本 1.0.0">
13
+ <img src="https://img.shields.io/badge/version-1.0.1-blueviolet" alt="版本 1.0.1">
14
14
  <img src="https://img.shields.io/badge/license-Non--Commercial-red" alt="禁止商用">
15
15
  <a href="https://github.com/tdcq9594/qq-bot-roost/actions"><img src="https://img.shields.io/github/actions/workflow/status/tdcq9594/qq-bot-roost/ci.yml?branch=master" alt="CI"></a>
16
16
 
@@ -76,6 +76,7 @@
76
76
  - **QQ号 / 群号 ↔ openid 绑定**:换机器人(换 AppID)之后 openid 会变,而记忆是按 openid 存的。绑定之后,读记忆会把同一个号码名下的所有键一起读——**存量数据一个字节不动**
77
77
  - **面板「身份」页**:绑定、追加、改绑、停用;未识别的人也能看到
78
78
  - **群内指令(0 / 1 级)**:`/绑定群号 <群号>`、`/绑定成员 @某人 <QQ号>`。只绑当前群和当前群里被 @ 的人,冲突不强制改绑
79
+ - **普通成员自助绑定**:`/绑定QQ <QQ号>` → 验证码发到 `<QQ号>@qq.com` → `/QQ验证码 <6位数字>` 完成绑定,**不需要管理员**;QQ 号已有绑定记录时直接拒绝。四条绑定指令在 `/help` 的「绑定」分组里,与「管理」同级(管理员看到四条,普通成员只看到自助两条)
79
80
  - **消息带四个字段**:`uin` / `groupUin` / `userAliases` / `groupAliases`。插件用 `ctx.identity` 只读视图查,绑定表由框架维护
80
81
  - 配套升级:`dsh-roost` 1.0.0(别名合并读取 + 账号迁移页)、`ai-chat` 4.4.1(透传字段)。**三个一起升**,少一个号码就传不到 DSH
81
82
 
@@ -150,6 +151,7 @@
150
151
  - 实时日志流(SSE),按级别和来源着色
151
152
  - 一个面板管多个机器人,顶栏随时切换
152
153
  - 插件卡片直接显示它自己上报的运行数据(已回复条数、在线人数…)
154
+ - **「通知」页**(在「身份」与「关于」之间):配任意邮箱服务商的 SMTP、一键发测试邮件到主人邮箱,普通成员自助绑定的说明也在这里
153
155
  - 日间 / 夜间主题,启动自动开浏览器,端口被占用自动顺延
154
156
 
155
157
  ---
@@ -277,6 +279,7 @@ docker compose up -d
277
279
  | 群组 | 全局主人增删、群档案(邀请人 / 群主 / 状态)、手动登记群 |
278
280
  | 权限 | 等级与作用域说明、按作用域增删管理员、已授权列表 |
279
281
  | 日志 | SSE 实时流,按级别和来源过滤,支持关键词搜索 |
282
+ | 通知 | SMTP 服务器 / 端口 / 用户名 / 授权码 / 发件人 / 主人邮箱、测试邮件通知、普通成员自助绑定说明(位于顶栏「身份」与「关于」之间) |
280
283
 
281
284
  事件页把官方 47 个事件全列出来,灰掉的是当前 intents 没订阅的:
282
285
 
@@ -312,6 +315,8 @@ node src/main.js --mock --serve
312
315
  | `/whoami` | 查自己的 openid / 群 openid |
313
316
  | `/绑定群号 <群号>` | 把**当前群**绑到群号(仅 0 / 1 级;冲突不强制改绑) |
314
317
  | `/绑定成员 @某人 <QQ号>` | 把**当前群里被 @ 的人**绑到 QQ号(仅 0 / 1 级) |
318
+ | `/绑定QQ <QQ号>` | **普通成员自助绑定**:验证码发到 `<QQ号>@qq.com`;需先在面板「通知」页配好 SMTP |
319
+ | `/QQ验证码 <6位数字>` | 核对上一条的验证码,完成绑定(5 分钟有效,最多试 5 次) |
315
320
  | `/claim <认领码>` | 认领主人身份(`owners` 为空时可用) |
316
321
  | `/ping` | 连通性测试 |
317
322
 
@@ -381,6 +386,7 @@ export default {
381
386
  | `ctx.identity` | **只读**身份视图:`openidOf(uin)` / `uinOf(openid)` / `aliasesOf(uin)` / `groupOf(openid)`。绑定表由框架维护,插件不能写 |
382
387
  | `ctx.registerTool(工具)` | 注册一个 AI 可调用的工具(跨插件,0.8.6+),返回注销函数 |
383
388
  | `ctx.tools()` / `ctx.callTool(名, 参数)` | 列出 / 调用别插件注册的工具 |
389
+ | `ctx.mail.send(收件人, 内容)` / `ctx.mail.configured` | **邮件通知**:收件人填完整邮箱(任意服务商)或 QQ 号;SMTP 在面板「通知」页配,见[插件开发规范](docs/插件开发规范.md) |
384
390
  | `msg.reply` / `push` / `replyMarkdown` / `replyImage` / … | 各种发送方式 |
385
391
 
386
392
  ---
@@ -424,6 +430,7 @@ bot/
424
430
  │ ├── webhook.js Webhook 接收服务(多机器人共用一个端口)
425
431
  │ ├── sign.js Webhook 回调签名(Ed25519)
426
432
  │ ├── qq/ 扫码一键建机器人(qrlogin.js / qrsvg.js)
433
+ │ ├── mail/ 零依赖 SMTP 客户端、邮件服务、验证码、自助绑定命令
427
434
  │ ├── tarball.js tar.gz 下载解包(不依赖 git)
428
435
  │ ├── updater.js 框架自身检查更新与升级
429
436
  │ ├── semver.js 版本比较与范围匹配(框架更新/市场/兼容校验共用)
@@ -438,7 +445,7 @@ bot/
438
445
  │ ├── web/server.js 面板服务端(REST + SSE)
439
446
  │ └── plugin/ 插件加载、配置、依赖、子进程宿主、面板页面注册
440
447
  ├── test/
441
- │ ├── selftest.mjs 框架自检(538 项)
448
+ │ ├── selftest.mjs 框架自检(622 项)
442
449
  │ ├── e2e.mjs 端到端测试(243 项,起真实服务打接口)
443
450
  │ ├── qrlogin.mjs 扫码建机器人(8 项)
444
451
  │ └── deps.mjs 插件依赖安装(11 项)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qq-bot-roost",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "type": "module",
5
5
  "description": "栖 Roost:零依赖的 QQ 官方机器人框架,填 AppID/AppSecret 即连,插件可写任意语言",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -29,9 +29,9 @@
29
29
  "start": "node src/main.js",
30
30
  "mock": "node src/main.js --mock",
31
31
  "check": "node src/main.js --check",
32
- "test": "node test/selftest.mjs && node test/qrlogin.mjs && node test/deps.mjs && node --test test/trust.mjs && node --test test/manifest.mjs && node --test test/trace.mjs && node --test test/fuzz-tar.mjs && node --test test/group-name.mjs && node --test test/identity.mjs && node --test test/identity-commands.mjs",
32
+ "test": "node test/selftest.mjs && node test/qrlogin.mjs && node test/deps.mjs && node --test test/trust.mjs && node --test test/manifest.mjs && node --test test/trace.mjs && node --test test/fuzz-tar.mjs && node --test test/group-name.mjs && node --test test/identity.mjs && node --test test/identity-commands.mjs && node --test test/mail.mjs",
33
33
  "test:e2e": "node test/e2e.mjs",
34
- "test:all": "node test/selftest.mjs && node test/qrlogin.mjs && node test/deps.mjs && node --test test/trust.mjs && node --test test/manifest.mjs && node --test test/trace.mjs && node --test test/fuzz-tar.mjs && node --test test/group-name.mjs && node --test test/identity.mjs && node --test test/identity-commands.mjs && node test/e2e.mjs && node test/live-driver.mjs",
34
+ "test:all": "node test/selftest.mjs && node test/qrlogin.mjs && node test/deps.mjs && node --test test/trust.mjs && node --test test/manifest.mjs && node --test test/trace.mjs && node --test test/fuzz-tar.mjs && node --test test/group-name.mjs && node --test test/identity.mjs && node --test test/identity-commands.mjs && node --test test/mail.mjs && node test/e2e.mjs && node test/live-driver.mjs",
35
35
  "check:secrets": "node scripts/check-secrets.mjs",
36
36
  "create": "node scripts/create-plugin.mjs",
37
37
  "test:plugin": "node scripts/test-plugin.mjs",
package/src/config.js CHANGED
@@ -4,6 +4,7 @@ import readline from 'node:readline/promises';
4
4
  import { stdin, stdout } from 'node:process';
5
5
  import { ROOT, DIRS, PATHS, botPaths, ensureDirs, listBotIds, rel } from './paths.js';
6
6
  import { createLogger } from './logger.js';
7
+ import { SMTP_DEFAULTS } from './mail/defaults.js';
7
8
 
8
9
  const log = createLogger('config');
9
10
 
@@ -171,6 +172,15 @@ const GLOBAL_DEFAULTS = {
171
172
  */
172
173
  ignoreFrameworkVersion: false,
173
174
  },
175
+
176
+ /**
177
+ * 邮箱通知 / SMTP(全局一份,所有机器人共用这个发信通道)。
178
+ * 字段对齐 BetterGI「设置 → 通知」的邮件部分;面板上就是「通知」页。
179
+ * 走 saveGlobalConfig(排除式写入),新字段会自动落盘,不用改白名单。
180
+ */
181
+ notifications: {
182
+ smtp: { ...SMTP_DEFAULTS },
183
+ },
174
184
  };
175
185
 
176
186
  /** 机器人配置:管连接与行为,每个机器人一份 */
package/src/identity.js CHANGED
@@ -486,7 +486,7 @@ export class IdentityStore {
486
486
  * 副作用要知道:给已停用的号码**补绑一条新 openid**(换 AppID 的常规操作)
487
487
  * 会顺带把这个号码重新启用,记忆又开始被读到。面板上会提示。
488
488
  */
489
- async bind({ kind = 'user', uin, openid, botId = '', force = false } = {}) {
489
+ async bind({ kind = 'user', uin, openid, botId = '', force = false, verifiedBy = 'panel' } = {}) {
490
490
  const id = String(uin ?? '').trim();
491
491
  if (!isValidUin(id)) throw invalidError(`号码不合法:${uin}(要 5–12 位数字)`, { kind, uin: id });
492
492
  if (!isOpenid(openid)) throw invalidError('openid 不合法(空、过长或含空白)', { kind, uin: id });
@@ -558,7 +558,8 @@ export class IdentityStore {
558
558
  rec.status = 'active';
559
559
  rec.statusUpdatedAt = now;
560
560
  rec.updatedAt = now;
561
- rec.verifiedBy = 'panel';
561
+ /** panel = 主人在面板/管理员指认;email = 成员自己用 QQ 邮箱验证码验证 */
562
+ rec.verifiedBy = String(verifiedBy || 'panel');
562
563
  rec.verifiedAt = now;
563
564
  if (!rec.createdAt) rec.createdAt = now;
564
565
 
@@ -0,0 +1,117 @@
1
+ /**
2
+ * 普通成员自助绑定 QQ 号的两条命令(纯逻辑,「消息 → 结果文本」)。
3
+ *
4
+ * 和 `identity-commands.js` 一样的规矩:永远返回 { ok, text },不抛异常。
5
+ * 命令本身注册在 `main.js` 的 `core()` 里 —— 插件拿不到身份表的写权限
6
+ * (`ctx.identity` 是只读视图),只有内核的 `sharedIdentity()` 能 bind。
7
+ *
8
+ * ## 命令名坑
9
+ * `message.js:417` 会把命令名 `.toLowerCase()`,所以注册时必须用
10
+ * `绑定qq` / `qq验证码`(写成大写 Q 永远匹配不上)。
11
+ */
12
+
13
+ import { isValidUin } from '../identity.js';
14
+ import { verifyMail } from './verify.js';
15
+
16
+ /** 只认 QQ 号;顺手剥掉用户可能多打的 @qq.com 后缀 */
17
+ export function parseUinArg(args = []) {
18
+ for (const v of Array.isArray(args) ? args : []) {
19
+ const s = String(v ?? '').trim().replace(/@qq\.com$/i, '');
20
+ if (isValidUin(s)) return s;
21
+ }
22
+ return '';
23
+ }
24
+
25
+ export function parseCodeArg(args = []) {
26
+ for (const v of Array.isArray(args) ? args : []) {
27
+ const s = String(v ?? '').trim();
28
+ if (/^\d{6}$/.test(s)) return s;
29
+ }
30
+ return '';
31
+ }
32
+
33
+ const NO_MAIL = '机器人还没配好邮箱,暂时没法自助绑定。请找主人用「绑定成员 @你 QQ号」代绑。';
34
+
35
+ /**
36
+ * 冲突检查:按需求「直接拒绝」,不做 force、不做改绑、不覆盖任何已有记录。
37
+ * 发验证码前和核对通过后各查一次(中间这 5 分钟里可能被人抢先绑了)。
38
+ */
39
+ function conflictOf(identity, botId, openid, uin) {
40
+ // 这个人在这只鸟下已经绑过号了
41
+ const mine = identity.resolveUser?.(botId, openid);
42
+ if (mine?.uin && String(mine.uin) !== uin) {
43
+ return `你已经绑过 QQ 号 ${mine.uin} 了,自助绑定不能改绑。要换号请找主人在面板处理。`;
44
+ }
45
+ // 这个号已经属于别人
46
+ const owner = String(identity.openidOf?.(botId, uin) ?? '').trim();
47
+ if (owner && owner !== openid) {
48
+ return `QQ 号 ${uin} 已经绑给别人了,直接拒绝。确认是这个号请找主人在面板处理。`;
49
+ }
50
+ // 有记录但本 bot 下查不到 openid(停用 / 被解绑 / 挂在别的机器人下):一样拒绝
51
+ if (!owner && identity.has?.('user', uin)) {
52
+ return `QQ 号 ${uin} 已有绑定记录,自助绑定不覆盖已有记录。请找主人确认。`;
53
+ }
54
+ return '';
55
+ }
56
+
57
+ /**
58
+ * `绑定QQ <QQ号>` → 发验证码到 `<QQ号>@qq.com`。
59
+ */
60
+ export async function runBindQq({ msg, mail, verify, identity, botId = '', log = () => {} } = {}) {
61
+ const openid = String(msg?.userId ?? '').trim();
62
+ if (!openid) return { ok: false, text: '拿不到你的身份标识,稍后再发一条消息试试。' };
63
+
64
+ const uin = parseUinArg(msg?.args);
65
+ if (!uin) {
66
+ return { ok: false, text: '用法:绑定QQ 你的QQ号(5–12 位数字)。验证码会发到「QQ号@qq.com」。' };
67
+ }
68
+ if (!mail || !mail.checkConfig().ok) return { ok: false, text: NO_MAIL };
69
+
70
+ try {
71
+ await identity.load?.();
72
+ const conflict = conflictOf(identity, botId, openid, uin);
73
+ if (conflict) return { ok: false, text: conflict };
74
+
75
+ const issued = verify.issue({ openid, uin });
76
+ try {
77
+ await mail.send({ to: issued.to, ...verifyMail({ uin, code: issued.code }) });
78
+ } catch (e) {
79
+ // 发失败就撤销记录,别让用户白等 60 秒冷却
80
+ verify.cancel?.(openid);
81
+ return { ok: false, text: `验证码没发出去:${e.message}。稍后重试,或找主人代绑。` };
82
+ }
83
+ log(`自助绑定:${openid} 申请绑定 QQ ${uin},验证码已发往 ${issued.to}`);
84
+ return {
85
+ ok: true,
86
+ text: `验证码已发到 ${issued.to},5 分钟内有效。请回复「QQ验证码 6位数字」完成绑定。`,
87
+ };
88
+ } catch (e) {
89
+ return { ok: false, text: e?.message || '申请失败,请稍后再试。' };
90
+ }
91
+ }
92
+
93
+ /**
94
+ * `QQ验证码 <6位数字>` → 核对后写入 identity.json。
95
+ * 验证码记录绑定的是发起者 openid,拿别人邮件里的码也绑不上。
96
+ */
97
+ export async function runVerifyQq({ msg, verify, identity, botId = '', log = () => {} } = {}) {
98
+ const openid = String(msg?.userId ?? '').trim();
99
+ if (!openid) return { ok: false, text: '拿不到你的身份标识,稍后再发一条消息试试。' };
100
+
101
+ const code = parseCodeArg(msg?.args);
102
+ if (!code) return { ok: false, text: '用法:QQ验证码 123456(6 位数字)' };
103
+
104
+ try {
105
+ const r = verify.consume({ openid, code });
106
+ await identity.load?.();
107
+ const conflict = conflictOf(identity, botId, openid, r.uin);
108
+ if (conflict) return { ok: false, text: conflict };
109
+
110
+ await identity.bind({ kind: 'user', uin: r.uin, openid, botId, verifiedBy: 'email' });
111
+ log(`自助绑定成功:${openid} ↔ QQ ${r.uin}(邮箱验证码)`);
112
+ return { ok: true, text: `绑定成功:QQ 号 ${r.uin} 已绑到你的账号。` };
113
+ } catch (e) {
114
+ if (e?.code === 'CONFLICT') return { ok: false, text: `这个 QQ 号有冲突:${e.message}。请找主人在面板处理。` };
115
+ return { ok: false, text: e?.message || '绑定失败,请稍后再试。' };
116
+ }
117
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * SMTP 默认值放在一个无依赖的纯数据模块里:
3
+ * `config.js`(全局默认配置)和 `mail/service.js`(运行时现读)共用同一份,
4
+ * 免得两边各写一份、以后加字段时悄悄漂移。
5
+ */
6
+
7
+ export const SMTP_DEFAULTS = Object.freeze({
8
+ enabled: false,
9
+ host: '',
10
+ port: 587,
11
+ /** auto = 按端口规则 + 服务商特例自动判定;也可手填 tls / starttls / none */
12
+ security: 'auto',
13
+ user: '',
14
+ pass: '',
15
+ from: '',
16
+ fromName: '',
17
+ /** 原「收件人邮箱」:只作测试邮件收件人。用户 QQ 邮箱由 QQ 号现拼,不在这里配 */
18
+ ownerEmail: '',
19
+
20
+ /* 高级项,面板不展示,可手改 config/bot.json */
21
+ timeoutMs: 15_000,
22
+ rejectUnauthorized: true,
23
+ maxPerMinute: 5,
24
+ dailyLimit: 50,
25
+ });
@@ -0,0 +1,275 @@
1
+ /**
2
+ * MailService —— 邮件能力的唯一入口。
3
+ *
4
+ * 职责:读全局配置(热生效)、收件地址白名单、发信限频、串行发送、测试发送。
5
+ * 不做的事(按需求明确砍掉):故障告警、失败重试队列、告警去重、
6
+ * 给主人发邮件/QQ 私聊、审计落盘。发信失败只写一行普通日志 + 把错误交给调用方展示。
7
+ *
8
+ * 三个调用方都走这里:面板「通知」页、内核自助绑定命令、`POST /api/mail/send`。
9
+ */
10
+
11
+ import { sendSmtpMail, resolveSecurity, formatSmtpError, extractAddress, parseRecipients } from './smtp.js';
12
+ import { isValidUin } from '../identity.js';
13
+ import { SMTP_DEFAULTS } from './defaults.js';
14
+
15
+ export { SMTP_DEFAULTS };
16
+
17
+ /**
18
+ * 字段与 BetterGI `NotificationConfig` 的邮件部分一一对齐
19
+ * (SmtpServer / SmtpPort / SmtpUsername / SmtpPassword / FromEmail / FromName / ToEmail),
20
+ * 只是把 ToEmail 改叫 ownerEmail:它现在只作「测试邮件收件人」。
21
+ */
22
+
23
+ export class MailError extends Error {
24
+ constructor(message, { code = 'EMAIL_FAILED' } = {}) {
25
+ super(message);
26
+ this.name = 'MailError';
27
+ this.code = code;
28
+ }
29
+ }
30
+
31
+ const dayKey = (d = new Date()) =>
32
+ `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
33
+
34
+ export class MailService {
35
+ /**
36
+ * @param {{globalCfg?:object, log?:object, identity?:object}} deps
37
+ * globalCfg 传引用,configWatch 热重载后这里立刻能读到新配置,不用重启。
38
+ * identity 是 IdentityStore(可空),用于 openid → QQ 号。
39
+ */
40
+ constructor({ globalCfg = {}, log = null, identity = null } = {}) {
41
+ this.globalCfg = globalCfg;
42
+ this.log = log;
43
+ this.identity = identity;
44
+ /** 一次只跑一封:SMTP 连接串行,也顺带把「验证码风暴」挡在队列里 */
45
+ this.chain = Promise.resolve();
46
+ this.minuteStamps = [];
47
+ this.dayCount = { key: dayKey(), n: 0 };
48
+ this.lastResult = null;
49
+ }
50
+
51
+ /** 每次现读,绝不在构造时把配置快照下来 */
52
+ cfg() {
53
+ const raw = this.globalCfg?.notifications?.smtp ?? {};
54
+ return { ...SMTP_DEFAULTS, ...raw };
55
+ }
56
+
57
+ /** 面板 GET 用:密码不回显,只回一个「已设置」标志 */
58
+ publicConfig() {
59
+ const c = this.cfg();
60
+ return {
61
+ ...c,
62
+ pass: '',
63
+ passSet: Boolean(c.pass),
64
+ resolvedSecurity: resolveSecurity(c.host, c.port, c.security),
65
+ lastResult: this.lastResult,
66
+ };
67
+ }
68
+
69
+ /** 配置是否够发信。SMTP 用户名/密码允许为空(免认证内网中继) */
70
+ checkConfig() {
71
+ const c = this.cfg();
72
+ const missing = [];
73
+ if (!c.enabled) missing.push('「启用邮箱通知」开关');
74
+ if (!String(c.host).trim()) missing.push('SMTP 服务器');
75
+ const port = Number(c.port);
76
+ if (!Number.isInteger(port) || port < 1 || port > 65535) missing.push('SMTP 端口');
77
+ if (!extractAddress(c.from)) missing.push('发件人邮箱');
78
+ return { ok: missing.length === 0, missing };
79
+ }
80
+
81
+ /**
82
+ * 把调用方给的目标解析成收件地址。
83
+ *
84
+ * 三种写法,优先级从高到低:
85
+ * 1. `openid` → 查绑定表反查 QQ 号 → `<QQ号>@qq.com`
86
+ * 2. `target` / `uin` / `to` 是**完整邮箱地址** → 原样使用(任意服务商:163 / Outlook /
87
+ * Gmail / 企业邮箱 / 自建都行),不再限制成 QQ 邮箱或主人邮箱
88
+ * 3. 纯数字的 QQ 号 → 自动补 `@qq.com`(保留自助绑定的简写)
89
+ *
90
+ * 仍然守着的边界:地址格式必须合法、发信走全局限频与串行队列、SMTP 凭据不下发给插件。
91
+ */
92
+ resolveRecipient({ target = '', uin = '', openid = '', to = '' } = {}) {
93
+ if (openid) {
94
+ const n = String(this.identity?.uinOf?.(openid) ?? '').trim();
95
+ if (!n) throw new MailError('这个用户还没绑定 QQ 号,暂时发不了邮件通知', { code: 'UNBOUND' });
96
+ return `${n}@qq.com`;
97
+ }
98
+ const raw = String(target || uin || to || '').trim();
99
+ if (!raw) throw new MailError('收件人不能为空(填完整邮箱地址,或 QQ 号)', { code: 'BAD_TO' });
100
+ const addr = extractAddress(raw);
101
+ if (addr) return addr;
102
+ if (isValidUin(raw)) return `${raw}@qq.com`;
103
+ throw new MailError('收件人要填完整邮箱地址,或 5–12 位的 QQ 号', { code: 'BAD_TO' });
104
+ }
105
+
106
+ #takeQuota() {
107
+ const c = this.cfg();
108
+ const now = Date.now();
109
+ this.minuteStamps = this.minuteStamps.filter((t) => now - t < 60_000);
110
+ const perMin = Math.max(1, Number(c.maxPerMinute) || SMTP_DEFAULTS.maxPerMinute);
111
+ const perDay = Math.max(1, Number(c.dailyLimit) || SMTP_DEFAULTS.dailyLimit);
112
+ if (this.minuteStamps.length >= perMin) {
113
+ throw new MailError(`发信太频繁(每分钟最多 ${perMin} 封),稍后再试`, { code: 'RATE_LIMIT' });
114
+ }
115
+ const key = dayKey();
116
+ if (this.dayCount.key !== key) this.dayCount = { key, n: 0 };
117
+ if (this.dayCount.n >= perDay) {
118
+ throw new MailError(`今天已发满 ${perDay} 封,明天再试(防邮箱被判垃圾邮件)`, { code: 'DAILY_LIMIT' });
119
+ }
120
+ this.minuteStamps.push(now);
121
+ this.dayCount.n += 1;
122
+ }
123
+
124
+ /** 前一次失败不能堵住队列;任务本身的结果照常返回给调用方 */
125
+ #enqueue(fn) {
126
+ const task = this.chain.then(fn, fn);
127
+ this.chain = task.catch(() => {});
128
+ return task;
129
+ }
130
+
131
+ /**
132
+ * 底层发送。`target` 由 resolveRecipient 得到,或内部直接给完整地址。
133
+ * @returns {Promise<{ok:true,to:string,messageId:string,security:string,auth:string}>}
134
+ */
135
+ async send({ to, subject, text, html = false } = {}) {
136
+ const check = this.checkConfig();
137
+ if (!check.ok) throw new MailError(`邮箱还没配好,缺:${check.missing.join('、')}`, { code: 'NOT_CONFIGURED' });
138
+ const list = parseRecipients(to);
139
+ if (!list.length) throw new MailError('收件人邮箱不合法', { code: 'BAD_TO' });
140
+ this.#takeQuota();
141
+
142
+ const c = this.cfg();
143
+ return this.#enqueue(async () => {
144
+ try {
145
+ const out = await sendSmtpMail({
146
+ host: c.host,
147
+ port: c.port,
148
+ security: c.security,
149
+ user: c.user,
150
+ pass: c.pass,
151
+ from: c.from,
152
+ fromName: c.fromName,
153
+ to: list,
154
+ subject: String(subject ?? '').replace(/[\r\n]+/g, ' ').slice(0, 200),
155
+ text: String(text ?? ''),
156
+ html,
157
+ timeoutMs: c.timeoutMs,
158
+ rejectUnauthorized: c.rejectUnauthorized,
159
+ });
160
+ this.lastResult = { ok: true, at: Date.now(), to: list.join(','), security: out.security };
161
+ this.log?.info?.(`邮件已发送 → ${list.join(',')}(${c.host}:${c.port} / ${out.security})`);
162
+ return { ok: true, to: list.join(','), ...out };
163
+ } catch (err) {
164
+ const f = formatSmtpError(err, c.host, c.port);
165
+ this.lastResult = { ok: false, at: Date.now(), to: list.join(','), code: f.code };
166
+ // 普通日志,不是「故障通知」:不额外发邮件、不推 QQ、不落盘队列
167
+ this.log?.error?.(`邮件发送失败 → ${c.host}:${c.port} [${f.code}] ${f.message}`);
168
+ throw new MailError(f.message, { code: f.code });
169
+ }
170
+ });
171
+ }
172
+
173
+ /**
174
+ * 发通知的主入口:`target` 可以是完整邮箱地址(任意服务商),也可以是 QQ 号简写。
175
+ * 框架接口 `ctx.mail.send()` 和 HTTP `/api/mail/send` 最终都走这里。
176
+ */
177
+ sendTo(target, subject, text, opts = {}) {
178
+ return this.send({ to: this.resolveRecipient({ target }), subject, text, ...opts });
179
+ }
180
+
181
+ /** QQ 号简写的便捷别名,等价 `sendTo(QQ号, ...)` */
182
+ sendToUin(uin, subject, text, opts = {}) {
183
+ return this.sendTo(uin, subject, text, opts);
184
+ }
185
+
186
+ /** 给已绑定的 openid 反查 QQ 号再发 */
187
+ sendToOpenid(openid, subject, text, opts = {}) {
188
+ return this.send({ to: this.resolveRecipient({ openid }), subject, text, ...opts });
189
+ }
190
+
191
+ /** 面板「测试邮件通知」按钮:发到主人邮箱 */
192
+ async test() {
193
+ const c = this.cfg();
194
+ const owner = extractAddress(c.ownerEmail);
195
+ if (!owner) throw new MailError('先填「主人邮箱」,测试邮件会发到这个地址', { code: 'NO_OWNER' });
196
+ const when = new Date().toLocaleString('zh-CN', { hour12: false });
197
+ const security = resolveSecurity(c.host, c.port, c.security);
198
+ return this.send({
199
+ to: owner,
200
+ subject: '【栖】邮箱通知测试',
201
+ text: [
202
+ '这是一封来自 QQ 机器人框架的测试邮件。',
203
+ '',
204
+ `发送时间:${when}`,
205
+ `SMTP 服务器:${c.host}:${c.port}`,
206
+ `加密方式:${security}(auto 会自动判定)`,
207
+ '',
208
+ '能收到这封邮件,说明 SMTP 配置正确。',
209
+ ].join('\r\n'),
210
+ });
211
+ }
212
+ }
213
+
214
+ /* ------------------------------------------------------------------ */
215
+ /* 框架接口:插件用的 ctx.mail */
216
+ /* ------------------------------------------------------------------ */
217
+
218
+ /**
219
+ * 进程级单例(照 `sharedIdentity()` 的写法)。
220
+ *
221
+ * 邮件通道是全进程唯一的一份:`config/bot.json` 里只有一组 SMTP 配置,
222
+ * 所有机器人、所有插件共用。用单例就不必把 MailService 沿着
223
+ * main → BotRuntime → PluginLoader → createContext 一路透传。
224
+ */
225
+ let shared = null;
226
+ export function setSharedMail(svc) {
227
+ shared = svc;
228
+ return shared;
229
+ }
230
+ export function sharedMail() {
231
+ return shared;
232
+ }
233
+
234
+ /**
235
+ * 生成挂到 `ctx.mail` 上的框架接口。插件只要给 **收件人 + 内容**:
236
+ *
237
+ * await ctx.mail.send('123456', '你的任务完成了'); // QQ 号 → 123456@qq.com
238
+ * await ctx.mail.send('someone@163.com', '日报已生成'); // 任意邮箱直接发
239
+ * await ctx.mail.send('someone@outlook.com', '<b>完成</b>', { subject: '提醒', html: true });
240
+ *
241
+ * 完整邮箱地址(任意服务商)原样使用,纯数字 QQ 号自动补 `@qq.com`。
242
+ * 地址格式校验、限频、串行发送、SMTP 凭据保护全在 MailService 里。
243
+ *
244
+ * @param {(()=>MailService)|MailService} getService 延迟取服务,配置热更新后仍是同一个对象
245
+ */
246
+ export function createMailApi(getService = sharedMail) {
247
+ const service = () => {
248
+ const svc = typeof getService === 'function' ? getService() : getService;
249
+ if (!svc) throw new MailError('邮件服务还没初始化(框架版本太旧或尚未启动完成)', { code: 'NO_MAIL' });
250
+ return svc;
251
+ };
252
+ return Object.freeze({
253
+ /** SMTP 是否配好了。没配好时 send() 会抛中文错误,插件可先判一下 */
254
+ get configured() {
255
+ try { return service().checkConfig().ok; } catch { return false; }
256
+ },
257
+ /**
258
+ * @param {string|number} target 完整邮箱地址,或 QQ 号(自动补 @qq.com)
259
+ * @param {string} content 邮件正文
260
+ * @param {{subject?:string, html?:boolean}} [opts] 主题默认「【栖】消息通知」
261
+ * @returns {Promise<{ok:true,to:string,messageId:string}>}
262
+ */
263
+ async send(target, content, opts = {}) {
264
+ const text = String(content ?? '').trim();
265
+ if (!text) throw new MailError('邮件内容不能为空', { code: 'EMPTY_CONTENT' });
266
+ const o = opts && typeof opts === 'object' ? opts : {};
267
+ return service().sendTo(
268
+ String(target ?? '').trim(),
269
+ String(o.subject ?? '').trim() || '【栖】消息通知',
270
+ text,
271
+ { html: o.html === true },
272
+ );
273
+ },
274
+ });
275
+ }