@zhin.js/adapter-qq 5.0.3 → 6.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/CHANGELOG.md +31 -0
- package/README.md +36 -4
- package/adapters/qq.ts +6 -0
- package/commands/endpoint/add/[name:string=].ts +21 -0
- package/commands/endpoint/cancel.ts +15 -0
- package/commands/endpoint/list.ts +10 -0
- package/commands/endpoint/remove/[name:string].ts +15 -0
- package/lib/endpoint.d.ts +7 -1
- package/lib/endpoint.js +43 -0
- package/lib/qq-bind-api.d.ts +36 -0
- package/lib/qq-bind-api.js +89 -0
- package/lib/qq-bind-flow.d.ts +19 -0
- package/lib/qq-bind-flow.js +85 -0
- package/lib/qq-bind-persist.d.ts +12 -0
- package/lib/qq-bind-persist.js +51 -0
- package/lib/qq-endpoint-commands.d.ts +25 -0
- package/lib/qq-endpoint-commands.js +147 -0
- package/lib/qq-endpoint-config.d.ts +17 -0
- package/lib/qq-endpoint-config.js +110 -0
- package/lib/qq-runtime-state.d.ts +17 -0
- package/lib/qq-runtime-state.js +13 -0
- package/package.json +20 -13
- package/plugin.ts +4 -1
- package/schema.json +47 -13
- package/src/endpoint.ts +53 -1
- package/src/qq-bind-api.ts +145 -0
- package/src/qq-bind-flow.ts +127 -0
- package/src/qq-bind-persist.ts +71 -0
- package/src/qq-endpoint-commands.ts +174 -0
- package/src/qq-endpoint-config.ts +135 -0
- package/src/qq-runtime-state.ts +36 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# @zhin.js/adapter-qq
|
|
2
2
|
|
|
3
|
+
## 6.0.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3ea88d8: 恢复 legacy 的聊天命令添加 endpoint 能力(随适配器发布):`qq endpoint add [name]`(手机 QQ 扫码绑定 → 凭据写 `.env` 的 `QQ_<NAME>_APPID/SECRET` → 追加 `plugins.qq.endpoints` 配置,重启生效)、`qq endpoint cancel`(中止绑定)、`qq endpoint list`(运行中 + 配置内 endpoints)、`qq endpoint remove <name>`(移除配置项)。add/cancel/remove 受 `master` 限制(顶层或 `endpoints[i]` 声明时仅 master 可执行,未配置则放行);schema 新增顶层共享字段 `master`。绑定协议(create_bind_task / poll_bind_result / AES-256-GCM 解密)自 legacy 移植,二维码当前以链接文本下发(QQ 出站富媒体待迁移)。
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- 7db69c1: 命令前缀改为适配器配置项:`MessageDispatcher` 不再硬编码 `/`,默认按消息所属适配器实例 config 的 `commandPrefix` 解析(默认 `''` 无前缀,任意文本按命令匹配),`endpoints[i].commandPrefix` 逐项覆盖;`ImRuntime({ commandPrefix })` 仍可设全局静态前缀。全部 20 个平台适配器 schema 新增 `commandPrefix` 属性。
|
|
12
|
+
|
|
13
|
+
BREAKING(行为变化):未配置时命令不再需要 `/` 前缀——原 `/zt` 写法不再命中,直接发 `zt` 即可;需要斜杠风格的适配器请在配置里显式设 `commandPrefix: '/'`。
|
|
14
|
+
|
|
15
|
+
- e5c84ed: Adapter 多账号:插件实例 config 支持 `endpoints: [{name, ...覆盖}]` 数组,`expandEndpointConfigs` 将一个实例展开为多个 endpoint record(id 为 `<slotId>~<name>`,顶层字段共享、逐项覆盖),替代多 `instanceKey` 方案;Console `/api/plugins` 收敛为一个插件卡片 + 多 endpoint。icqq / qq schema 与 README 补 `endpoints` 配置。
|
|
16
|
+
|
|
17
|
+
Plugin Runtime Console Host:补 `/esm/*` React/router ESM 代理路由(legacy `consoleApiRouter` 对齐),TypeScriptClientBuilder 裸导入改写为 `/esm/<enc>.mjs`。
|
|
18
|
+
|
|
19
|
+
- 713445c: 适配器配置格式定稿(不兼容旧格式):`plugins.<adapter>` 顶层仅共享字段 + `commandPrefix`,`endpoints[i]` 携带 endpoint 级字段(`name` + 凭据,各 schema 已类型化),`endpoints` 为必填(icqq 另需顶层 `master`);icqq 新增 `trusted` 列表(顶层/逐项均可)。scaffold-wizard 全部字段式与自定义 configure() 产出改为新格式,examples(full-bot / qq-games-bot)与 20 个适配器 README 同步迁移。
|
|
20
|
+
- f32c424: 适配器配置统一为 endpoints 数组格式:`plugins.<adapter>` 为该 adapter 所有 endpoint 的通用配置,`plugins.<adapter>.endpoints[i]` 为单个 endpoint 的特殊配置(逐项覆盖,`name` 必填)。slack / github schema 新增 `endpoints` 属性;icqq / qq / slack 顶层必填改为 `anyOf`(单 endpoint 字段或 `endpoints` 数组二选一,兼容 Ajv strictRequired)。
|
|
21
|
+
- Updated dependencies [7db69c1]
|
|
22
|
+
- Updated dependencies [e5c84ed]
|
|
23
|
+
- Updated dependencies [3ea84a0]
|
|
24
|
+
- Updated dependencies [1ddcd70]
|
|
25
|
+
- Updated dependencies [ac9da66]
|
|
26
|
+
- @zhin.js/core@1.4.0
|
|
27
|
+
- @zhin.js/adapter@1.1.0
|
|
28
|
+
- @zhin.js/plugin-runtime@1.1.0
|
|
29
|
+
- @zhin.js/agent@1.0.5
|
|
30
|
+
- @zhin.js/host-http@1.0.2
|
|
31
|
+
- zhin.js@5.0.0
|
|
32
|
+
- @zhin.js/command@1.0.2
|
|
33
|
+
|
|
3
34
|
## 5.0.3
|
|
4
35
|
|
|
5
36
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ pnpm add @zhin.js/adapter-qq
|
|
|
36
36
|
| **WebSocket(默认)** | `qq-official-bot` 正向连接;无需公网回调 |
|
|
37
37
|
| **host-http** | WebSocket **不需要**;Webhook / middleware 模式需要(经 `httpHostToken`) |
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
必填字段(`endpoints[i]`):`name`、`appid`、`secret`。
|
|
40
40
|
|
|
41
41
|
## 最小配置
|
|
42
42
|
|
|
@@ -44,14 +44,46 @@ pnpm add @zhin.js/adapter-qq
|
|
|
44
44
|
# zhin.config.yml(Plugin Runtime)
|
|
45
45
|
plugins:
|
|
46
46
|
qq:
|
|
47
|
-
name: my-qq-bot
|
|
48
|
-
appid: ${QQ_APPID}
|
|
49
|
-
secret: ${QQ_SECRET}
|
|
50
47
|
# mode: websocket # 默认
|
|
48
|
+
endpoints:
|
|
49
|
+
- name: my-qq-bot
|
|
50
|
+
appid: ${QQ_APPID}
|
|
51
|
+
secret: ${QQ_SECRET}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
多账号:一个插件实例挂多个 endpoint(`endpoints` 数组逐项覆盖顶层字段,`name` 必填):
|
|
55
|
+
|
|
56
|
+
```yaml
|
|
57
|
+
plugins:
|
|
58
|
+
qq:
|
|
59
|
+
mode: websocket
|
|
60
|
+
intents: [GUILDS, GROUP_AND_C2C_EVENT]
|
|
61
|
+
endpoints:
|
|
62
|
+
- name: main-bot
|
|
63
|
+
appid: ${QQ_APPID}
|
|
64
|
+
secret: ${QQ_SECRET}
|
|
65
|
+
- name: second-bot
|
|
66
|
+
appid: ${QQ_APPID_2}
|
|
67
|
+
secret: ${QQ_SECRET_2}
|
|
51
68
|
```
|
|
52
69
|
|
|
53
70
|
根插件 `zhin.plugins`(或项目图)需引用 `@zhin.js/adapter-qq`(`instanceKey: qq`)。
|
|
54
71
|
|
|
72
|
+
## Endpoint 管理命令
|
|
73
|
+
|
|
74
|
+
适配器自带 `qq endpoint` 命令组(聊天内直接使用,默认无前缀;受 `commandPrefix` 影响):
|
|
75
|
+
|
|
76
|
+
| 命令 | 说明 |
|
|
77
|
+
|------|------|
|
|
78
|
+
| `qq endpoint add [name]` | 手机 QQ 扫码绑定:下发二维码链接 → 确认后凭据写入 `.env`(`QQ_<NAME>_APPID/SECRET`),并追加 `plugins.qq.endpoints`(重启生效) |
|
|
79
|
+
| `qq endpoint cancel` | 取消进行中的扫码绑定(同时只允许一个流程) |
|
|
80
|
+
| `qq endpoint list` | 列出运行中与配置中的 endpoints |
|
|
81
|
+
| `qq endpoint remove <name>` | 从配置移除 endpoint(`.env` 键保留,可手动清理) |
|
|
82
|
+
|
|
83
|
+
add/cancel/remove 受 `master` 限制:实例配置声明了 `master`(顶层或 `endpoints[i]`)时仅
|
|
84
|
+
master 可执行;未配置则放行(首个扫码绑定者即 owner)。二维码当前以链接文本下发
|
|
85
|
+
(出站富媒体待迁移),用手机 QQ 打开链接即可扫码。
|
|
86
|
+
|
|
55
87
|
## 环境变量
|
|
56
88
|
|
|
57
89
|
| 变量 | 说明 |
|
package/adapters/qq.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
resolveQqConfig,
|
|
10
10
|
type QqAdapterConfig,
|
|
11
11
|
} from '../src/protocol.js';
|
|
12
|
+
import { qqRuntimeStateToken } from '../src/qq-runtime-state.js';
|
|
12
13
|
|
|
13
14
|
export {
|
|
14
15
|
QqHttpEndpoint,
|
|
@@ -28,6 +29,11 @@ export default defineAdapter<QqAdapterConfig>({
|
|
|
28
29
|
create(context) {
|
|
29
30
|
const config = resolveQqConfig(context.config);
|
|
30
31
|
const gateway = context.use(messageGatewayToken);
|
|
32
|
+
// 注册到插件运行时状态(qq endpoint list 的"运行中"数据源)
|
|
33
|
+
context.use(qqRuntimeStateToken).endpoints.set(config.name, {
|
|
34
|
+
name: config.name,
|
|
35
|
+
mode: config.mode,
|
|
36
|
+
});
|
|
31
37
|
if (config.mode === 'webhook' || config.mode === 'middleware') {
|
|
32
38
|
return new QqHttpEndpoint({
|
|
33
39
|
id: context.id,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineCommand } from '@zhin.js/command';
|
|
2
|
+
import {
|
|
3
|
+
extractQqCommandReply,
|
|
4
|
+
isQqEndpointOperator,
|
|
5
|
+
QQ_ENDPOINT_FORBIDDEN,
|
|
6
|
+
runQqEndpointAdd,
|
|
7
|
+
} from '../../../src/qq-endpoint-commands.js';
|
|
8
|
+
import { qqRuntimeStateToken } from '../../../src/qq-runtime-state.js';
|
|
9
|
+
|
|
10
|
+
export default defineCommand({
|
|
11
|
+
description: '手机 QQ 扫码绑定机器人,凭据写入 .env 并追加到 zhin.config.yml(重启生效)',
|
|
12
|
+
execute({ config, input, params, use }) {
|
|
13
|
+
if (!isQqEndpointOperator(config, input)) return QQ_ENDPOINT_FORBIDDEN;
|
|
14
|
+
const name = typeof params.name === 'string' && params.name.trim() ? params.name.trim() : undefined;
|
|
15
|
+
return runQqEndpointAdd(
|
|
16
|
+
use(qqRuntimeStateToken),
|
|
17
|
+
name,
|
|
18
|
+
extractQqCommandReply(input),
|
|
19
|
+
);
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineCommand } from '@zhin.js/command';
|
|
2
|
+
import {
|
|
3
|
+
isQqEndpointOperator,
|
|
4
|
+
QQ_ENDPOINT_FORBIDDEN,
|
|
5
|
+
runQqEndpointCancel,
|
|
6
|
+
} from '../../src/qq-endpoint-commands.js';
|
|
7
|
+
import { qqRuntimeStateToken } from '../../src/qq-runtime-state.js';
|
|
8
|
+
|
|
9
|
+
export default defineCommand({
|
|
10
|
+
description: '取消进行中的 QQ 扫码绑定流程',
|
|
11
|
+
execute({ config, input, use }) {
|
|
12
|
+
if (!isQqEndpointOperator(config, input)) return QQ_ENDPOINT_FORBIDDEN;
|
|
13
|
+
return runQqEndpointCancel(use(qqRuntimeStateToken));
|
|
14
|
+
},
|
|
15
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { defineCommand } from '@zhin.js/command';
|
|
2
|
+
import { runQqEndpointList } from '../../src/qq-endpoint-commands.js';
|
|
3
|
+
import { qqRuntimeStateToken } from '../../src/qq-runtime-state.js';
|
|
4
|
+
|
|
5
|
+
export default defineCommand({
|
|
6
|
+
description: '列出 QQ endpoints(运行中 + zhin.config.yml 配置)',
|
|
7
|
+
execute({ use }) {
|
|
8
|
+
return runQqEndpointList(use(qqRuntimeStateToken));
|
|
9
|
+
},
|
|
10
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineCommand } from '@zhin.js/command';
|
|
2
|
+
import {
|
|
3
|
+
isQqEndpointOperator,
|
|
4
|
+
QQ_ENDPOINT_FORBIDDEN,
|
|
5
|
+
runQqEndpointRemove,
|
|
6
|
+
} from '../../../src/qq-endpoint-commands.js';
|
|
7
|
+
import { qqRuntimeStateToken } from '../../../src/qq-runtime-state.js';
|
|
8
|
+
|
|
9
|
+
export default defineCommand({
|
|
10
|
+
description: '从 zhin.config.yml 的 plugins.qq.endpoints 移除指定 endpoint(重启生效)',
|
|
11
|
+
execute({ config, input, params, use }) {
|
|
12
|
+
if (!isQqEndpointOperator(config, input)) return QQ_ENDPOINT_FORBIDDEN;
|
|
13
|
+
return runQqEndpointRemove(use(qqRuntimeStateToken), String(params.name ?? ''));
|
|
14
|
+
},
|
|
15
|
+
});
|
package/lib/endpoint.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* QQ endpoints — lifecycle, outbound, admit, agent tool surface.
|
|
3
3
|
*/
|
|
4
|
-
import type { EndpointInstance } from '@zhin.js/adapter';
|
|
4
|
+
import type { EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
|
|
5
5
|
import type { MessageGateway } from '@zhin.js/core/runtime';
|
|
6
6
|
import type { HttpHost } from '@zhin.js/host-http';
|
|
7
7
|
import type { CapabilityId } from '@zhin.js/plugin-runtime';
|
|
@@ -18,7 +18,10 @@ export interface QqEndpointOptions {
|
|
|
18
18
|
}
|
|
19
19
|
export declare class QqWebsocketEndpoint implements EndpointInstance {
|
|
20
20
|
#private;
|
|
21
|
+
readonly management: EndpointManagement;
|
|
21
22
|
constructor(options: QqEndpointOptions);
|
|
23
|
+
/** Live endpoint 名(Console/AdapterIndex 展示用,如 bot appid 别名)。 */
|
|
24
|
+
get name(): string;
|
|
22
25
|
start(): Promise<void>;
|
|
23
26
|
open(): void;
|
|
24
27
|
close(): void;
|
|
@@ -48,7 +51,10 @@ export interface QqHttpEndpointOptions {
|
|
|
48
51
|
/** Webhook / middleware inbound via httpHostToken POST (qq-official-bot Middleware receiver). */
|
|
49
52
|
export declare class QqHttpEndpoint implements EndpointInstance {
|
|
50
53
|
#private;
|
|
54
|
+
readonly management: EndpointManagement;
|
|
51
55
|
constructor(options: QqHttpEndpointOptions);
|
|
56
|
+
/** Live endpoint 名(Console/AdapterIndex 展示用)。 */
|
|
57
|
+
get name(): string;
|
|
52
58
|
start(): Promise<void>;
|
|
53
59
|
open(): void;
|
|
54
60
|
close(): void;
|
package/lib/endpoint.js
CHANGED
|
@@ -11,10 +11,15 @@ export class QqWebsocketEndpoint {
|
|
|
11
11
|
#open = false;
|
|
12
12
|
#started = false;
|
|
13
13
|
#unregisterAgent;
|
|
14
|
+
management = createQqEndpointManagement(this);
|
|
14
15
|
constructor(options) {
|
|
15
16
|
this.#options = options;
|
|
16
17
|
this.#createBot = options.createBot ?? defaultCreateBot;
|
|
17
18
|
}
|
|
19
|
+
/** Live endpoint 名(Console/AdapterIndex 展示用,如 bot appid 别名)。 */
|
|
20
|
+
get name() {
|
|
21
|
+
return this.#options.config.name;
|
|
22
|
+
}
|
|
18
23
|
async start() {
|
|
19
24
|
if (this.#started)
|
|
20
25
|
return;
|
|
@@ -166,10 +171,15 @@ export class QqHttpEndpoint {
|
|
|
166
171
|
#open = false;
|
|
167
172
|
#started = false;
|
|
168
173
|
#unregisterAgent;
|
|
174
|
+
management = createQqEndpointManagement(this);
|
|
169
175
|
constructor(options) {
|
|
170
176
|
this.#options = options;
|
|
171
177
|
this.#createBot = options.createBot ?? defaultCreateHttpBot;
|
|
172
178
|
}
|
|
179
|
+
/** Live endpoint 名(Console/AdapterIndex 展示用)。 */
|
|
180
|
+
get name() {
|
|
181
|
+
return this.#options.config.name;
|
|
182
|
+
}
|
|
173
183
|
async start() {
|
|
174
184
|
if (this.#started)
|
|
175
185
|
return;
|
|
@@ -313,3 +323,36 @@ export class QqHttpEndpoint {
|
|
|
313
323
|
return this.#bot;
|
|
314
324
|
}
|
|
315
325
|
}
|
|
326
|
+
function createQqEndpointManagement(endpoint) {
|
|
327
|
+
return Object.freeze({
|
|
328
|
+
async listChannels() {
|
|
329
|
+
const channels = [];
|
|
330
|
+
const guilds = await endpoint.getGuilds();
|
|
331
|
+
for (const guildValue of Array.isArray(guilds) ? guilds : []) {
|
|
332
|
+
const guild = asRecord(guildValue);
|
|
333
|
+
const guildId = String(guild.id ?? guild.guild_id ?? guildValue ?? '');
|
|
334
|
+
if (!guildId)
|
|
335
|
+
continue;
|
|
336
|
+
const guildName = String(guild.name ?? guild.guild_name ?? guildId);
|
|
337
|
+
const rows = await endpoint.getChannels(guildId);
|
|
338
|
+
for (const channelValue of Array.isArray(rows) ? rows : []) {
|
|
339
|
+
const channel = asRecord(channelValue);
|
|
340
|
+
const id = String(channel.id ?? channel.channel_id ?? channelValue ?? '');
|
|
341
|
+
if (!id)
|
|
342
|
+
continue;
|
|
343
|
+
channels.push({
|
|
344
|
+
id,
|
|
345
|
+
name: String(channel.name ?? channel.channel_name ?? id),
|
|
346
|
+
parent: { type: 'guild', id: guildId, name: guildName },
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return channels;
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
function asRecord(value) {
|
|
355
|
+
return value !== null && typeof value === 'object'
|
|
356
|
+
? value
|
|
357
|
+
: {};
|
|
358
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type QQBotEnv = 'production' | 'test';
|
|
2
|
+
export declare enum BindStatus {
|
|
3
|
+
NONE = 0,
|
|
4
|
+
PENDING = 1,
|
|
5
|
+
COMPLETED = 2,
|
|
6
|
+
EXPIRED = 3
|
|
7
|
+
}
|
|
8
|
+
export declare function getQQBotHost(env?: QQBotEnv): string;
|
|
9
|
+
export declare function generateBindKey(): string;
|
|
10
|
+
export declare function buildConnectUrl(taskId: string, source?: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* AES-256-GCM 解密 base64 密文。
|
|
13
|
+
* 密文格式: IV(12 bytes) + ciphertext + AuthTag(16 bytes)
|
|
14
|
+
*/
|
|
15
|
+
export declare function decryptSecret(encryptedBase64: string, keyBase64: string): string;
|
|
16
|
+
export interface BindTaskResult {
|
|
17
|
+
taskId: string;
|
|
18
|
+
key: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function createBindTask(env?: QQBotEnv, timeoutMs?: number): Promise<BindTaskResult>;
|
|
21
|
+
export interface PollBindResultOk {
|
|
22
|
+
status: BindStatus;
|
|
23
|
+
botAppId: string;
|
|
24
|
+
botEncryptSecret: string;
|
|
25
|
+
/** 扫码绑定开发者 QQ 的 OpenID(poll_bind_result 返回) */
|
|
26
|
+
userOpenId: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function parsePollBindResultData(data: {
|
|
29
|
+
status?: number;
|
|
30
|
+
bot_appid?: string | number;
|
|
31
|
+
bot_encrypt_secret?: string;
|
|
32
|
+
user_openid?: string;
|
|
33
|
+
userOpenid?: string;
|
|
34
|
+
openid?: string;
|
|
35
|
+
} | undefined): PollBindResultOk;
|
|
36
|
+
export declare function pollBindResult(taskId: string, env?: QQBotEnv, timeoutMs?: number): Promise<PollBindResultOk>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Bot 扫码绑定协议(内联)
|
|
3
|
+
* Protocol aligned with @tencent-connect/qqbot-connector@1.1.0 qqbot-session — inlined to avoid runtime dependency.
|
|
4
|
+
*/
|
|
5
|
+
import { createDecipheriv, randomBytes } from 'node:crypto';
|
|
6
|
+
const HOSTS = {
|
|
7
|
+
production: 'q.qq.com',
|
|
8
|
+
test: 'test.q.qq.com',
|
|
9
|
+
};
|
|
10
|
+
export var BindStatus;
|
|
11
|
+
(function (BindStatus) {
|
|
12
|
+
BindStatus[BindStatus["NONE"] = 0] = "NONE";
|
|
13
|
+
BindStatus[BindStatus["PENDING"] = 1] = "PENDING";
|
|
14
|
+
BindStatus[BindStatus["COMPLETED"] = 2] = "COMPLETED";
|
|
15
|
+
BindStatus[BindStatus["EXPIRED"] = 3] = "EXPIRED";
|
|
16
|
+
})(BindStatus || (BindStatus = {}));
|
|
17
|
+
export function getQQBotHost(env = 'production') {
|
|
18
|
+
return HOSTS[env];
|
|
19
|
+
}
|
|
20
|
+
export function generateBindKey() {
|
|
21
|
+
return randomBytes(32).toString('base64');
|
|
22
|
+
}
|
|
23
|
+
export function buildConnectUrl(taskId, source = 'zhin') {
|
|
24
|
+
return `https://${getQQBotHost('production')}/qqbot/openclaw/connect.html?task_id=${encodeURIComponent(taskId)}&source=${encodeURIComponent(source)}&_wv=2`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* AES-256-GCM 解密 base64 密文。
|
|
28
|
+
* 密文格式: IV(12 bytes) + ciphertext + AuthTag(16 bytes)
|
|
29
|
+
*/
|
|
30
|
+
export function decryptSecret(encryptedBase64, keyBase64) {
|
|
31
|
+
const key = Buffer.from(keyBase64, 'base64');
|
|
32
|
+
const payload = Buffer.from(encryptedBase64, 'base64');
|
|
33
|
+
const iv = payload.subarray(0, 12);
|
|
34
|
+
const authTag = payload.subarray(payload.length - 16);
|
|
35
|
+
const ciphertext = payload.subarray(12, payload.length - 16);
|
|
36
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
37
|
+
decipher.setAuthTag(authTag);
|
|
38
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
39
|
+
}
|
|
40
|
+
async function postJson(url, body, timeoutMs) {
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch(url, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: {
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
Accept: 'application/json',
|
|
49
|
+
},
|
|
50
|
+
body: JSON.stringify(body),
|
|
51
|
+
signal: controller.signal,
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
throw new Error(`HTTP ${res.status} from ${url}`);
|
|
55
|
+
}
|
|
56
|
+
return (await res.json());
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export async function createBindTask(env = 'production', timeoutMs = 10_000) {
|
|
63
|
+
const url = `https://${getQQBotHost(env)}/lite/create_bind_task`;
|
|
64
|
+
const key = generateBindKey();
|
|
65
|
+
const res = await postJson(url, { key }, timeoutMs);
|
|
66
|
+
if (res.retcode !== 0) {
|
|
67
|
+
throw new Error(res.msg ?? 'create_bind_task failed');
|
|
68
|
+
}
|
|
69
|
+
if (!res.data?.task_id) {
|
|
70
|
+
throw new Error('create_bind_task: missing task_id');
|
|
71
|
+
}
|
|
72
|
+
return { taskId: res.data.task_id, key };
|
|
73
|
+
}
|
|
74
|
+
export function parsePollBindResultData(data) {
|
|
75
|
+
return {
|
|
76
|
+
status: (data?.status ?? BindStatus.NONE),
|
|
77
|
+
botAppId: String(data?.bot_appid ?? ''),
|
|
78
|
+
botEncryptSecret: data?.bot_encrypt_secret ?? '',
|
|
79
|
+
userOpenId: String(data?.user_openid ?? data?.userOpenid ?? data?.openid ?? ''),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export async function pollBindResult(taskId, env = 'production', timeoutMs = 10_000) {
|
|
83
|
+
const url = `https://${getQQBotHost(env)}/lite/poll_bind_result`;
|
|
84
|
+
const res = await postJson(url, { task_id: taskId }, timeoutMs);
|
|
85
|
+
if (res.retcode !== 0) {
|
|
86
|
+
throw new Error(res.msg ?? 'poll_bind_result failed');
|
|
87
|
+
}
|
|
88
|
+
return parsePollBindResultData(res.data);
|
|
89
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface QqBindCredentials {
|
|
2
|
+
appId: string;
|
|
3
|
+
appSecret: string;
|
|
4
|
+
userOpenId?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface QqBindCallbacks {
|
|
7
|
+
onSuccess: (credentials: QqBindCredentials[]) => void;
|
|
8
|
+
onFailure: (error: Error) => void;
|
|
9
|
+
onQrDisplayed?: (url: string) => void | Promise<void>;
|
|
10
|
+
onQrExpired?: () => void | Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export interface QqBindFlowOptions {
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
source?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 轮询等待扫码结果(回调风格)。返回 stop 函数可中止流程。
|
|
18
|
+
*/
|
|
19
|
+
export declare function startQqBindFlow(callbacks: QqBindCallbacks, options?: QqBindFlowOptions): () => void;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Bot 扫码绑定流程(内联,等价 qqbot-connector startQrConnect)
|
|
3
|
+
*/
|
|
4
|
+
import { BindStatus, buildConnectUrl, createBindTask, decryptSecret, pollBindResult, } from './qq-bind-api.js';
|
|
5
|
+
const POLL_INTERVAL_MS = 2000;
|
|
6
|
+
function sleep(ms, signal) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
if (signal?.aborted) {
|
|
9
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const timer = setTimeout(resolve, ms);
|
|
13
|
+
signal?.addEventListener('abort', () => {
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
16
|
+
}, { once: true });
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
async function pollUntilResult(taskId, key, signal) {
|
|
20
|
+
while (!signal?.aborted) {
|
|
21
|
+
let result;
|
|
22
|
+
try {
|
|
23
|
+
result = await pollBindResult(taskId);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
await sleep(POLL_INTERVAL_MS, signal);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (result.status === BindStatus.COMPLETED) {
|
|
30
|
+
const appSecret = decryptSecret(result.botEncryptSecret, key);
|
|
31
|
+
return {
|
|
32
|
+
outcome: 'scanned',
|
|
33
|
+
appId: result.botAppId,
|
|
34
|
+
appSecret,
|
|
35
|
+
userOpenId: result.userOpenId,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (result.status === BindStatus.EXPIRED) {
|
|
39
|
+
return { outcome: 'expired' };
|
|
40
|
+
}
|
|
41
|
+
await sleep(POLL_INTERVAL_MS, signal);
|
|
42
|
+
}
|
|
43
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 轮询等待扫码结果(回调风格)。返回 stop 函数可中止流程。
|
|
47
|
+
*/
|
|
48
|
+
export function startQqBindFlow(callbacks, options = {}) {
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
const signal = options.signal
|
|
51
|
+
? AbortSignal.any([controller.signal, options.signal])
|
|
52
|
+
: controller.signal;
|
|
53
|
+
void (async () => {
|
|
54
|
+
for (;;) {
|
|
55
|
+
if (signal.aborted) {
|
|
56
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
57
|
+
}
|
|
58
|
+
let task;
|
|
59
|
+
try {
|
|
60
|
+
task = await createBindTask();
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
throw new Error(`获取绑定任务失败: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
64
|
+
}
|
|
65
|
+
const connectUrl = buildConnectUrl(task.taskId, options.source ?? 'zhin');
|
|
66
|
+
await callbacks.onQrDisplayed?.(connectUrl);
|
|
67
|
+
const pollResult = await pollUntilResult(task.taskId, task.key, signal);
|
|
68
|
+
if (pollResult.outcome === 'scanned') {
|
|
69
|
+
callbacks.onSuccess([{
|
|
70
|
+
appId: pollResult.appId,
|
|
71
|
+
appSecret: pollResult.appSecret,
|
|
72
|
+
userOpenId: pollResult.userOpenId || undefined,
|
|
73
|
+
}]);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
await callbacks.onQrExpired?.();
|
|
77
|
+
}
|
|
78
|
+
})().catch((err) => {
|
|
79
|
+
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
callbacks.onFailure(err instanceof Error ? err : new Error(String(err)));
|
|
83
|
+
});
|
|
84
|
+
return () => controller.abort();
|
|
85
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface QqCredentialEnvKeys {
|
|
2
|
+
appidKey: string;
|
|
3
|
+
secretKey: string;
|
|
4
|
+
appidRef: string;
|
|
5
|
+
secretRef: string;
|
|
6
|
+
}
|
|
7
|
+
/** 按 endpoint 名称生成唯一 env 键(如 `QQ_MY_BOT_APPID`) */
|
|
8
|
+
export declare function buildQqCredentialEnvKeys(endpointName: string): QqCredentialEnvKeys;
|
|
9
|
+
/** 项目根:ZHIN_PROJECT_ROOT 优先,缺省 process.cwd()(替代 legacy runtimeCwd) */
|
|
10
|
+
export declare function resolveProjectRoot(): string;
|
|
11
|
+
/** 写入或更新 `.env` 中的键值,并同步到当前进程 `process.env` */
|
|
12
|
+
export declare function persistQqCredentialsToEnv(endpointName: string, appId: string, appSecret: string, projectRoot?: string): QqCredentialEnvKeys;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/** 按 endpoint 名称生成唯一 env 键(如 `QQ_MY_BOT_APPID`) */
|
|
4
|
+
export function buildQqCredentialEnvKeys(endpointName) {
|
|
5
|
+
const slug = endpointName.replace(/[^a-zA-Z0-9_]/g, '_').toUpperCase();
|
|
6
|
+
const appidKey = `QQ_${slug}_APPID`;
|
|
7
|
+
const secretKey = `QQ_${slug}_SECRET`;
|
|
8
|
+
return {
|
|
9
|
+
appidKey,
|
|
10
|
+
secretKey,
|
|
11
|
+
appidRef: `\${${appidKey}}`,
|
|
12
|
+
secretRef: `\${${secretKey}}`,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** 项目根:ZHIN_PROJECT_ROOT 优先,缺省 process.cwd()(替代 legacy runtimeCwd) */
|
|
16
|
+
export function resolveProjectRoot() {
|
|
17
|
+
const envRoot = process.env.ZHIN_PROJECT_ROOT?.trim();
|
|
18
|
+
return path.resolve(envRoot || process.cwd());
|
|
19
|
+
}
|
|
20
|
+
function escapeRegExp(text) {
|
|
21
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
22
|
+
}
|
|
23
|
+
function upsertEnvLine(content, key, value) {
|
|
24
|
+
const lineRe = new RegExp(`^${escapeRegExp(key)}\\s*=.*$`, 'm');
|
|
25
|
+
const newLine = `${key}=${value}`;
|
|
26
|
+
if (lineRe.test(content)) {
|
|
27
|
+
return content.replace(lineRe, newLine);
|
|
28
|
+
}
|
|
29
|
+
const trimmed = content.replace(/\s*$/, '');
|
|
30
|
+
if (trimmed.length === 0) {
|
|
31
|
+
return `${newLine}\n`;
|
|
32
|
+
}
|
|
33
|
+
return `${trimmed}\n${newLine}\n`;
|
|
34
|
+
}
|
|
35
|
+
/** 写入或更新 `.env` 中的键值,并同步到当前进程 `process.env` */
|
|
36
|
+
export function persistQqCredentialsToEnv(endpointName, appId, appSecret, projectRoot) {
|
|
37
|
+
const keys = buildQqCredentialEnvKeys(endpointName);
|
|
38
|
+
const root = projectRoot ?? resolveProjectRoot();
|
|
39
|
+
const envPath = path.join(root, '.env');
|
|
40
|
+
let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf-8') : '';
|
|
41
|
+
const entries = {
|
|
42
|
+
[keys.appidKey]: appId,
|
|
43
|
+
[keys.secretKey]: appSecret,
|
|
44
|
+
};
|
|
45
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
46
|
+
content = upsertEnvLine(content, key, value);
|
|
47
|
+
process.env[key] = value;
|
|
48
|
+
}
|
|
49
|
+
fs.writeFileSync(envPath, content);
|
|
50
|
+
return keys;
|
|
51
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { QqRuntimeState } from './qq-runtime-state.js';
|
|
2
|
+
export type QqCommandReply = (text: string) => Promise<unknown>;
|
|
3
|
+
/**
|
|
4
|
+
* endpoint 管理命令的操作者校验:实例配置声明了 master(顶层或任一端点项)时
|
|
5
|
+
* 仅 master 可执行 add/cancel/remove;未配置则放行(首个扫码绑定者即为 owner,
|
|
6
|
+
* legacy applyBindOwnership 会把 operator 写为新端点的 master)。
|
|
7
|
+
*/
|
|
8
|
+
export declare function isQqEndpointOperator(config: unknown, input: unknown): boolean;
|
|
9
|
+
export declare const QQ_ENDPOINT_FORBIDDEN = "\u4EC5 master \u53EF\u6267\u884C QQ endpoint \u7BA1\u7406\u547D\u4EE4";
|
|
10
|
+
/**
|
|
11
|
+
* 从命令 input(Runtime Message)提取 $reply;非消息来源(如 Host API 调用)降级为 no-op。
|
|
12
|
+
*/
|
|
13
|
+
export declare function extractQqCommandReply(input: unknown): QqCommandReply;
|
|
14
|
+
/** `qq endpoint list`:运行中的 endpoints(本 generation adapter create 注册)+ 配置里的 endpoints */
|
|
15
|
+
export declare function runQqEndpointList(state: QqRuntimeState, projectRoot?: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* `qq endpoint add [name]`:启动扫码绑定流程。
|
|
18
|
+
* 返回的 Promise 在二维码链接就绪(或前置失败)时 resolve 为首条回复;
|
|
19
|
+
* 后续状态(已扫码 / 成功 / 失败 / 过期刷新)通过 reply 推回当前会话。
|
|
20
|
+
*/
|
|
21
|
+
export declare function runQqEndpointAdd(state: QqRuntimeState, name: string | undefined, reply: QqCommandReply, projectRoot?: string): Promise<string>;
|
|
22
|
+
/** `qq endpoint cancel`:中止进行中的绑定流程 */
|
|
23
|
+
export declare function runQqEndpointCancel(state: QqRuntimeState): string;
|
|
24
|
+
/** `qq endpoint remove <name>`:从 zhin.config.yml 移除对应 endpoints 项 */
|
|
25
|
+
export declare function runQqEndpointRemove(_state: QqRuntimeState, name: string, projectRoot?: string): string;
|