@zhin.js/adapter-qq 4.0.2 → 5.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.
@@ -0,0 +1,77 @@
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 { outcome: 'scanned', appId: result.botAppId, appSecret };
32
+ }
33
+ if (result.status === BindStatus.EXPIRED) {
34
+ return { outcome: 'expired' };
35
+ }
36
+ await sleep(POLL_INTERVAL_MS, signal);
37
+ }
38
+ throw new DOMException('Aborted', 'AbortError');
39
+ }
40
+ /**
41
+ * 轮询等待扫码结果(回调风格)。返回 stop 函数可中止流程。
42
+ */
43
+ export function startQqBindFlow(callbacks, options = {}) {
44
+ const controller = new AbortController();
45
+ const signal = options.signal
46
+ ? AbortSignal.any([controller.signal, options.signal])
47
+ : controller.signal;
48
+ void (async () => {
49
+ for (;;) {
50
+ if (signal.aborted) {
51
+ throw new DOMException('Aborted', 'AbortError');
52
+ }
53
+ let task;
54
+ try {
55
+ task = await createBindTask();
56
+ }
57
+ catch (err) {
58
+ throw new Error(`获取绑定任务失败: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
59
+ }
60
+ const connectUrl = buildConnectUrl(task.taskId, options.source ?? 'zhin');
61
+ await callbacks.onQrDisplayed?.(connectUrl);
62
+ const pollResult = await pollUntilResult(task.taskId, task.key, signal);
63
+ if (pollResult.outcome === 'scanned') {
64
+ callbacks.onSuccess([{ appId: pollResult.appId, appSecret: pollResult.appSecret }]);
65
+ return;
66
+ }
67
+ await callbacks.onQrExpired?.();
68
+ }
69
+ })().catch((err) => {
70
+ if (err instanceof DOMException && err.name === 'AbortError') {
71
+ return;
72
+ }
73
+ callbacks.onFailure(err instanceof Error ? err : new Error(String(err)));
74
+ });
75
+ return () => controller.abort();
76
+ }
77
+ //# sourceMappingURL=qq-bind-flow.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"qq-bind-flow.js","sourceRoot":"","sources":["../src/qq-bind-flow.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,16 @@
1
+ import type { EndpointConfigRecord, EndpointManager, ProvisionContext } from 'zhin.js';
2
+ import type { QQAdapter } from './adapter.js';
3
+ export declare class QqEndpointManager implements EndpointManager {
4
+ private readonly adapter;
5
+ constructor(adapter: QQAdapter);
6
+ supportsProvision(): boolean;
7
+ listEndpoints(): EndpointConfigRecord[];
8
+ cancelProvision(): boolean;
9
+ addEndpoint(ctx: ProvisionContext): Promise<EndpointConfigRecord>;
10
+ editEndpoint(name: string, ctx: ProvisionContext): Promise<EndpointConfigRecord>;
11
+ removeEndpoint(name: string): Promise<boolean>;
12
+ startEndpoint(name: string, ctx: ProvisionContext): Promise<void>;
13
+ stopEndpoint(name: string): Promise<boolean>;
14
+ }
15
+ export declare function disposeQqEndpointProvision(): void;
16
+ //# sourceMappingURL=qq-endpoint-manager.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"qq-endpoint-manager.d.ts","sourceRoot":"","sources":["../src/qq-endpoint-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,oBAAoB,EACpB,eAAe,EACf,gBAAgB,EACjB,MAAM,SAAS,CAAC;AACjB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AA4C9C,qBAAa,iBAAkB,YAAW,eAAe;IAC3C,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,SAAS;IAE/C,iBAAiB,IAAI,OAAO;IAI5B,aAAa,IAAI,oBAAoB,EAAE;IAIvC,eAAe,IAAI,OAAO;IAOpB,WAAW,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,oBAAoB,CAAC;IA8CjE,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,oBAAoB,CAAC;IAWhF,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAI9C,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC;IAOjE,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAGnD;AAED,wBAAgB,0BAA0B,IAAI,IAAI,CAKjD"}
@@ -0,0 +1,124 @@
1
+ import { ReceiverMode as QQReceiverMode } from './types.js';
2
+ import { startQqBindFlow } from './qq-bind-flow.js';
3
+ const DEFAULT_INTENTS = [
4
+ 'GUILDS',
5
+ 'GUILD_MEMBERS',
6
+ 'GUILD_MESSAGE_REACTIONS',
7
+ 'DIRECT_MESSAGE',
8
+ 'GROUP_AND_C2C_EVENT',
9
+ 'INTERACTION',
10
+ 'MESSAGE_AUDIT',
11
+ 'AUDIO_ACTION',
12
+ 'PUBLIC_GUILD_MESSAGES',
13
+ ];
14
+ function resolveQqEndpointTemplate(root) {
15
+ const configService = root.inject('config');
16
+ const doc = configService?.getPrimary?.();
17
+ const existing = doc?.endpoints?.find((e) => e.context === 'qq');
18
+ if (!existing) {
19
+ return {
20
+ mode: QQReceiverMode.WEBSOCKET,
21
+ sandbox: false,
22
+ timeout: 10_000,
23
+ data_dir: './data',
24
+ intents: [...DEFAULT_INTENTS],
25
+ };
26
+ }
27
+ const { name: _n, appid: _a, secret: _s, context: _c, ...rest } = existing;
28
+ return rest;
29
+ }
30
+ function readQqConfigs(root) {
31
+ const configService = root.inject('config');
32
+ if (!configService?.primaryFile || !configService.getRaw)
33
+ return [];
34
+ const raw = configService.getRaw(configService.primaryFile);
35
+ return (raw?.endpoints ?? []).filter((e) => e.context === 'qq');
36
+ }
37
+ /** 全局单例:同一时间只允许一个 QQ 扫码绑定 */
38
+ let activeStopFlow = null;
39
+ export class QqEndpointManager {
40
+ adapter;
41
+ constructor(adapter) {
42
+ this.adapter = adapter;
43
+ }
44
+ supportsProvision() {
45
+ return true;
46
+ }
47
+ listEndpoints() {
48
+ return readQqConfigs(this.adapter.plugin.root);
49
+ }
50
+ cancelProvision() {
51
+ if (!activeStopFlow)
52
+ return false;
53
+ activeStopFlow();
54
+ activeStopFlow = null;
55
+ return true;
56
+ }
57
+ async addEndpoint(ctx) {
58
+ if (activeStopFlow) {
59
+ throw new Error('已有进行中的 QQ 机器人绑定,请先 /endpoint cancel');
60
+ }
61
+ let endpointName;
62
+ const nameMatch = ctx.message.$raw?.match(/\/endpoint\s+add\s+qq\s+(\S+)/);
63
+ if (nameMatch?.[1]) {
64
+ endpointName = nameMatch[1];
65
+ }
66
+ return new Promise((resolve, reject) => {
67
+ const stopFlow = startQqBindFlow({
68
+ onQrDisplayed: async (url) => {
69
+ await ctx.onStatusUpdate('请用手机 QQ 扫描下方二维码完成机器人绑定(source=zhin)。', {
70
+ qrcode: url,
71
+ });
72
+ },
73
+ onQrExpired: async () => {
74
+ await ctx.onStatusUpdate('二维码已过期,正在刷新,请扫描新二维码…');
75
+ },
76
+ onSuccess: async (credentials) => {
77
+ activeStopFlow = null;
78
+ const [{ appId, appSecret }] = credentials;
79
+ const name = endpointName?.trim() || appId;
80
+ const template = resolveQqEndpointTemplate(ctx.root);
81
+ resolve({
82
+ context: 'qq',
83
+ name,
84
+ appid: appId,
85
+ secret: appSecret,
86
+ ...template,
87
+ });
88
+ },
89
+ onFailure: async (error) => {
90
+ activeStopFlow = null;
91
+ reject(error);
92
+ },
93
+ }, { source: 'zhin' });
94
+ activeStopFlow = stopFlow;
95
+ });
96
+ }
97
+ async editEndpoint(name, ctx) {
98
+ const existing = this.listEndpoints().find((e) => e.name === name);
99
+ if (!existing) {
100
+ throw new Error(`配置中不存在 qq/${name}`);
101
+ }
102
+ await ctx.onStatusUpdate(`QQ 官方机器人暂不支持在线改凭据,请 /endpoint remove qq ${name} 后重新 /endpoint add qq。`);
103
+ return existing;
104
+ }
105
+ async removeEndpoint(name) {
106
+ return this.listEndpoints().some((e) => e.name === name);
107
+ }
108
+ async startEndpoint(name, ctx) {
109
+ if (!this.listEndpoints().some((e) => e.name === name)) {
110
+ throw new Error(`配置中不存在 qq/${name}`);
111
+ }
112
+ await ctx.onStatusUpdate(`正在连接 qq/${name}…`);
113
+ }
114
+ async stopEndpoint(name) {
115
+ return this.adapter.endpoints.has(name);
116
+ }
117
+ }
118
+ export function disposeQqEndpointProvision() {
119
+ if (activeStopFlow) {
120
+ activeStopFlow();
121
+ activeStopFlow = null;
122
+ }
123
+ }
124
+ //# sourceMappingURL=qq-endpoint-manager.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"qq-endpoint-manager.js","sourceRoot":"","sources":["../src/qq-endpoint-manager.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,YAAY,IAAI,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5D,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAEpD,MAAM,eAAe,GAAG;IACtB,QAAQ;IACR,eAAe;IACf,yBAAyB;IACzB,gBAAgB;IAChB,qBAAqB;IACrB,aAAa;IACb,eAAe;IACf,cAAc;IACd,uBAAuB;CACf,CAAC;AAEX,SAAS,yBAAyB,CAAC,IAA8B;IAC/D,MAAM,aAAa,GAAG,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAsF,CAAC;IACjI,MAAM,GAAG,GAAG,aAAa,EAAE,UAAU,EAAE,EAAE,CAAC;IAC1C,MAAM,QAAQ,GAAG,GAAG,EAAE,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,KAAK,IAAI,CAAC,CAAC;IACjE,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,OAAO;YACL,IAAI,EAAE,cAAc,CAAC,SAAS;YAC9B,OAAO,EAAE,KAAK;YACd,OAAO,EAAE,MAAM;YACf,QAAQ,EAAE,QAAQ;YAClB,OAAO,EAAE,CAAC,GAAG,eAAe,CAAC;SAC9B,CAAC;IACJ,CAAC;IACD,MAAM,EAAE,IAAI,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,GAAG,IAAI,EAAE,GAAG,QAAQ,CAAC;IAC3E,OAAO,IAA+C,CAAC;AACzD,CAAC;AAED,SAAS,aAAa,CAAC,IAA8B;IACnD,MAAM,aAAa,GAAG,IAAI,CAAC,MAAM,CAAC,QAAQ,CAA0G,CAAC;IACrJ,IAAI,CAAC,aAAa,EAAE,WAAW,IAAI,CAAC,aAAa,CAAC,MAAM;QAAE,OAAO,EAAE,CAAC;IACpE,MAAM,GAAG,GAAG,aAAa,CAAC,MAAM,CAAC,aAAa,CAAC,WAAW,CAAC,CAAC;IAC5D,OAAO,CAAC,GAAG,EAAE,SAAS,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,KAAK,IAAI,CAAC,CAAC;AAClE,CAAC;AAED,6BAA6B;AAC7B,IAAI,cAAc,GAAwB,IAAI,CAAC;AAE/C,MAAM,OAAO,iBAAiB;IACC;IAA7B,YAA6B,OAAkB;QAAlB,YAAO,GAAP,OAAO,CAAW;IAAG,CAAC;IAEnD,iBAAiB;QACf,OAAO,IAAI,CAAC;IACd,CAAC;IAED,aAAa;QACX,OAAO,aAAa,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IACjD,CAAC;IAED,eAAe;QACb,IAAI,CAAC,cAAc;YAAE,OAAO,KAAK,CAAC;QAClC,cAAc,EAAE,CAAC;QACjB,cAAc,GAAG,IAAI,CAAC;QACtB,OAAO,IAAI,CAAC;IACd,CAAC;IAED,KAAK,CAAC,WAAW,CAAC,GAAqB;QACrC,IAAI,cAAc,EAAE,CAAC;YACnB,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;QACzD,CAAC;QAED,IAAI,YAAgC,CAAC;QACrC,MAAM,SAAS,GAAG,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,+BAA+B,CAAC,CAAC;QAC3E,IAAI,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACnB,YAAY,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC;QAC9B,CAAC;QAED,OAAO,IAAI,OAAO,CAAuB,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YAC3D,MAAM,QAAQ,GAAG,eAAe,CAC9B;gBACE,aAAa,EAAE,KAAK,EAAE,GAAG,EAAE,EAAE;oBAC3B,MAAM,GAAG,CAAC,cAAc,CAAC,sCAAsC,EAAE;wBAC/D,MAAM,EAAE,GAAG;qBACZ,CAAC,CAAC;gBACL,CAAC;gBACD,WAAW,EAAE,KAAK,IAAI,EAAE;oBACtB,MAAM,GAAG,CAAC,cAAc,CAAC,sBAAsB,CAAC,CAAC;gBACnD,CAAC;gBACD,SAAS,EAAE,KAAK,EAAE,WAAW,EAAE,EAAE;oBAC/B,cAAc,GAAG,IAAI,CAAC;oBACtB,MAAM,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC,GAAG,WAAW,CAAC;oBAC3C,MAAM,IAAI,GAAG,YAAY,EAAE,IAAI,EAAE,IAAI,KAAK,CAAC;oBAC3C,MAAM,QAAQ,GAAG,yBAAyB,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;oBACrD,OAAO,CAAC;wBACN,OAAO,EAAE,IAAI;wBACb,IAAI;wBACJ,KAAK,EAAE,KAAK;wBACZ,MAAM,EAAE,SAAS;wBACjB,GAAG,QAAQ;qBACY,CAAC,CAAC;gBAC7B,CAAC;gBACD,SAAS,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE;oBACzB,cAAc,GAAG,IAAI,CAAC;oBACtB,MAAM,CAAC,KAAK,CAAC,CAAC;gBAChB,CAAC;aACF,EACD,EAAE,MAAM,EAAE,MAAM,EAAE,CACnB,CAAC;YACF,cAAc,GAAG,QAAQ,CAAC;QAC5B,CAAC,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,IAAY,EAAE,GAAqB;QACpD,MAAM,QAAQ,GAAG,IAAI,CAAC,aAAa,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC;QACnE,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,MAAM,IAAI,KAAK,CAAC,aAAa,IAAI,EAAE,CAAC,CAAC;QACvC,CAAC;QACD,MAAM,GAAG,CAAC,cAAc,CACtB,2CAA2C,IAAI,wBAAwB,CACxE,CAAC;QACF,OAAO,QAAQ,CAAC;IAClB,CAAC;IAED,KAAK,CAAC,cAAc,CAAC,IAAY;QAC/B,OAAO,IAAI,CAAC,aAAa,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC;IAC3D,CAAC;IAED,KAAK,CAAC,aAAa,CAAC,IAAY,EAAE,GAAqB;QACrD,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI,CAAC,EAAE,CAAC;YACvD,MAAM,IAAI,KAAK,CAAC,aAAa,IAAI,EAAE,CAAC,CAAC;QACvC,CAAC;QACD,MAAM,GAAG,CAAC,cAAc,CAAC,WAAW,IAAI,GAAG,CAAC,CAAC;IAC/C,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,IAAY;QAC7B,OAAO,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAC1C,CAAC;CACF;AAED,MAAM,UAAU,0BAA0B;IACxC,IAAI,cAAc,EAAE,CAAC;QACnB,cAAc,EAAE,CAAC;QACjB,cAAc,GAAG,IAAI,CAAC;IACxB,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-qq",
3
- "version": "4.0.2",
3
+ "version": "5.0.0",
4
4
  "description": "Zhin.js adapter for QQ Official Bot",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -39,23 +39,23 @@
39
39
  "devDependencies": {
40
40
  "@types/react": "^19.2.17",
41
41
  "@types/react-dom": "^19.2.3",
42
- "lucide-react": "^1.17.0",
42
+ "lucide-react": "^1.21.0",
43
43
  "typescript": "^6.0.3",
44
+ "@zhin.js/cli": "1.0.91",
44
45
  "@zhin.js/client": "2.0.3",
45
46
  "@zhin.js/contract": "1.0.1",
46
- "@zhin.js/host-api": "2.0.2",
47
- "@zhin.js/cli": "1.0.90",
48
- "@zhin.js/host-router": "2.0.0",
49
- "zhin.js": "4.0.1",
50
- "@zhin.js/logger": "0.1.71"
47
+ "@zhin.js/host-api": "2.0.3",
48
+ "@zhin.js/host-router": "2.0.1",
49
+ "@zhin.js/logger": "1.0.72",
50
+ "zhin.js": "4.1.0"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "@zhin.js/client": "2.0.3",
54
- "@zhin.js/host-api": "2.0.2",
55
- "@zhin.js/host-router": "2.0.0",
56
- "@zhin.js/logger": "0.1.71",
54
+ "@zhin.js/host-api": "2.0.3",
57
55
  "@zhin.js/contract": "1.0.1",
58
- "zhin.js": "4.0.1"
56
+ "@zhin.js/host-router": "2.0.1",
57
+ "@zhin.js/logger": "1.0.72",
58
+ "zhin.js": "4.1.0"
59
59
  },
60
60
  "peerDependenciesMeta": {
61
61
  "@zhin.js/client": {
package/src/adapter.ts CHANGED
@@ -6,13 +6,22 @@ import {
6
6
  Plugin,
7
7
  } from "zhin.js";
8
8
  import type { Router } from "@zhin.js/host-router";
9
+ import type { EndpointManager } from "zhin.js";
9
10
  import { QQEndpoint } from "./endpoint.js";
11
+ import { QqEndpointManager } from "./qq-endpoint-manager.js";
10
12
  import type { QQEndpointConfig, ReceiverMode } from "./types.js";
13
+ import type { OutboundRichSegmentPolicy } from "zhin.js";
11
14
 
12
15
  export class QQAdapter extends Adapter<QQEndpoint<ReceiverMode>> {
13
16
  static override readonly capabilities = ['inbound', 'outbound'] as const;
17
+ static override outboundRichSegmentPolicy: OutboundRichSegmentPolicy = {
18
+ qrcode: 'image',
19
+ html: 'image',
20
+ markdown: 'origin',
21
+ };
14
22
 
15
23
  #router?: Router;
24
+ #endpointManager?: QqEndpointManager;
16
25
 
17
26
  constructor(plugin: Plugin, router?: Router) {
18
27
  super(plugin, "qq", []);
@@ -27,6 +36,13 @@ export class QQAdapter extends Adapter<QQEndpoint<ReceiverMode>> {
27
36
  return new QQEndpoint(this, config);
28
37
  }
29
38
 
39
+ override getEndpointManager(): EndpointManager {
40
+ if (!this.#endpointManager) {
41
+ this.#endpointManager = new QqEndpointManager(this);
42
+ }
43
+ return this.#endpointManager;
44
+ }
45
+
30
46
  // ── IGroupManagement 标准群管方法 ──────────────────────────────────
31
47
 
32
48
  async kickMember(endpointId: string, sceneId: string, userId: string) {
package/src/endpoint.ts CHANGED
@@ -14,8 +14,7 @@ import {
14
14
  Message,
15
15
  SendOptions,
16
16
  SendContent,
17
- segment,
18
- } from "zhin.js";
17
+ segment,} from 'zhin.js';
19
18
  import type { MessageElement } from "zhin.js";
20
19
  import { ReceiverMode, type QQEndpointConfig, type ApplicationPlatform } from "./types.js";
21
20
  import type { QQAdapter } from "./adapter.js";
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  qqGuildPermitResolver,
12
12
  registerQqPlatformPermitChecker,
13
13
  } from "./platform-permit.js";
14
+ import { disposeQqEndpointProvision } from "./qq-endpoint-manager.js";
14
15
 
15
16
  declare module "zhin.js" {
16
17
  namespace Plugin {
@@ -30,9 +31,12 @@ declare module "zhin.js" {
30
31
  export * from "./types.js";
31
32
  export { QQEndpoint } from "./endpoint.js";
32
33
  export { QQAdapter } from "./adapter.js";
34
+ export { startQqBindFlow } from "./qq-bind-flow.js";
35
+ export * from "./qq-bind-api.js";
36
+ export { QqEndpointManager, disposeQqEndpointProvision } from "./qq-endpoint-manager.js";
33
37
 
34
38
  const plugin = usePlugin();
35
- const { provide, useContext } = plugin;
39
+ const { provide, useContext, onDispose } = plugin;
36
40
 
37
41
  useContext("router", (router: Router) => {
38
42
  provide({
@@ -361,3 +365,7 @@ useContext("router", "qq", (router: any, qq: QQAdapter) => {
361
365
  }
362
366
  });
363
367
  });
368
+
369
+ onDispose(() => {
370
+ disposeQqEndpointProvision();
371
+ });
@@ -0,0 +1,126 @@
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
+
7
+ export type QQBotEnv = 'production' | 'test';
8
+
9
+ const HOSTS: Record<QQBotEnv, string> = {
10
+ production: 'q.qq.com',
11
+ test: 'test.q.qq.com',
12
+ };
13
+
14
+ export enum BindStatus {
15
+ NONE = 0,
16
+ PENDING = 1,
17
+ COMPLETED = 2,
18
+ EXPIRED = 3,
19
+ }
20
+
21
+ export function getQQBotHost(env: QQBotEnv = 'production'): string {
22
+ return HOSTS[env];
23
+ }
24
+
25
+ export function generateBindKey(): string {
26
+ return randomBytes(32).toString('base64');
27
+ }
28
+
29
+ export function buildConnectUrl(taskId: string, source = 'zhin'): string {
30
+ return `https://${getQQBotHost('production')}/qqbot/openclaw/connect.html?task_id=${encodeURIComponent(taskId)}&source=${encodeURIComponent(source)}&_wv=2`;
31
+ }
32
+
33
+ /**
34
+ * AES-256-GCM 解密 base64 密文。
35
+ * 密文格式: IV(12 bytes) + ciphertext + AuthTag(16 bytes)
36
+ */
37
+ export function decryptSecret(encryptedBase64: string, keyBase64: string): string {
38
+ const key = Buffer.from(keyBase64, 'base64');
39
+ const payload = Buffer.from(encryptedBase64, 'base64');
40
+ const iv = payload.subarray(0, 12);
41
+ const authTag = payload.subarray(payload.length - 16);
42
+ const ciphertext = payload.subarray(12, payload.length - 16);
43
+ const decipher = createDecipheriv('aes-256-gcm', key, iv);
44
+ decipher.setAuthTag(authTag);
45
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
46
+ }
47
+
48
+ interface LiteApiResponse<T> {
49
+ retcode: number;
50
+ msg?: string;
51
+ data?: T;
52
+ }
53
+
54
+ async function postJson<T>(
55
+ url: string,
56
+ body: Record<string, unknown>,
57
+ timeoutMs: number,
58
+ ): Promise<LiteApiResponse<T>> {
59
+ const controller = new AbortController();
60
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
61
+ try {
62
+ const res = await fetch(url, {
63
+ method: 'POST',
64
+ headers: {
65
+ 'Content-Type': 'application/json',
66
+ Accept: 'application/json',
67
+ },
68
+ body: JSON.stringify(body),
69
+ signal: controller.signal,
70
+ });
71
+ if (!res.ok) {
72
+ throw new Error(`HTTP ${res.status} from ${url}`);
73
+ }
74
+ return (await res.json()) as LiteApiResponse<T>;
75
+ } finally {
76
+ clearTimeout(timer);
77
+ }
78
+ }
79
+
80
+ export interface BindTaskResult {
81
+ taskId: string;
82
+ key: string;
83
+ }
84
+
85
+ export async function createBindTask(
86
+ env: QQBotEnv = 'production',
87
+ timeoutMs = 10_000,
88
+ ): Promise<BindTaskResult> {
89
+ const url = `https://${getQQBotHost(env)}/lite/create_bind_task`;
90
+ const key = generateBindKey();
91
+ const res = await postJson<{ task_id?: string }>(url, { key }, timeoutMs);
92
+ if (res.retcode !== 0) {
93
+ throw new Error(res.msg ?? 'create_bind_task failed');
94
+ }
95
+ if (!res.data?.task_id) {
96
+ throw new Error('create_bind_task: missing task_id');
97
+ }
98
+ return { taskId: res.data.task_id, key };
99
+ }
100
+
101
+ export interface PollBindResultOk {
102
+ status: BindStatus;
103
+ botAppId: string;
104
+ botEncryptSecret: string;
105
+ }
106
+
107
+ export async function pollBindResult(
108
+ taskId: string,
109
+ env: QQBotEnv = 'production',
110
+ timeoutMs = 10_000,
111
+ ): Promise<PollBindResultOk> {
112
+ const url = `https://${getQQBotHost(env)}/lite/poll_bind_result`;
113
+ const res = await postJson<{
114
+ status?: number;
115
+ bot_appid?: string | number;
116
+ bot_encrypt_secret?: string;
117
+ }>(url, { task_id: taskId }, timeoutMs);
118
+ if (res.retcode !== 0) {
119
+ throw new Error(res.msg ?? 'poll_bind_result failed');
120
+ }
121
+ return {
122
+ status: (res.data?.status ?? BindStatus.NONE) as BindStatus,
123
+ botAppId: String(res.data?.bot_appid ?? ''),
124
+ botEncryptSecret: res.data?.bot_encrypt_secret ?? '',
125
+ };
126
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * QQ Bot 扫码绑定流程(内联,等价 qqbot-connector startQrConnect)
3
+ */
4
+ import {
5
+ BindStatus,
6
+ buildConnectUrl,
7
+ createBindTask,
8
+ decryptSecret,
9
+ pollBindResult,
10
+ } from './qq-bind-api.js';
11
+
12
+ const POLL_INTERVAL_MS = 2000;
13
+
14
+ export interface QqBindCredentials {
15
+ appId: string;
16
+ appSecret: string;
17
+ }
18
+
19
+ export interface QqBindCallbacks {
20
+ onSuccess: (credentials: QqBindCredentials[]) => void;
21
+ onFailure: (error: Error) => void;
22
+ onQrDisplayed?: (url: string) => void | Promise<void>;
23
+ onQrExpired?: () => void | Promise<void>;
24
+ }
25
+
26
+ export interface QqBindFlowOptions {
27
+ signal?: AbortSignal;
28
+ source?: string;
29
+ }
30
+
31
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
32
+ return new Promise((resolve, reject) => {
33
+ if (signal?.aborted) {
34
+ reject(new DOMException('Aborted', 'AbortError'));
35
+ return;
36
+ }
37
+ const timer = setTimeout(resolve, ms);
38
+ signal?.addEventListener(
39
+ 'abort',
40
+ () => {
41
+ clearTimeout(timer);
42
+ reject(new DOMException('Aborted', 'AbortError'));
43
+ },
44
+ { once: true },
45
+ );
46
+ });
47
+ }
48
+
49
+ async function pollUntilResult(
50
+ taskId: string,
51
+ key: string,
52
+ signal?: AbortSignal,
53
+ ): Promise<{ outcome: 'scanned'; appId: string; appSecret: string } | { outcome: 'expired' }> {
54
+ while (!signal?.aborted) {
55
+ let result;
56
+ try {
57
+ result = await pollBindResult(taskId);
58
+ } catch {
59
+ await sleep(POLL_INTERVAL_MS, signal);
60
+ continue;
61
+ }
62
+ if (result.status === BindStatus.COMPLETED) {
63
+ const appSecret = decryptSecret(result.botEncryptSecret, key);
64
+ return { outcome: 'scanned', appId: result.botAppId, appSecret };
65
+ }
66
+ if (result.status === BindStatus.EXPIRED) {
67
+ return { outcome: 'expired' };
68
+ }
69
+ await sleep(POLL_INTERVAL_MS, signal);
70
+ }
71
+ throw new DOMException('Aborted', 'AbortError');
72
+ }
73
+
74
+ /**
75
+ * 轮询等待扫码结果(回调风格)。返回 stop 函数可中止流程。
76
+ */
77
+ export function startQqBindFlow(
78
+ callbacks: QqBindCallbacks,
79
+ options: QqBindFlowOptions = {},
80
+ ): () => void {
81
+ const controller = new AbortController();
82
+ const signal = options.signal
83
+ ? AbortSignal.any([controller.signal, options.signal])
84
+ : controller.signal;
85
+
86
+ void (async () => {
87
+ for (;;) {
88
+ if (signal.aborted) {
89
+ throw new DOMException('Aborted', 'AbortError');
90
+ }
91
+ let task;
92
+ try {
93
+ task = await createBindTask();
94
+ } catch (err) {
95
+ throw new Error(
96
+ `获取绑定任务失败: ${err instanceof Error ? err.message : String(err)}`,
97
+ { cause: err },
98
+ );
99
+ }
100
+ const connectUrl = buildConnectUrl(task.taskId, options.source ?? 'zhin');
101
+ await callbacks.onQrDisplayed?.(connectUrl);
102
+ const pollResult = await pollUntilResult(task.taskId, task.key, signal);
103
+ if (pollResult.outcome === 'scanned') {
104
+ callbacks.onSuccess([{ appId: pollResult.appId, appSecret: pollResult.appSecret }]);
105
+ return;
106
+ }
107
+ await callbacks.onQrExpired?.();
108
+ }
109
+ })().catch((err) => {
110
+ if (err instanceof DOMException && err.name === 'AbortError') {
111
+ return;
112
+ }
113
+ callbacks.onFailure(err instanceof Error ? err : new Error(String(err)));
114
+ });
115
+
116
+ return () => controller.abort();
117
+ }