@zhin.js/adapter-qq 4.0.2 → 5.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 (84) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +54 -0
  3. package/dist/index.js +2 -2
  4. package/lib/adapter.d.ts +14 -3
  5. package/lib/adapter.d.ts.map +1 -1
  6. package/lib/adapter.js +25 -4
  7. package/lib/adapter.js.map +1 -1
  8. package/lib/endpoint.d.ts +6 -1
  9. package/lib/endpoint.d.ts.map +1 -1
  10. package/lib/endpoint.js +80 -3
  11. package/lib/endpoint.js.map +1 -1
  12. package/lib/group-at-normalize.d.ts.map +1 -1
  13. package/lib/group-at-normalize.js +2 -5
  14. package/lib/group-at-normalize.js.map +1 -1
  15. package/lib/inbound-action.d.ts +15 -0
  16. package/lib/inbound-action.d.ts.map +1 -0
  17. package/lib/inbound-action.js +57 -0
  18. package/lib/inbound-action.js.map +1 -0
  19. package/lib/index.d.ts +3 -0
  20. package/lib/index.d.ts.map +1 -1
  21. package/lib/index.js +11 -4
  22. package/lib/index.js.map +1 -1
  23. package/lib/mention-enrich.d.ts +12 -0
  24. package/lib/mention-enrich.d.ts.map +1 -0
  25. package/lib/mention-enrich.js +52 -0
  26. package/lib/mention-enrich.js.map +1 -0
  27. package/lib/outbound-keyboard.d.ts +4 -0
  28. package/lib/outbound-keyboard.d.ts.map +1 -0
  29. package/lib/outbound-keyboard.js +96 -0
  30. package/lib/outbound-keyboard.js.map +1 -0
  31. package/lib/outbound-markdown.d.ts +16 -4
  32. package/lib/outbound-markdown.d.ts.map +1 -1
  33. package/lib/outbound-markdown.js +116 -17
  34. package/lib/outbound-markdown.js.map +1 -1
  35. package/lib/outbound-media.d.ts +2 -0
  36. package/lib/outbound-media.d.ts.map +1 -1
  37. package/lib/outbound-media.js +2 -1
  38. package/lib/outbound-media.js.map +1 -1
  39. package/lib/outbound-mixed-media.d.ts +21 -0
  40. package/lib/outbound-mixed-media.d.ts.map +1 -0
  41. package/lib/outbound-mixed-media.js +78 -0
  42. package/lib/outbound-mixed-media.js.map +1 -0
  43. package/lib/platform-permit.js +2 -2
  44. package/lib/qq-bind-api.d.ts +37 -0
  45. package/lib/qq-bind-api.d.ts.map +1 -0
  46. package/lib/qq-bind-api.js +90 -0
  47. package/lib/qq-bind-api.js.map +1 -0
  48. package/lib/qq-bind-flow.d.ts +20 -0
  49. package/lib/qq-bind-flow.d.ts.map +1 -0
  50. package/lib/qq-bind-flow.js +86 -0
  51. package/lib/qq-bind-flow.js.map +1 -0
  52. package/lib/qq-bind-persist.d.ts +11 -0
  53. package/lib/qq-bind-persist.d.ts.map +1 -0
  54. package/lib/qq-bind-persist.js +52 -0
  55. package/lib/qq-bind-persist.js.map +1 -0
  56. package/lib/qq-endpoint-manager.d.ts +16 -0
  57. package/lib/qq-endpoint-manager.d.ts.map +1 -0
  58. package/lib/qq-endpoint-manager.js +159 -0
  59. package/lib/qq-endpoint-manager.js.map +1 -0
  60. package/lib/segment-mapper.d.ts +2 -0
  61. package/lib/segment-mapper.d.ts.map +1 -0
  62. package/lib/segment-mapper.js +2 -0
  63. package/lib/segment-mapper.js.map +1 -0
  64. package/lib/types.d.ts +7 -0
  65. package/lib/types.d.ts.map +1 -1
  66. package/package.json +16 -16
  67. package/skills/qq/PERMITS.md +1 -1
  68. package/src/adapter.ts +28 -4
  69. package/src/endpoint.ts +118 -6
  70. package/src/group-at-normalize.ts +2 -4
  71. package/src/inbound-action.ts +87 -0
  72. package/src/index.ts +13 -5
  73. package/src/mention-enrich.ts +71 -0
  74. package/src/outbound-keyboard.ts +118 -0
  75. package/src/outbound-markdown.ts +127 -17
  76. package/src/outbound-media.ts +2 -1
  77. package/src/outbound-mixed-media.ts +101 -0
  78. package/src/platform-permit.ts +2 -2
  79. package/src/qq-bind-api.ts +145 -0
  80. package/src/qq-bind-flow.ts +127 -0
  81. package/src/qq-bind-persist.ts +71 -0
  82. package/src/qq-endpoint-manager.ts +199 -0
  83. package/src/segment-mapper.ts +1 -0
  84. package/src/types.ts +7 -0
@@ -0,0 +1,145 @@
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
+ /** 扫码绑定开发者 QQ 的 OpenID(poll_bind_result 返回) */
106
+ userOpenId: string;
107
+ }
108
+
109
+ export function parsePollBindResultData(data: {
110
+ status?: number;
111
+ bot_appid?: string | number;
112
+ bot_encrypt_secret?: string;
113
+ user_openid?: string;
114
+ userOpenid?: string;
115
+ openid?: string;
116
+ } | undefined): PollBindResultOk {
117
+ return {
118
+ status: (data?.status ?? BindStatus.NONE) as BindStatus,
119
+ botAppId: String(data?.bot_appid ?? ''),
120
+ botEncryptSecret: data?.bot_encrypt_secret ?? '',
121
+ userOpenId: String(
122
+ data?.user_openid ?? data?.userOpenid ?? data?.openid ?? '',
123
+ ),
124
+ };
125
+ }
126
+
127
+ export async function pollBindResult(
128
+ taskId: string,
129
+ env: QQBotEnv = 'production',
130
+ timeoutMs = 10_000,
131
+ ): Promise<PollBindResultOk> {
132
+ const url = `https://${getQQBotHost(env)}/lite/poll_bind_result`;
133
+ const res = await postJson<{
134
+ status?: number;
135
+ bot_appid?: string | number;
136
+ bot_encrypt_secret?: string;
137
+ user_openid?: string;
138
+ userOpenid?: string;
139
+ openid?: string;
140
+ }>(url, { task_id: taskId }, timeoutMs);
141
+ if (res.retcode !== 0) {
142
+ throw new Error(res.msg ?? 'poll_bind_result failed');
143
+ }
144
+ return parsePollBindResultData(res.data);
145
+ }
@@ -0,0 +1,127 @@
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
+ userOpenId?: string;
18
+ }
19
+
20
+ export interface QqBindCallbacks {
21
+ onSuccess: (credentials: QqBindCredentials[]) => void;
22
+ onFailure: (error: Error) => void;
23
+ onQrDisplayed?: (url: string) => void | Promise<void>;
24
+ onQrExpired?: () => void | Promise<void>;
25
+ }
26
+
27
+ export interface QqBindFlowOptions {
28
+ signal?: AbortSignal;
29
+ source?: string;
30
+ }
31
+
32
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
33
+ return new Promise((resolve, reject) => {
34
+ if (signal?.aborted) {
35
+ reject(new DOMException('Aborted', 'AbortError'));
36
+ return;
37
+ }
38
+ const timer = setTimeout(resolve, ms);
39
+ signal?.addEventListener(
40
+ 'abort',
41
+ () => {
42
+ clearTimeout(timer);
43
+ reject(new DOMException('Aborted', 'AbortError'));
44
+ },
45
+ { once: true },
46
+ );
47
+ });
48
+ }
49
+
50
+ async function pollUntilResult(
51
+ taskId: string,
52
+ key: string,
53
+ signal?: AbortSignal,
54
+ ): Promise<{ outcome: 'scanned'; appId: string; appSecret: string; userOpenId: string } | { outcome: 'expired' }> {
55
+ while (!signal?.aborted) {
56
+ let result;
57
+ try {
58
+ result = await pollBindResult(taskId);
59
+ } catch {
60
+ await sleep(POLL_INTERVAL_MS, signal);
61
+ continue;
62
+ }
63
+ if (result.status === BindStatus.COMPLETED) {
64
+ const appSecret = decryptSecret(result.botEncryptSecret, key);
65
+ return {
66
+ outcome: 'scanned',
67
+ appId: result.botAppId,
68
+ appSecret,
69
+ userOpenId: result.userOpenId,
70
+ };
71
+ }
72
+ if (result.status === BindStatus.EXPIRED) {
73
+ return { outcome: 'expired' };
74
+ }
75
+ await sleep(POLL_INTERVAL_MS, signal);
76
+ }
77
+ throw new DOMException('Aborted', 'AbortError');
78
+ }
79
+
80
+ /**
81
+ * 轮询等待扫码结果(回调风格)。返回 stop 函数可中止流程。
82
+ */
83
+ export function startQqBindFlow(
84
+ callbacks: QqBindCallbacks,
85
+ options: QqBindFlowOptions = {},
86
+ ): () => void {
87
+ const controller = new AbortController();
88
+ const signal = options.signal
89
+ ? AbortSignal.any([controller.signal, options.signal])
90
+ : controller.signal;
91
+
92
+ void (async () => {
93
+ for (;;) {
94
+ if (signal.aborted) {
95
+ throw new DOMException('Aborted', 'AbortError');
96
+ }
97
+ let task;
98
+ try {
99
+ task = await createBindTask();
100
+ } catch (err) {
101
+ throw new Error(
102
+ `获取绑定任务失败: ${err instanceof Error ? err.message : String(err)}`,
103
+ { cause: err },
104
+ );
105
+ }
106
+ const connectUrl = buildConnectUrl(task.taskId, options.source ?? 'zhin');
107
+ await callbacks.onQrDisplayed?.(connectUrl);
108
+ const pollResult = await pollUntilResult(task.taskId, task.key, signal);
109
+ if (pollResult.outcome === 'scanned') {
110
+ callbacks.onSuccess([{
111
+ appId: pollResult.appId,
112
+ appSecret: pollResult.appSecret,
113
+ userOpenId: pollResult.userOpenId || undefined,
114
+ }]);
115
+ return;
116
+ }
117
+ await callbacks.onQrExpired?.();
118
+ }
119
+ })().catch((err) => {
120
+ if (err instanceof DOMException && err.name === 'AbortError') {
121
+ return;
122
+ }
123
+ callbacks.onFailure(err instanceof Error ? err : new Error(String(err)));
124
+ });
125
+
126
+ return () => controller.abort();
127
+ }
@@ -0,0 +1,71 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { runtimeCwd } from 'zhin.js';
4
+
5
+ export interface QqCredentialEnvKeys {
6
+ appidKey: string;
7
+ secretKey: string;
8
+ appidRef: string;
9
+ secretRef: string;
10
+ }
11
+
12
+ /** 按 endpoint 名称生成唯一 env 键(如 `QQ_MOCK_QQ_BOT_APPID`) */
13
+ export function buildQqCredentialEnvKeys(endpointName: string): QqCredentialEnvKeys {
14
+ const slug = endpointName.replace(/[^a-zA-Z0-9_]/g, '_').toUpperCase();
15
+ const appidKey = `QQ_${slug}_APPID`;
16
+ const secretKey = `QQ_${slug}_SECRET`;
17
+ return {
18
+ appidKey,
19
+ secretKey,
20
+ appidRef: `\${${appidKey}}`,
21
+ secretRef: `\${${secretKey}}`,
22
+ };
23
+ }
24
+
25
+ function resolveProjectRoot(): string {
26
+ const envRoot = process.env.ZHIN_PROJECT_ROOT?.trim();
27
+ return path.resolve(envRoot || runtimeCwd());
28
+ }
29
+
30
+ function escapeRegExp(text: string): string {
31
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
32
+ }
33
+
34
+ function upsertEnvLine(content: string, key: string, value: string): string {
35
+ const lineRe = new RegExp(`^${escapeRegExp(key)}\\s*=.*$`, 'm');
36
+ const newLine = `${key}=${value}`;
37
+ if (lineRe.test(content)) {
38
+ return content.replace(lineRe, newLine);
39
+ }
40
+ const trimmed = content.replace(/\s*$/, '');
41
+ if (trimmed.length === 0) {
42
+ return `${newLine}\n`;
43
+ }
44
+ return `${trimmed}\n${newLine}\n`;
45
+ }
46
+
47
+ /** 写入或更新 `.env` 中的键值,并同步到当前进程 `process.env` */
48
+ export function persistQqCredentialsToEnv(
49
+ endpointName: string,
50
+ appId: string,
51
+ appSecret: string,
52
+ projectRoot?: string,
53
+ ): QqCredentialEnvKeys {
54
+ const keys = buildQqCredentialEnvKeys(endpointName);
55
+ const root = projectRoot ?? resolveProjectRoot();
56
+ const envPath = path.join(root, '.env');
57
+ let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf-8') : '';
58
+
59
+ const entries: Record<string, string> = {
60
+ [keys.appidKey]: appId,
61
+ [keys.secretKey]: appSecret,
62
+ };
63
+
64
+ for (const [key, value] of Object.entries(entries)) {
65
+ content = upsertEnvLine(content, key, value);
66
+ process.env[key] = value;
67
+ }
68
+
69
+ fs.writeFileSync(envPath, content);
70
+ return keys;
71
+ }
@@ -0,0 +1,199 @@
1
+ import type {
2
+ EndpointConfigRecord,
3
+ EndpointManager,
4
+ ProvisionContext,
5
+ } from 'zhin.js';
6
+ import type { QQAdapter } from './adapter.js';
7
+ import type { QQEndpointConfig, ReceiverMode } from './types.js';
8
+ import { ReceiverMode as QQReceiverMode } from './types.js';
9
+ import { startQqBindFlow } from './qq-bind-flow.js';
10
+ import { persistQqCredentialsToEnv } from './qq-bind-persist.js';
11
+
12
+ const DEFAULT_INTENTS = [
13
+ 'GUILDS',
14
+ 'GUILD_MEMBERS',
15
+ 'GUILD_MESSAGE_REACTIONS',
16
+ 'DIRECT_MESSAGE',
17
+ 'GROUP_AND_C2C_EVENT',
18
+ 'INTERACTION',
19
+ 'MESSAGE_AUDIT',
20
+ 'AUDIO_ACTION',
21
+ 'PUBLIC_GUILD_MESSAGES',
22
+ ] as const;
23
+
24
+ function clonePlainConfig<T>(value: T): T {
25
+ return JSON.parse(JSON.stringify(value)) as T;
26
+ }
27
+
28
+ function resolveQqEndpointTemplate(root: ProvisionContext['root']): Partial<QQEndpointConfig<ReceiverMode>> {
29
+ const configService = root.inject('config') as {
30
+ getRaw?: (f: string) => { endpoints?: Array<Record<string, unknown>> };
31
+ primaryFile?: string;
32
+ } | undefined;
33
+ const raw = configService?.primaryFile && configService.getRaw
34
+ ? configService.getRaw(configService.primaryFile)
35
+ : undefined;
36
+ const existing = raw?.endpoints?.find((e) => e.context === 'qq');
37
+ if (!existing) {
38
+ return {
39
+ mode: QQReceiverMode.WEBSOCKET,
40
+ sandbox: false,
41
+ timeout: 10_000,
42
+ data_dir: './data',
43
+ intents: [...DEFAULT_INTENTS],
44
+ };
45
+ }
46
+ const { name: _n, appid: _a, secret: _s, context: _c, ...rest } = existing;
47
+ return clonePlainConfig(rest) as Partial<QQEndpointConfig<ReceiverMode>>;
48
+ }
49
+
50
+ function resolveBindOperatorId(
51
+ userOpenId: string | undefined,
52
+ ctx: ProvisionContext,
53
+ ): string {
54
+ const fromApi = userOpenId?.trim();
55
+ if (fromApi) return fromApi;
56
+ return ctx.message.$sender?.id?.trim() ?? '';
57
+ }
58
+
59
+ function applyBindOwnership(
60
+ template: Partial<QQEndpointConfig<ReceiverMode>>,
61
+ operatorId: string,
62
+ ): Partial<QQEndpointConfig<ReceiverMode>> {
63
+ const { master: _m, aiAccess, ...rest } = template as Record<string, unknown>;
64
+ if (!operatorId) {
65
+ return rest as Partial<QQEndpointConfig<ReceiverMode>>;
66
+ }
67
+ const prior = aiAccess as {
68
+ mode?: string;
69
+ groups?: string[];
70
+ denyMessage?: string;
71
+ } | undefined;
72
+ return {
73
+ ...rest,
74
+ master: operatorId,
75
+ aiAccess: {
76
+ mode: prior?.mode ?? 'whitelist',
77
+ users: [operatorId],
78
+ groups: Array.isArray(prior?.groups) ? [...prior.groups] : [],
79
+ denyMessage:
80
+ typeof prior?.denyMessage === 'string'
81
+ ? prior.denyMessage
82
+ : '当前用户尚未开放 AI 功能,请联系开启。',
83
+ },
84
+ } as Partial<QQEndpointConfig<ReceiverMode>>;
85
+ }
86
+
87
+ function readQqConfigs(root: ProvisionContext['root']): EndpointConfigRecord[] {
88
+ const configService = root.inject('config') as { getRaw?: (f: string) => { endpoints?: EndpointConfigRecord[] } ; primaryFile?: string } | undefined;
89
+ if (!configService?.primaryFile || !configService.getRaw) return [];
90
+ const raw = configService.getRaw(configService.primaryFile);
91
+ return (raw?.endpoints ?? []).filter((e) => e.context === 'qq');
92
+ }
93
+
94
+ /** 全局单例:同一时间只允许一个 QQ 扫码绑定 */
95
+ let activeStopFlow: (() => void) | null = null;
96
+
97
+ export class QqEndpointManager implements EndpointManager {
98
+ constructor(private readonly adapter: QQAdapter) {}
99
+
100
+ supportsProvision(): boolean {
101
+ return true;
102
+ }
103
+
104
+ listEndpoints(): EndpointConfigRecord[] {
105
+ return readQqConfigs(this.adapter.plugin.root);
106
+ }
107
+
108
+ cancelProvision(): boolean {
109
+ if (!activeStopFlow) return false;
110
+ activeStopFlow();
111
+ activeStopFlow = null;
112
+ return true;
113
+ }
114
+
115
+ async addEndpoint(ctx: ProvisionContext): Promise<EndpointConfigRecord> {
116
+ if (activeStopFlow) {
117
+ throw new Error('已有进行中的 QQ 机器人绑定,请先 /endpoint cancel');
118
+ }
119
+
120
+ let endpointName: string | undefined;
121
+ const nameMatch = ctx.message.$raw?.match(/\/endpoint\s+add\s+qq\s+(\S+)/);
122
+ if (nameMatch?.[1]) {
123
+ endpointName = nameMatch[1];
124
+ }
125
+
126
+ return new Promise<EndpointConfigRecord>((resolve, reject) => {
127
+ const stopFlow = startQqBindFlow(
128
+ {
129
+ onQrDisplayed: async (url) => {
130
+ await ctx.onStatusUpdate('请用手机 QQ 扫描二维码完成机器人绑定。', {
131
+ qrcode: url,
132
+ });
133
+ },
134
+ onQrExpired: async () => {
135
+ await ctx.onStatusUpdate('二维码已过期,正在刷新,请扫描新二维码…');
136
+ },
137
+ onSuccess: async (credentials) => {
138
+ activeStopFlow = null;
139
+ const [{ appId, appSecret, userOpenId }] = credentials;
140
+ const name = endpointName?.trim() || appId;
141
+ await ctx.onStatusUpdate(
142
+ '凭据绑定成功,正在写入配置…',
143
+ );
144
+ const envKeys = persistQqCredentialsToEnv(name, appId, appSecret);
145
+ const template = resolveQqEndpointTemplate(ctx.root);
146
+ const operatorId = resolveBindOperatorId(userOpenId, ctx);
147
+ const owned = applyBindOwnership(template, operatorId);
148
+ resolve({
149
+ context: 'qq',
150
+ name,
151
+ appid: envKeys.appidRef,
152
+ secret: envKeys.secretRef,
153
+ ...owned,
154
+ } as EndpointConfigRecord);
155
+ },
156
+ onFailure: async (error) => {
157
+ activeStopFlow = null;
158
+ reject(error);
159
+ },
160
+ },
161
+ { source: 'zhin' },
162
+ );
163
+ activeStopFlow = stopFlow;
164
+ });
165
+ }
166
+
167
+ async editEndpoint(name: string, ctx: ProvisionContext): Promise<EndpointConfigRecord> {
168
+ const existing = this.listEndpoints().find((e) => e.name === name);
169
+ if (!existing) {
170
+ throw new Error(`配置中不存在 qq/${name}`);
171
+ }
172
+ await ctx.onStatusUpdate(
173
+ `QQ 官方机器人暂不支持在线改凭据,请 /endpoint remove qq ${name} 后重新 /endpoint add qq。`,
174
+ );
175
+ return existing;
176
+ }
177
+
178
+ async removeEndpoint(name: string): Promise<boolean> {
179
+ return this.listEndpoints().some((e) => e.name === name);
180
+ }
181
+
182
+ async startEndpoint(name: string, ctx: ProvisionContext): Promise<void> {
183
+ if (!this.listEndpoints().some((e) => e.name === name)) {
184
+ throw new Error(`配置中不存在 qq/${name}`);
185
+ }
186
+ await ctx.onStatusUpdate(`正在连接 qq/${name}…`);
187
+ }
188
+
189
+ async stopEndpoint(name: string): Promise<boolean> {
190
+ return this.adapter.endpoints.has(name);
191
+ }
192
+ }
193
+
194
+ export function disposeQqEndpointProvision(): void {
195
+ if (activeStopFlow) {
196
+ activeStopFlow();
197
+ activeStopFlow = null;
198
+ }
199
+ }
@@ -0,0 +1 @@
1
+ export { toCanonicalSegments, fromCanonicalSegments } from 'zhin.js';
package/src/types.ts CHANGED
@@ -36,6 +36,13 @@ export type QQEndpointConfig<
36
36
  * - `false`:保持纯文本
37
37
  */
38
38
  outboundMarkdown?: boolean | "auto";
39
+ /** 本 Endpoint AI 访问控制(见 docs/advanced/content-moderation.md) */
40
+ aiAccess?: {
41
+ mode?: 'open' | 'closed' | 'whitelist';
42
+ users?: string[];
43
+ groups?: string[];
44
+ denyMessage?: string;
45
+ };
39
46
  };
40
47
 
41
48
  export interface QQEndpoint<