@koishi-ce/plugin-bind 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/lib/assets/en-US-C0a1paCV.yml +18 -0
- package/lib/assets/zh-CN-kAS4medd.yml +20 -0
- package/lib/index.d.ts +15 -0
- package/lib/index.mjs +95 -0
- package/locales/de-DE.yml +18 -0
- package/locales/en-US.yml +18 -0
- package/locales/fr-FR.yml +18 -0
- package/locales/ja-JP.yml +18 -0
- package/locales/ru-RU.yml +18 -0
- package/locales/zh-CN.yml +20 -0
- package/locales/zh-TW.yml +18 -0
- package/package.json +67 -0
- package/src/index.test.ts +60 -0
- package/src/index.ts +151 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
commands.bind:
|
|
2
|
+
description: Bind to account
|
|
3
|
+
options:
|
|
4
|
+
remove: Unbind
|
|
5
|
+
messages:
|
|
6
|
+
generated-1: |-
|
|
7
|
+
The Bind command can be used to bind user data across multiple platforms. During the bind process, the original platform's user data is fully preserved, while the target platform's user data is overwritten with the original platform's data.
|
|
8
|
+
Please make sure that the current platform is your target platform and send the following text to the bot within the original platform using your account within 5 minutes:
|
|
9
|
+
{0}
|
|
10
|
+
Once the binding is complete, you can always use "bind -r" to unbind the status.
|
|
11
|
+
generated-2: |-
|
|
12
|
+
Token verification successful! The second step will be performed next.
|
|
13
|
+
Please use your account to send the following text to the bot on the target platform within 5 minutes:
|
|
14
|
+
{0}
|
|
15
|
+
Note: The current platform is your original platform, the user data here will overwrite the data from the target platform.
|
|
16
|
+
success: Account binding has been successful!
|
|
17
|
+
remove-success: Your account has been successfully unbundled!
|
|
18
|
+
remove-original: 'Cannot disconnect: This is your original account.'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
commands.bind:
|
|
2
|
+
description: 绑定到账号
|
|
3
|
+
options:
|
|
4
|
+
remove: 解除绑定
|
|
5
|
+
messages:
|
|
6
|
+
generated-1: |-
|
|
7
|
+
Bind 指令可用于在多个平台间绑定用户数据。绑定过程中,原始平台的用户数据将完全保留,而目标平台的用户数据将被原始平台的数据所覆盖。
|
|
8
|
+
请确认当前平台是你的目标平台,并在 5 分钟内使用你的账号在原始平台内向机器人发送以下文本:
|
|
9
|
+
{0}
|
|
10
|
+
绑定完成后,你可以随时使用「bind -r」来解除绑定状态。
|
|
11
|
+
generated-2: |-
|
|
12
|
+
令牌核验成功!下面将进行第二步操作。
|
|
13
|
+
请在 5 分钟内使用你的账号在目标平台内向机器人发送以下文本:
|
|
14
|
+
{0}
|
|
15
|
+
注意:当前平台是你的原始平台,这里的用户数据将覆盖目标平台的数据。
|
|
16
|
+
self-1: 请前往原始平台输入。
|
|
17
|
+
self-2: 请前往目标平台输入。
|
|
18
|
+
success: 账号绑定成功!
|
|
19
|
+
remove-success: 账号解绑成功!
|
|
20
|
+
remove-original: 无法解除绑定:这是你的原始账号。
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Context, Schema } from "@koishi-ce/koishi";
|
|
2
|
+
//#region src/index.d.ts
|
|
3
|
+
/** 配置项 */
|
|
4
|
+
interface Config {
|
|
5
|
+
/** 令牌前缀,默认 `koishi/` */
|
|
6
|
+
tokenPrefix?: string;
|
|
7
|
+
/** 自定义令牌生成器(测试中可注入确定性实现) */
|
|
8
|
+
generateToken?: () => string;
|
|
9
|
+
}
|
|
10
|
+
declare const name = "bind";
|
|
11
|
+
declare const inject: string[];
|
|
12
|
+
declare const Config: Schema<Config>;
|
|
13
|
+
declare function apply(ctx: Context, config?: Config): void;
|
|
14
|
+
//#endregion
|
|
15
|
+
export { Config, apply, inject, name };
|
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Schema, Time } from "@koishi-ce/koishi";
|
|
2
|
+
import enUS from "./assets/en-US-C0a1paCV.yml";
|
|
3
|
+
import zhCN from "./assets/zh-CN-kAS4medd.yml";
|
|
4
|
+
//#region src/index.ts
|
|
5
|
+
const name = "bind";
|
|
6
|
+
const inject = ["database"];
|
|
7
|
+
const Config = Schema.object({ generateToken: Schema.function().hidden() });
|
|
8
|
+
/** 基于密码学安全的随机字节生成指定长度的纯数字串(250 以上的字节拒绝采样,保证分布均匀) */
|
|
9
|
+
function randomDigits(length) {
|
|
10
|
+
const result = [];
|
|
11
|
+
while (result.length < length) {
|
|
12
|
+
const bytes = new Uint8Array(length - result.length);
|
|
13
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
14
|
+
for (const byte of bytes) if (byte < 250) result.push(String(byte % 10));
|
|
15
|
+
}
|
|
16
|
+
return result.join("");
|
|
17
|
+
}
|
|
18
|
+
function apply(ctx, config = {}) {
|
|
19
|
+
ctx.i18n.define("zh-CN", zhCN);
|
|
20
|
+
ctx.i18n.define("en-US", enUS);
|
|
21
|
+
const tokens = Object.create(null);
|
|
22
|
+
const { tokenPrefix: prefix = "koishi/" } = config;
|
|
23
|
+
const { generateToken = () => `${prefix}${randomDigits(6)}` } = config;
|
|
24
|
+
/** 为当前会话用户签发一次性令牌并记录 phase,5 分钟后自动过期 */
|
|
25
|
+
function generate(session, phase) {
|
|
26
|
+
const { userId } = session;
|
|
27
|
+
if (!userId) return;
|
|
28
|
+
const token = generateToken();
|
|
29
|
+
tokens[token] = [
|
|
30
|
+
session.platform,
|
|
31
|
+
userId,
|
|
32
|
+
phase
|
|
33
|
+
];
|
|
34
|
+
ctx.setTimeout(() => delete tokens[token], 5 * Time.minute);
|
|
35
|
+
return token;
|
|
36
|
+
}
|
|
37
|
+
/** 将平台账号(platform + pid)绑定到指定用户(aid)名下 */
|
|
38
|
+
async function bind(aid, platform, pid) {
|
|
39
|
+
await ctx.database.set("binding", {
|
|
40
|
+
platform,
|
|
41
|
+
pid
|
|
42
|
+
}, { aid });
|
|
43
|
+
}
|
|
44
|
+
ctx.command("bind", { authority: 0 }).userFields(["id"]).option("remove", "-r").action(async ({ session, options }) => {
|
|
45
|
+
if (!session || !options) return;
|
|
46
|
+
if (options.remove) {
|
|
47
|
+
const { platform, userId: pid, user } = session;
|
|
48
|
+
if (!pid || !user) return;
|
|
49
|
+
const bindings = await ctx.database.get("binding", { aid: user.id });
|
|
50
|
+
const binding = bindings.find((item) => item.platform === platform && item.pid === pid);
|
|
51
|
+
if (!binding) return;
|
|
52
|
+
if (binding.aid !== binding.bid) {
|
|
53
|
+
await bind(binding.bid, platform, pid);
|
|
54
|
+
return session.text(".remove-success");
|
|
55
|
+
} else if (bindings.filter((item) => item.aid === item.bid).length === 1) return session.text(".remove-original");
|
|
56
|
+
else {
|
|
57
|
+
const authority = await session.resolve(ctx.root.config.autoAuthorize);
|
|
58
|
+
await bind((await ctx.database.create("user", { authority })).id, platform, pid);
|
|
59
|
+
return session.text(".remove-success");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const token = generate(session, +!session.isDirect);
|
|
63
|
+
return session.text(".generated-1", [token]);
|
|
64
|
+
});
|
|
65
|
+
ctx.middleware(async (session, next) => {
|
|
66
|
+
const token = session.stripped.content;
|
|
67
|
+
const data = tokens[token];
|
|
68
|
+
if (!data) return next();
|
|
69
|
+
const { userId } = session;
|
|
70
|
+
if (!userId) return next();
|
|
71
|
+
if (data[0] === session.platform && data[1] === session.userId) return session.text(`commands.bind.messages.self-${data[2] < 0 ? "2" : "1"}`);
|
|
72
|
+
delete tokens[token];
|
|
73
|
+
if (data[2] < 0) {
|
|
74
|
+
const [binding] = await ctx.database.get("binding", {
|
|
75
|
+
platform: data[0],
|
|
76
|
+
pid: data[1]
|
|
77
|
+
}, ["aid"]);
|
|
78
|
+
if (!binding) return;
|
|
79
|
+
await bind(binding.aid, session.platform, userId);
|
|
80
|
+
return session.text("commands.bind.messages.success");
|
|
81
|
+
} else {
|
|
82
|
+
const user = await ctx.database.getUser(session.platform, userId, ["id", "authority"]);
|
|
83
|
+
if (!user.authority) return session.text("internal.low-authority");
|
|
84
|
+
if (data[2]) {
|
|
85
|
+
const token = generate(session, -1);
|
|
86
|
+
return session.text("commands.bind.messages.generated-2", [token]);
|
|
87
|
+
} else {
|
|
88
|
+
await bind(user.id, data[0], data[1]);
|
|
89
|
+
return session.text("commands.bind.messages.success");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}, true);
|
|
93
|
+
}
|
|
94
|
+
//#endregion
|
|
95
|
+
export { Config, apply, inject, name };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
commands.bind:
|
|
2
|
+
description: 绑定到账号
|
|
3
|
+
options:
|
|
4
|
+
remove: 解除绑定
|
|
5
|
+
messages:
|
|
6
|
+
generated-1: |-
|
|
7
|
+
Bind 指令可用于在多个平台间绑定用户数据。绑定过程中,原始平台的用户数据将完全保留,而目标平台的用户数据将被原始平台的数据所覆盖。
|
|
8
|
+
请确认当前平台是你的目标平台,并在 5 分钟内使用你的账号在原始平台内向机器人发送以下文本:
|
|
9
|
+
{0}
|
|
10
|
+
绑定完成后,你可以随时使用「bind -r」来解除绑定状态。
|
|
11
|
+
generated-2: |-
|
|
12
|
+
令牌核验成功!下面将进行第二步操作。
|
|
13
|
+
请在 5 分钟内使用你的账号在目标平台内向机器人发送以下文本:
|
|
14
|
+
{0}
|
|
15
|
+
注意:当前平台是你的原始平台,这里的用户数据将覆盖目标平台的数据。
|
|
16
|
+
success: 账号绑定成功!
|
|
17
|
+
remove-success: 账号解绑成功!
|
|
18
|
+
remove-original: 无法解除绑定:这是你的原始账号。
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
commands.bind:
|
|
2
|
+
description: Bind to account
|
|
3
|
+
options:
|
|
4
|
+
remove: Unbind
|
|
5
|
+
messages:
|
|
6
|
+
generated-1: |-
|
|
7
|
+
The Bind command can be used to bind user data across multiple platforms. During the bind process, the original platform's user data is fully preserved, while the target platform's user data is overwritten with the original platform's data.
|
|
8
|
+
Please make sure that the current platform is your target platform and send the following text to the bot within the original platform using your account within 5 minutes:
|
|
9
|
+
{0}
|
|
10
|
+
Once the binding is complete, you can always use "bind -r" to unbind the status.
|
|
11
|
+
generated-2: |-
|
|
12
|
+
Token verification successful! The second step will be performed next.
|
|
13
|
+
Please use your account to send the following text to the bot on the target platform within 5 minutes:
|
|
14
|
+
{0}
|
|
15
|
+
Note: The current platform is your original platform, the user data here will overwrite the data from the target platform.
|
|
16
|
+
success: Account binding has been successful!
|
|
17
|
+
remove-success: Your account has been successfully unbundled!
|
|
18
|
+
remove-original: 'Cannot disconnect: This is your original account.'
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
commands.bind:
|
|
2
|
+
description: 绑定到账号
|
|
3
|
+
options:
|
|
4
|
+
remove: 解除绑定
|
|
5
|
+
messages:
|
|
6
|
+
generated-1: |-
|
|
7
|
+
Bind 指令可用于在多个平台间绑定用户数据。绑定过程中,原始平台的用户数据将完全保留,而目标平台的用户数据将被原始平台的数据所覆盖。
|
|
8
|
+
请确认当前平台是你的目标平台,并在 5 分钟内使用你的账号在原始平台内向机器人发送以下文本:
|
|
9
|
+
{0}
|
|
10
|
+
绑定完成后,你可以随时使用「bind -r」来解除绑定状态。
|
|
11
|
+
generated-2: |-
|
|
12
|
+
令牌核验成功!下面将进行第二步操作。
|
|
13
|
+
请在 5 分钟内使用你的账号在目标平台内向机器人发送以下文本:
|
|
14
|
+
{0}
|
|
15
|
+
注意:当前平台是你的原始平台,这里的用户数据将覆盖目标平台的数据。
|
|
16
|
+
success: 账号绑定成功!
|
|
17
|
+
remove-success: 账号解绑成功!
|
|
18
|
+
remove-original: 无法解除绑定:这是你的原始账号。
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
commands.bind:
|
|
2
|
+
description: 绑定到账号
|
|
3
|
+
options:
|
|
4
|
+
remove: 解除绑定
|
|
5
|
+
messages:
|
|
6
|
+
generated-1: |-
|
|
7
|
+
Bind 指令可用于在多个平台间绑定用户数据。绑定过程中,原始平台的用户数据将完全保留,而目标平台的用户数据将被原始平台的数据所覆盖。
|
|
8
|
+
请确认当前平台是你的目标平台,并在 5 分钟内使用你的账号在原始平台内向机器人发送以下文本:
|
|
9
|
+
{0}
|
|
10
|
+
绑定完成后,你可以随时使用「bind -r」来解除绑定状态。
|
|
11
|
+
generated-2: |-
|
|
12
|
+
令牌核验成功!下面将进行第二步操作。
|
|
13
|
+
请在 5 分钟内使用你的账号在目标平台内向机器人发送以下文本:
|
|
14
|
+
{0}
|
|
15
|
+
注意:当前平台是你的原始平台,这里的用户数据将覆盖目标平台的数据。
|
|
16
|
+
success: 账号绑定成功!
|
|
17
|
+
remove-success: 账号解绑成功!
|
|
18
|
+
remove-original: 无法解除绑定:这是你的原始账号。
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
commands.bind:
|
|
2
|
+
description: 绑定到账号
|
|
3
|
+
options:
|
|
4
|
+
remove: 解除绑定
|
|
5
|
+
messages:
|
|
6
|
+
generated-1: |-
|
|
7
|
+
Bind 指令可用于在多个平台间绑定用户数据。绑定过程中,原始平台的用户数据将完全保留,而目标平台的用户数据将被原始平台的数据所覆盖。
|
|
8
|
+
请确认当前平台是你的目标平台,并在 5 分钟内使用你的账号在原始平台内向机器人发送以下文本:
|
|
9
|
+
{0}
|
|
10
|
+
绑定完成后,你可以随时使用「bind -r」来解除绑定状态。
|
|
11
|
+
generated-2: |-
|
|
12
|
+
令牌核验成功!下面将进行第二步操作。
|
|
13
|
+
请在 5 分钟内使用你的账号在目标平台内向机器人发送以下文本:
|
|
14
|
+
{0}
|
|
15
|
+
注意:当前平台是你的原始平台,这里的用户数据将覆盖目标平台的数据。
|
|
16
|
+
success: 账号绑定成功!
|
|
17
|
+
remove-success: 账号解绑成功!
|
|
18
|
+
remove-original: 无法解除绑定:这是你的原始账号。
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
commands.bind:
|
|
2
|
+
description: 绑定到账号
|
|
3
|
+
options:
|
|
4
|
+
remove: 解除绑定
|
|
5
|
+
messages:
|
|
6
|
+
generated-1: |-
|
|
7
|
+
Bind 指令可用于在多个平台间绑定用户数据。绑定过程中,原始平台的用户数据将完全保留,而目标平台的用户数据将被原始平台的数据所覆盖。
|
|
8
|
+
请确认当前平台是你的目标平台,并在 5 分钟内使用你的账号在原始平台内向机器人发送以下文本:
|
|
9
|
+
{0}
|
|
10
|
+
绑定完成后,你可以随时使用「bind -r」来解除绑定状态。
|
|
11
|
+
generated-2: |-
|
|
12
|
+
令牌核验成功!下面将进行第二步操作。
|
|
13
|
+
请在 5 分钟内使用你的账号在目标平台内向机器人发送以下文本:
|
|
14
|
+
{0}
|
|
15
|
+
注意:当前平台是你的原始平台,这里的用户数据将覆盖目标平台的数据。
|
|
16
|
+
self-1: 请前往原始平台输入。
|
|
17
|
+
self-2: 请前往目标平台输入。
|
|
18
|
+
success: 账号绑定成功!
|
|
19
|
+
remove-success: 账号解绑成功!
|
|
20
|
+
remove-original: 无法解除绑定:这是你的原始账号。
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
commands.bind:
|
|
2
|
+
description: 綁定到賬號
|
|
3
|
+
options:
|
|
4
|
+
remove: 解除綁定
|
|
5
|
+
messages:
|
|
6
|
+
generated-1: |-
|
|
7
|
+
Bind 指令可用於在多個平台間綁定用戶數據。綁定過程中,原始平台的使用者數據將完全保留,而目標平台的使用者數據將被原始平台的數據所覆蓋。
|
|
8
|
+
請確認當前平台是你的目標平台,並在 5 分鐘內使用你的賬號在原始平台內向機器人發送以下文本:
|
|
9
|
+
{0}
|
|
10
|
+
綁定完成後,你可以隨時使用「bind -r」來解除綁定狀態。
|
|
11
|
+
generated-2: |-
|
|
12
|
+
令牌核驗成功!下面將進行第二步操作。
|
|
13
|
+
請在 5 分鐘內使用你的賬號在目標平台內向機器人發送以下文本:
|
|
14
|
+
{0}
|
|
15
|
+
注意:當前平台是你的原始平台,這裡的使用者數據將覆蓋目標平台的數據。
|
|
16
|
+
success: 帳號綁定成功!
|
|
17
|
+
remove-success: 賬號解綁成功!
|
|
18
|
+
remove-original: 無法解除綁定:這是你的原始賬號。
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@koishi-ce/plugin-bind",
|
|
3
|
+
"description": "Bind User Accounts Across Platforms in Koishi",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.mjs",
|
|
7
|
+
"typings": "lib/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"lib",
|
|
10
|
+
"src",
|
|
11
|
+
"locales"
|
|
12
|
+
],
|
|
13
|
+
"contributors": [
|
|
14
|
+
"Shigma <shigma10826@gmail.com>",
|
|
15
|
+
"Oppenheymu <oppenheymu@gmail.com>"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/Koishi-CE/koishi.git",
|
|
21
|
+
"directory": "plugins/common/bind"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/Koishi-CE/koishi/issues"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://koishi.chat/plugins/common/bind.html",
|
|
27
|
+
"keywords": [
|
|
28
|
+
"bot",
|
|
29
|
+
"chatbot",
|
|
30
|
+
"koishi",
|
|
31
|
+
"plugin",
|
|
32
|
+
"bind"
|
|
33
|
+
],
|
|
34
|
+
"koishi": {
|
|
35
|
+
"category": "tool",
|
|
36
|
+
"description": {
|
|
37
|
+
"en": "Bind user accounts across platforms",
|
|
38
|
+
"zh": "跨平台账户绑定 (绑定后在多个平台将共享同一套用户数据)"
|
|
39
|
+
},
|
|
40
|
+
"service": {
|
|
41
|
+
"required": [
|
|
42
|
+
"database"
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
"locales": [
|
|
46
|
+
"zh",
|
|
47
|
+
"en"
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"koishi": "^4.18.11"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@koishi-ce/plugin-mock": "^1.0.0",
|
|
55
|
+
"@minatojs/driver-memory": "^3.7.0",
|
|
56
|
+
"@koishi-ce/koishi": "^1.0.0",
|
|
57
|
+
"minato": "^3.7.0"
|
|
58
|
+
},
|
|
59
|
+
"exports": {
|
|
60
|
+
".": {
|
|
61
|
+
"source": "./src/index.ts",
|
|
62
|
+
"types": "./lib/index.d.ts",
|
|
63
|
+
"import": "./lib/index.mjs",
|
|
64
|
+
"default": "./lib/index.mjs"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bind 插件测试:用两个 mock 客户端模拟跨平台用户,
|
|
3
|
+
* 验证两步令牌绑定流程与解绑(含原初账号保护)。
|
|
4
|
+
*/
|
|
5
|
+
import { afterAll, beforeAll, describe, it } from "bun:test";
|
|
6
|
+
import { Context } from "@koishi-ce/koishi";
|
|
7
|
+
import * as bind from "@koishi-ce/plugin-bind";
|
|
8
|
+
import mock from "@koishi-ce/plugin-mock";
|
|
9
|
+
import memory from "@minatojs/driver-memory";
|
|
10
|
+
|
|
11
|
+
const app = new Context();
|
|
12
|
+
|
|
13
|
+
let counter = 0;
|
|
14
|
+
|
|
15
|
+
app.plugin(bind, {
|
|
16
|
+
generateToken: () => `koishi/${(++counter).toString().padStart(6, "0")}`,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
app.plugin(mock);
|
|
20
|
+
app.plugin(memory);
|
|
21
|
+
|
|
22
|
+
app
|
|
23
|
+
.command("name")
|
|
24
|
+
.userFields(["name"])
|
|
25
|
+
.action(({ session }) => session!.username);
|
|
26
|
+
|
|
27
|
+
const client1 = app.mock.client("123", "321");
|
|
28
|
+
const client2 = app.mock.client("456", "654");
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
await app.start();
|
|
32
|
+
await app.mock.initUser("123", 1, { name: "foo" });
|
|
33
|
+
await app.mock.initUser("456", 1, { name: "bar" });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterAll(() => app.stop());
|
|
37
|
+
|
|
38
|
+
describe("@koishi-ce/plugin-bind", () => {
|
|
39
|
+
// 两用户经两枚令牌完成绑定后,两侧昵称应统一为被绑定者的昵称
|
|
40
|
+
it("create binding", async () => {
|
|
41
|
+
await client1.shouldReply("name", "foo");
|
|
42
|
+
await client2.shouldReply("name", "bar");
|
|
43
|
+
await client1.shouldReply("bind", /^koishi\/000001$/m);
|
|
44
|
+
await client1.shouldReply("koishi/000001", "请前往原始平台输入。");
|
|
45
|
+
await client2.shouldReply("koishi/000001", /^koishi\/000002$/m);
|
|
46
|
+
await client2.shouldReply("koishi/000002", "请前往目标平台输入。");
|
|
47
|
+
await client1.shouldReply("koishi/000002", "账号绑定成功!");
|
|
48
|
+
await client1.shouldReply("name", "bar");
|
|
49
|
+
await client2.shouldReply("name", "bar");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// 解绑:原初账号受保护不可解绑;迁移绑定的账号解绑后各回各的昵称
|
|
53
|
+
it("remove binding", async () => {
|
|
54
|
+
await client2.shouldReply("bind -r", "无法解除绑定:这是你的原始账号。");
|
|
55
|
+
await client1.shouldReply("bind -r", "账号解绑成功!");
|
|
56
|
+
await client1.shouldReply("name", "foo");
|
|
57
|
+
await client2.shouldReply("name", "bar");
|
|
58
|
+
await client1.shouldReply("bind -r", "无法解除绑定:这是你的原始账号。");
|
|
59
|
+
});
|
|
60
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 账号绑定插件(bind)。
|
|
3
|
+
*
|
|
4
|
+
* 提供跨平台账号绑定:在源平台执行 `bind` 获取一次性令牌,
|
|
5
|
+
* 前往目标平台发送令牌完成绑定(群聊需要两步互发令牌,私聊一步到位)。
|
|
6
|
+
* 指令:`bind`(`-r` 解除当前平台账号的绑定)。
|
|
7
|
+
* 依赖数据库(binding / user 表),令牌默认 5 分钟过期。
|
|
8
|
+
*/
|
|
9
|
+
import type { Context, Dict, Session } from "@koishi-ce/koishi";
|
|
10
|
+
import { Schema, Time } from "@koishi-ce/koishi";
|
|
11
|
+
import enUS from "../locales/en-US.yml";
|
|
12
|
+
import zhCN from "../locales/zh-CN.yml";
|
|
13
|
+
|
|
14
|
+
/** 配置项 */
|
|
15
|
+
export interface Config {
|
|
16
|
+
/** 令牌前缀,默认 `koishi/` */
|
|
17
|
+
tokenPrefix?: string;
|
|
18
|
+
/** 自定义令牌生成器(测试中可注入确定性实现) */
|
|
19
|
+
generateToken?: () => string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const name = "bind";
|
|
23
|
+
export const inject = ["database"];
|
|
24
|
+
export const Config: Schema<Config> = Schema.object({
|
|
25
|
+
generateToken: Schema.function().hidden(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** 基于密码学安全的随机字节生成指定长度的纯数字串(250 以上的字节拒绝采样,保证分布均匀) */
|
|
29
|
+
function randomDigits(length: number) {
|
|
30
|
+
const result: string[] = [];
|
|
31
|
+
while (result.length < length) {
|
|
32
|
+
const bytes = new Uint8Array(length - result.length);
|
|
33
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
34
|
+
for (const byte of bytes) {
|
|
35
|
+
if (byte < 250) result.push(String(byte % 10));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return result.join("");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function apply(ctx: Context, config: Config = {}) {
|
|
42
|
+
ctx.i18n.define("zh-CN", zhCN);
|
|
43
|
+
ctx.i18n.define("en-US", enUS);
|
|
44
|
+
|
|
45
|
+
// 令牌 phase 含义:1 = 群聊第一步(源平台签发);0 = 私聊(一步完成);-1 = 群聊第二步(目标平台签发)
|
|
46
|
+
type TokenData = [platform: string, id: string, phase: number];
|
|
47
|
+
const tokens: Dict<TokenData> = Object.create(null);
|
|
48
|
+
|
|
49
|
+
const { tokenPrefix: prefix = "koishi/" } = config;
|
|
50
|
+
const { generateToken = () => `${prefix}${randomDigits(6)}` } = config;
|
|
51
|
+
|
|
52
|
+
/** 为当前会话用户签发一次性令牌并记录 phase,5 分钟后自动过期 */
|
|
53
|
+
function generate(session: Session, phase: number) {
|
|
54
|
+
const { userId } = session;
|
|
55
|
+
if (!userId) return;
|
|
56
|
+
const token = generateToken();
|
|
57
|
+
tokens[token] = [session.platform, userId, phase];
|
|
58
|
+
ctx.setTimeout(() => delete tokens[token], 5 * Time.minute);
|
|
59
|
+
return token;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 将平台账号(platform + pid)绑定到指定用户(aid)名下 */
|
|
63
|
+
async function bind(aid: number, platform: string, pid: string) {
|
|
64
|
+
await ctx.database.set("binding", { platform, pid }, { aid });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
ctx
|
|
68
|
+
.command("bind", { authority: 0 })
|
|
69
|
+
.userFields(["id"])
|
|
70
|
+
.option("remove", "-r")
|
|
71
|
+
.action(async ({ session, options }) => {
|
|
72
|
+
if (!session || !options) return;
|
|
73
|
+
if (options.remove) {
|
|
74
|
+
const { platform, userId: pid, user } = session;
|
|
75
|
+
if (!pid || !user) return;
|
|
76
|
+
const bindings = await ctx.database.get("binding", {
|
|
77
|
+
aid: user.id,
|
|
78
|
+
});
|
|
79
|
+
const binding = bindings.find(
|
|
80
|
+
(item) => item.platform === platform && item.pid === pid,
|
|
81
|
+
);
|
|
82
|
+
if (!binding) return;
|
|
83
|
+
if (binding.aid !== binding.bid) {
|
|
84
|
+
// 该账号是从别的用户迁移绑定而来:恢复其原初绑定即可
|
|
85
|
+
await bind(binding.bid, platform, pid);
|
|
86
|
+
return session.text(".remove-success");
|
|
87
|
+
} else if (
|
|
88
|
+
bindings.filter((item) => item.aid === item.bid).length === 1
|
|
89
|
+
) {
|
|
90
|
+
// 原初绑定只剩最后一个时不允许解绑,否则用户将失去入口
|
|
91
|
+
return session.text(".remove-original");
|
|
92
|
+
} else {
|
|
93
|
+
// 另建一个新用户(按 autoAuthorize 赋初始权限),把当前平台账号归入其名下
|
|
94
|
+
const authority = await session.resolve(
|
|
95
|
+
ctx.root.config.autoAuthorize,
|
|
96
|
+
);
|
|
97
|
+
const user = await ctx.database.create("user", { authority });
|
|
98
|
+
await bind(user.id, platform, pid);
|
|
99
|
+
return session.text(".remove-success");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 群聊进入两步流程(phase 1),私聊一步完成(phase 0)
|
|
104
|
+
const token = generate(session, +!session.isDirect);
|
|
105
|
+
return session.text(".generated-1", [token]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// 前置中间件:拦截形如令牌的消息,驱动绑定流程
|
|
109
|
+
ctx.middleware(async (session, next) => {
|
|
110
|
+
const token = session.stripped.content;
|
|
111
|
+
const data = tokens[token];
|
|
112
|
+
if (!data) return next();
|
|
113
|
+
const { userId } = session;
|
|
114
|
+
if (!userId) return next();
|
|
115
|
+
// 令牌在同一账号上被重复使用:提示不要在同一平台输入
|
|
116
|
+
if (data[0] === session.platform && data[1] === session.userId) {
|
|
117
|
+
return session.text(
|
|
118
|
+
`commands.bind.messages.self-${data[2] < 0 ? "2" : "1"}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
delete tokens[token];
|
|
122
|
+
if (data[2] < 0) {
|
|
123
|
+
// 第二步令牌:把当前用户并入令牌签发者所属的用户
|
|
124
|
+
const [binding] = await ctx.database.get(
|
|
125
|
+
"binding",
|
|
126
|
+
{ platform: data[0], pid: data[1] },
|
|
127
|
+
["aid"],
|
|
128
|
+
);
|
|
129
|
+
if (!binding) return;
|
|
130
|
+
await bind(binding.aid, session.platform, userId);
|
|
131
|
+
return session.text("commands.bind.messages.success");
|
|
132
|
+
} else {
|
|
133
|
+
// 第一步令牌 / 私聊令牌:以当前用户为主体,把令牌签发账号并入自己
|
|
134
|
+
const user = await ctx.database.getUser(session.platform, userId, [
|
|
135
|
+
"id",
|
|
136
|
+
"authority",
|
|
137
|
+
]);
|
|
138
|
+
// 未注册(无 authority)的账号无权拉人绑定
|
|
139
|
+
if (!user.authority) return session.text("internal.low-authority");
|
|
140
|
+
if (data[2]) {
|
|
141
|
+
// 群聊第一步:签发第二枚令牌,请对方回原平台输入
|
|
142
|
+
const token = generate(session, -1);
|
|
143
|
+
return session.text("commands.bind.messages.generated-2", [token]);
|
|
144
|
+
} else {
|
|
145
|
+
// 私聊一步:直接把令牌签发账号并入当前用户
|
|
146
|
+
await bind(user.id, data[0], data[1]);
|
|
147
|
+
return session.text("commands.bind.messages.success");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}, true);
|
|
151
|
+
}
|