@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
package/lib/endpoint.js CHANGED
@@ -1,99 +1,110 @@
1
+ import { Bot } from 'qq-official-bot';
1
2
  import { installQqUploadDiagnostics } from './upload-diagnostics.js';
2
3
  import { projectQqGuildSenderRoles } from './platform-permit.js';
3
4
  import { qqDeliveryFailure } from './delivery-error.js';
4
5
  import { normalizeQqInteraction } from './interaction.js';
5
6
  import { awaitQqStartup } from './start-deadline.js';
6
7
  import { EndpointDeliveryError } from '@zhin.js/im-contract';
8
+ import { inspectQqGroupMember, inspectQqGroupMemberProfile, inspectQqGroupScene, kickQqGroupMember, moderateQqGroupMember, muteQqGroupMember, qqGroupService, } from './group-admin.js';
7
9
  /**
8
10
  * QQ endpoints — lifecycle, outbound, admit, agent tool surface.
9
11
  */
10
- import { Endpoint, createEndpointLifecycle, createRecallEndpointControl, } from 'zhin.js/adapter';
12
+ import { createEndpointSpecification, endpointSpec, createEndpointLifecycle, createEndpointOperation, } from 'zhin.js/adapter';
11
13
  import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
12
14
  import { collectOutboundImageSizes, formatOutboundMessages } from './outbound.js';
13
15
  import { QqActiveMessageGate, QqPassiveReplyLedger } from './passive-reply.js';
14
16
  import { sendQqMessages } from './qq-send.js';
15
17
  import { formatInboundContent, parseCompoundMessageId, qqInboundConversation, qqOutboundKind, resolveOutboundMessageId, senderDisplayName, } from './protocol.js';
16
- import { defaultCreateHttpBot, registerQqWebhookRoutes, } from './webhook.js';
17
- import { bindQqBotInboundEvents, defaultCreateBot, normalizeQqMessage, } from './ws.js';
18
+ import { qqHttpBotOptions, registerQqWebhookRoutes, } from './webhook.js';
19
+ import { bindQqBotInboundEvents, qqWebsocketBotOptions, configureQqWebsocketReceiver, disposeQqAuthorization, normalizeQqMessage, } from './ws.js';
18
20
  import { bindQqBotSideEvents, receiveQqSideEvent, } from './side-event-dispatch.js';
19
- export class QqWebsocketEndpoint extends Endpoint {
21
+ export class QqWebsocketEndpoint extends Bot {
22
+ [endpointSpec];
23
+ #receiverResources;
20
24
  #logger;
21
25
  #options;
22
- #createBot;
23
- #bot = null;
26
+ #connection;
27
+ #stopping;
24
28
  #open = false;
29
+ #executionSignal;
30
+ #offlineNoticeAt = 0;
25
31
  #seenInteractions = new Map();
26
32
  /** Recent msg_id / event_id per conversation, so replies go out as passive messages. */
27
33
  #passiveReplies = new QqPassiveReplyLedger();
28
34
  #activeGate = new QqActiveMessageGate();
29
35
  #lifecycle;
30
36
  #cleanup;
31
- management = createQqEndpointManagement(() => this.#requireBot());
32
- control = createRecallEndpointControl((id) => this.recallMessage(id));
37
+ #management = createQqEndpointManagement(() => this.#requireBot(), () => this.#open && this.#connection !== undefined);
38
+ #control = createQqEndpointControl(() => this.#requireBot(), (id) => this.recallMessage(id));
33
39
  constructor(options) {
34
- super();
40
+ super(qqWebsocketBotOptions(options.config));
35
41
  this.#logger = getAdapterLogger('qq', options.config.id);
36
42
  this.#lifecycle = createEndpointLifecycle({ name: options.config.id });
37
43
  this.#options = options;
38
- this.#createBot = options.createBot ?? defaultCreateBot;
39
- }
40
- get client() {
41
- return this.#requireBot();
44
+ this.#receiverResources = configureQqWebsocketReceiver(this, options.config);
45
+ const endpoint = this;
46
+ this[endpointSpec] = createEndpointSpecification(this, {
47
+ start: signal => this.#startFramework(signal), open: () => this.#openFramework(), close: () => this.#closeFramework(),
48
+ stop: () => this.#stopFramework(), send: request => this.#sendFramework(request), name: options.config.id,
49
+ get transportState() { return endpoint.#transportState; }, management: this.#management, control: this.#control,
50
+ });
51
+ this.#bindBot(this);
42
52
  }
43
53
  /** Live endpoint 名(Console/AdapterIndex 展示用,如 bot appid 别名)。 */
44
54
  get name() {
45
55
  return this.#options.config.id;
46
56
  }
47
- get transportState() {
57
+ get #transportState() {
48
58
  if (this.#lifecycle.state !== 'open')
49
59
  return this.#lifecycle.state;
50
- return this.#bot?.transportState ?? this.#lifecycle.state;
60
+ return this.#receiverResources.state();
51
61
  }
52
- async start() {
62
+ async #startFramework(executionSignal) {
63
+ this.#executionSignal = executionSignal;
53
64
  await this.#lifecycle.start(async (handle) => {
54
- const bot = this.#createBot(this.#options.config);
55
- this.#bot = bot;
56
- const releaseUploadDiagnostics = installQqUploadDiagnostics(bot.api, metadata => this.#logger.debug(formatCompact(metadata)));
65
+ const bot = this;
66
+ const previousCleanup = this.#cleanup;
67
+ await previousCleanup?.();
68
+ const releaseUploadDiagnostics = installQqUploadDiagnostics(bot, metadata => this.#logger.debug(formatCompact(metadata)));
57
69
  let cancelled = false;
58
70
  const startupAbort = new AbortController();
71
+ this.#connection = startupAbort;
72
+ let nativeStart;
59
73
  let stopping;
74
+ let transportClose;
60
75
  const stopBot = async () => {
61
- try {
62
- bot.removeAllListeners();
63
- }
64
- catch { /* continue SDK cleanup */ }
65
- try {
66
- await bot.stop();
67
- }
68
- catch { /* best-effort SDK cleanup */ }
76
+ await super.stop();
77
+ await nativeStart?.catch(() => undefined);
69
78
  };
70
79
  const cleanup = () => {
71
80
  cancelled = true;
72
81
  releaseUploadDiagnostics();
73
82
  startupAbort.abort();
74
- if (this.#bot === bot)
75
- this.#bot = null;
83
+ if (transportClose)
84
+ this.receiver.off('close', transportClose);
85
+ if (this.#connection === startupAbort)
86
+ this.#connection = undefined;
76
87
  return stopping ??= stopBot();
77
88
  };
78
89
  this.#cleanup = cleanup;
79
- handle.onForceClose(() => { void cleanup(); });
90
+ handle.onForceClose(() => { void cleanup().catch(() => undefined); });
80
91
  try {
81
- bot.on('transport.close', () => { if (cancelled || this.#bot !== bot)
82
- return; void cleanup(); handle.notifyClosed(); });
83
- this.#bindBot(bot);
84
- await awaitQqStartup(bot.start().then(async () => {
85
- if (cancelled)
86
- await stopBot();
87
- }, async (error) => {
88
- if (cancelled)
89
- await stopBot();
90
- throw error;
91
- }), startupAbort.signal);
92
+ transportClose = () => {
93
+ if (cancelled || this.#connection === undefined)
94
+ return;
95
+ this.#notifyOffline('transport_close');
96
+ void cleanup().catch(() => undefined);
97
+ handle.notifyClosed();
98
+ };
99
+ this.receiver.on('close', transportClose);
100
+ nativeStart = super.start();
101
+ await awaitQqStartup(nativeStart.then(() => undefined), startupAbort.signal);
92
102
  if (cancelled) {
93
103
  // SDK startup may create resources after stop; close this exact old client again.
94
104
  await stopping;
95
105
  return;
96
106
  }
107
+ this.#offlineNoticeAt = 0;
97
108
  this.#logger.info(`connected (websocket) | appid: ${this.#options.config.appid}`);
98
109
  }
99
110
  catch (error) {
@@ -101,29 +112,41 @@ export class QqWebsocketEndpoint extends Endpoint {
101
112
  await cleanup();
102
113
  if (wasCancelled)
103
114
  return;
115
+ this.#notifyOffline('start_failed');
104
116
  const raw = error instanceof Error ? error.message : String(error);
105
117
  throw new Error(`QQ WebSocket 连接失败:请检查 appid/secret 是否配对、网关地址(gatewayUrl/accessTokenUrl)是否可达(原始错误:${raw})`, { cause: error });
106
118
  }
107
119
  });
108
120
  }
109
- open() {
121
+ #openFramework() {
110
122
  this.#open = true;
111
123
  }
112
- close() {
124
+ #closeFramework() {
113
125
  this.#open = false;
114
126
  }
115
- async stop() {
116
- this.#open = false;
117
- const cleanup = this.#cleanup;
118
- this.#cleanup = undefined;
119
- await this.#lifecycle.stop();
120
- await cleanup?.();
127
+ async #stopFramework() {
128
+ this.#stopping ??= Promise.resolve().then(async () => {
129
+ this.#open = false;
130
+ const cleanup = this.#cleanup;
131
+ this.#cleanup = undefined;
132
+ try {
133
+ await this.#lifecycle.stop();
134
+ await cleanup?.();
135
+ }
136
+ finally {
137
+ this.removeAllListeners(undefined);
138
+ this.#receiverResources.dispose();
139
+ disposeQqAuthorization(this);
140
+ }
141
+ });
142
+ await this.#stopping;
121
143
  this.#logger.debug(formatCompact({ op: 'disconnect' }));
122
144
  }
123
- async send({ conversation, payload }) {
145
+ async #sendFramework({ conversation, payload, signal }) {
124
146
  // 媒体与 markdown/按钮不能同条发送(msg_type 7 与 2 互斥):公网图片嵌入 markdown,
125
147
  // 拿不到 URL/尺寸的媒体降级为独立消息先发
126
- const messages = formatOutboundMessages(payload, { imageSizes: await collectOutboundImageSizes(payload) });
148
+ const operation = createEndpointOperation({ signal });
149
+ const messages = formatOutboundMessages(payload, { imageSizes: await operation.prepare(() => collectOutboundImageSizes(payload, { signal })) });
127
150
  const body = messages[messages.length - 1];
128
151
  const kind = qqOutboundKind(conversation);
129
152
  const bot = this.#requireBot();
@@ -134,7 +157,7 @@ export class QqWebsocketEndpoint extends Endpoint {
134
157
  ledger: this.#passiveReplies,
135
158
  activeGate: this.#activeGate,
136
159
  log: fields => this.#logger.warn(formatCompact(fields)),
137
- }, conversation, messages);
160
+ }, { conversation, messages, signal });
138
161
  }
139
162
  catch (error) {
140
163
  const { failure, diagnostic } = qqDeliveryFailure(error);
@@ -150,7 +173,7 @@ export class QqWebsocketEndpoint extends Endpoint {
150
173
  async recallMessage(messageId) {
151
174
  const { kind, channelId, qqMsgId } = parseCompoundMessageId(messageId);
152
175
  const bot = this.#requireBot();
153
- await recallQqMessage(bot, kind, channelId, qqMsgId);
176
+ await recallQqMessage(bot, { kind, channelId, messageId: qqMsgId });
154
177
  this.#logger.debug(formatCompact({
155
178
  op: 'qq_recall',
156
179
  endpoint: this.#options.config.id,
@@ -171,8 +194,10 @@ export class QqWebsocketEndpoint extends Endpoint {
171
194
  const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
172
195
  if (msg.id)
173
196
  this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { id: msg.id });
174
- void this.emit('message.receive', {
197
+ void this[endpointSpec].events.emit('message.receive', {
175
198
  conversation,
199
+ event: msg.event,
200
+ platformSentAt: msg.platformSentAt,
176
201
  message: { conversation, id: msg.id },
177
202
  content,
178
203
  ...(msg.segments?.length ? { segments: msg.segments } : {}),
@@ -216,13 +241,16 @@ export class QqWebsocketEndpoint extends Endpoint {
216
241
  if (this.#seenInteractions.size > 10_000)
217
242
  this.#seenInteractions.delete(this.#seenInteractions.keys().next().value);
218
243
  // ACK owns only platform loading state, never implies business acceptance.
219
- void action.ack().then(ok => { if (ok === false)
220
- this.#logger.warn('qq_interaction_ack_failed'); }, () => this.#logger.warn('qq_interaction_ack_failed'));
244
+ void this[endpointSpec].events.run(() => awaitOwnedAck(action.ack(), this.#executionSignal)).then(ok => { if (ok === false)
245
+ this.#logger.warn('qq_interaction_ack_failed'); }, () => { if (this.#executionSignal?.aborted)
246
+ this.#logger.debug('QQ ACK wait cancelled');
247
+ else
248
+ this.#logger.warn('qq_interaction_ack_failed'); });
221
249
  const conversation = qqInboundConversation(String(this.#options.id), action.message);
222
250
  // INTERACTION_CREATE's id is an event_id the reply may answer passively.
223
251
  this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { event_id: action.message.id });
224
- void this.emit('message.receive', {
225
- conversation, message: { conversation, id: action.message.id }, content: '',
252
+ void this[endpointSpec].events.emit('message.receive', {
253
+ event: action.message.event, conversation, message: { conversation, id: action.message.id }, content: '',
226
254
  segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
227
255
  metadata: Object.freeze({ eventType: 'INTERACTION_CREATE', sourceMessageIdAvailable: action.sourceMessageId !== undefined,
228
256
  ...(action.sourceMessageId ? { sourceMessageId: action.sourceMessageId } : {}),
@@ -230,26 +258,50 @@ export class QqWebsocketEndpoint extends Endpoint {
230
258
  }).catch(() => this.#logger.warn('qq_interaction_receive_failed'));
231
259
  return true;
232
260
  }
261
+ #notifyOffline(reason) {
262
+ const notifier = this.#options.notifier;
263
+ if (!notifier?.configured)
264
+ return;
265
+ const now = Date.now();
266
+ if (now - this.#offlineNoticeAt < 60_000)
267
+ return;
268
+ this.#offlineNoticeAt = now;
269
+ const text = `[Zhin] QQ 机器人 ${this.#options.config.id} 掉线(${reason})。正在自动重连。\n`
270
+ + '恢复:在 QQ 回复 `qq endpoint add` 重新扫码绑定,或打开 Console 的 QQ 页面。';
271
+ void notifier.send(text).then((sent) => {
272
+ if (sent)
273
+ return;
274
+ this.#logger.warn(formatCompact({
275
+ op: 'qq_notify_failed',
276
+ endpoint: this.#options.config.id,
277
+ reason,
278
+ }));
279
+ });
280
+ }
233
281
  #bindBot(bot) {
234
282
  bindQqBotInboundEvents(bot, (raw) => {
235
- if (this.#bot !== bot)
236
- return;
237
- this.#emitPlatformEvent('message', raw);
238
- const msg = normalizeQqMessage(raw);
239
- if (msg)
240
- this.admit(msg);
283
+ void this[endpointSpec].events.run(() => {
284
+ if (this.#connection === undefined)
285
+ return;
286
+ this.#emitPlatformEvent('message', raw);
287
+ const msg = normalizeQqMessage(raw);
288
+ if (msg)
289
+ this.admit(msg);
290
+ }).catch(() => this.#logger.debug('Native callback was not admitted'));
241
291
  });
242
292
  bindQqBotSideEvents(bot, (eventName, raw) => {
243
- if (this.#bot !== bot)
244
- return;
245
- this.#emitPlatformEvent(eventName, raw);
246
- if (this.admitInteraction(raw))
247
- return;
248
- receiveQqSideEvent((name, payload) => this.emit(name, payload), this.#options.config.id, bot, eventName, raw, this.#logger);
293
+ void this[endpointSpec].events.run(() => {
294
+ if (this.#connection === undefined)
295
+ return;
296
+ this.#emitPlatformEvent(eventName, raw);
297
+ if (this.admitInteraction(raw))
298
+ return;
299
+ receiveQqSideEvent((name, payload) => this[endpointSpec].events.emit(name, payload), this.#options.config.id, bot, eventName, raw, this.#logger);
300
+ }).catch(() => this.#logger.debug('Native callback was not admitted'));
249
301
  });
250
302
  }
251
303
  #emitPlatformEvent(name, event) {
252
- void this.emitPlatform(name, event).catch((error) => {
304
+ void this[endpointSpec].events.platform(name, event).catch((error) => {
253
305
  this.#logger.warn(formatCompact({
254
306
  op: 'qq_platform_event_failed',
255
307
  event: name,
@@ -258,93 +310,92 @@ export class QqWebsocketEndpoint extends Endpoint {
258
310
  });
259
311
  }
260
312
  #requireBot() {
261
- if (!this.#bot)
313
+ if (!this.#connection)
262
314
  throw new EndpointDeliveryError('endpoint_disconnected', 'QQ bot not connected', 'not_sent');
263
- return this.#bot;
315
+ return this;
264
316
  }
265
317
  }
266
318
  /** Webhook / middleware inbound via httpHostToken POST (qq-official-bot Middleware receiver). */
267
- export class QqHttpEndpoint extends Endpoint {
319
+ export class QqHttpEndpoint extends Bot {
320
+ [endpointSpec];
268
321
  #logger;
269
322
  #options;
270
- #createBot;
271
- #bot = null;
323
+ #connection;
324
+ #stopping;
272
325
  #open = false;
326
+ #executionSignal;
327
+ #offlineNoticeAt = 0;
273
328
  #seenInteractions = new Map();
274
329
  /** Recent msg_id / event_id per conversation, so replies go out as passive messages. */
275
330
  #passiveReplies = new QqPassiveReplyLedger();
276
331
  #activeGate = new QqActiveMessageGate();
277
332
  #lifecycle;
278
333
  #cleanup;
279
- management = createQqEndpointManagement(() => this.#requireBot());
280
- control = createRecallEndpointControl((id) => this.recallMessage(id));
334
+ #management = createQqEndpointManagement(() => this.#requireBot(), () => this.#open && this.#connection !== undefined);
335
+ #control = createQqEndpointControl(() => this.#requireBot(), (id) => this.recallMessage(id));
281
336
  constructor(options) {
282
- super();
337
+ super(qqHttpBotOptions(options.config));
283
338
  this.#logger = getAdapterLogger('qq', options.config.id);
284
339
  this.#lifecycle = createEndpointLifecycle({ name: options.config.id, reconnect: false });
285
340
  this.#options = options;
286
- this.#createBot = options.createBot ?? defaultCreateHttpBot;
287
- }
288
- get client() {
289
- return this.#requireBot();
341
+ const endpoint = this;
342
+ this[endpointSpec] = createEndpointSpecification(this, {
343
+ start: signal => this.#startFramework(signal), open: () => this.#openFramework(), close: () => this.#closeFramework(),
344
+ stop: () => this.#stopFramework(), send: request => this.#sendFramework(request), name: options.config.id,
345
+ get transportState() { return endpoint.#transportState; },
346
+ management: this.#management, control: this.#control,
347
+ });
348
+ this.#bindBot(this);
290
349
  }
291
350
  /** Live endpoint 名(Console/AdapterIndex 展示用)。 */
292
351
  get name() {
293
352
  return this.#options.config.id;
294
353
  }
295
- get transportState() {
354
+ get #transportState() {
296
355
  return this.#lifecycle.state;
297
356
  }
298
- async start() {
357
+ async #startFramework(executionSignal) {
358
+ this.#executionSignal = executionSignal;
299
359
  await this.#lifecycle.start(async (handle) => {
300
- const bot = this.#createBot(this.#options.config);
301
- this.#bot = bot;
302
- const releaseUploadDiagnostics = installQqUploadDiagnostics(bot.api, metadata => this.#logger.debug(formatCompact(metadata)));
360
+ const bot = this;
361
+ const previousCleanup = this.#cleanup;
362
+ await previousCleanup?.();
363
+ const releaseUploadDiagnostics = installQqUploadDiagnostics(bot, metadata => this.#logger.debug(formatCompact(metadata)));
303
364
  let cancelled = false;
304
365
  const startupAbort = new AbortController();
366
+ this.#connection = startupAbort;
367
+ let nativeStart;
305
368
  let stopping;
306
369
  const routes = [];
307
370
  const stopBot = async () => {
308
- try {
309
- bot.removeAllListeners();
310
- }
311
- catch { /* continue SDK cleanup */ }
312
- try {
313
- await bot.stop();
314
- }
315
- catch { /* best-effort SDK cleanup */ }
371
+ await super.stop();
372
+ await nativeStart?.catch(() => undefined);
316
373
  };
317
374
  const cleanup = () => {
318
375
  cancelled = true;
319
376
  releaseUploadDiagnostics();
320
377
  startupAbort.abort();
321
- if (this.#bot === bot)
322
- this.#bot = null;
378
+ if (this.#connection === startupAbort)
379
+ this.#connection = undefined;
323
380
  for (const release of routes.splice(0))
324
381
  release();
325
382
  return stopping ??= stopBot();
326
383
  };
327
384
  this.#cleanup = cleanup;
328
- handle.onForceClose(() => { void cleanup(); });
385
+ handle.onForceClose(() => { void cleanup().catch(() => undefined); });
329
386
  try {
330
- this.#bindBot(bot);
331
387
  routes.push(...registerQqWebhookRoutes(this.#options.http, {
332
388
  config: this.#options.config,
333
389
  getBot: () => cancelled ? null : bot,
334
390
  }));
335
- await awaitQqStartup(bot.start().then(async () => {
336
- if (cancelled)
337
- await stopBot();
338
- }, async (error) => {
339
- if (cancelled)
340
- await stopBot();
341
- throw error;
342
- }), startupAbort.signal);
391
+ nativeStart = super.start();
392
+ await awaitQqStartup(nativeStart.then(() => undefined), startupAbort.signal);
343
393
  if (cancelled) {
344
394
  // SDK startup may create resources after stop; close this exact old client again.
345
395
  await stopping;
346
396
  return;
347
397
  }
398
+ this.#offlineNoticeAt = 0;
348
399
  this.#logger.info(`connected (${this.#options.config.mode}) | path: ${this.#options.config.webhookPath}`);
349
400
  }
350
401
  catch (error) {
@@ -352,28 +403,39 @@ export class QqHttpEndpoint extends Endpoint {
352
403
  await cleanup();
353
404
  if (wasCancelled)
354
405
  return;
406
+ this.#notifyOffline('start_failed');
355
407
  throw error;
356
408
  }
357
409
  });
358
410
  }
359
- open() {
411
+ #openFramework() {
360
412
  this.#open = true;
361
413
  }
362
- close() {
414
+ #closeFramework() {
363
415
  this.#open = false;
364
416
  }
365
- async stop() {
366
- this.#open = false;
367
- const cleanup = this.#cleanup;
368
- this.#cleanup = undefined;
369
- await this.#lifecycle.stop();
370
- await cleanup?.();
371
- this.#logger.debug(formatCompact({ op: 'disconnect' }));
417
+ async #stopFramework() {
418
+ this.#stopping ??= Promise.resolve().then(async () => {
419
+ this.#open = false;
420
+ const cleanup = this.#cleanup;
421
+ this.#cleanup = undefined;
422
+ try {
423
+ await this.#lifecycle.stop();
424
+ await cleanup?.();
425
+ }
426
+ finally {
427
+ this.removeAllListeners(undefined);
428
+ disposeQqAuthorization(this);
429
+ }
430
+ this.#logger.debug(formatCompact({ op: 'disconnect' }));
431
+ });
432
+ await this.#stopping;
372
433
  }
373
- async send({ conversation, payload }) {
434
+ async #sendFramework({ conversation, payload, signal }) {
374
435
  // 媒体与 markdown/按钮不能同条发送(msg_type 7 与 2 互斥):公网图片嵌入 markdown,
375
436
  // 拿不到 URL/尺寸的媒体降级为独立消息先发
376
- const messages = formatOutboundMessages(payload, { imageSizes: await collectOutboundImageSizes(payload) });
437
+ const operation = createEndpointOperation({ signal });
438
+ const messages = formatOutboundMessages(payload, { imageSizes: await operation.prepare(() => collectOutboundImageSizes(payload, { signal })) });
377
439
  const body = messages[messages.length - 1];
378
440
  const kind = qqOutboundKind(conversation);
379
441
  const bot = this.#requireBot();
@@ -384,7 +446,7 @@ export class QqHttpEndpoint extends Endpoint {
384
446
  ledger: this.#passiveReplies,
385
447
  activeGate: this.#activeGate,
386
448
  log: fields => this.#logger.warn(formatCompact(fields)),
387
- }, conversation, messages);
449
+ }, { conversation, messages, signal });
388
450
  }
389
451
  catch (error) {
390
452
  const { failure, diagnostic } = qqDeliveryFailure(error);
@@ -400,7 +462,7 @@ export class QqHttpEndpoint extends Endpoint {
400
462
  async recallMessage(messageId) {
401
463
  const { kind, channelId, qqMsgId } = parseCompoundMessageId(messageId);
402
464
  const bot = this.#requireBot();
403
- await recallQqMessage(bot, kind, channelId, qqMsgId);
465
+ await recallQqMessage(bot, { kind, channelId, messageId: qqMsgId });
404
466
  this.#logger.debug(formatCompact({
405
467
  op: 'qq_recall',
406
468
  endpoint: this.#options.config.id,
@@ -420,8 +482,10 @@ export class QqHttpEndpoint extends Endpoint {
420
482
  const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
421
483
  if (msg.id)
422
484
  this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { id: msg.id });
423
- void this.emit('message.receive', {
485
+ void this[endpointSpec].events.emit('message.receive', {
424
486
  conversation,
487
+ event: msg.event,
488
+ platformSentAt: msg.platformSentAt,
425
489
  message: { conversation, id: msg.id },
426
490
  content,
427
491
  ...(msg.segments?.length ? { segments: msg.segments } : {}),
@@ -465,13 +529,16 @@ export class QqHttpEndpoint extends Endpoint {
465
529
  if (this.#seenInteractions.size > 10_000)
466
530
  this.#seenInteractions.delete(this.#seenInteractions.keys().next().value);
467
531
  // ACK owns only platform loading state, never implies business acceptance.
468
- void action.ack().then(ok => { if (ok === false)
469
- this.#logger.warn('qq_interaction_ack_failed'); }, () => this.#logger.warn('qq_interaction_ack_failed'));
532
+ void this[endpointSpec].events.run(() => awaitOwnedAck(action.ack(), this.#executionSignal)).then(ok => { if (ok === false)
533
+ this.#logger.warn('qq_interaction_ack_failed'); }, () => { if (this.#executionSignal?.aborted)
534
+ this.#logger.debug('QQ ACK wait cancelled');
535
+ else
536
+ this.#logger.warn('qq_interaction_ack_failed'); });
470
537
  const conversation = qqInboundConversation(String(this.#options.id), action.message);
471
538
  // INTERACTION_CREATE's id is an event_id the reply may answer passively.
472
539
  this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { event_id: action.message.id });
473
- void this.emit('message.receive', {
474
- conversation, message: { conversation, id: action.message.id }, content: '',
540
+ void this[endpointSpec].events.emit('message.receive', {
541
+ event: action.message.event, conversation, message: { conversation, id: action.message.id }, content: '',
475
542
  segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
476
543
  metadata: Object.freeze({ eventType: 'INTERACTION_CREATE', sourceMessageIdAvailable: action.sourceMessageId !== undefined,
477
544
  ...(action.sourceMessageId ? { sourceMessageId: action.sourceMessageId } : {}),
@@ -479,26 +546,50 @@ export class QqHttpEndpoint extends Endpoint {
479
546
  }).catch(() => this.#logger.warn('qq_interaction_receive_failed'));
480
547
  return true;
481
548
  }
549
+ #notifyOffline(reason) {
550
+ const notifier = this.#options.notifier;
551
+ if (!notifier?.configured)
552
+ return;
553
+ const now = Date.now();
554
+ if (now - this.#offlineNoticeAt < 60_000)
555
+ return;
556
+ this.#offlineNoticeAt = now;
557
+ const text = `[Zhin] QQ 机器人 ${this.#options.config.id} 掉线(${reason})。正在自动重连。\n`
558
+ + '恢复:在 QQ 回复 `qq endpoint add` 重新扫码绑定,或打开 Console 的 QQ 页面。';
559
+ void notifier.send(text).then((sent) => {
560
+ if (sent)
561
+ return;
562
+ this.#logger.warn(formatCompact({
563
+ op: 'qq_notify_failed',
564
+ endpoint: this.#options.config.id,
565
+ reason,
566
+ }));
567
+ });
568
+ }
482
569
  #bindBot(bot) {
483
570
  bindQqBotInboundEvents(bot, (raw) => {
484
- if (this.#bot !== bot)
485
- return;
486
- this.#emitPlatformEvent('message', raw);
487
- const msg = normalizeQqMessage(raw);
488
- if (msg)
489
- this.admit(msg);
571
+ void this[endpointSpec].events.run(() => {
572
+ if (this.#connection === undefined)
573
+ return;
574
+ this.#emitPlatformEvent('message', raw);
575
+ const msg = normalizeQqMessage(raw);
576
+ if (msg)
577
+ this.admit(msg);
578
+ }).catch(() => this.#logger.debug('Native callback was not admitted'));
490
579
  });
491
580
  bindQqBotSideEvents(bot, (eventName, raw) => {
492
- if (this.#bot !== bot)
493
- return;
494
- this.#emitPlatformEvent(eventName, raw);
495
- if (this.admitInteraction(raw))
496
- return;
497
- receiveQqSideEvent((name, payload) => this.emit(name, payload), this.#options.config.id, bot, eventName, raw, this.#logger);
581
+ void this[endpointSpec].events.run(() => {
582
+ if (this.#connection === undefined)
583
+ return;
584
+ this.#emitPlatformEvent(eventName, raw);
585
+ if (this.admitInteraction(raw))
586
+ return;
587
+ receiveQqSideEvent((name, payload) => this[endpointSpec].events.emit(name, payload), this.#options.config.id, bot, eventName, raw, this.#logger);
588
+ }).catch(() => this.#logger.debug('Native callback was not admitted'));
498
589
  });
499
590
  }
500
591
  #emitPlatformEvent(name, event) {
501
- void this.emitPlatform(name, event).catch((error) => {
592
+ void this[endpointSpec].events.platform(name, event).catch((error) => {
502
593
  this.#logger.warn(formatCompact({
503
594
  op: 'qq_platform_event_failed',
504
595
  event: name,
@@ -507,24 +598,41 @@ export class QqHttpEndpoint extends Endpoint {
507
598
  });
508
599
  }
509
600
  #requireBot() {
510
- if (!this.#bot)
601
+ if (!this.#connection)
511
602
  throw new EndpointDeliveryError('endpoint_disconnected', 'QQ bot not connected', 'not_sent');
512
- return this.#bot;
603
+ return this;
513
604
  }
514
605
  }
515
- function createQqEndpointManagement(requireClient) {
606
+ function createQqEndpointManagement(requireClient, isActive) {
607
+ const api = () => requireClient();
516
608
  return Object.freeze({
517
- async listChannels() {
609
+ // QQ 群(group_openid / member_openid):官方 v2 群管理接口,部分为白名单能力(11253)。
610
+ inspectGroupMember: request => inspectQqGroupMember(api(), request),
611
+ inspectGroupScene: request => inspectQqGroupScene(api(), request),
612
+ inspectGroupMemberProfile: request => inspectQqGroupMemberProfile(api(), request),
613
+ moderateGroupMember: request => moderateQqGroupMember({ api: api(), isActive }, request),
614
+ async listGroupMembers({ groupId, signal }) {
615
+ const operation = createEndpointOperation({ signal });
616
+ return operation.run(() => qqGroupService(api()).getMembers(groupId));
617
+ },
618
+ async kickGroupMember({ groupId, userId, signal }) {
619
+ await kickQqGroupMember(api(), { groupId, userId, signal });
620
+ },
621
+ async muteGroupMember({ groupId, userId, durationSeconds, signal }) {
622
+ await muteQqGroupMember(api(), { groupId, userId, durationSeconds, signal });
623
+ },
624
+ async listChannels(request = {}) {
625
+ const operation = createEndpointOperation(request);
518
626
  const channels = [];
519
627
  const client = requireClient();
520
- const guilds = await client.getGuilds();
628
+ const guilds = await operation.run(() => client.guildService.getList());
521
629
  for (const guildValue of Array.isArray(guilds) ? guilds : []) {
522
630
  const guild = asRecord(guildValue);
523
631
  const guildId = String(guild.id ?? guild.guild_id ?? guildValue ?? '');
524
632
  if (!guildId)
525
633
  continue;
526
634
  const guildName = String(guild.name ?? guild.guild_name ?? guildId);
527
- const rows = await client.getChannels(guildId);
635
+ const rows = await operation.run(() => client.channelService.getList(guildId));
528
636
  for (const channelValue of Array.isArray(rows) ? rows : []) {
529
637
  const channel = asRecord(channelValue);
530
638
  const id = String(channel.id ?? channel.channel_id ?? channelValue ?? '');
@@ -546,7 +654,66 @@ function asRecord(value) {
546
654
  ? value
547
655
  : {};
548
656
  }
549
- async function recallQqMessage(bot, kind, channelId, messageId) {
657
+ /**
658
+ * Recall + 频道表情回应。recall 同时接受 send() 的复合 ID 与入站原生消息 ID(目标取自 MessageRef.conversation),
659
+ * 因此群管理员机器人可撤回普通成员 2 分钟内的消息(官方限制由平台执行)。
660
+ * 表情回应只有频道子频道消息支持(官方无群 / 单聊表情回应接口)。
661
+ */
662
+ function createQqEndpointControl(requireBot, recallById) {
663
+ return Object.freeze({
664
+ recall: async ({ message, signal }) => {
665
+ if (isCompoundMessageId(message.id))
666
+ return createEndpointOperation({ signal }).run(() => recallById(message.id));
667
+ const kind = qqOutboundKind(message.conversation);
668
+ await createEndpointOperation({ signal }).run(() => recallQqMessage(requireBot(), { kind, channelId: message.conversation.id, messageId: message.id }));
669
+ },
670
+ addReaction: async ({ message, emoji, signal }) => {
671
+ const target = qqGuildReactionTarget(message, emoji);
672
+ if (!target)
673
+ return null;
674
+ await createEndpointOperation({ signal }).run(() => requireBot().reactionService.addGuildMessageReaction(target.channelId, target.messageId, target.type, target.id));
675
+ return `${target.type}:${target.id}`;
676
+ },
677
+ removeReaction: async ({ message, reactionId, signal }) => {
678
+ const target = qqGuildReactionTarget(message, reactionId);
679
+ if (!target)
680
+ return;
681
+ await createEndpointOperation({ signal }).run(() => requireBot().reactionService.deleteGuildMessageReaction(target.channelId, target.messageId, target.type, target.id));
682
+ },
683
+ });
684
+ }
685
+ function isCompoundMessageId(id) {
686
+ try {
687
+ parseCompoundMessageId(id);
688
+ return true;
689
+ }
690
+ catch {
691
+ return false;
692
+ }
693
+ }
694
+ /** `type:id`(1 系统表情 / 2 emoji)或单个 Unicode emoji(→ type 2 + 码点)。 */
695
+ export function parseQqEmoji(value) {
696
+ const explicit = /^([12]):(\d+)$/.exec(value.trim());
697
+ if (explicit)
698
+ return { type: Number(explicit[1]), id: explicit[2] };
699
+ const chars = [...value.trim().replace(/\uFE0F/g, '')];
700
+ if (chars.length !== 1)
701
+ return null;
702
+ const code = chars[0].codePointAt(0);
703
+ return code > 0x7f ? { type: 2, id: String(code) } : null;
704
+ }
705
+ function qqGuildReactionTarget(message, emoji) {
706
+ let kind = qqOutboundKind(message.conversation);
707
+ let channelId = message.conversation.id;
708
+ let messageId = message.id;
709
+ if (isCompoundMessageId(message.id))
710
+ ({ kind, channelId, qqMsgId: messageId } = parseCompoundMessageId(message.id));
711
+ if (kind !== 'channel' || !channelId || !messageId)
712
+ return null;
713
+ const parsed = parseQqEmoji(emoji);
714
+ return parsed ? { channelId, messageId, ...parsed } : null;
715
+ }
716
+ async function recallQqMessage(bot, { kind, channelId, messageId }) {
550
717
  const recall = { private: bot.recallPrivateMessage, group: bot.recallGroupMessage,
551
718
  channel: bot.recallGuildMessage, direct: bot.recallDirectMessage }[kind];
552
719
  if (!recall)
@@ -555,3 +722,15 @@ async function recallQqMessage(bot, kind, channelId, messageId) {
555
722
  throw new EndpointDeliveryError('invalid_message_id', 'QQ recall requires a nonempty message ID', 'not_sent');
556
723
  await recall.call(bot, channelId, messageId);
557
724
  }
725
+ /** Logical cancellation only; the native SDK still owns its already-started ACK. */
726
+ function awaitOwnedAck(operation, signal) {
727
+ if (!signal)
728
+ return operation;
729
+ return new Promise((resolve, reject) => {
730
+ const abort = () => reject(signal.reason);
731
+ signal.addEventListener('abort', abort, { once: true });
732
+ void operation.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
733
+ if (signal.aborted)
734
+ abort();
735
+ });
736
+ }