@zhin.js/adapter-qq 1.0.83 → 1.1.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +853 -16
  2. package/README.md +136 -233
  3. package/adapters/qq.js +50 -0
  4. package/adapters/qq.ts +71 -0
  5. package/agent/PERMITS.md +25 -0
  6. package/{skills/qq/SKILL.md → agent/skills/qq.md} +1 -1
  7. package/agent/tools/add_role.ts +21 -0
  8. package/agent/tools/channel_info.ts +17 -0
  9. package/agent/tools/create_role.ts +20 -0
  10. package/agent/tools/list_channels.ts +18 -0
  11. package/agent/tools/list_guilds.ts +14 -0
  12. package/agent/tools/list_roles.ts +18 -0
  13. package/agent/tools/member_detail.ts +18 -0
  14. package/agent/tools/remove_role.ts +21 -0
  15. package/commands/endpoint/add/[[id]].js +3 -0
  16. package/commands/endpoint/add/[[id]].ts +3 -0
  17. package/commands/endpoint/cancel.js +12 -0
  18. package/commands/endpoint/cancel.ts +15 -0
  19. package/commands/endpoint/list.js +3 -0
  20. package/commands/endpoint/list.ts +3 -0
  21. package/commands/endpoint/remove/[id].js +3 -0
  22. package/commands/endpoint/remove/[id].ts +3 -0
  23. package/lib/client.d.ts +12 -0
  24. package/lib/client.js +2 -0
  25. package/lib/endpoint.d.ts +56 -0
  26. package/lib/endpoint.js +456 -0
  27. package/lib/index.d.ts +8 -18
  28. package/lib/index.js +8 -338
  29. package/lib/outbound.d.ts +17 -0
  30. package/lib/outbound.js +234 -0
  31. package/lib/platform-permit.d.ts +15 -0
  32. package/lib/platform-permit.js +41 -0
  33. package/lib/protocol.d.ts +111 -0
  34. package/lib/protocol.js +166 -0
  35. package/lib/qq-bind-api.d.ts +36 -0
  36. package/lib/qq-bind-api.js +89 -0
  37. package/lib/qq-bind-flow.d.ts +19 -0
  38. package/lib/qq-bind-flow.js +85 -0
  39. package/lib/qq-bind-persist.d.ts +12 -0
  40. package/lib/qq-bind-persist.js +22 -0
  41. package/lib/qq-bot-kind-prompt.d.ts +9 -0
  42. package/lib/qq-bot-kind-prompt.js +37 -0
  43. package/lib/qq-endpoint-commands.d.ts +45 -0
  44. package/lib/qq-endpoint-commands.js +180 -0
  45. package/lib/qq-endpoint-config.d.ts +17 -0
  46. package/lib/qq-endpoint-config.js +22 -0
  47. package/lib/qq-intents.d.ts +27 -0
  48. package/lib/qq-intents.js +51 -0
  49. package/lib/qq-runtime-state.d.ts +30 -0
  50. package/lib/qq-runtime-state.js +14 -0
  51. package/lib/side-event-dispatch.d.ts +10 -0
  52. package/lib/side-event-dispatch.js +114 -0
  53. package/lib/webhook.d.ts +18 -0
  54. package/lib/webhook.js +103 -0
  55. package/lib/ws.d.ts +55 -0
  56. package/lib/ws.js +234 -0
  57. package/middlewares/bot-kind-reply.js +46 -0
  58. package/middlewares/bot-kind-reply.ts +60 -0
  59. package/package.json +69 -28
  60. package/plugin.js +18 -0
  61. package/schema.json +142 -0
  62. package/src/client.ts +16 -0
  63. package/src/endpoint.ts +534 -0
  64. package/src/index.ts +66 -326
  65. package/src/outbound.ts +255 -0
  66. package/src/platform-permit.ts +56 -0
  67. package/src/protocol.ts +273 -0
  68. package/src/qq-bind-api.ts +145 -0
  69. package/src/qq-bind-flow.ts +127 -0
  70. package/src/qq-bind-persist.ts +44 -0
  71. package/src/qq-bot-kind-prompt.ts +47 -0
  72. package/src/qq-endpoint-commands.ts +251 -0
  73. package/src/qq-endpoint-config.ts +44 -0
  74. package/src/qq-intents.ts +77 -0
  75. package/src/qq-runtime-state.ts +51 -0
  76. package/src/side-event-dispatch.ts +140 -0
  77. package/src/webhook.ts +132 -0
  78. package/src/ws.ts +331 -0
  79. package/client/Dashboard.tsx +0 -230
  80. package/client/index.tsx +0 -11
  81. package/client/tsconfig.json +0 -7
  82. package/client/utils/api.ts +0 -17
  83. package/dist/index.js +0 -30
  84. package/lib/adapter.d.ts +0 -17
  85. package/lib/adapter.d.ts.map +0 -1
  86. package/lib/adapter.js +0 -49
  87. package/lib/adapter.js.map +0 -1
  88. package/lib/bot.d.ts +0 -102
  89. package/lib/bot.d.ts.map +0 -1
  90. package/lib/bot.js +0 -337
  91. package/lib/bot.js.map +0 -1
  92. package/lib/index.d.ts.map +0 -1
  93. package/lib/index.js.map +0 -1
  94. package/lib/types.d.ts +0 -16
  95. package/lib/types.d.ts.map +0 -1
  96. package/lib/types.js +0 -2
  97. package/lib/types.js.map +0 -1
  98. package/plugin.yml +0 -3
  99. package/src/adapter.ts +0 -58
  100. package/src/bot.ts +0 -347
  101. package/src/types.ts +0 -24
@@ -0,0 +1,56 @@
1
+ /**
2
+ * QQ 官方频道 platform permit(guild/channel,非 QQ 群 group_*)
3
+ */
4
+ import type { PermissionSubject } from '@zhin.js/permission';
5
+
6
+ const ADAPTER = 'qq';
7
+
8
+ export function platformPermit(perm: string): string {
9
+ return `platform(${ADAPTER},${perm})`;
10
+ }
11
+
12
+ /** 官方频道工厂工具:映射到 guild 语义,不用 group_* */
13
+ const FACTORY_PERM_MAP: Record<string, string> = {
14
+ scene_admin: 'guild_admin',
15
+ scene_owner: 'guild_owner',
16
+ };
17
+
18
+ export function qqGuildPermitResolver(logicalPerm: string): string {
19
+ return platformPermit(FACTORY_PERM_MAP[logicalPerm] ?? logicalPerm);
20
+ }
21
+
22
+ export function normalizeQqGuildSenderForPermit(input: {
23
+ roles?: string[];
24
+ isOwner?: boolean;
25
+ isAdmin?: boolean;
26
+ }): { role?: string; permissions?: string[] } {
27
+ const permissions = [...(input.roles ?? [])];
28
+ if (input.isOwner) {
29
+ permissions.push('guild_owner', 'guild_admin');
30
+ return { role: 'owner', permissions };
31
+ }
32
+ if (input.isAdmin) {
33
+ permissions.push('guild_admin');
34
+ return { role: 'admin', permissions };
35
+ }
36
+ return { role: 'member', permissions };
37
+ }
38
+
39
+ export function checkQqPlatformPermit(perm: string, subject: PermissionSubject): boolean {
40
+ const role = subject.sender?.role?.[0];
41
+ const permissions = subject.sender?.permissions ?? [];
42
+ const has = (t: string) => permissions.includes(t);
43
+
44
+ switch (perm) {
45
+ case 'guild_owner':
46
+ return role === 'owner' || has('guild_owner');
47
+ case 'guild_admin':
48
+ return role === 'owner' || role === 'admin' || has('guild_admin') || has('guild_owner');
49
+ case 'manage_roles':
50
+ return role === 'owner' || has('guild_owner') || has('manage_roles');
51
+ case 'manage_channels':
52
+ return role === 'owner' || role === 'admin' || has('guild_owner') || has('guild_admin') || has('manage_channels');
53
+ default:
54
+ return false;
55
+ }
56
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * QQ Official Bot protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
3
+ * Canonicalization is owned by gateway/core before endpoint.send.
4
+ */
5
+
6
+ import { pickCredential } from 'zhin.js/adapter';
7
+ import type { ConversationKind, ConversationRef } from '@zhin.js/im-contract';
8
+ import { formatCompact, getLogger } from '@zhin.js/logger';
9
+ import { resolveQqIntents } from './qq-intents.js';
10
+
11
+ const logger = getLogger('qq');
12
+
13
+ /** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
14
+ export interface QqAdapterConfig {
15
+ readonly id?: string;
16
+ readonly appid?: string;
17
+ readonly secret?: string;
18
+ /** Default `websocket`. `webhook` / `middleware` use httpHostToken POST. */
19
+ readonly mode?: 'websocket' | 'webhook' | 'middleware';
20
+ readonly sandbox?: boolean;
21
+ /** Explicit intents; when omitted, derived from `botKind` (default public). */
22
+ readonly intents?: readonly string[];
23
+ /** public | private — drives default intents when `intents` omitted. */
24
+ readonly botKind?: string;
25
+ readonly accessTokenUrl?: string;
26
+ readonly gatewayUrl?: string;
27
+ readonly webhookPath?: string;
28
+ readonly port?: number;
29
+ readonly path?: string;
30
+ /** Transitional: legacy root `endpoints[]` with `context: qq`. */
31
+ readonly endpoints?: ReadonlyArray<{
32
+ readonly context?: string;
33
+ readonly id?: string;
34
+ readonly appid?: string;
35
+ readonly secret?: string;
36
+ readonly mode?: 'websocket' | 'webhook' | 'middleware';
37
+ readonly sandbox?: boolean;
38
+ readonly intents?: readonly string[];
39
+ readonly botKind?: string;
40
+ readonly accessTokenUrl?: string;
41
+ readonly gatewayUrl?: string;
42
+ readonly webhookPath?: string;
43
+ readonly port?: number;
44
+ readonly path?: string;
45
+ }>;
46
+ }
47
+
48
+ export interface ResolvedQqWebsocketConfig {
49
+ readonly context: 'qq';
50
+ readonly mode: 'websocket';
51
+ readonly id: string;
52
+ readonly appid: string;
53
+ readonly secret: string;
54
+ readonly sandbox: boolean;
55
+ readonly intents?: readonly string[];
56
+ readonly accessTokenUrl?: string;
57
+ readonly gatewayUrl?: string;
58
+ }
59
+
60
+ export interface ResolvedQqHttpConfig {
61
+ readonly context: 'qq';
62
+ readonly mode: 'webhook' | 'middleware';
63
+ readonly id: string;
64
+ readonly appid: string;
65
+ readonly secret: string;
66
+ readonly webhookPath: string;
67
+ readonly sandbox: boolean;
68
+ }
69
+
70
+ export type ResolvedQqConfig = ResolvedQqWebsocketConfig | ResolvedQqHttpConfig;
71
+
72
+ export type QqChannelKind = 'private' | 'group' | 'channel' | 'direct';
73
+
74
+ export interface QqInboundMessage {
75
+ readonly id: string;
76
+ readonly content: string;
77
+ readonly channelKind: QqChannelKind;
78
+ readonly channelId: string;
79
+ readonly authorId: string;
80
+ readonly authorName: string;
81
+ readonly authorRoles?: string[];
82
+ readonly timestamp: number;
83
+ readonly guildId?: string;
84
+ readonly rawMessage?: string;
85
+ /** canonical 入站段(text/mention/face/image/audio/video/file/reply),与 content 同源 */
86
+ readonly segments?: readonly import('@zhin.js/core').Segment[];
87
+ /**
88
+ * 入站是否 @ 当前机器人:统一按 mentions 判定。群场景只认 is_you(@ 另一个
89
+ * 机器人时 bot:true 但无 is_you,不误判);bot 回退仅限频道 AT 事件。
90
+ * 群 GROUP_MESSAGE_CREATE 的非 @ 消息不置位。新 Runtime 纯文本 content 需经
91
+ * metadata 传递。
92
+ */
93
+ readonly mentioned?: boolean;
94
+ }
95
+
96
+ export interface QqWireSegment {
97
+ readonly type: string;
98
+ readonly data?: Record<string, unknown>;
99
+ }
100
+
101
+ export function resolveQqConfig(config: QqAdapterConfig = {}): ResolvedQqConfig {
102
+ const entry = config.endpoints?.find((item) => item.context === 'qq' || !item.context);
103
+ const appid = pickCredential(config.appid, entry?.appid, process.env.QQ_APPID, process.env.QQ_BOT_APPID);
104
+ const secret = pickCredential(config.secret, entry?.secret, process.env.QQ_SECRET, process.env.QQ_BOT_SECRET);
105
+ if (!appid || !secret) {
106
+ throw new TypeError(
107
+ 'QQ adapter requires appid + secret (plugins.<key>.appid/secret or endpoints with context: qq)',
108
+ );
109
+ }
110
+ const id = (typeof config.id === 'string' && config.id)
111
+ || (typeof entry?.id === 'string' && entry.id)
112
+ || process.env.QQ_BOT_NAME
113
+ || 'qq-bot';
114
+ const mode = config.mode ?? entry?.mode ?? 'websocket';
115
+
116
+ if (mode === 'webhook' || mode === 'middleware') {
117
+ const webhookPathRaw = config.webhookPath ?? entry?.webhookPath ?? '/qq/webhook';
118
+ const webhookPath = webhookPathRaw.startsWith('/') ? webhookPathRaw : `/${webhookPathRaw}`;
119
+ return {
120
+ context: 'qq',
121
+ mode,
122
+ id,
123
+ appid,
124
+ secret,
125
+ webhookPath,
126
+ sandbox: config.sandbox === true || entry?.sandbox === true,
127
+ };
128
+ }
129
+
130
+ return {
131
+ context: 'qq',
132
+ mode: 'websocket',
133
+ id,
134
+ appid,
135
+ secret,
136
+ sandbox: config.sandbox === true || entry?.sandbox === true,
137
+ intents: resolveQqIntents({
138
+ intents: config.intents ?? entry?.intents,
139
+ botKind: config.botKind ?? entry?.botKind,
140
+ }),
141
+ accessTokenUrl: config.accessTokenUrl ?? entry?.accessTokenUrl,
142
+ gatewayUrl: config.gatewayUrl ?? entry?.gatewayUrl,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * 入站归一化 → ConversationRef:`direct`(频道私信)映射为 guild 容器内的
148
+ * private 会话;频道消息的所属 guild 进 `parent`。
149
+ */
150
+ export function qqInboundConversation(endpointKey: string, msg: QqInboundMessage): ConversationRef {
151
+ const kind: ConversationKind = msg.channelKind === 'direct' ? 'private' : msg.channelKind;
152
+ return {
153
+ endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
154
+ kind,
155
+ id: msg.channelId,
156
+ ...(msg.guildId && (msg.channelKind === 'channel' || msg.channelKind === 'direct')
157
+ ? { parent: { kind: 'channel' as const, id: msg.guildId } }
158
+ : {}),
159
+ };
160
+ }
161
+
162
+ /** 出站:guild 容器内的 private 会话即 QQ `direct`(频道私信)。 */
163
+ export function qqOutboundKind(conversation: ConversationRef): QqChannelKind {
164
+ if (conversation.kind === 'private' && conversation.parent?.kind === 'channel') return 'direct';
165
+ return conversation.kind;
166
+ }
167
+
168
+ export function senderDisplayName(msg: QqInboundMessage): string {
169
+ return msg.authorName || msg.authorId;
170
+ }
171
+
172
+ export function formatInboundContent(msg: QqInboundMessage): string {
173
+ const text = (msg.content || msg.rawMessage || '').trim();
174
+ return text || '(Empty message)';
175
+ }
176
+
177
+ /**
178
+ * Wire-encode an already-rendered outbound payload into a plain text / QQ Sendable string.
179
+ * Segment canonicalization is intentionally not done here.
180
+ */
181
+ export function formatOutboundText(payload: unknown): string {
182
+ if (typeof payload === 'string') return payload;
183
+
184
+ const segments: Array<string | QqWireSegment> = Array.isArray(payload)
185
+ ? payload as Array<string | QqWireSegment>
186
+ : payload && typeof payload === 'object' && 'type' in (payload as object)
187
+ ? [payload as QqWireSegment]
188
+ : [];
189
+
190
+ if (segments.length === 0) {
191
+ return payload == null
192
+ ? ''
193
+ : typeof payload === 'object'
194
+ ? JSON.stringify(payload)
195
+ : String(payload);
196
+ }
197
+
198
+ return segments
199
+ .map((item) => {
200
+ if (typeof item === 'string') return item;
201
+ const data = item.data ?? {};
202
+ switch (item.type) {
203
+ case 'text':
204
+ return String(data.text ?? data.content ?? '');
205
+ case 'at': {
206
+ const id = String(data.user_id ?? data.qq ?? data.id ?? '');
207
+ return id ? `<@!${id}>` : '';
208
+ }
209
+ case 'image': {
210
+ const src = String(data.url || data.file || '');
211
+ // base64/本地路径由 formatOutbound 走媒体上传,文本视图静默省略;
212
+ // 仅当彻底缺少来源时留痕,避免出站静默降级排障无头绪。
213
+ if (!src) {
214
+ logger.warn(formatCompact({
215
+ op: 'qq_outbound_image_dropped',
216
+ reason: 'missing_source',
217
+ }));
218
+ return '';
219
+ }
220
+ return /^https?:\/\//.test(src) ? src : '';
221
+ }
222
+ case 'reply':
223
+ return '';
224
+ default:
225
+ return data.text != null ? String(data.text) : '';
226
+ }
227
+ })
228
+ .filter(Boolean)
229
+ .join('');
230
+ }
231
+
232
+ /**
233
+ * Parse a compound messageId produced by `send()`: `kind-channelId:qqMsgId`.
234
+ */
235
+ export function parseCompoundMessageId(messageId: string): {
236
+ kind: QqChannelKind;
237
+ channelId: string;
238
+ qqMsgId: string;
239
+ } {
240
+ const hyphen = messageId.indexOf('-');
241
+ if (hyphen <= 0) throw new Error(`invalid QQ messageId format: ${messageId}`);
242
+ const kind = messageId.slice(0, hyphen);
243
+ if (kind !== 'private' && kind !== 'group' && kind !== 'channel' && kind !== 'direct') {
244
+ throw new Error(`invalid QQ messageId kind: ${kind}`);
245
+ }
246
+ const rest = messageId.slice(hyphen + 1);
247
+ const colon = rest.indexOf(':');
248
+ if (colon <= 0) throw new Error(`invalid QQ messageId format: ${messageId}`);
249
+ return { kind, channelId: rest.slice(0, colon), qqMsgId: rest.slice(colon + 1) };
250
+ }
251
+
252
+ /** 从 QQ API SendResult / 审核回包中解析出站消息 ID */
253
+ export function resolveOutboundMessageId(result: unknown): string {
254
+ if (!result || typeof result !== 'object') {
255
+ throw new Error('QQ 发送消息失败:响应为空');
256
+ }
257
+ const row = result as Record<string, unknown>;
258
+ const nested = row.data && typeof row.data === 'object'
259
+ ? row.data as Record<string, unknown>
260
+ : undefined;
261
+ const audit = (row.message_audit ?? nested?.message_audit) as Record<string, unknown> | undefined;
262
+ const id = row.id ?? row.message_id ?? audit?.audit_id;
263
+ if (id == null || id === '') {
264
+ const code = row.code;
265
+ const msg = row.message;
266
+ throw new Error(
267
+ code != null
268
+ ? `QQ 发送消息失败(${String(code)})${msg ? `: ${String(msg)}` : ''}`
269
+ : 'QQ 发送消息失败:响应缺少消息 ID',
270
+ );
271
+ }
272
+ return String(id);
273
+ }
@@ -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,44 @@
1
+ import {
2
+ buildEndpointEnvKey,
3
+ persistEndpointEnvValues,
4
+ resolveProjectRoot,
5
+ } from 'zhin.js/adapter';
6
+
7
+ export { resolveProjectRoot };
8
+
9
+ export interface QqCredentialEnvKeys {
10
+ appidKey: string;
11
+ secretKey: string;
12
+ appidRef: string;
13
+ secretRef: string;
14
+ }
15
+
16
+ /** 按 endpoint 名称生成唯一 env 键(如 `QQ_MY_BOT_APPID`) */
17
+ export function buildQqCredentialEnvKeys(endpointId: string): QqCredentialEnvKeys {
18
+ const appidKey = buildEndpointEnvKey('qq', endpointId, 'appid');
19
+ const secretKey = buildEndpointEnvKey('qq', endpointId, 'secret');
20
+ return {
21
+ appidKey,
22
+ secretKey,
23
+ appidRef: `\${${appidKey}}`,
24
+ secretRef: `\${${secretKey}}`,
25
+ };
26
+ }
27
+
28
+ /** 写入或更新 `.env` 中的键值,并同步到当前进程 `process.env` */
29
+ export function persistQqCredentialsToEnv(
30
+ endpointId: string,
31
+ appId: string,
32
+ appSecret: string,
33
+ projectRoot?: string,
34
+ ): QqCredentialEnvKeys {
35
+ const keys = buildQqCredentialEnvKeys(endpointId);
36
+ persistEndpointEnvValues(
37
+ {
38
+ [keys.appidKey]: appId,
39
+ [keys.secretKey]: appSecret,
40
+ },
41
+ projectRoot,
42
+ );
43
+ return keys;
44
+ }
@@ -0,0 +1,47 @@
1
+ import { parseQqBotKind, type QqBotKind } from './qq-intents.js';
2
+
3
+ /** 从命令/中间件入站消息提取会话键;无法识别时返回 undefined。 */
4
+ export function qqCommandSessionKey(input: unknown): string | undefined {
5
+ const message = input as {
6
+ conversation?: {
7
+ endpoint?: { id?: unknown; adapter?: unknown };
8
+ kind?: unknown;
9
+ id?: unknown;
10
+ };
11
+ sender?: unknown;
12
+ } | null | undefined;
13
+ const conversation = message?.conversation;
14
+ const adapter = conversation?.endpoint?.adapter;
15
+ const endpointKey = conversation?.endpoint?.id;
16
+ const kind = conversation?.kind;
17
+ const id = conversation?.id;
18
+ const sender = message?.sender;
19
+ if (
20
+ adapter == null
21
+ || endpointKey == null
22
+ || (kind !== 'private' && kind !== 'group' && kind !== 'channel')
23
+ || typeof id !== 'string'
24
+ || !id
25
+ || sender == null
26
+ || String(sender).trim() === ''
27
+ ) {
28
+ return undefined;
29
+ }
30
+ return `${String(adapter)}\0${String(endpointKey)}\0${kind}\0${id}\0${String(sender).trim()}`;
31
+ }
32
+
33
+ /**
34
+ * 解析用户对公域/私域的回复。
35
+ * 接受:public / private / 公域 / 私域(大小写不敏感,可带前后空白)。
36
+ */
37
+ export function parseQqBotKindAnswer(raw: unknown): QqBotKind | undefined {
38
+ const text = String(raw ?? '').trim().toLowerCase();
39
+ if (!text) return undefined;
40
+ if (text === '公域' || text === '公开' || text === '1') return 'public';
41
+ if (text === '私域' || text === '2') return 'private';
42
+ return parseQqBotKind(text);
43
+ }
44
+
45
+ export const QQ_BOT_KIND_PROMPT =
46
+ '请回复 public(公域)或 private(私域),确认后一次性写入 .env 与配置。\n'
47
+ + '也可发 qq.endpoint cancel 取消。';