@zhin.js/adapter-qq 1.1.17 → 1.1.19

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 (122) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +75 -21
  3. package/adapters/qq/index.js +4 -33
  4. package/adapters/qq/index.ts +3 -41
  5. package/agents/qq/boundaries.md +2 -0
  6. package/agents/qq/conversation.js +92 -0
  7. package/agents/qq/conversation.ts +99 -0
  8. package/agents/qq/messages.js +60 -0
  9. package/agents/qq/messages.ts +60 -0
  10. package/agents/qq/skills/qq/SKILL.md +8 -2
  11. package/agents/qq/skills/qq/tools/add_role/index.js +2 -1
  12. package/agents/qq/skills/qq/tools/add_role/index.ts +2 -1
  13. package/agents/qq/skills/qq/tools/channel_info/index.js +1 -1
  14. package/agents/qq/skills/qq/tools/channel_info/index.ts +1 -1
  15. package/agents/qq/skills/qq/tools/create_role/index.js +1 -1
  16. package/agents/qq/skills/qq/tools/create_role/index.ts +1 -1
  17. package/agents/qq/skills/qq/tools/guild_kick_member/index.js +30 -0
  18. package/agents/qq/skills/qq/tools/guild_kick_member/index.ts +29 -0
  19. package/agents/qq/skills/qq/tools/guild_mute_member/index.js +31 -0
  20. package/agents/qq/skills/qq/tools/guild_mute_member/index.ts +30 -0
  21. package/agents/qq/skills/qq/tools/list_channels/index.js +1 -1
  22. package/agents/qq/skills/qq/tools/list_channels/index.ts +1 -1
  23. package/agents/qq/skills/qq/tools/list_roles/index.js +1 -1
  24. package/agents/qq/skills/qq/tools/list_roles/index.ts +1 -1
  25. package/agents/qq/skills/qq/tools/member_detail/index.js +1 -1
  26. package/agents/qq/skills/qq/tools/member_detail/index.ts +1 -1
  27. package/agents/qq/skills/qq/tools/remove_role/index.js +2 -1
  28. package/agents/qq/skills/qq/tools/remove_role/index.ts +2 -1
  29. package/agents/qq/skills/qq-conversation/SKILL.md +24 -0
  30. package/agents/qq/skills/qq-conversation/tools/conversation_info/index.js +78 -0
  31. package/agents/qq/skills/qq-conversation/tools/conversation_info/index.ts +76 -0
  32. package/agents/qq/skills/qq-directory/tools/list_guilds/index.js +1 -1
  33. package/agents/qq/skills/qq-directory/tools/list_guilds/index.ts +1 -1
  34. package/agents/qq/skills/qq-group-admin/SKILL.md +34 -0
  35. package/agents/qq/skills/qq-group-admin/tools/group_kick/index.js +39 -0
  36. package/agents/qq/skills/qq-group-admin/tools/group_kick/index.ts +38 -0
  37. package/agents/qq/skills/qq-group-admin/tools/handle_join_request/index.js +33 -0
  38. package/agents/qq/skills/qq-group-admin/tools/handle_join_request/index.ts +32 -0
  39. package/agents/qq/skills/qq-group-admin/tools/recall_member_message/index.js +25 -0
  40. package/agents/qq/skills/qq-group-admin/tools/recall_member_message/index.ts +25 -0
  41. package/agents/qq/skills/qq-group-admin/tools/set_mute/index.js +37 -0
  42. package/agents/qq/skills/qq-group-admin/tools/set_mute/index.ts +36 -0
  43. package/agents/qq/skills/qq-group-directory/SKILL.md +24 -0
  44. package/agents/qq/skills/qq-group-directory/tools/group_info/index.js +16 -0
  45. package/agents/qq/skills/qq-group-directory/tools/group_info/index.ts +16 -0
  46. package/agents/qq/skills/qq-group-directory/tools/group_member_info/index.js +19 -0
  47. package/agents/qq/skills/qq-group-directory/tools/group_member_info/index.ts +19 -0
  48. package/agents/qq/skills/qq-group-directory/tools/group_members/index.js +19 -0
  49. package/agents/qq/skills/qq-group-directory/tools/group_members/index.ts +19 -0
  50. package/agents/qq/skills/qq-group-state/SKILL.md +21 -0
  51. package/agents/qq/skills/qq-group-state/tools/group_blacklist/index.js +19 -0
  52. package/agents/qq/skills/qq-group-state/tools/group_blacklist/index.ts +19 -0
  53. package/agents/qq/skills/qq-group-state/tools/list_join_requests/index.js +19 -0
  54. package/agents/qq/skills/qq-group-state/tools/list_join_requests/index.ts +19 -0
  55. package/agents/qq/skills/qq-group-state/tools/list_muted/index.js +21 -0
  56. package/agents/qq/skills/qq-group-state/tools/list_muted/index.ts +21 -0
  57. package/agents/qq/skills/qq-messages/SKILL.md +27 -0
  58. package/agents/qq/skills/qq-messages/tools/quote_reply/index.js +21 -0
  59. package/agents/qq/skills/qq-messages/tools/quote_reply/index.ts +21 -0
  60. package/agents/qq/skills/qq-messages/tools/recall_own_message/index.js +18 -0
  61. package/agents/qq/skills/qq-messages/tools/recall_own_message/index.ts +17 -0
  62. package/commands/qq/endpoint/definition.js +73 -55
  63. package/commands/qq/endpoint/definition.ts +75 -86
  64. package/lib/adapter.d.ts +13 -0
  65. package/lib/adapter.js +27 -0
  66. package/lib/client.d.ts +7 -9
  67. package/lib/client.js +1 -2
  68. package/lib/console.d.ts +3 -0
  69. package/lib/console.js +93 -0
  70. package/lib/endpoint.d.ts +17 -27
  71. package/lib/endpoint.js +325 -146
  72. package/lib/group-admin.d.ts +41 -0
  73. package/lib/group-admin.js +114 -0
  74. package/lib/i18n.js +4 -4
  75. package/lib/image-size.d.ts +6 -1
  76. package/lib/image-size.js +2 -3
  77. package/lib/index.d.ts +5 -4
  78. package/lib/index.js +3 -3
  79. package/lib/interaction.js +1 -1
  80. package/lib/outbound.d.ts +4 -1
  81. package/lib/outbound.js +3 -2
  82. package/lib/protocol.d.ts +7 -0
  83. package/lib/protocol.js +4 -0
  84. package/lib/qq-console-spec.d.ts +17 -0
  85. package/lib/qq-console-spec.js +21 -0
  86. package/lib/qq-console.d.ts +10 -0
  87. package/lib/qq-console.js +126 -0
  88. package/lib/qq-endpoint-write.d.ts +18 -0
  89. package/lib/qq-endpoint-write.js +7 -0
  90. package/lib/qq-runtime-state.d.ts +29 -9
  91. package/lib/qq-runtime-state.js +5 -5
  92. package/lib/qq-send.d.ts +7 -14
  93. package/lib/qq-send.js +20 -6
  94. package/lib/webhook.d.ts +3 -6
  95. package/lib/webhook.js +6 -49
  96. package/lib/ws.d.ts +10 -33
  97. package/lib/ws.js +21 -70
  98. package/middlewares/bot-kind-reply/index.js +1 -2
  99. package/middlewares/bot-kind-reply/index.ts +0 -2
  100. package/package.json +26 -18
  101. package/pages/index/index.js +15 -0
  102. package/pages/index/index.ts +16 -0
  103. package/plugin.js +9 -6
  104. package/schema.json +174 -0
  105. package/src/adapter.ts +23 -0
  106. package/src/client.ts +4 -12
  107. package/src/console.ts +103 -0
  108. package/src/endpoint.ts +328 -141
  109. package/src/group-admin.ts +155 -0
  110. package/src/i18n.ts +4 -4
  111. package/src/image-size.ts +7 -3
  112. package/src/index.ts +5 -5
  113. package/src/interaction.ts +1 -1
  114. package/src/outbound.ts +3 -2
  115. package/src/protocol.ts +10 -0
  116. package/src/qq-console-spec.ts +29 -0
  117. package/src/qq-console.ts +130 -0
  118. package/src/qq-endpoint-write.ts +24 -0
  119. package/src/qq-runtime-state.ts +31 -15
  120. package/src/qq-send.ts +28 -12
  121. package/src/webhook.ts +7 -57
  122. package/src/ws.ts +27 -105
@@ -3,16 +3,16 @@ import { resolveLocale } from 'zhin.js/i18n';
3
3
  import { messages as t } from "../../../lib/i18n.js";
4
4
  /**
5
5
  * `qq endpoint` 命令的业务逻辑(与命令定义文件分离,便于测试)。
6
- * 通用部分(权限 / list / remove / 配置写回 / .env 持久化)已迁移到
7
- * @zhin.js/adapter 的 createEndpointCommands 套件;本文件保留 QQ 特化的
8
- * 扫码绑定 add 流程(经套件 bindFlow 钩子接入)与 cancel,并导出
6
+ * list / remove 与权限共用 @zhin.js/adapter 的账号管理入口;
7
+ * 本文件保留 QQ 扫码绑定 add 与 cancel,保存委派捕获的所属 Adapter CAS,并导出
9
8
  * qqEndpointCommands 供 commands/qq/endpoint/ 下的命令文件默认导出。
10
9
  */
11
- import { buildEndpointEnvKey, createEndpointCommands, endpointCommandForbidden, endpointConfigurationStoreToken, extractEndpointCommandReply, formatEndpointList, isEndpointOperator, removeEndpointById, } from 'zhin.js/adapter';
10
+ import { createEndpointCommands, endpointCommandForbidden, createDurableEndpointCommandReply, isEndpointOperator, extractEndpointCommandReply, adapterFeatureId, isAdapterIndex } from 'zhin.js/adapter';
12
11
  import { defineCommand } from 'zhin.js/command';
13
12
  import { qqBotKindPrompt, qqCommandSessionKey } from "../../../lib/qq-bot-kind-prompt.js";
14
13
  import { startQqBindFlow } from "../../../lib/qq-bind-flow.js";
15
- import { defaultQqEndpointIntentFields } from "../../../lib/qq-intents.js";
14
+ import { writeQqEndpointWithBotKind } from "../../../lib/qq-endpoint-write.js";
15
+ import { cancelQqConsoleBind } from "../../../lib/qq-console.js";
16
16
  import { qqRuntimeStateToken, } from "../../../lib/qq-runtime-state.js";
17
17
  export { qqRuntimeStateToken };
18
18
  /**
@@ -21,7 +21,17 @@ export { qqRuntimeStateToken };
21
21
  * legacy applyBindOwnership 会把 operator 写为新端点的 master)。
22
22
  */
23
23
  export function isQqEndpointOperator(config, input) {
24
- return isEndpointOperator(config, input);
24
+ if (isEndpointOperator(config, input))
25
+ return true;
26
+ // The outage-notify target is the operator's conversation on another adapter.
27
+ const cfg = (config ?? {});
28
+ const notifyIds = [
29
+ cfg.notify?.id,
30
+ ...(Array.isArray(cfg.endpoints) ? cfg.endpoints.map((endpoint) => endpoint.notify?.id) : []),
31
+ ];
32
+ const senderRaw = input?.sender;
33
+ const senderId = (typeof senderRaw === 'object' && senderRaw?.id) ? String(senderRaw.id).trim() : '';
34
+ return senderId !== '' && notifyIds.some((id) => id != null && String(id) === senderId);
25
35
  }
26
36
  export const QQ_ENDPOINT_FORBIDDEN = endpointCommandForbidden('QQ');
27
37
  /**
@@ -36,59 +46,47 @@ function busyFooter(state, locale) {
36
46
  if (state.pendingBotKind) {
37
47
  return t(locale, 'kindBusy', { id: state.pendingBotKind.endpointId });
38
48
  }
49
+ if (state.consoleBind?.state === 'scanned') {
50
+ return t(locale, 'kindBusy', { id: state.consoleBind.pending?.endpointId ?? 'QQ' });
51
+ }
39
52
  return undefined;
40
53
  }
41
- /** `qq endpoint list`:运行中的 endpoints(本 generation adapter create 注册)+ 配置里的 endpoints */
42
- export async function runQqEndpointList(state, store, locale) {
43
- return formatEndpointList(qqEndpointListSpec, {
44
- running: state.endpoints.values(),
45
- configured: await store.list('qq'),
46
- footer: busyFooter(state, locale), locale,
47
- });
48
- }
49
54
  const qqEndpointListSpec = {
50
55
  adapterKey: 'qq',
51
56
  adapterDisplayName: 'QQ',
52
57
  describeEntry: (entry) => `appid: ${String(entry.appid)}`,
53
58
  };
54
- async function writeEndpointWithBotKind(pending, botKind, store) {
55
- const locale = pending.locale;
56
- const appidKey = buildEndpointEnvKey('qq', pending.endpointId, 'appid');
57
- const secretKey = buildEndpointEnvKey('qq', pending.endpointId, 'secret');
58
- const intentFields = defaultQqEndpointIntentFields(botKind);
59
- const { filePath } = await store.add({
60
- adapterKey: 'qq',
61
- entry: {
62
- id: pending.endpointId,
63
- appid: `\${${appidKey}}`,
64
- secret: `\${${secretKey}}`,
65
- botKind: intentFields.botKind,
66
- intents: [...intentFields.intents],
67
- },
68
- environment: {
69
- [appidKey]: pending.appId,
70
- [secretKey]: pending.appSecret,
71
- },
59
+ async function writeEndpointWithBotKind(pending, botKind, save) {
60
+ const result = await writeQqEndpointWithBotKind({
61
+ endpointId: pending.endpointId,
62
+ appId: pending.appId,
63
+ appSecret: pending.appSecret,
64
+ botKind,
65
+ }, save);
66
+ return t(pending.locale, 'endpointSaved', {
67
+ kind: result.botKind,
68
+ id: result.endpointId,
69
+ intents: result.intents.join(', '),
72
70
  });
73
- return t(locale, 'endpointSaved', { kind: intentFields.botKind, id: pending.endpointId, file: filePath, intents: intentFields.intents.join(', ') });
74
71
  }
75
72
  /**
76
- * 完成 pending 的公域/私域选择:一次性写 .env + yaml。
73
+ * 完成 pending 的公域/私域选择:通过已捕获的所属 Adapter 保存一次账号配置。
77
74
  * @returns 成功文案;调用方负责清 pending。
78
75
  */
79
- export async function completeQqPendingBotKind(pending, botKind, store) {
80
- return await writeEndpointWithBotKind(pending, botKind, store);
76
+ export async function completeQqPendingBotKind(pending, botKind) {
77
+ return await writeEndpointWithBotKind(pending, botKind, pending.save);
81
78
  }
82
79
  /**
83
80
  * `qq endpoint add [id]`:启动扫码绑定流程。
84
81
  * 返回的 Promise 在二维码链接就绪(或前置失败)时 resolve 为首条回复;
85
82
  * 后续状态(已扫码 / 成功 / 失败 / 过期刷新)通过 reply 推回当前会话。
86
- * 扫码成功后凭据暂存内存并询问公域/私域;确认后一次性写 .env + yaml。
83
+ * 扫码成功后凭据暂存内存并询问公域/私域;确认后保存到账号库,等待显式启动。
87
84
  */
88
- export function runQqEndpointAdd(state, id, reply, store, input, locale = 'zh-CN', cancelCommand, commandHeads) {
89
- if (state.bindFlow || state.pendingBotKind) {
85
+ export function runQqEndpointAdd(state, id, reply, save, input, locale = 'zh-CN', cancelCommand, commandHeads) {
86
+ if (state.bindFlow || state.pendingBotKind || state.consoleBind?.pending || state.consoleBind?.state === 'waiting') {
90
87
  return Promise.resolve(t(locale, 'existingFlow'));
91
88
  }
89
+ state.consoleBind = null;
92
90
  const endpointId = id?.trim() || undefined;
93
91
  const sessionKey = qqCommandSessionKey(input);
94
92
  return new Promise((resolve) => {
@@ -128,8 +126,8 @@ export function runQqEndpointAdd(state, id, reply, store, input, locale = 'zh-CN
128
126
  endpointId: finalName,
129
127
  appId,
130
128
  appSecret,
131
- sessionKey: '', locale, cancelCommand,
132
- }, 'public', store) +
129
+ sessionKey: '', locale, cancelCommand, save,
130
+ }, 'public', save) +
133
131
  t(locale, 'noInteractiveKind'));
134
132
  return;
135
133
  }
@@ -137,7 +135,7 @@ export function runQqEndpointAdd(state, id, reply, store, input, locale = 'zh-CN
137
135
  endpointId: finalName,
138
136
  appId,
139
137
  appSecret,
140
- sessionKey, locale, cancelCommand, commandHeads,
138
+ sessionKey, locale, cancelCommand, commandHeads, save,
141
139
  };
142
140
  settle(t(locale, 'scanned', { id: finalName, prompt: qqBotKindPrompt(locale, cancelCommand) }));
143
141
  }
@@ -159,6 +157,7 @@ export function runQqEndpointCancel(state, preference) {
159
157
  if (state.bindFlow) {
160
158
  state.bindFlow.stop();
161
159
  state.bindFlow = null;
160
+ cancelQqConsoleBind(state);
162
161
  return t(locale, 'cancelled');
163
162
  }
164
163
  if (state.pendingBotKind) {
@@ -166,25 +165,44 @@ export function runQqEndpointCancel(state, preference) {
166
165
  state.pendingBotKind = null;
167
166
  return t(locale, 'pendingCancelled', { name });
168
167
  }
168
+ if (state.consoleBind) {
169
+ cancelQqConsoleBind(state);
170
+ return t(locale, 'cancelled');
171
+ }
169
172
  return t(locale, 'noFlow');
170
173
  }
171
- /** `qq endpoint remove <id>`:从 zhin.config.yml 移除对应 endpoints 项 */
172
- export async function runQqEndpointRemove(_state, id, store) {
173
- return await removeEndpointById(qqEndpointListSpec, id, store);
174
- }
175
174
  /**
176
175
  * 通用套件生成的 QQ endpoint 命令(add 经 bindFlow 钩子走扫码绑定)。
177
176
  * commands/qq/endpoint/ 下的 list / add / remove 直接默认导出这三项;cancel 为 QQ 特化,单独定义。
178
177
  */
179
- export const qqEndpointCommands = createEndpointCommands({
178
+ const standard = createEndpointCommands({
180
179
  ...qqEndpointListSpec,
181
- fields: [
182
- { key: 'appid', required: true, env: true, description: 'QQ 机器人 appid', descriptionI18n: { en: "QQ bot app ID" } },
183
- { key: 'secret', required: true, env: true, description: 'QQ 机器人 secret', descriptionI18n: { en: "QQ bot secret" } },
184
- ],
185
- running: (use) => use(qqRuntimeStateToken).endpoints.values(),
180
+ fields: [{ key: 'appid', required: true }, { key: 'secret', required: true, secret: true }],
186
181
  listFooter: (use, locale) => busyFooter(use(qqRuntimeStateToken), locale),
187
- addDescriptionI18n: { en: 'Bind a bot through mobile QQ, choose public/private, then save configuration; restart to apply' },
188
- addDescription: '手机 QQ 扫码绑定机器人:确认公域/私域后一次性写入 .env 与 zhin.config.yml(重启生效)',
189
- bindFlow: ({ id, reply, input, use, locale, formatCommand }) => runQqEndpointAdd(use(qqRuntimeStateToken), id, reply, use(endpointConfigurationStoreToken), input, locale, formatCommand?.('qq endpoint cancel'), ['qq endpoint list', 'qq endpoint add', 'qq endpoint remove', 'qq endpoint cancel'].flatMap(path => { const head = formatCommand?.(path); return head ? [head] : []; })),
190
182
  }, defineCommand);
183
+ export const qqEndpointCommands = {
184
+ ...standard,
185
+ add: defineCommand({
186
+ description: '手机 QQ 扫码绑定;确认公域/私域后保存账号,手动启动',
187
+ descriptionI18n: { en: 'Scan with mobile QQ, choose public/private, save the account and start explicitly' },
188
+ params: { id: { type: 'string', description: 'endpoint ID' } },
189
+ async execute(context) {
190
+ if (!isEndpointOperator(context.config, context.input))
191
+ return endpointCommandForbidden('QQ', context.locale);
192
+ const index = context.project(adapterFeatureId);
193
+ if (!isAdapterIndex(index))
194
+ throw new Error('QQ Adapter projection is unavailable');
195
+ const own = index.forOwner(context.owner.id).adapters().filter(adapter => adapter.descriptor.name === 'qq');
196
+ if (own.length !== 1)
197
+ throw new Error('QQ Adapter ownership is ambiguous');
198
+ const adapter = own[0];
199
+ if (adapter.needsMigration)
200
+ return '首次修改账号前,请由管理员在 Console 确认迁移账号清单';
201
+ if (!adapter.canManage)
202
+ return '此环境未提供持久化账号管理';
203
+ const revision = adapter.revision;
204
+ const save = config => adapter.add(revision, { endpointId: config.id, config: { ...config } }, { waitForExecution: false });
205
+ return runQqEndpointAdd(context.use(qqRuntimeStateToken), context.params.id, createDurableEndpointCommandReply(context.input, context.use), save, context.input, context.locale, context.formatCommand?.('qq endpoint cancel'), ['qq endpoint list', 'qq endpoint add', 'qq endpoint remove', 'qq endpoint cancel'].flatMap(path => { const head = context.formatCommand?.(path); return head ? [head] : []; }));
206
+ },
207
+ }),
208
+ };
@@ -2,28 +2,18 @@ import { resolveLocale, type Locale } from 'zhin.js/i18n';
2
2
  import { messages as t } from '../../../src/i18n.js';
3
3
  /**
4
4
  * `qq endpoint` 命令的业务逻辑(与命令定义文件分离,便于测试)。
5
- * 通用部分(权限 / list / remove / 配置写回 / .env 持久化)已迁移到
6
- * @zhin.js/adapter 的 createEndpointCommands 套件;本文件保留 QQ 特化的
7
- * 扫码绑定 add 流程(经套件 bindFlow 钩子接入)与 cancel,并导出
5
+ * list / remove 与权限共用 @zhin.js/adapter 的账号管理入口;
6
+ * 本文件保留 QQ 扫码绑定 add 与 cancel,保存委派捕获的所属 Adapter CAS,并导出
8
7
  * qqEndpointCommands 供 commands/qq/endpoint/ 下的命令文件默认导出。
9
8
  */
10
- import {
11
- buildEndpointEnvKey,
12
- createEndpointCommands,
13
- endpointCommandForbidden,
14
- endpointConfigurationStoreToken,
15
- extractEndpointCommandReply,
16
- formatEndpointList,
17
- isEndpointOperator,
18
- removeEndpointById,
19
- type ConfiguredEndpointEntry,
20
- type EndpointCommandReply,
21
- type EndpointConfigurationStore,
22
- } from 'zhin.js/adapter';
9
+ import { createEndpointCommands, endpointCommandForbidden, createDurableEndpointCommandReply, isEndpointOperator, extractEndpointCommandReply,
10
+ adapterFeatureId, isAdapterIndex, type ConfiguredEndpointEntry, type EndpointCommandReply } from 'zhin.js/adapter';
23
11
  import { defineCommand } from 'zhin.js/command';
24
12
  import { qqBotKindPrompt, qqCommandSessionKey } from '../../../src/qq-bot-kind-prompt.js';
25
13
  import { startQqBindFlow } from '../../../src/qq-bind-flow.js';
26
- import { defaultQqEndpointIntentFields, type QqBotKind } from '../../../src/qq-intents.js';
14
+ import { writeQqEndpointWithBotKind, type QqEndpointSavePort } from '../../../src/qq-endpoint-write.js';
15
+ import type { QqBotKind } from '../../../src/qq-intents.js';
16
+ import { cancelQqConsoleBind } from '../../../src/qq-console.js';
27
17
  import {
28
18
  qqRuntimeStateToken,
29
19
  type QqPendingBotKind,
@@ -40,7 +30,19 @@ export type QqCommandReply = EndpointCommandReply;
40
30
  * legacy applyBindOwnership 会把 operator 写为新端点的 master)。
41
31
  */
42
32
  export function isQqEndpointOperator(config: unknown, input: unknown): boolean {
43
- return isEndpointOperator(config, input);
33
+ if (isEndpointOperator(config, input)) return true;
34
+ // The outage-notify target is the operator's conversation on another adapter.
35
+ const cfg = (config ?? {}) as {
36
+ notify?: { id?: unknown };
37
+ endpoints?: readonly { notify?: { id?: unknown } }[];
38
+ };
39
+ const notifyIds = [
40
+ cfg.notify?.id,
41
+ ...(Array.isArray(cfg.endpoints) ? cfg.endpoints.map((endpoint) => endpoint.notify?.id) : []),
42
+ ];
43
+ const senderRaw = (input as { sender?: { id?: unknown } } | null | undefined)?.sender;
44
+ const senderId = (typeof senderRaw === 'object' && senderRaw?.id) ? String(senderRaw.id).trim() : '';
45
+ return senderId !== '' && notifyIds.some((id) => id != null && String(id) === senderId);
44
46
  }
45
47
 
46
48
  export const QQ_ENDPOINT_FORBIDDEN = endpointCommandForbidden('QQ');
@@ -57,22 +59,12 @@ function busyFooter(state: QqRuntimeState, locale?: Locale): string | undefined
57
59
  if (state.pendingBotKind) {
58
60
  return t(locale, 'kindBusy', { id: state.pendingBotKind.endpointId });
59
61
  }
62
+ if (state.consoleBind?.state === 'scanned') {
63
+ return t(locale, 'kindBusy', { id: state.consoleBind.pending?.endpointId ?? 'QQ' });
64
+ }
60
65
  return undefined;
61
66
  }
62
67
 
63
- /** `qq endpoint list`:运行中的 endpoints(本 generation adapter create 注册)+ 配置里的 endpoints */
64
- export async function runQqEndpointList(
65
- state: QqRuntimeState,
66
- store: EndpointConfigurationStore,
67
- locale?: Locale,
68
- ): Promise<string> {
69
- return formatEndpointList(qqEndpointListSpec, {
70
- running: state.endpoints.values(),
71
- configured: await store.list('qq'),
72
- footer: busyFooter(state, locale), locale,
73
- });
74
- }
75
-
76
68
  const qqEndpointListSpec = {
77
69
  adapterKey: 'qq',
78
70
  adapterDisplayName: 'QQ',
@@ -82,62 +74,54 @@ const qqEndpointListSpec = {
82
74
  async function writeEndpointWithBotKind(
83
75
  pending: QqPendingBotKind,
84
76
  botKind: QqBotKind,
85
- store: EndpointConfigurationStore,
77
+ save: QqEndpointSavePort,
86
78
  ): Promise<string> {
87
- const locale = pending.locale;
88
- const appidKey = buildEndpointEnvKey('qq', pending.endpointId, 'appid');
89
- const secretKey = buildEndpointEnvKey('qq', pending.endpointId, 'secret');
90
- const intentFields = defaultQqEndpointIntentFields(botKind);
91
- const { filePath } = await store.add({
92
- adapterKey: 'qq',
93
- entry: {
94
- id: pending.endpointId,
95
- appid: `\${${appidKey}}`,
96
- secret: `\${${secretKey}}`,
97
- botKind: intentFields.botKind,
98
- intents: [...intentFields.intents],
99
- },
100
- environment: {
101
- [appidKey]: pending.appId,
102
- [secretKey]: pending.appSecret,
103
- },
79
+ const result = await writeQqEndpointWithBotKind({
80
+ endpointId: pending.endpointId,
81
+ appId: pending.appId,
82
+ appSecret: pending.appSecret,
83
+ botKind,
84
+ }, save);
85
+ return t(pending.locale, 'endpointSaved', {
86
+ kind: result.botKind,
87
+ id: result.endpointId,
88
+ intents: result.intents.join(', '),
104
89
  });
105
- return t(locale, 'endpointSaved', { kind: intentFields.botKind, id: pending.endpointId, file: filePath, intents: intentFields.intents.join(', ') });
106
90
  }
107
91
 
108
92
  /**
109
- * 完成 pending 的公域/私域选择:一次性写 .env + yaml。
93
+ * 完成 pending 的公域/私域选择:通过已捕获的所属 Adapter 保存一次账号配置。
110
94
  * @returns 成功文案;调用方负责清 pending。
111
95
  */
112
96
  export async function completeQqPendingBotKind(
113
97
  pending: QqPendingBotKind,
114
98
  botKind: QqBotKind,
115
- store: EndpointConfigurationStore,
116
99
  ): Promise<string> {
117
- return await writeEndpointWithBotKind(pending, botKind, store);
100
+ return await writeEndpointWithBotKind(pending, botKind, pending.save);
118
101
  }
119
102
 
120
103
  /**
121
104
  * `qq endpoint add [id]`:启动扫码绑定流程。
122
105
  * 返回的 Promise 在二维码链接就绪(或前置失败)时 resolve 为首条回复;
123
106
  * 后续状态(已扫码 / 成功 / 失败 / 过期刷新)通过 reply 推回当前会话。
124
- * 扫码成功后凭据暂存内存并询问公域/私域;确认后一次性写 .env + yaml。
107
+ * 扫码成功后凭据暂存内存并询问公域/私域;确认后保存到账号库,等待显式启动。
125
108
  */
126
109
  export function runQqEndpointAdd(
127
110
  state: QqRuntimeState,
128
111
  id: string | undefined,
129
112
  reply: QqCommandReply,
130
- store: EndpointConfigurationStore,
113
+ save: QqEndpointSavePort,
131
114
  input?: unknown,
132
115
  locale: Locale = 'zh-CN',
133
116
  cancelCommand?: string,
134
117
  commandHeads?: readonly string[],
135
118
  ): Promise<string> {
136
- if (state.bindFlow || state.pendingBotKind) {
119
+ if (state.bindFlow || state.pendingBotKind || state.consoleBind?.pending || state.consoleBind?.state === 'waiting') {
137
120
  return Promise.resolve(
138
121
  t(locale, 'existingFlow'),
139
122
  );
140
123
  }
124
+ state.consoleBind = null;
141
125
  const endpointId = id?.trim() || undefined;
142
126
  const sessionKey = qqCommandSessionKey(input);
143
127
  return new Promise<string>((resolve) => {
@@ -182,10 +166,10 @@ export function runQqEndpointAdd(
182
166
  endpointId: finalName,
183
167
  appId,
184
168
  appSecret,
185
- sessionKey: '', locale, cancelCommand,
169
+ sessionKey: '', locale, cancelCommand, save,
186
170
  },
187
171
  'public',
188
- store,
172
+ save,
189
173
  ) +
190
174
  t(locale, 'noInteractiveKind'),
191
175
  );
@@ -196,7 +180,7 @@ export function runQqEndpointAdd(
196
180
  endpointId: finalName,
197
181
  appId,
198
182
  appSecret,
199
- sessionKey, locale, cancelCommand, commandHeads,
183
+ sessionKey, locale, cancelCommand, commandHeads, save,
200
184
  };
201
185
  settle(
202
186
  t(locale, 'scanned', { id: finalName, prompt: qqBotKindPrompt(locale, cancelCommand) }),
@@ -222,6 +206,7 @@ export function runQqEndpointCancel(state: QqRuntimeState, preference?: Locale):
222
206
  if (state.bindFlow) {
223
207
  state.bindFlow.stop();
224
208
  state.bindFlow = null;
209
+ cancelQqConsoleBind(state);
225
210
  return t(locale, 'cancelled');
226
211
  }
227
212
  if (state.pendingBotKind) {
@@ -229,40 +214,44 @@ export function runQqEndpointCancel(state: QqRuntimeState, preference?: Locale):
229
214
  state.pendingBotKind = null;
230
215
  return t(locale, 'pendingCancelled', { name });
231
216
  }
217
+ if (state.consoleBind) {
218
+ cancelQqConsoleBind(state);
219
+ return t(locale, 'cancelled');
220
+ }
232
221
  return t(locale, 'noFlow');
233
222
  }
234
223
 
235
- /** `qq endpoint remove <id>`:从 zhin.config.yml 移除对应 endpoints 项 */
236
- export async function runQqEndpointRemove(
237
- _state: QqRuntimeState,
238
- id: string,
239
- store: EndpointConfigurationStore,
240
- ): Promise<string> {
241
- return await removeEndpointById(qqEndpointListSpec, id, store);
242
- }
243
-
244
224
  /**
245
225
  * 通用套件生成的 QQ endpoint 命令(add 经 bindFlow 钩子走扫码绑定)。
246
226
  * commands/qq/endpoint/ 下的 list / add / remove 直接默认导出这三项;cancel 为 QQ 特化,单独定义。
247
227
  */
248
- export const qqEndpointCommands = createEndpointCommands({
228
+ const standard = createEndpointCommands({
249
229
  ...qqEndpointListSpec,
250
- fields: [
251
- { key: 'appid', required: true, env: true, description: 'QQ 机器人 appid', descriptionI18n: { en: "QQ bot app ID" } },
252
- { key: 'secret', required: true, env: true, description: 'QQ 机器人 secret', descriptionI18n: { en: "QQ bot secret" } },
253
- ],
254
- running: (use) => use(qqRuntimeStateToken).endpoints.values(),
230
+ fields: [{ key: 'appid', required: true }, { key: 'secret', required: true, secret: true }],
255
231
  listFooter: (use, locale) => busyFooter(use(qqRuntimeStateToken), locale),
256
- addDescriptionI18n: { en: 'Bind a bot through mobile QQ, choose public/private, then save configuration; restart to apply' },
257
- addDescription:
258
- '手机 QQ 扫码绑定机器人:确认公域/私域后一次性写入 .env 与 zhin.config.yml(重启生效)',
259
- bindFlow: ({ id, reply, input, use, locale, formatCommand }) =>
260
- runQqEndpointAdd(
261
- use(qqRuntimeStateToken),
262
- id,
263
- reply,
264
- use(endpointConfigurationStoreToken),
265
- input, locale, formatCommand?.('qq endpoint cancel'),
266
- ['qq endpoint list', 'qq endpoint add', 'qq endpoint remove', 'qq endpoint cancel'].flatMap(path => { const head = formatCommand?.(path); return head ? [head] : []; }),
267
- ),
268
232
  }, defineCommand);
233
+
234
+ export const qqEndpointCommands = {
235
+ ...standard,
236
+ add: defineCommand({
237
+ description: '手机 QQ 扫码绑定;确认公域/私域后保存账号,手动启动',
238
+ descriptionI18n: { en: 'Scan with mobile QQ, choose public/private, save the account and start explicitly' },
239
+ params: { id: { type: 'string', description: 'endpoint ID' } },
240
+ async execute(context) {
241
+ if (!isEndpointOperator(context.config, context.input)) return endpointCommandForbidden('QQ', context.locale);
242
+ const index = context.project<unknown>(adapterFeatureId);
243
+ if (!isAdapterIndex(index)) throw new Error('QQ Adapter projection is unavailable');
244
+ const own = index.forOwner(context.owner.id).adapters().filter(adapter => adapter.descriptor.name === 'qq');
245
+ if (own.length !== 1) throw new Error('QQ Adapter ownership is ambiguous');
246
+ const adapter = own[0];
247
+ if (adapter.needsMigration) return '首次修改账号前,请由管理员在 Console 确认迁移账号清单';
248
+ if (!adapter.canManage) return '此环境未提供持久化账号管理';
249
+ const revision = adapter.revision;
250
+ const save: QqEndpointSavePort = config => adapter.add(revision, { endpointId: config.id, config: { ...config } }, { waitForExecution: false });
251
+ return runQqEndpointAdd(context.use(qqRuntimeStateToken), context.params.id,
252
+ createDurableEndpointCommandReply(context.input, context.use), save, context.input, context.locale,
253
+ context.formatCommand?.('qq endpoint cancel'),
254
+ ['qq endpoint list', 'qq endpoint add', 'qq endpoint remove', 'qq endpoint cancel'].flatMap(path => { const head = context.formatCommand?.(path); return head ? [head] : []; }));
255
+ },
256
+ }),
257
+ };
@@ -0,0 +1,13 @@
1
+ import { Adapter, type AdapterContext, type EndpointCreationContext } from 'zhin.js/adapter';
2
+ import { QqHttpEndpoint, QqWebsocketEndpoint } from './endpoint.js';
3
+ import { type QqEndpointConfig } from './protocol.js';
4
+ import type { QqClientEventMap } from './client.js';
5
+ export declare class QqAdapter extends Adapter<QqHttpEndpoint | QqWebsocketEndpoint, QqEndpointConfig, QqClientEventMap> {
6
+ private readonly context;
7
+ constructor(context: AdapterContext<QqEndpointConfig>);
8
+ get configurationIdentityFields(): readonly string[];
9
+ get configurationIdentityProbeFields(): readonly string[];
10
+ get configurationUpdateFields(): readonly string[];
11
+ protected validateConfiguration(input: QqEndpointConfig): void;
12
+ protected createEndpoint(input: QqEndpointConfig, execution: EndpointCreationContext): QqHttpEndpoint | QqWebsocketEndpoint;
13
+ }
package/lib/adapter.js ADDED
@@ -0,0 +1,27 @@
1
+ import { Adapter, createAdapterOutageNotifier } from 'zhin.js/adapter';
2
+ import { outboundHostToken } from 'zhin.js';
3
+ import { httpHostToken } from '@zhin.js/host-http';
4
+ import { QqHttpEndpoint, QqWebsocketEndpoint } from './endpoint.js';
5
+ import { resolveQqConfig } from './protocol.js';
6
+ export class QqAdapter extends Adapter {
7
+ context;
8
+ constructor(context) {
9
+ super();
10
+ this.context = context;
11
+ }
12
+ get configurationIdentityFields() { return ['id', 'appid']; }
13
+ get configurationIdentityProbeFields() { return ['accessTokenUrl', 'gatewayUrl']; }
14
+ get configurationUpdateFields() {
15
+ return ['secret', 'mode', 'sandbox', 'intents', 'botKind',
16
+ 'accessTokenUrl', 'gatewayUrl', 'streamProxy', 'streamProxy.port', 'streamProxy.serverName', 'webhookPath'];
17
+ }
18
+ validateConfiguration(input) { resolveQqConfig(input); }
19
+ createEndpoint(input, execution) {
20
+ const config = resolveQqConfig(input);
21
+ const outbound = this.context.has?.(outboundHostToken) ? this.context.use(outboundHostToken) : undefined;
22
+ const notifier = createAdapterOutageNotifier(outbound, config.notify);
23
+ return config.mode === 'websocket'
24
+ ? new QqWebsocketEndpoint({ id: execution.canonicalId, config, notifier })
25
+ : new QqHttpEndpoint({ id: execution.canonicalId, config, notifier, http: this.context.use(httpHostToken) });
26
+ }
27
+ }
package/lib/client.d.ts CHANGED
@@ -1,12 +1,10 @@
1
- import type { QqInboundMessage } from './protocol.js';
2
- import type { QqBotTransport } from './ws.js';
3
- export type QqClientEventMap = Record<string, QqInboundMessage | unknown>;
1
+ import type { EventMap } from 'qq-official-bot';
2
+ import type { QqAdapter } from './adapter.js';
3
+ export type QqClientEventMap = {
4
+ readonly [K in keyof EventMap]: EventMap[K] extends (event: infer E, ...args: never[]) => unknown ? E : never;
5
+ };
4
6
  declare module '@zhin.js/feature-kit' {
5
- interface AdapterClientRegistry {
6
- readonly qq: {
7
- readonly client: QqBotTransport;
8
- readonly events: QqClientEventMap;
9
- };
7
+ interface AdapterRegistry {
8
+ readonly qq: QqAdapter;
10
9
  }
11
10
  }
12
- export declare const qqClient: import("@zhin.js/adapter").EndpointClientToken<QqBotTransport, QqClientEventMap>;
package/lib/client.js CHANGED
@@ -1,2 +1 @@
1
- import { defineEndpointClient } from 'zhin.js/adapter';
2
- export const qqClient = defineEndpointClient('qq');
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import { type PluginConsoleRpc } from 'zhin.js';
2
+ import type { QqRuntimeState } from './qq-runtime-state.js';
3
+ export declare function createQqConsoleRpc(state: QqRuntimeState, getConfig: () => unknown): PluginConsoleRpc;
package/lib/console.js ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * QQ Console RPC:除通用 endpoint 管理外,把 `qq endpoint add` 的扫码绑定
3
+ * (以及公域 / 私域选择、取消)搬到 Console 页面。
4
+ */
5
+ import { prepareConsoleAdapterMutation, ownedConsoleAdapter, createAdapterConsoleRpc, } from 'zhin.js/adapter';
6
+ import { PluginConsoleRpcError } from 'zhin.js';
7
+ import { qqConsoleSpec } from './qq-console-spec.js';
8
+ import { cancelQqConsoleBind, completeQqConsoleBind, startQqConsoleBind, } from './qq-console.js';
9
+ export function createQqConsoleRpc(state, getConfig) {
10
+ const spec = {
11
+ ...qqConsoleSpec,
12
+ getConfig,
13
+ extraStatus: () => {
14
+ const bind = state.consoleBind;
15
+ if (!bind) {
16
+ return { bindState: state.bindFlow ? 'binding' : 'idle' };
17
+ }
18
+ return {
19
+ bindState: bind.state,
20
+ ...(bind.url ? { scanUrl: bind.url } : {}),
21
+ ...(bind.message ? { bindMessage: bind.message } : {}),
22
+ ...(bind.pending ? { bindEndpoint: bind.pending.endpointId } : {}),
23
+ ...(bind.error ? { bindError: bind.error } : {}),
24
+ };
25
+ },
26
+ availableActions: () => {
27
+ if (state.consoleBind?.pending)
28
+ return ['qq-bind-kind', 'qq-bind-cancel'];
29
+ if (state.bindFlow || state.consoleBind?.state === 'waiting')
30
+ return ['qq-bind-cancel'];
31
+ return ['qq-bind-start'];
32
+ },
33
+ actions: {
34
+ 'qq-bind-start': {
35
+ label: '扫码绑定新机器人',
36
+ labelI18n: { en: 'Bind a bot with QR' },
37
+ description: '用手机 QQ 扫码,选择公域 / 私域后保存到账号库;保存后需手动启动。',
38
+ descriptionI18n: { en: 'Scan with mobile QQ, choose public/private and save the account; start it explicitly afterwards.' },
39
+ handler: async (input, call) => {
40
+ if (!call.runtime)
41
+ throw new PluginConsoleRpcError('unsupported', '账号运行目录不可用');
42
+ const adapter = ownedConsoleAdapter(spec, call);
43
+ if (!adapter.canManage)
44
+ throw new PluginConsoleRpcError('unsupported', '此环境未提供持久化账号管理');
45
+ if (input.expectedGeneration !== call.runtime.generation || input.expectedRevision !== adapter.revision) {
46
+ throw new PluginConsoleRpcError('conflict', '账号清单已变化,请刷新核实');
47
+ }
48
+ const starting = startQqConsoleBind(state);
49
+ state.consoleAdmission = { adapterId: adapter.descriptor.id, generation: call.runtime.generation, revision: adapter.revision };
50
+ const { url } = await starting;
51
+ return { message: '请用手机 QQ 扫码;扫码成功后选择公域或私域', url };
52
+ },
53
+ },
54
+ 'qq-bind-kind': {
55
+ accountMutation: true,
56
+ label: '选择公域 / 私域',
57
+ labelI18n: { en: 'Choose public/private' },
58
+ description: '公域订阅 PUBLIC_GUILD_MESSAGES;私域订阅 GUILD_MESSAGES。',
59
+ descriptionI18n: { en: 'Public subscribes PUBLIC_GUILD_MESSAGES; private subscribes GUILD_MESSAGES.' },
60
+ options: [
61
+ { value: 'public', label: '公域', labelI18n: { en: 'Public' } },
62
+ { value: 'private', label: '私域', labelI18n: { en: 'Private' } },
63
+ ],
64
+ handler: async (input, call) => {
65
+ const captured = state.consoleAdmission;
66
+ if (!captured || captured.generation !== input.expectedGeneration || captured.revision !== input.expectedRevision) {
67
+ throw new PluginConsoleRpcError('conflict', '扫码期间账号清单已变化,请刷新核实');
68
+ }
69
+ const admission = prepareConsoleAdapterMutation(spec, call, input);
70
+ if (admission.adapter.descriptor.id !== captured.adapterId)
71
+ throw new PluginConsoleRpcError('conflict', '扫码所属 Adapter 已变化');
72
+ const result = await completeQqConsoleBind(state, config => admission.adapter.add(admission.revision, { endpointId: config.id, config: { ...config } }, admission.options), input.value);
73
+ return {
74
+ message: `已保存账号「${result.endpointId}」(${result.botKind}),尚未启动`,
75
+ saved: true, restartRequired: false, execution: { ...result.mutation.execution },
76
+ details: { endpoint: result.endpointId, botKind: result.botKind, intents: result.intents.join(', ') },
77
+ };
78
+ },
79
+ },
80
+ 'qq-bind-cancel': {
81
+ label: '取消扫码绑定',
82
+ labelI18n: { en: 'Cancel QR binding' },
83
+ description: '中止正在进行的扫码绑定;已写入的配置不会删除。',
84
+ descriptionI18n: { en: 'Stop the current QR binding flow; already-saved configuration is untouched.' },
85
+ handler: async () => {
86
+ const cancelled = cancelQqConsoleBind(state);
87
+ return { message: cancelled ? '已取消绑定' : '当前没有进行中的绑定流程' };
88
+ },
89
+ },
90
+ },
91
+ };
92
+ return createAdapterConsoleRpc(spec);
93
+ }