@zhin.js/adapter-qq 7.0.3 → 7.0.5

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 (51) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +36 -9
  3. package/adapters/qq.js +9 -7
  4. package/adapters/qq.ts +9 -7
  5. package/commands/endpoint/cancel.js +1 -1
  6. package/commands/endpoint/cancel.ts +1 -1
  7. package/lib/endpoint.js +55 -45
  8. package/lib/index.d.ts +2 -1
  9. package/lib/index.js +2 -1
  10. package/lib/platform-permit.d.ts +2 -3
  11. package/lib/platform-permit.js +3 -11
  12. package/lib/protocol.d.ts +9 -5
  13. package/lib/protocol.js +11 -7
  14. package/lib/qq-agent-deps.d.ts +2 -2
  15. package/lib/qq-agent-deps.js +7 -7
  16. package/lib/qq-bind-persist.d.ts +2 -2
  17. package/lib/qq-bind-persist.js +5 -5
  18. package/lib/qq-bot-kind-prompt.d.ts +9 -0
  19. package/lib/qq-bot-kind-prompt.js +37 -0
  20. package/lib/qq-endpoint-commands.d.ts +13 -6
  21. package/lib/qq-endpoint-commands.js +89 -31
  22. package/lib/qq-endpoint-config.d.ts +4 -4
  23. package/lib/qq-endpoint-config.js +4 -4
  24. package/lib/qq-intents.d.ts +27 -0
  25. package/lib/qq-intents.js +50 -0
  26. package/lib/qq-runtime-state.d.ts +17 -4
  27. package/lib/qq-runtime-state.js +3 -2
  28. package/lib/webhook.js +2 -0
  29. package/lib/ws.js +2 -2
  30. package/middlewares/bot-kind-reply.js +46 -0
  31. package/middlewares/bot-kind-reply.ts +60 -0
  32. package/package.json +23 -16
  33. package/plugin.js +6 -4
  34. package/schema.json +66 -9
  35. package/src/endpoint.ts +69 -46
  36. package/src/index.ts +10 -1
  37. package/src/platform-permit.ts +4 -9
  38. package/src/protocol.ts +19 -11
  39. package/src/qq-agent-deps.ts +8 -8
  40. package/src/qq-bind-persist.ts +5 -5
  41. package/src/qq-bot-kind-prompt.ts +47 -0
  42. package/src/qq-endpoint-commands.ts +125 -34
  43. package/src/qq-endpoint-config.ts +5 -5
  44. package/src/qq-intents.ts +76 -0
  45. package/src/qq-runtime-state.ts +21 -6
  46. package/src/webhook.ts +2 -0
  47. package/src/ws.ts +2 -2
  48. /package/commands/endpoint/add/{[[name]].js → [[id]].js} +0 -0
  49. /package/commands/endpoint/add/{[[name]].ts → [[id]].ts} +0 -0
  50. /package/commands/endpoint/remove/{[name].js → [id].js} +0 -0
  51. /package/commands/endpoint/remove/{[name].ts → [id].ts} +0 -0
package/src/protocol.ts CHANGED
@@ -6,18 +6,22 @@
6
6
  import { pickCredential } from '@zhin.js/adapter';
7
7
  import type { ConversationKind, ConversationRef } from '@zhin.js/im-contract';
8
8
  import { formatCompact, getLogger } from '@zhin.js/logger';
9
+ import { resolveQqIntents } from './qq-intents.js';
9
10
 
10
11
  const logger = getLogger('qq');
11
12
 
12
13
  /** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
13
14
  export interface QqAdapterConfig {
14
- readonly name?: string;
15
+ readonly id?: string;
15
16
  readonly appid?: string;
16
17
  readonly secret?: string;
17
18
  /** Default `websocket`. `webhook` / `middleware` use httpHostToken POST. */
18
19
  readonly mode?: 'websocket' | 'webhook' | 'middleware';
19
20
  readonly sandbox?: boolean;
21
+ /** Explicit intents; when omitted, derived from `botKind` (default public). */
20
22
  readonly intents?: readonly string[];
23
+ /** public | private — drives default intents when `intents` omitted. */
24
+ readonly botKind?: string;
21
25
  readonly accessTokenUrl?: string;
22
26
  readonly gatewayUrl?: string;
23
27
  readonly webhookPath?: string;
@@ -26,12 +30,13 @@ export interface QqAdapterConfig {
26
30
  /** Transitional: legacy root `endpoints[]` with `context: qq`. */
27
31
  readonly endpoints?: ReadonlyArray<{
28
32
  readonly context?: string;
29
- readonly name?: string;
33
+ readonly id?: string;
30
34
  readonly appid?: string;
31
35
  readonly secret?: string;
32
36
  readonly mode?: 'websocket' | 'webhook' | 'middleware';
33
37
  readonly sandbox?: boolean;
34
38
  readonly intents?: readonly string[];
39
+ readonly botKind?: string;
35
40
  readonly accessTokenUrl?: string;
36
41
  readonly gatewayUrl?: string;
37
42
  readonly webhookPath?: string;
@@ -43,7 +48,7 @@ export interface QqAdapterConfig {
43
48
  export interface ResolvedQqWebsocketConfig {
44
49
  readonly context: 'qq';
45
50
  readonly mode: 'websocket';
46
- readonly name: string;
51
+ readonly id: string;
47
52
  readonly appid: string;
48
53
  readonly secret: string;
49
54
  readonly sandbox: boolean;
@@ -55,7 +60,7 @@ export interface ResolvedQqWebsocketConfig {
55
60
  export interface ResolvedQqHttpConfig {
56
61
  readonly context: 'qq';
57
62
  readonly mode: 'webhook' | 'middleware';
58
- readonly name: string;
63
+ readonly id: string;
59
64
  readonly appid: string;
60
65
  readonly secret: string;
61
66
  readonly webhookPath: string;
@@ -102,8 +107,8 @@ export function resolveQqConfig(config: QqAdapterConfig = {}): ResolvedQqConfig
102
107
  'QQ adapter requires appid + secret (plugins.<key>.appid/secret or endpoints with context: qq)',
103
108
  );
104
109
  }
105
- const name = (typeof config.name === 'string' && config.name)
106
- || (typeof entry?.name === 'string' && entry.name)
110
+ const id = (typeof config.id === 'string' && config.id)
111
+ || (typeof entry?.id === 'string' && entry.id)
107
112
  || process.env.QQ_BOT_NAME
108
113
  || 'qq-bot';
109
114
  const mode = config.mode ?? entry?.mode ?? 'websocket';
@@ -114,7 +119,7 @@ export function resolveQqConfig(config: QqAdapterConfig = {}): ResolvedQqConfig
114
119
  return {
115
120
  context: 'qq',
116
121
  mode,
117
- name,
122
+ id,
118
123
  appid,
119
124
  secret,
120
125
  webhookPath,
@@ -125,11 +130,14 @@ export function resolveQqConfig(config: QqAdapterConfig = {}): ResolvedQqConfig
125
130
  return {
126
131
  context: 'qq',
127
132
  mode: 'websocket',
128
- name,
133
+ id,
129
134
  appid,
130
135
  secret,
131
136
  sandbox: config.sandbox === true || entry?.sandbox === true,
132
- intents: config.intents ?? entry?.intents,
137
+ intents: resolveQqIntents({
138
+ intents: config.intents ?? entry?.intents,
139
+ botKind: config.botKind ?? entry?.botKind,
140
+ }),
133
141
  accessTokenUrl: config.accessTokenUrl ?? entry?.accessTokenUrl,
134
142
  gatewayUrl: config.gatewayUrl ?? entry?.gatewayUrl,
135
143
  };
@@ -139,10 +147,10 @@ export function resolveQqConfig(config: QqAdapterConfig = {}): ResolvedQqConfig
139
147
  * 入站归一化 → ConversationRef:`direct`(频道私信)映射为 guild 容器内的
140
148
  * private 会话;频道消息的所属 guild 进 `parent`。
141
149
  */
142
- export function qqInboundConversation(endpointId: string, msg: QqInboundMessage): ConversationRef {
150
+ export function qqInboundConversation(endpointKey: string, msg: QqInboundMessage): ConversationRef {
143
151
  const kind: ConversationKind = msg.channelKind === 'direct' ? 'private' : msg.channelKind;
144
152
  return {
145
- endpoint: { id: endpointId, adapter: endpointId.split('\0')[0] ?? endpointId },
153
+ endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
146
154
  kind,
147
155
  id: msg.channelId,
148
156
  ...(msg.guildId && (msg.channelKind === 'channel' || msg.channelKind === 'direct')
@@ -25,20 +25,20 @@ export interface QqAgentEndpoint {
25
25
  }
26
26
 
27
27
  export interface QqAgentDeps {
28
- getEndpoint: (endpointId: string) => QqAgentEndpoint;
28
+ getEndpoint: (endpointKey: string) => QqAgentEndpoint;
29
29
  }
30
30
 
31
31
  const endpoints = new Map<string, QqAgentEndpoint>();
32
32
  let override: QqAgentDeps | null = null;
33
33
 
34
34
  export function registerQqAgentEndpoint(
35
- endpointId: string,
35
+ endpointKey: string,
36
36
  endpoint: QqAgentEndpoint,
37
37
  ): () => void {
38
- endpoints.set(endpointId, endpoint);
38
+ endpoints.set(endpointKey, endpoint);
39
39
  return () => {
40
- if (endpoints.get(endpointId) === endpoint) {
41
- endpoints.delete(endpointId);
40
+ if (endpoints.get(endpointKey) === endpoint) {
41
+ endpoints.delete(endpointKey);
42
42
  }
43
43
  };
44
44
  }
@@ -51,9 +51,9 @@ export function setQqAgentDeps(deps: QqAgentDeps | null): void {
51
51
  export function getQqAgentDeps(): QqAgentDeps {
52
52
  if (override) return override;
53
53
  return {
54
- getEndpoint(endpointId: string): QqAgentEndpoint {
55
- const registered = endpoints.get(endpointId);
56
- if (!registered) throw new Error(`Endpoint ${endpointId} 不存在`);
54
+ getEndpoint(endpointKey: string): QqAgentEndpoint {
55
+ const registered = endpoints.get(endpointKey);
56
+ if (!registered) throw new Error(`Endpoint ${endpointKey} 不存在`);
57
57
  return registered;
58
58
  },
59
59
  };
@@ -14,9 +14,9 @@ export interface QqCredentialEnvKeys {
14
14
  }
15
15
 
16
16
  /** 按 endpoint 名称生成唯一 env 键(如 `QQ_MY_BOT_APPID`) */
17
- export function buildQqCredentialEnvKeys(endpointName: string): QqCredentialEnvKeys {
18
- const appidKey = buildEndpointEnvKey('qq', endpointName, 'appid');
19
- const secretKey = buildEndpointEnvKey('qq', endpointName, 'secret');
17
+ export function buildQqCredentialEnvKeys(endpointId: string): QqCredentialEnvKeys {
18
+ const appidKey = buildEndpointEnvKey('qq', endpointId, 'appid');
19
+ const secretKey = buildEndpointEnvKey('qq', endpointId, 'secret');
20
20
  return {
21
21
  appidKey,
22
22
  secretKey,
@@ -27,12 +27,12 @@ export function buildQqCredentialEnvKeys(endpointName: string): QqCredentialEnvK
27
27
 
28
28
  /** 写入或更新 `.env` 中的键值,并同步到当前进程 `process.env` */
29
29
  export function persistQqCredentialsToEnv(
30
- endpointName: string,
30
+ endpointId: string,
31
31
  appId: string,
32
32
  appSecret: string,
33
33
  projectRoot?: string,
34
34
  ): QqCredentialEnvKeys {
35
- const keys = buildQqCredentialEnvKeys(endpointName);
35
+ const keys = buildQqCredentialEnvKeys(endpointId);
36
36
  persistEndpointEnvValues(
37
37
  {
38
38
  [keys.appidKey]: appId,
@@ -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 取消。';
@@ -11,15 +11,21 @@ import {
11
11
  extractEndpointCommandReply,
12
12
  formatEndpointList,
13
13
  isEndpointOperator,
14
- removeEndpointByName,
14
+ removeEndpointById,
15
15
  type ConfiguredEndpointEntry,
16
16
  type EndpointCommandReply,
17
17
  } from '@zhin.js/adapter';
18
18
  import { defineCommand } from '@zhin.js/command';
19
+ import { QQ_BOT_KIND_PROMPT, qqCommandSessionKey } from './qq-bot-kind-prompt.js';
19
20
  import { startQqBindFlow } from './qq-bind-flow.js';
20
21
  import { persistQqCredentialsToEnv } from './qq-bind-persist.js';
21
22
  import { addQqEndpointToConfig, listQqEndpointEntries } from './qq-endpoint-config.js';
22
- import { qqRuntimeStateToken, type QqRuntimeState } from './qq-runtime-state.js';
23
+ import { defaultQqEndpointIntentFields, type QqBotKind } from './qq-intents.js';
24
+ import {
25
+ qqRuntimeStateToken,
26
+ type QqPendingBotKind,
27
+ type QqRuntimeState,
28
+ } from './qq-runtime-state.js';
23
29
 
24
30
  export type QqCommandReply = EndpointCommandReply;
25
31
 
@@ -41,12 +47,20 @@ export function extractQqCommandReply(input: unknown): QqCommandReply {
41
47
  return extractEndpointCommandReply(input);
42
48
  }
43
49
 
50
+ function busyFooter(state: QqRuntimeState): string | undefined {
51
+ if (state.bindFlow) return '⚠️ 有进行中的扫码绑定,可用 qq.endpoint cancel 取消';
52
+ if (state.pendingBotKind) {
53
+ return `⚠️ endpoint「${state.pendingBotKind.endpointId}」等待公域/私域选择,回复 public/private 或 qq.endpoint cancel`;
54
+ }
55
+ return undefined;
56
+ }
57
+
44
58
  /** `qq.endpoint list`:运行中的 endpoints(本 generation adapter create 注册)+ 配置里的 endpoints */
45
59
  export function runQqEndpointList(state: QqRuntimeState, projectRoot?: string): string {
46
60
  return formatEndpointList(qqEndpointListSpec, {
47
61
  running: state.endpoints.values(),
48
62
  configured: listQqEndpointEntries(projectRoot),
49
- footer: state.bindFlow ? '⚠️ 有进行中的扫码绑定,可用 qq.endpoint cancel 取消' : undefined,
63
+ footer: busyFooter(state),
50
64
  });
51
65
  }
52
66
 
@@ -56,21 +70,67 @@ const qqEndpointListSpec = {
56
70
  describeEntry: (entry: ConfiguredEndpointEntry) => `appid: ${String(entry.appid)}`,
57
71
  } as const;
58
72
 
73
+ function writeEndpointWithBotKind(
74
+ pending: QqPendingBotKind,
75
+ botKind: QqBotKind,
76
+ projectRoot?: string,
77
+ ): string {
78
+ const envKeys = persistQqCredentialsToEnv(
79
+ pending.endpointId,
80
+ pending.appId,
81
+ pending.appSecret,
82
+ projectRoot,
83
+ );
84
+ const intentFields = defaultQqEndpointIntentFields(botKind);
85
+ const filePath = addQqEndpointToConfig(
86
+ {
87
+ id: pending.endpointId,
88
+ appid: envKeys.appidRef,
89
+ secret: envKeys.secretRef,
90
+ botKind: intentFields.botKind,
91
+ intents: [...intentFields.intents],
92
+ },
93
+ projectRoot,
94
+ );
95
+ return (
96
+ `✅ 已按 botKind=${intentFields.botKind} 将 endpoint「${pending.endpointId}」写入 .env 与 ${filePath}。\n` +
97
+ `intents: ${intentFields.intents.join(', ')}。\n` +
98
+ '⚠️ 需重启 zhin 后新 endpoint 才会生效。'
99
+ );
100
+ }
101
+
59
102
  /**
60
- * `qq.endpoint add [name]`:启动扫码绑定流程。
103
+ * 完成 pending 的公域/私域选择:一次性写 .env + yaml。
104
+ * @returns 成功文案;调用方负责清 pending。
105
+ */
106
+ export function completeQqPendingBotKind(
107
+ pending: QqPendingBotKind,
108
+ botKind: QqBotKind,
109
+ projectRoot?: string,
110
+ ): string {
111
+ return writeEndpointWithBotKind(pending, botKind, projectRoot);
112
+ }
113
+
114
+ /**
115
+ * `qq.endpoint add [id]`:启动扫码绑定流程。
61
116
  * 返回的 Promise 在二维码链接就绪(或前置失败)时 resolve 为首条回复;
62
117
  * 后续状态(已扫码 / 成功 / 失败 / 过期刷新)通过 reply 推回当前会话。
118
+ * 扫码成功后凭据暂存内存并询问公域/私域;确认后一次性写 .env + yaml。
63
119
  */
64
120
  export function runQqEndpointAdd(
65
121
  state: QqRuntimeState,
66
- name: string | undefined,
122
+ id: string | undefined,
67
123
  reply: QqCommandReply,
68
124
  projectRoot?: string,
125
+ input?: unknown,
69
126
  ): Promise<string> {
70
- if (state.bindFlow) {
71
- return Promise.resolve('已有进行中的 QQ 机器人绑定,请先发送 qq.endpoint cancel 取消后再试');
127
+ if (state.bindFlow || state.pendingBotKind) {
128
+ return Promise.resolve(
129
+ '已有进行中的 QQ 机器人绑定或待确认的公域/私域选择,请先发送 qq.endpoint cancel 取消后再试',
130
+ );
72
131
  }
73
- const endpointName = name?.trim() || undefined;
132
+ const endpointId = id?.trim() || undefined;
133
+ const sessionKey = qqCommandSessionKey(input);
74
134
  return new Promise<string>((resolve) => {
75
135
  let firstReplied = false;
76
136
  const settle = (text: string) => {
@@ -79,7 +139,10 @@ export function runQqEndpointAdd(
79
139
  resolve(text);
80
140
  return;
81
141
  }
82
- void reply(text);
142
+ // Follow-ups outlive the inbound Message reply scope; durable reply
143
+ // (OutboundHost) is provided by createEndpointCommands.bindFlow. Never
144
+ // let a late notify crash the process.
145
+ void Promise.resolve(reply(text)).catch(() => undefined);
83
146
  };
84
147
  const stop = startQqBindFlow(
85
148
  {
@@ -90,21 +153,45 @@ export function runQqEndpointAdd(
90
153
  );
91
154
  },
92
155
  onQrExpired: async () => {
93
- await reply('二维码已过期,正在刷新,请扫描新链接…');
156
+ try {
157
+ await reply('二维码已过期,正在刷新,请扫描新链接…');
158
+ } catch {
159
+ // same as settle follow-up: best-effort after command scope ends
160
+ }
94
161
  },
95
162
  onSuccess: async (credentials) => {
96
163
  state.bindFlow = null;
97
164
  try {
98
165
  const [{ appId, appSecret }] = credentials;
99
- const finalName = endpointName ?? appId;
100
- const envKeys = persistQqCredentialsToEnv(finalName, appId, appSecret, projectRoot);
101
- const filePath = addQqEndpointToConfig(
102
- { name: finalName, appid: envKeys.appidRef, secret: envKeys.secretRef },
103
- projectRoot,
104
- );
166
+ const finalName = endpointId ?? appId;
167
+
168
+ // 无会话上下文(Host 等)时无法追问,直接按公域一次性写入
169
+ if (!sessionKey) {
170
+ settle(
171
+ writeEndpointWithBotKind(
172
+ {
173
+ endpointId: finalName,
174
+ appId,
175
+ appSecret,
176
+ sessionKey: '',
177
+ },
178
+ 'public',
179
+ projectRoot,
180
+ ) +
181
+ '\n(当前会话无法交互选择公/私域;私域请手动改配置。)',
182
+ );
183
+ return;
184
+ }
185
+
186
+ state.pendingBotKind = {
187
+ endpointId: finalName,
188
+ appId,
189
+ appSecret,
190
+ sessionKey,
191
+ };
105
192
  settle(
106
- `✅ 绑定成功!endpoint「${finalName}」的凭据已写入 .env,并已追加到 ${filePath} 的 plugins.qq.endpoints。\n` +
107
- '⚠️ 需重启 zhin 后新 endpoint 才会生效。',
193
+ `✅ 扫码成功!endpoint「${finalName}」。\n` +
194
+ QQ_BOT_KIND_PROMPT,
108
195
  );
109
196
  } catch (error) {
110
197
  settle(`绑定成功但写入配置失败:${error instanceof Error ? error.message : String(error)}`);
@@ -117,27 +204,32 @@ export function runQqEndpointAdd(
117
204
  },
118
205
  { source: 'zhin' },
119
206
  );
120
- state.bindFlow = { name: endpointName, stop };
207
+ state.bindFlow = { id: endpointId, stop };
121
208
  });
122
209
  }
123
210
 
124
- /** `qq.endpoint cancel`:中止进行中的绑定流程 */
211
+ /** `qq.endpoint cancel`:中止进行中的绑定或待选 botKind */
125
212
  export function runQqEndpointCancel(state: QqRuntimeState): string {
126
- if (!state.bindFlow) {
127
- return '当前没有进行中的 QQ 绑定流程';
213
+ if (state.bindFlow) {
214
+ state.bindFlow.stop();
215
+ state.bindFlow = null;
216
+ return '已取消进行中的 QQ 绑定流程';
217
+ }
218
+ if (state.pendingBotKind) {
219
+ const name = state.pendingBotKind.endpointId;
220
+ state.pendingBotKind = null;
221
+ return `已取消 endpoint「${name}」的绑定(尚未写入任何文件)`;
128
222
  }
129
- state.bindFlow.stop();
130
- state.bindFlow = null;
131
- return '已取消进行中的 QQ 绑定流程';
223
+ return '当前没有进行中的 QQ 绑定流程';
132
224
  }
133
225
 
134
- /** `qq.endpoint remove <name>`:从 zhin.config.yml 移除对应 endpoints 项 */
226
+ /** `qq.endpoint remove <id>`:从 zhin.config.yml 移除对应 endpoints 项 */
135
227
  export function runQqEndpointRemove(
136
228
  _state: QqRuntimeState,
137
- name: string,
229
+ id: string,
138
230
  projectRoot?: string,
139
231
  ): string {
140
- return removeEndpointByName(qqEndpointListSpec, name, projectRoot);
232
+ return removeEndpointById(qqEndpointListSpec, id, projectRoot);
141
233
  }
142
234
 
143
235
  /**
@@ -151,10 +243,9 @@ export const qqEndpointCommands = createEndpointCommands({
151
243
  { key: 'secret', required: true, env: true, description: 'QQ 机器人 secret' },
152
244
  ],
153
245
  running: (use) => use(qqRuntimeStateToken).endpoints.values(),
154
- listFooter: (use) => {
155
- const state = use(qqRuntimeStateToken);
156
- return state.bindFlow ? '⚠️ 有进行中的扫码绑定,可用 qq.endpoint cancel 取消' : undefined;
157
- },
158
- addDescription: '手机 QQ 扫码绑定机器人,凭据写入 .env 并追加到 zhin.config.yml(重启生效)',
159
- bindFlow: ({ name, reply, use }) => runQqEndpointAdd(use(qqRuntimeStateToken), name, reply),
246
+ listFooter: (use) => busyFooter(use(qqRuntimeStateToken)),
247
+ addDescription:
248
+ '手机 QQ 扫码绑定机器人:确认公域/私域后一次性写入 .env 与 zhin.config.yml(重启生效)',
249
+ bindFlow: ({ id, reply, input, use }) =>
250
+ runQqEndpointAdd(use(qqRuntimeStateToken), id, reply, undefined, input),
160
251
  }, defineCommand);
@@ -11,7 +11,7 @@ import {
11
11
  } from '@zhin.js/adapter';
12
12
 
13
13
  export interface QqEndpointConfigEntry {
14
- name: string;
14
+ id: string;
15
15
  appid: string;
16
16
  secret: string;
17
17
  [key: string]: unknown;
@@ -27,7 +27,7 @@ export function listQqEndpointEntries(projectRoot?: string): QqEndpointConfigEnt
27
27
  return listConfiguredEndpoints('qq', projectRoot) as QqEndpointConfigEntry[];
28
28
  }
29
29
 
30
- /** 追加 endpoint 到 plugins.qq.endpoints;name 已存在时报错 */
30
+ /** 追加 endpoint 到 plugins.qq.endpoints;id 已存在时报错 */
31
31
  export function addQqEndpointToConfig(
32
32
  entry: QqEndpointConfigEntry,
33
33
  projectRoot?: string,
@@ -35,10 +35,10 @@ export function addQqEndpointToConfig(
35
35
  return addEndpointToConfig('qq', entry, projectRoot);
36
36
  }
37
37
 
38
- /** 按 name 移除 plugins.qq.endpoints 项;不存在返回 false */
38
+ /** 按 id 移除 plugins.qq.endpoints 项;不存在返回 false */
39
39
  export function removeQqEndpointFromConfig(
40
- name: string,
40
+ id: string,
41
41
  projectRoot?: string,
42
42
  ): { removed: boolean; filePath: string } {
43
- return removeEndpointFromConfig('qq', name, projectRoot);
43
+ return removeEndpointFromConfig('qq', id, projectRoot);
44
44
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * QQ 官方机器人 intents 与公域/私域映射。
3
+ *
4
+ * 群聊无公/私域之分,两类都订阅 `GROUP_AND_C2C_EVENT`。
5
+ * 频道消息 intent 不同:
6
+ * - public(公域):`PUBLIC_GUILD_MESSAGES`(仅 @ 机器人)
7
+ * - private(私域):`GUILD_MESSAGES`(频道全量消息;公域订阅会 Identify 失败)
8
+ */
9
+
10
+ export type QqBotKind = 'public' | 'private';
11
+
12
+ export type QqIntent =
13
+ | 'GUILDS'
14
+ | 'GUILD_MEMBERS'
15
+ | 'GUILD_MESSAGES'
16
+ | 'GUILD_MESSAGE_REACTIONS'
17
+ | 'DIRECT_MESSAGE'
18
+ | 'GROUP_AND_C2C_EVENT'
19
+ | 'INTERACTION'
20
+ | 'MESSAGE_AUDIT'
21
+ | 'FORUMS_EVENT'
22
+ | 'AUDIO_ACTION'
23
+ | 'PUBLIC_GUILD_MESSAGES'
24
+ | 'GROUP_MEMBER';
25
+
26
+ /** 扫码绑定 / 未声明 botKind 时的默认(公域更常见)。 */
27
+ export const DEFAULT_QQ_BOT_KIND: QqBotKind = 'public';
28
+
29
+ /** 公/私域共用:群@+私聊 C2C、频道基础事件、频道私信。 */
30
+ const QQ_SHARED_INTENTS = Object.freeze([
31
+ 'GROUP_AND_C2C_EVENT',
32
+ 'GUILDS',
33
+ 'GUILD_MEMBERS',
34
+ 'DIRECT_MESSAGE',
35
+ ] as const satisfies readonly QqIntent[]);
36
+
37
+ export const QQ_INTENTS_BY_KIND: Readonly<Record<QqBotKind, readonly QqIntent[]>> = Object.freeze({
38
+ public: Object.freeze([
39
+ ...QQ_SHARED_INTENTS,
40
+ 'PUBLIC_GUILD_MESSAGES',
41
+ ] as const),
42
+ private: Object.freeze([
43
+ ...QQ_SHARED_INTENTS,
44
+ 'GUILD_MESSAGES',
45
+ ] as const),
46
+ });
47
+
48
+ /** 解析 botKind:仅接受 `public` / `private`。 */
49
+ export function parseQqBotKind(value: unknown): QqBotKind | undefined {
50
+ if (value === 'public' || value === 'private') return value;
51
+ return undefined;
52
+ }
53
+
54
+ /**
55
+ * 解析最终 intents:显式 `intents` 优先;否则按 `botKind`(默认 public)展开。
56
+ */
57
+ export function resolveQqIntents(input: {
58
+ readonly intents?: readonly string[] | undefined;
59
+ readonly botKind?: unknown;
60
+ }): readonly QqIntent[] {
61
+ if (Array.isArray(input.intents) && input.intents.length > 0) {
62
+ return Object.freeze(input.intents.map(String)) as readonly QqIntent[];
63
+ }
64
+ const kind = parseQqBotKind(input.botKind) ?? DEFAULT_QQ_BOT_KIND;
65
+ return QQ_INTENTS_BY_KIND[kind];
66
+ }
67
+
68
+ /** 添加 endpoint 时写入配置的默认字段(显式展开,便于用户改)。 */
69
+ export function defaultQqEndpointIntentFields(
70
+ botKind: QqBotKind = DEFAULT_QQ_BOT_KIND,
71
+ ): { readonly botKind: QqBotKind; readonly intents: readonly QqIntent[] } {
72
+ return Object.freeze({
73
+ botKind,
74
+ intents: QQ_INTENTS_BY_KIND[botKind],
75
+ });
76
+ }
@@ -1,32 +1,47 @@
1
1
  /**
2
2
  * QQ 插件实例的运行时状态:adapter create() 注册的 endpoint 列表 +
3
- * 扫码绑定流程的全局单例。由 plugin.ts setup() provide,
4
- * adapter create 与 commands 通过 use(qqRuntimeStateToken) 共享(同一 owner generation)。
3
+ * 扫码绑定 / 待选 botKind 流程。由 plugin.ts setup() provide,
4
+ * adapter create 与 commands / middleware 通过 use(qqRuntimeStateToken) 共享。
5
5
  */
6
6
  import { createToken } from '@zhin.js/plugin-runtime';
7
7
 
8
8
  export interface QqRunningEndpoint {
9
- name: string;
9
+ id: string;
10
10
  mode: string;
11
11
  }
12
12
 
13
13
  export interface QqBindFlowHandle {
14
- /** 绑定期望的 endpoint 名(未指定时为 undefined,成功后取 appId) */
15
- name?: string;
14
+ /** 绑定期望的 endpoint id(未指定时为 undefined,成功后取 appId) */
15
+ id?: string;
16
16
  stop: () => void;
17
17
  }
18
18
 
19
+ /**
20
+ * 扫码成功后暂存在内存:确认公域/私域后再一次性写 .env + yaml。
21
+ * (提前写 .env 会触发 HMR,冲掉 pending。)
22
+ */
23
+ export interface QqPendingBotKind {
24
+ readonly endpointId: string;
25
+ readonly appId: string;
26
+ readonly appSecret: string;
27
+ /** 发起 add 的会话键;仅同会话回复可完成选择 */
28
+ readonly sessionKey: string;
29
+ }
30
+
19
31
  export interface QqRuntimeState {
20
- /** 当前 generation 已成功创建的 endpoint(name → 描述) */
32
+ /** 当前 generation 已成功创建的 endpoint(id → 描述) */
21
33
  readonly endpoints: Map<string, QqRunningEndpoint>;
22
34
  /** 全局单例:同一时间只允许一个扫码绑定流程 */
23
35
  bindFlow: QqBindFlowHandle | null;
36
+ /** 扫码完成后等待 botKind 选择(凭据仅在内存) */
37
+ pendingBotKind: QqPendingBotKind | null;
24
38
  }
25
39
 
26
40
  export function createQqRuntimeState(): QqRuntimeState {
27
41
  return {
28
42
  endpoints: new Map(),
29
43
  bindFlow: null,
44
+ pendingBotKind: null,
30
45
  };
31
46
  }
32
47
 
package/src/webhook.ts CHANGED
@@ -86,6 +86,8 @@ export function defaultCreateHttpBot(config: ResolvedQqHttpConfig): QqHttpBotTra
86
86
  platform: 'koa',
87
87
  sandbox: config.sandbox,
88
88
  dataDir: path.join(process.cwd(), 'data', 'qq'),
89
+ // 关闭内置 log4js,与 WebSocket 模式一致走 zhin adapter logger
90
+ logLevel: 'off',
89
91
  } as never) as Bot<ReceiverMode.MIDDLEWARE, 'koa'> & QqBotTransport;
90
92
 
91
93
  return {
package/src/ws.ts CHANGED
@@ -3,7 +3,6 @@
3
3
  */
4
4
  import path from 'node:path';
5
5
  import { Bot, ReceiverMode, type Sendable } from 'qq-official-bot';
6
- import { getLevel, toLog4jsLevel } from '@zhin.js/logger';
7
6
  import type { MediaRef, Segment } from '@zhin.js/core';
8
7
  import type { QqOutboundMessage } from './outbound.js';
9
8
  import type { QqChannelKind, QqInboundMessage, ResolvedQqWebsocketConfig } from './protocol.js';
@@ -250,7 +249,8 @@ export function defaultCreateBot(config: ResolvedQqWebsocketConfig): QqBotTransp
250
249
  mode: ReceiverMode.WEBSOCKET,
251
250
  sandbox: config.sandbox,
252
251
  dataDir: path.join(process.cwd(), 'data', 'qq'),
253
- logLevel: toLog4jsLevel(getLevel()),
252
+ // 关闭 qq-official-bot 内置 log4js,入/出站由 getAdapterLogger('qq', …) 统一格式
253
+ logLevel: 'off',
254
254
  ...(config.intents ? { intents: config.intents as never } : {}),
255
255
  ...(config.accessTokenUrl ? { accessTokenUrl: config.accessTokenUrl } : {}),
256
256
  ...(config.gatewayUrl ? { gatewayUrl: config.gatewayUrl } : {}),
File without changes
File without changes