qq-bot-roost 0.9.11 → 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 +21 -3
- package/package.json +3 -3
- package/src/bot.js +21 -2
- package/src/config.js +10 -0
- package/src/identity-commands.js +99 -0
- package/src/identity.js +887 -0
- package/src/ledger.js +52 -15
- package/src/mail/bind-commands.js +117 -0
- package/src/mail/defaults.js +25 -0
- package/src/mail/service.js +275 -0
- package/src/mail/smtp.js +606 -0
- package/src/mail/verify.js +156 -0
- package/src/main.js +94 -2
- package/src/message.js +44 -1
- package/src/paths.js +11 -0
- package/src/plugin/context.js +63 -1
- package/src/plugin/loader.js +4 -1
- package/src/plugin/protocol.js +12 -0
- package/src/storage/index.js +83 -0
- package/src/web/qq-call.js +6 -1
- package/src/web/server.js +326 -0
- package/web/app.js +313 -0
- package/web/index.html +155 -0
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-
|
|
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-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
|
|
|
@@ -71,6 +71,15 @@
|
|
|
71
71
|
- **素材缓存**:同一个图片 url 两天内只上传一次,省富媒体频控和流量
|
|
72
72
|
- **中间件管道**:去重、白名单、限流、命令冷却都可插拔
|
|
73
73
|
|
|
74
|
+
**身份与记忆**(v1.0.0)
|
|
75
|
+
|
|
76
|
+
- **QQ号 / 群号 ↔ openid 绑定**:换机器人(换 AppID)之后 openid 会变,而记忆是按 openid 存的。绑定之后,读记忆会把同一个号码名下的所有键一起读——**存量数据一个字节不动**
|
|
77
|
+
- **面板「身份」页**:绑定、追加、改绑、停用;未识别的人也能看到
|
|
78
|
+
- **群内指令(0 / 1 级)**:`/绑定群号 <群号>`、`/绑定成员 @某人 <QQ号>`。只绑当前群和当前群里被 @ 的人,冲突不强制改绑
|
|
79
|
+
- **普通成员自助绑定**:`/绑定QQ <QQ号>` → 验证码发到 `<QQ号>@qq.com` → `/QQ验证码 <6位数字>` 完成绑定,**不需要管理员**;QQ 号已有绑定记录时直接拒绝。四条绑定指令在 `/help` 的「绑定」分组里,与「管理」同级(管理员看到四条,普通成员只看到自助两条)
|
|
80
|
+
- **消息带四个字段**:`uin` / `groupUin` / `userAliases` / `groupAliases`。插件用 `ctx.identity` 只读视图查,绑定表由框架维护
|
|
81
|
+
- 配套升级:`dsh-roost` 1.0.0(别名合并读取 + 账号迁移页)、`ai-chat` 4.4.1(透传字段)。**三个一起升**,少一个号码就传不到 DSH
|
|
82
|
+
|
|
74
83
|
**连接与容错**
|
|
75
84
|
|
|
76
85
|
- **两种收事件方式**:WebSocket 长连接(本地默认)或 **Webhook 回调**(公网 / Serverless / 容器)
|
|
@@ -142,6 +151,7 @@
|
|
|
142
151
|
- 实时日志流(SSE),按级别和来源着色
|
|
143
152
|
- 一个面板管多个机器人,顶栏随时切换
|
|
144
153
|
- 插件卡片直接显示它自己上报的运行数据(已回复条数、在线人数…)
|
|
154
|
+
- **「通知」页**(在「身份」与「关于」之间):配任意邮箱服务商的 SMTP、一键发测试邮件到主人邮箱,普通成员自助绑定的说明也在这里
|
|
145
155
|
- 日间 / 夜间主题,启动自动开浏览器,端口被占用自动顺延
|
|
146
156
|
|
|
147
157
|
---
|
|
@@ -269,6 +279,7 @@ docker compose up -d
|
|
|
269
279
|
| 群组 | 全局主人增删、群档案(邀请人 / 群主 / 状态)、手动登记群 |
|
|
270
280
|
| 权限 | 等级与作用域说明、按作用域增删管理员、已授权列表 |
|
|
271
281
|
| 日志 | SSE 实时流,按级别和来源过滤,支持关键词搜索 |
|
|
282
|
+
| 通知 | SMTP 服务器 / 端口 / 用户名 / 授权码 / 发件人 / 主人邮箱、测试邮件通知、普通成员自助绑定说明(位于顶栏「身份」与「关于」之间) |
|
|
272
283
|
|
|
273
284
|
事件页把官方 47 个事件全列出来,灰掉的是当前 intents 没订阅的:
|
|
274
285
|
|
|
@@ -302,6 +313,10 @@ node src/main.js --mock --serve
|
|
|
302
313
|
| `/enable` `/disable` `/reload [id]` | 插件管理(需全局管理员) |
|
|
303
314
|
| `/群插件` | 看 / 改**这个群**能用哪些插件(群管理;面板「群组 → 插件」也能改) |
|
|
304
315
|
| `/whoami` | 查自己的 openid / 群 openid |
|
|
316
|
+
| `/绑定群号 <群号>` | 把**当前群**绑到群号(仅 0 / 1 级;冲突不强制改绑) |
|
|
317
|
+
| `/绑定成员 @某人 <QQ号>` | 把**当前群里被 @ 的人**绑到 QQ号(仅 0 / 1 级) |
|
|
318
|
+
| `/绑定QQ <QQ号>` | **普通成员自助绑定**:验证码发到 `<QQ号>@qq.com`;需先在面板「通知」页配好 SMTP |
|
|
319
|
+
| `/QQ验证码 <6位数字>` | 核对上一条的验证码,完成绑定(5 分钟有效,最多试 5 次) |
|
|
305
320
|
| `/claim <认领码>` | 认领主人身份(`owners` 为空时可用) |
|
|
306
321
|
| `/ping` | 连通性测试 |
|
|
307
322
|
|
|
@@ -368,8 +383,10 @@ export default {
|
|
|
368
383
|
| `ctx.storage.get/set/all` | 插件私有存档 |
|
|
369
384
|
| `ctx.config` / `ctx.setConfig()` | 插件配置读写 |
|
|
370
385
|
| `ctx.can` / `ctx.levelOf` / `ctx.scopeOf` | 权限判断 |
|
|
386
|
+
| `ctx.identity` | **只读**身份视图:`openidOf(uin)` / `uinOf(openid)` / `aliasesOf(uin)` / `groupOf(openid)`。绑定表由框架维护,插件不能写 |
|
|
371
387
|
| `ctx.registerTool(工具)` | 注册一个 AI 可调用的工具(跨插件,0.8.6+),返回注销函数 |
|
|
372
388
|
| `ctx.tools()` / `ctx.callTool(名, 参数)` | 列出 / 调用别插件注册的工具 |
|
|
389
|
+
| `ctx.mail.send(收件人, 内容)` / `ctx.mail.configured` | **邮件通知**:收件人填完整邮箱(任意服务商)或 QQ 号;SMTP 在面板「通知」页配,见[插件开发规范](docs/插件开发规范.md) |
|
|
373
390
|
| `msg.reply` / `push` / `replyMarkdown` / `replyImage` / … | 各种发送方式 |
|
|
374
391
|
|
|
375
392
|
---
|
|
@@ -413,6 +430,7 @@ bot/
|
|
|
413
430
|
│ ├── webhook.js Webhook 接收服务(多机器人共用一个端口)
|
|
414
431
|
│ ├── sign.js Webhook 回调签名(Ed25519)
|
|
415
432
|
│ ├── qq/ 扫码一键建机器人(qrlogin.js / qrsvg.js)
|
|
433
|
+
│ ├── mail/ 零依赖 SMTP 客户端、邮件服务、验证码、自助绑定命令
|
|
416
434
|
│ ├── tarball.js tar.gz 下载解包(不依赖 git)
|
|
417
435
|
│ ├── updater.js 框架自身检查更新与升级
|
|
418
436
|
│ ├── semver.js 版本比较与范围匹配(框架更新/市场/兼容校验共用)
|
|
@@ -427,7 +445,7 @@ bot/
|
|
|
427
445
|
│ ├── web/server.js 面板服务端(REST + SSE)
|
|
428
446
|
│ └── plugin/ 插件加载、配置、依赖、子进程宿主、面板页面注册
|
|
429
447
|
├── test/
|
|
430
|
-
│ ├── selftest.mjs 框架自检(
|
|
448
|
+
│ ├── selftest.mjs 框架自检(622 项)
|
|
431
449
|
│ ├── e2e.mjs 端到端测试(243 项,起真实服务打接口)
|
|
432
450
|
│ ├── qrlogin.mjs 扫码建机器人(8 项)
|
|
433
451
|
│ └── deps.mjs 插件依赖安装(11 项)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qq-bot-roost",
|
|
3
|
-
"version": "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",
|
|
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/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/bot.js
CHANGED
|
@@ -20,6 +20,7 @@ import { createReach } from './reach.js';
|
|
|
20
20
|
import { createLedger } from './ledger.js';
|
|
21
21
|
import { createGate } from './gate.js';
|
|
22
22
|
import { createDeliveryLedger } from './delivery.js';
|
|
23
|
+
import { sharedIdentity } from './identity.js';
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* 一个机器人的完整运行时。
|
|
@@ -68,6 +69,14 @@ export class BotRuntime {
|
|
|
68
69
|
this.gateway = null;
|
|
69
70
|
this.registry = new Registry();
|
|
70
71
|
this.groups = new GroupStore(this.paths.groups);
|
|
72
|
+
/**
|
|
73
|
+
* 身份对照表(QQ号 / 群号 ↔ openid)。
|
|
74
|
+
*
|
|
75
|
+
* **进程级单例、不按机器人拆**——它的全部意义就是横跨机器人。
|
|
76
|
+
* 在这里建而不是在 `init()` 里:`buildMessage()` 要用它,
|
|
77
|
+
* 不能依赖「init 已经跑过」。真正的读盘在 `init()` 里 `await load()`。
|
|
78
|
+
*/
|
|
79
|
+
this.identity = sharedIdentity();
|
|
71
80
|
|
|
72
81
|
/**
|
|
73
82
|
* 最近说过话的人:openid -> { openid, name, chatId, scope, at }
|
|
@@ -166,13 +175,21 @@ export class BotRuntime {
|
|
|
166
175
|
});
|
|
167
176
|
}
|
|
168
177
|
|
|
178
|
+
/**
|
|
179
|
+
* 身份表要在**插件加载和收消息之前**读好。
|
|
180
|
+
*
|
|
181
|
+
* 晚一步的话,启动后第一批消息就没有 `uin`,AI 会把老朋友当陌生人;
|
|
182
|
+
* 插件 setup 里想用 `ctx.identity` 也会查到空。
|
|
183
|
+
* 载入失败不拦启动——`load()` 内部已经把坏文件降级成空表并留了档。
|
|
184
|
+
*/
|
|
185
|
+
await this.identity.load().catch((e) => this.log.warn(`身份表载入失败:${e.message}`));
|
|
186
|
+
|
|
169
187
|
if (this.mock) {
|
|
170
188
|
this.api = this.makeMockApi ? this.makeMockApi(this) : null;
|
|
171
189
|
} else {
|
|
172
190
|
this.tokens = new TokenManager(cfg);
|
|
173
191
|
this.api = new QQApi({ tokenManager: this.tokens, baseUrl: cfg.baseUrl, metrics: this.metrics });
|
|
174
192
|
}
|
|
175
|
-
|
|
176
193
|
this.outbound = new Outbound({
|
|
177
194
|
api: this.api,
|
|
178
195
|
cfg,
|
|
@@ -233,6 +250,8 @@ export class BotRuntime {
|
|
|
233
250
|
reach: this.reach,
|
|
234
251
|
ledger: this.ledger,
|
|
235
252
|
gate: this.gate,
|
|
253
|
+
/** 身份对照表:插件通过 ctx.identity 只读查询(QQ号 → openid) */
|
|
254
|
+
identity: this.identity,
|
|
236
255
|
isOwner: (uid) => cfg.owners.includes(uid),
|
|
237
256
|
// 插件声明了 frameworkVersion 就做兼容校验;作者本地调试可以关掉
|
|
238
257
|
ignoreCompat: this.globalCfg.plugins?.ignoreFrameworkVersion === true,
|
|
@@ -770,7 +789,7 @@ export class BotRuntime {
|
|
|
770
789
|
}
|
|
771
790
|
|
|
772
791
|
/* ---- 消息事件 ---- */
|
|
773
|
-
const msg = buildMessage(eventName, d, { api: this.api, cfg, outbound, session });
|
|
792
|
+
const msg = buildMessage(eventName, d, { api: this.api, cfg, outbound, session, identity: this.identity });
|
|
774
793
|
|
|
775
794
|
/**
|
|
776
795
|
* 群名不在官方事件里(只有 `group_openid`),但框架有缓存。
|
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
|
/** 机器人配置:管连接与行为,每个机器人一份 */
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 群里的绑定指令(内核命令的实现)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么单独一个文件
|
|
5
|
+
*
|
|
6
|
+
* `main.js` 里的命令 handler 拿不到可测的上下文(registry / roles / cfg / 真消息),
|
|
7
|
+
* 逻辑写在里面只能靠端到端测。这里只做「消息 → 结果文本」的纯逻辑,
|
|
8
|
+
* 权限由调用方算好传进来(`allowed`),身份表通过参数注入。
|
|
9
|
+
*
|
|
10
|
+
* ## 三条硬规矩
|
|
11
|
+
*
|
|
12
|
+
* 1. **只绑当前群、以及当前群里被 @ 的人。** 不接受任何「把别的群的 openid
|
|
13
|
+
* 绑过来」的参数——群里发一句话就能改别人身份的入口,范围必须收到最小。
|
|
14
|
+
* 2. **不做 force。** 冲突就停,让用户去面板确认。群里一句话把别人的身份
|
|
15
|
+
* 改掉,代价比多点两下鼠标高得多。
|
|
16
|
+
* 3. **永远返回文本,不抛。** 聊天命令抛异常等于用户什么都收不到,
|
|
17
|
+
* 而发指令的人只会以为「机器人没反应」。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { isValidUin } from './identity.js';
|
|
21
|
+
|
|
22
|
+
/** 参数里可能混着 `<@openid>` 这样的 token,取**最后一个**合法号码 */
|
|
23
|
+
export function parseTargetUin(args = []) {
|
|
24
|
+
const list = Array.isArray(args) ? args : [];
|
|
25
|
+
for (let i = list.length - 1; i >= 0; i -= 1) {
|
|
26
|
+
const v = String(list[i] ?? '').trim();
|
|
27
|
+
if (isValidUin(v)) return v;
|
|
28
|
+
}
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 被 @ 的人里第一个不是机器人的 */
|
|
33
|
+
export function pickMember(mentions = []) {
|
|
34
|
+
for (const m of Array.isArray(mentions) ? mentions : []) {
|
|
35
|
+
if (!m || m.isBot) continue;
|
|
36
|
+
const id = String(m.id ?? '').trim();
|
|
37
|
+
if (id) return { id, name: String(m.name || '') };
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const NEED_ADMIN = '只有主人和管理员(0 / 1 级)能改绑定。';
|
|
43
|
+
const ONLY_GROUP = '这个指令只能在群里用。';
|
|
44
|
+
|
|
45
|
+
function bindErrorText(e) {
|
|
46
|
+
if (e?.code === 'CONFLICT') {
|
|
47
|
+
return `${e.message}。要改绑请去 Roost 面板 →「身份」页,群里不做强制改绑。`;
|
|
48
|
+
}
|
|
49
|
+
if (e?.code === 'INVALID') return `绑定失败:${e.message}`;
|
|
50
|
+
return `绑定失败:${e?.message || e}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `绑定群号 <群号>`:把**当前群**的 openid 绑到群号。
|
|
55
|
+
*/
|
|
56
|
+
export async function runBindGroup({ msg, identity, botId = '', allowed = false, log = () => {} } = {}) {
|
|
57
|
+
if (msg?.kind !== 'group') return { ok: false, text: ONLY_GROUP };
|
|
58
|
+
if (!allowed) return { ok: false, text: NEED_ADMIN };
|
|
59
|
+
|
|
60
|
+
const uin = parseTargetUin(msg.args);
|
|
61
|
+
if (!uin) return { ok: false, text: '用法:绑定群号 763366431(5–12 位数字,首位不能是 0)' };
|
|
62
|
+
|
|
63
|
+
/** 群聊时 `chatId` 就是群的 openid;只认这一个来源 */
|
|
64
|
+
const openid = String(msg.chatId || '').trim();
|
|
65
|
+
if (!openid) return { ok: false, text: '拿不到本群的 openid,让群里有人先说句话再试。' };
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await identity.bind({ kind: 'group', uin, openid, botId });
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return { ok: false, text: bindErrorText(e) };
|
|
71
|
+
}
|
|
72
|
+
log(`绑定指令:${msg.userId} 在 ${openid} 把本群绑到群号 ${uin}`);
|
|
73
|
+
return {
|
|
74
|
+
ok: true,
|
|
75
|
+
text: `已把本群绑到群号 ${uin}。以后换机器人,新群的 openid 也要绑到同一个群号。`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `绑定成员 @某人 <QQ号>`:把**当前群里被 @ 的那个人**绑到 QQ号。
|
|
81
|
+
*/
|
|
82
|
+
export async function runBindUser({ msg, identity, botId = '', allowed = false, log = () => {} } = {}) {
|
|
83
|
+
if (msg?.kind !== 'group') return { ok: false, text: ONLY_GROUP };
|
|
84
|
+
if (!allowed) return { ok: false, text: NEED_ADMIN };
|
|
85
|
+
|
|
86
|
+
const member = pickMember(msg.mentions);
|
|
87
|
+
if (!member) return { ok: false, text: '用法:绑定成员 @某人 10001(先 @ 要绑的人,再写 QQ号)' };
|
|
88
|
+
|
|
89
|
+
const uin = parseTargetUin(msg.args);
|
|
90
|
+
if (!uin) return { ok: false, text: '缺 QQ号。用法:绑定成员 @某人 10001' };
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
await identity.bind({ kind: 'user', uin, openid: member.id, botId });
|
|
94
|
+
} catch (e) {
|
|
95
|
+
return { ok: false, text: bindErrorText(e) };
|
|
96
|
+
}
|
|
97
|
+
log(`绑定指令:${msg.userId} 在 ${msg.chatId} 把 ${member.id} 绑到 QQ号 ${uin}`);
|
|
98
|
+
return { ok: true, text: `已把 ${member.name ? `@${member.name} ` : '这个人'}绑到 QQ号 ${uin}。` };
|
|
99
|
+
}
|