@zhin.js/adapter-qq 5.0.3 → 6.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +36 -4
  3. package/adapters/qq.ts +27 -8
  4. package/commands/endpoint/add/[name:string=].ts +3 -0
  5. package/commands/endpoint/cancel.ts +15 -0
  6. package/commands/endpoint/list.ts +3 -0
  7. package/commands/endpoint/remove/[name:string].ts +3 -0
  8. package/lib/endpoint.d.ts +7 -1
  9. package/lib/endpoint.js +49 -4
  10. package/lib/index.d.ts +1 -0
  11. package/lib/index.js +1 -0
  12. package/lib/outbound.d.ts +17 -0
  13. package/lib/outbound.js +241 -0
  14. package/lib/protocol.js +15 -2
  15. package/lib/qq-bind-api.d.ts +36 -0
  16. package/lib/qq-bind-api.js +89 -0
  17. package/lib/qq-bind-flow.d.ts +19 -0
  18. package/lib/qq-bind-flow.js +85 -0
  19. package/lib/qq-bind-persist.d.ts +12 -0
  20. package/lib/qq-bind-persist.js +22 -0
  21. package/lib/qq-endpoint-commands.d.ts +38 -0
  22. package/lib/qq-endpoint-commands.js +122 -0
  23. package/lib/qq-endpoint-config.d.ts +17 -0
  24. package/lib/qq-endpoint-config.js +22 -0
  25. package/lib/qq-runtime-state.d.ts +17 -0
  26. package/lib/qq-runtime-state.js +13 -0
  27. package/lib/webhook.js +4 -11
  28. package/lib/ws.d.ts +25 -4
  29. package/lib/ws.js +18 -1
  30. package/package.json +19 -13
  31. package/plugin.ts +4 -1
  32. package/schema.json +47 -13
  33. package/src/endpoint.ts +61 -5
  34. package/src/index.ts +7 -0
  35. package/src/outbound.ts +262 -0
  36. package/src/protocol.ts +16 -2
  37. package/src/qq-bind-api.ts +145 -0
  38. package/src/qq-bind-flow.ts +127 -0
  39. package/src/qq-bind-persist.ts +44 -0
  40. package/src/qq-endpoint-commands.ts +160 -0
  41. package/src/qq-endpoint-config.ts +44 -0
  42. package/src/qq-runtime-state.ts +36 -0
  43. package/src/webhook.ts +9 -15
  44. package/src/ws.ts +37 -10
@@ -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
+ import { resolveProjectRoot } from '@zhin.js/adapter';
2
+ export { resolveProjectRoot };
3
+ export interface QqCredentialEnvKeys {
4
+ appidKey: string;
5
+ secretKey: string;
6
+ appidRef: string;
7
+ secretRef: string;
8
+ }
9
+ /** 按 endpoint 名称生成唯一 env 键(如 `QQ_MY_BOT_APPID`) */
10
+ export declare function buildQqCredentialEnvKeys(endpointName: string): QqCredentialEnvKeys;
11
+ /** 写入或更新 `.env` 中的键值,并同步到当前进程 `process.env` */
12
+ export declare function persistQqCredentialsToEnv(endpointName: string, appId: string, appSecret: string, projectRoot?: string): QqCredentialEnvKeys;
@@ -0,0 +1,22 @@
1
+ import { buildEndpointEnvKey, persistEndpointEnvValues, resolveProjectRoot, } from '@zhin.js/adapter';
2
+ export { resolveProjectRoot };
3
+ /** 按 endpoint 名称生成唯一 env 键(如 `QQ_MY_BOT_APPID`) */
4
+ export function buildQqCredentialEnvKeys(endpointName) {
5
+ const appidKey = buildEndpointEnvKey('qq', endpointName, 'appid');
6
+ const secretKey = buildEndpointEnvKey('qq', endpointName, 'secret');
7
+ return {
8
+ appidKey,
9
+ secretKey,
10
+ appidRef: `\${${appidKey}}`,
11
+ secretRef: `\${${secretKey}}`,
12
+ };
13
+ }
14
+ /** 写入或更新 `.env` 中的键值,并同步到当前进程 `process.env` */
15
+ export function persistQqCredentialsToEnv(endpointName, appId, appSecret, projectRoot) {
16
+ const keys = buildQqCredentialEnvKeys(endpointName);
17
+ persistEndpointEnvValues({
18
+ [keys.appidKey]: appId,
19
+ [keys.secretKey]: appSecret,
20
+ }, projectRoot);
21
+ return keys;
22
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `qq endpoint` 命令的业务逻辑(与命令定义文件分离,便于测试)。
3
+ * 通用部分(权限 / list / remove / 配置写回 / .env 持久化)已迁移到
4
+ * @zhin.js/adapter 的 createEndpointCommands 套件;本文件保留 QQ 特化的
5
+ * 扫码绑定 add 流程(经套件 bindFlow 钩子接入)与 cancel,并导出
6
+ * qqEndpointCommands 供 commands/endpoint/ 下的命令文件默认导出。
7
+ */
8
+ import { type EndpointCommandReply } from '@zhin.js/adapter';
9
+ import { type QqRuntimeState } from './qq-runtime-state.js';
10
+ export type QqCommandReply = EndpointCommandReply;
11
+ /**
12
+ * endpoint 管理命令的操作者校验:实例配置声明了 master(顶层或任一端点项)时
13
+ * 仅 master 可执行 add/cancel/remove;未配置则放行(首个扫码绑定者即为 owner,
14
+ * legacy applyBindOwnership 会把 operator 写为新端点的 master)。
15
+ */
16
+ export declare function isQqEndpointOperator(config: unknown, input: unknown): boolean;
17
+ export declare const QQ_ENDPOINT_FORBIDDEN: string;
18
+ /**
19
+ * 从命令 input(Runtime Message)提取 $reply;非消息来源(如 Host API 调用)降级为 no-op。
20
+ */
21
+ export declare function extractQqCommandReply(input: unknown): QqCommandReply;
22
+ /** `qq endpoint list`:运行中的 endpoints(本 generation adapter create 注册)+ 配置里的 endpoints */
23
+ export declare function runQqEndpointList(state: QqRuntimeState, projectRoot?: string): string;
24
+ /**
25
+ * `qq endpoint add [name]`:启动扫码绑定流程。
26
+ * 返回的 Promise 在二维码链接就绪(或前置失败)时 resolve 为首条回复;
27
+ * 后续状态(已扫码 / 成功 / 失败 / 过期刷新)通过 reply 推回当前会话。
28
+ */
29
+ export declare function runQqEndpointAdd(state: QqRuntimeState, name: string | undefined, reply: QqCommandReply, projectRoot?: string): Promise<string>;
30
+ /** `qq endpoint cancel`:中止进行中的绑定流程 */
31
+ export declare function runQqEndpointCancel(state: QqRuntimeState): string;
32
+ /** `qq endpoint remove <name>`:从 zhin.config.yml 移除对应 endpoints 项 */
33
+ export declare function runQqEndpointRemove(_state: QqRuntimeState, name: string, projectRoot?: string): string;
34
+ /**
35
+ * 通用套件生成的 QQ endpoint 命令(add 经 bindFlow 钩子走扫码绑定)。
36
+ * commands/endpoint/ 下的 list / add / remove 直接默认导出这三项;cancel 为 QQ 特化,单独定义。
37
+ */
38
+ export declare const qqEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage>>>;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `qq endpoint` 命令的业务逻辑(与命令定义文件分离,便于测试)。
3
+ * 通用部分(权限 / list / remove / 配置写回 / .env 持久化)已迁移到
4
+ * @zhin.js/adapter 的 createEndpointCommands 套件;本文件保留 QQ 特化的
5
+ * 扫码绑定 add 流程(经套件 bindFlow 钩子接入)与 cancel,并导出
6
+ * qqEndpointCommands 供 commands/endpoint/ 下的命令文件默认导出。
7
+ */
8
+ import { createEndpointCommands, endpointCommandForbidden, extractEndpointCommandReply, formatEndpointList, isEndpointOperator, removeEndpointByName, } from '@zhin.js/adapter';
9
+ import { defineCommand } from '@zhin.js/command';
10
+ import { startQqBindFlow } from './qq-bind-flow.js';
11
+ import { persistQqCredentialsToEnv } from './qq-bind-persist.js';
12
+ import { addQqEndpointToConfig, listQqEndpointEntries } from './qq-endpoint-config.js';
13
+ import { qqRuntimeStateToken } from './qq-runtime-state.js';
14
+ /**
15
+ * endpoint 管理命令的操作者校验:实例配置声明了 master(顶层或任一端点项)时
16
+ * 仅 master 可执行 add/cancel/remove;未配置则放行(首个扫码绑定者即为 owner,
17
+ * legacy applyBindOwnership 会把 operator 写为新端点的 master)。
18
+ */
19
+ export function isQqEndpointOperator(config, input) {
20
+ return isEndpointOperator(config, input);
21
+ }
22
+ export const QQ_ENDPOINT_FORBIDDEN = endpointCommandForbidden('QQ');
23
+ /**
24
+ * 从命令 input(Runtime Message)提取 $reply;非消息来源(如 Host API 调用)降级为 no-op。
25
+ */
26
+ export function extractQqCommandReply(input) {
27
+ return extractEndpointCommandReply(input);
28
+ }
29
+ /** `qq endpoint list`:运行中的 endpoints(本 generation adapter create 注册)+ 配置里的 endpoints */
30
+ export function runQqEndpointList(state, projectRoot) {
31
+ return formatEndpointList(qqEndpointListSpec, {
32
+ running: state.endpoints.values(),
33
+ configured: listQqEndpointEntries(projectRoot),
34
+ footer: state.bindFlow ? '⚠️ 有进行中的扫码绑定,可用 qq endpoint cancel 取消' : undefined,
35
+ });
36
+ }
37
+ const qqEndpointListSpec = {
38
+ adapterKey: 'qq',
39
+ adapterDisplayName: 'QQ',
40
+ describeEntry: (entry) => `appid: ${String(entry.appid)}`,
41
+ };
42
+ /**
43
+ * `qq endpoint add [name]`:启动扫码绑定流程。
44
+ * 返回的 Promise 在二维码链接就绪(或前置失败)时 resolve 为首条回复;
45
+ * 后续状态(已扫码 / 成功 / 失败 / 过期刷新)通过 reply 推回当前会话。
46
+ */
47
+ export function runQqEndpointAdd(state, name, reply, projectRoot) {
48
+ if (state.bindFlow) {
49
+ return Promise.resolve('已有进行中的 QQ 机器人绑定,请先发送 qq endpoint cancel 取消后再试');
50
+ }
51
+ const endpointName = name?.trim() || undefined;
52
+ return new Promise((resolve) => {
53
+ let firstReplied = false;
54
+ const settle = (text) => {
55
+ if (!firstReplied) {
56
+ firstReplied = true;
57
+ resolve(text);
58
+ return;
59
+ }
60
+ void reply(text);
61
+ };
62
+ const stop = startQqBindFlow({
63
+ onQrDisplayed: (url) => {
64
+ // QQ 出站当前仅支持纯文本(富媒体未迁移),二维码只能发链接文本
65
+ settle(`请用手机 QQ 打开以下链接完成扫码绑定(二维码图片出站暂未支持,故发送链接):\n${url}`);
66
+ },
67
+ onQrExpired: async () => {
68
+ await reply('二维码已过期,正在刷新,请扫描新链接…');
69
+ },
70
+ onSuccess: async (credentials) => {
71
+ state.bindFlow = null;
72
+ try {
73
+ const [{ appId, appSecret }] = credentials;
74
+ const finalName = endpointName ?? appId;
75
+ const envKeys = persistQqCredentialsToEnv(finalName, appId, appSecret, projectRoot);
76
+ const filePath = addQqEndpointToConfig({ name: finalName, appid: envKeys.appidRef, secret: envKeys.secretRef }, projectRoot);
77
+ settle(`✅ 绑定成功!endpoint「${finalName}」的凭据已写入 .env,并已追加到 ${filePath} 的 plugins.qq.endpoints。\n` +
78
+ '⚠️ 需重启 zhin 后新 endpoint 才会生效。');
79
+ }
80
+ catch (error) {
81
+ settle(`绑定成功但写入配置失败:${error instanceof Error ? error.message : String(error)}`);
82
+ }
83
+ },
84
+ onFailure: (error) => {
85
+ state.bindFlow = null;
86
+ settle(`❌ QQ 绑定失败:${error.message}`);
87
+ },
88
+ }, { source: 'zhin' });
89
+ state.bindFlow = { name: endpointName, stop };
90
+ });
91
+ }
92
+ /** `qq endpoint cancel`:中止进行中的绑定流程 */
93
+ export function runQqEndpointCancel(state) {
94
+ if (!state.bindFlow) {
95
+ return '当前没有进行中的 QQ 绑定流程';
96
+ }
97
+ state.bindFlow.stop();
98
+ state.bindFlow = null;
99
+ return '已取消进行中的 QQ 绑定流程';
100
+ }
101
+ /** `qq endpoint remove <name>`:从 zhin.config.yml 移除对应 endpoints 项 */
102
+ export function runQqEndpointRemove(_state, name, projectRoot) {
103
+ return removeEndpointByName(qqEndpointListSpec, name, projectRoot);
104
+ }
105
+ /**
106
+ * 通用套件生成的 QQ endpoint 命令(add 经 bindFlow 钩子走扫码绑定)。
107
+ * commands/endpoint/ 下的 list / add / remove 直接默认导出这三项;cancel 为 QQ 特化,单独定义。
108
+ */
109
+ export const qqEndpointCommands = createEndpointCommands({
110
+ ...qqEndpointListSpec,
111
+ fields: [
112
+ { key: 'appid', required: true, env: true, description: 'QQ 机器人 appid' },
113
+ { key: 'secret', required: true, env: true, description: 'QQ 机器人 secret' },
114
+ ],
115
+ running: (use) => use(qqRuntimeStateToken).endpoints.values(),
116
+ listFooter: (use) => {
117
+ const state = use(qqRuntimeStateToken);
118
+ return state.bindFlow ? '⚠️ 有进行中的扫码绑定,可用 qq endpoint cancel 取消' : undefined;
119
+ },
120
+ addDescription: '手机 QQ 扫码绑定机器人,凭据写入 .env 并追加到 zhin.config.yml(重启生效)',
121
+ bindFlow: ({ name, reply, use }) => runQqEndpointAdd(use(qqRuntimeStateToken), name, reply),
122
+ }, defineCommand);
@@ -0,0 +1,17 @@
1
+ export interface QqEndpointConfigEntry {
2
+ name: string;
3
+ appid: string;
4
+ secret: string;
5
+ [key: string]: unknown;
6
+ }
7
+ /** 定位项目配置文件:ZHIN_CONFIG 指定优先,否则发现 zhin.config.yml/.yaml,都没有则默认新建 zhin.config.yml */
8
+ export declare function findQqConfigFile(projectRoot?: string): string;
9
+ /** 读取 plugins.qq.endpoints(plain JS);plugins/qq 缺失或形态不符时返回 [] */
10
+ export declare function listQqEndpointEntries(projectRoot?: string): QqEndpointConfigEntry[];
11
+ /** 追加 endpoint 到 plugins.qq.endpoints;name 已存在时报错 */
12
+ export declare function addQqEndpointToConfig(entry: QqEndpointConfigEntry, projectRoot?: string): string;
13
+ /** 按 name 移除 plugins.qq.endpoints 项;不存在返回 false */
14
+ export declare function removeQqEndpointFromConfig(name: string, projectRoot?: string): {
15
+ removed: boolean;
16
+ filePath: string;
17
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * zhin.config.yml 中 plugins.qq.endpoints 的读写(Plugin Runtime 版)。
3
+ * 实现已泛化到 @zhin.js/adapter 的 endpoint-commands 套件(Document 节点级保留注释);
4
+ * 本文件仅保留 QQ 专用签名以兼容既有调用与测试。
5
+ */
6
+ import { addEndpointToConfig, findEndpointConfigFile, listConfiguredEndpoints, removeEndpointFromConfig, } from '@zhin.js/adapter';
7
+ /** 定位项目配置文件:ZHIN_CONFIG 指定优先,否则发现 zhin.config.yml/.yaml,都没有则默认新建 zhin.config.yml */
8
+ export function findQqConfigFile(projectRoot) {
9
+ return findEndpointConfigFile('qq', projectRoot);
10
+ }
11
+ /** 读取 plugins.qq.endpoints(plain JS);plugins/qq 缺失或形态不符时返回 [] */
12
+ export function listQqEndpointEntries(projectRoot) {
13
+ return listConfiguredEndpoints('qq', projectRoot);
14
+ }
15
+ /** 追加 endpoint 到 plugins.qq.endpoints;name 已存在时报错 */
16
+ export function addQqEndpointToConfig(entry, projectRoot) {
17
+ return addEndpointToConfig('qq', entry, projectRoot);
18
+ }
19
+ /** 按 name 移除 plugins.qq.endpoints 项;不存在返回 false */
20
+ export function removeQqEndpointFromConfig(name, projectRoot) {
21
+ return removeEndpointFromConfig('qq', name, projectRoot);
22
+ }
@@ -0,0 +1,17 @@
1
+ export interface QqRunningEndpoint {
2
+ name: string;
3
+ mode: string;
4
+ }
5
+ export interface QqBindFlowHandle {
6
+ /** 绑定期望的 endpoint 名(未指定时为 undefined,成功后取 appId) */
7
+ name?: string;
8
+ stop: () => void;
9
+ }
10
+ export interface QqRuntimeState {
11
+ /** 当前 generation 已成功创建的 endpoint(name → 描述) */
12
+ readonly endpoints: Map<string, QqRunningEndpoint>;
13
+ /** 全局单例:同一时间只允许一个扫码绑定流程 */
14
+ bindFlow: QqBindFlowHandle | null;
15
+ }
16
+ export declare function createQqRuntimeState(): QqRuntimeState;
17
+ export declare const qqRuntimeStateToken: import("@zhin.js/plugin-runtime").Token<QqRuntimeState>;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * QQ 插件实例的运行时状态:adapter create() 注册的 endpoint 列表 +
3
+ * 扫码绑定流程的全局单例。由 plugin.ts setup() provide,
4
+ * adapter create 与 commands 通过 use(qqRuntimeStateToken) 共享(同一 owner generation)。
5
+ */
6
+ import { createToken } from '@zhin.js/plugin-runtime';
7
+ export function createQqRuntimeState() {
8
+ return {
9
+ endpoints: new Map(),
10
+ bindFlow: null,
11
+ };
12
+ }
13
+ export const qqRuntimeStateToken = createToken('zhin.qq.runtime-state', 'QQ adapter runtime state (running endpoints + bind flow singleton)');
package/lib/webhook.js CHANGED
@@ -21,20 +21,13 @@ export async function handleQqWebhookRequest(request, response, handler) {
21
21
  return;
22
22
  }
23
23
  try {
24
- let parsedBody;
25
- const raw = await readRequestBodyText(request);
26
- if (raw) {
27
- try {
28
- parsedBody = JSON.parse(raw);
29
- }
30
- catch {
31
- parsedBody = undefined;
32
- }
33
- }
24
+ // 不预消费请求流:库的验签必须基于原始字节(JSON.parse → JSON.stringify
25
+ // 会改变空白/转义/键序导致 401),body 留 undefined 让库自己读流;
26
+ // 预消费还会让非 JSON body 时库监听已结束的 end 事件而挂死。
34
27
  await bot.middleware({
35
28
  req: request,
36
29
  res: response,
37
- request: { body: parsedBody },
30
+ request: { body: undefined },
38
31
  }, async () => undefined);
39
32
  }
40
33
  catch (error) {
package/lib/ws.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { QqOutboundMessage } from './outbound.js';
1
2
  import type { QqInboundMessage, ResolvedQqWebsocketConfig } from './protocol.js';
2
3
  /** Minimal bot surface used by the endpoint (real qq-official-bot or test mock). */
3
4
  export interface QqBotTransport {
@@ -5,10 +6,10 @@ export interface QqBotTransport {
5
6
  stop(): Promise<void>;
6
7
  on(event: string, listener: (...args: unknown[]) => void): void;
7
8
  removeAllListeners(): void;
8
- sendPrivateMessage(userId: string, message: string): Promise<unknown>;
9
- sendGroupMessage(groupId: string, message: string): Promise<unknown>;
10
- sendGuildMessage(channelId: string, message: string): Promise<unknown>;
11
- sendDirectMessage?(guildId: string, message: string): Promise<unknown>;
9
+ sendPrivateMessage(userId: string, message: QqOutboundMessage): Promise<unknown>;
10
+ sendGroupMessage(groupId: string, message: QqOutboundMessage): Promise<unknown>;
11
+ sendGuildMessage(channelId: string, message: QqOutboundMessage): Promise<unknown>;
12
+ sendDirectMessage?(guildId: string, message: QqOutboundMessage): Promise<unknown>;
12
13
  getGuilds(): Promise<unknown[]>;
13
14
  getChannels(guildId: string): Promise<unknown[]>;
14
15
  getChannelInfo(channelId: string): Promise<unknown>;
@@ -19,6 +20,26 @@ export interface QqBotTransport {
19
20
  removeMemberRole(guildId: string, channelId: string, userId: string, roleId: string): Promise<boolean>;
20
21
  }
21
22
  export type CreateQqBot = (config: ResolvedQqWebsocketConfig) => QqBotTransport;
23
+ interface QqOfficialBotLifecycle {
24
+ stop(): Promise<unknown>;
25
+ readonly sessionManager: {
26
+ userClose: boolean;
27
+ readonly connectionManager?: {
28
+ destroy(): void;
29
+ };
30
+ readonly authManager?: {
31
+ destroy(): void;
32
+ };
33
+ };
34
+ }
35
+ /**
36
+ * qq-official-bot 1.2.x keeps a second `userClose` flag in Connection.
37
+ * Session.stop() does not update it, so a normal shutdown is treated as an
38
+ * unexpected disconnect and starts a reconnect loop. Destroy both managers at
39
+ * the SDK boundary until upstream exposes a complete public shutdown method.
40
+ */
41
+ export declare function stopQqOfficialBot(bot: QqOfficialBotLifecycle): Promise<void>;
22
42
  export declare function bindQqBotInboundEvents(bot: QqBotTransport, admitRaw: (raw: unknown) => void): void;
23
43
  export declare function normalizeQqMessage(raw: unknown): QqInboundMessage | null;
24
44
  export declare function defaultCreateBot(config: ResolvedQqWebsocketConfig): QqBotTransport;
45
+ export {};
package/lib/ws.js CHANGED
@@ -4,6 +4,23 @@
4
4
  import path from 'node:path';
5
5
  import { Bot, ReceiverMode } from 'qq-official-bot';
6
6
  import { getLevel, toLog4jsLevel } from '@zhin.js/logger';
7
+ /**
8
+ * qq-official-bot 1.2.x keeps a second `userClose` flag in Connection.
9
+ * Session.stop() does not update it, so a normal shutdown is treated as an
10
+ * unexpected disconnect and starts a reconnect loop. Destroy both managers at
11
+ * the SDK boundary until upstream exposes a complete public shutdown method.
12
+ */
13
+ export async function stopQqOfficialBot(bot) {
14
+ const session = bot.sessionManager;
15
+ session.userClose = true;
16
+ session.connectionManager?.destroy();
17
+ try {
18
+ await bot.stop();
19
+ }
20
+ finally {
21
+ session.authManager?.destroy();
22
+ }
23
+ }
7
24
  export function bindQqBotInboundEvents(bot, admitRaw) {
8
25
  bot.on('message.group', admitRaw);
9
26
  bot.on('message.guild', admitRaw);
@@ -89,7 +106,7 @@ export function defaultCreateBot(config) {
89
106
  });
90
107
  return {
91
108
  start: () => bot.start().then(() => undefined),
92
- stop: () => bot.stop().then(() => undefined),
109
+ stop: () => stopQqOfficialBot(bot),
93
110
  on: (event, listener) => {
94
111
  bot.on(event, listener);
95
112
  },