@zhin.js/adapter-qq 1.1.18 → 1.1.20

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 (102) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +21 -18
  3. package/adapters/qq/index.js +4 -38
  4. package/adapters/qq/index.ts +3 -46
  5. package/agents/qq/conversation.js +1 -1
  6. package/agents/qq/conversation.ts +1 -1
  7. package/agents/qq/skills/qq/tools/add_role/index.js +2 -1
  8. package/agents/qq/skills/qq/tools/add_role/index.ts +2 -1
  9. package/agents/qq/skills/qq/tools/channel_info/index.js +1 -1
  10. package/agents/qq/skills/qq/tools/channel_info/index.ts +1 -1
  11. package/agents/qq/skills/qq/tools/create_role/index.js +1 -1
  12. package/agents/qq/skills/qq/tools/create_role/index.ts +1 -1
  13. package/agents/qq/skills/qq/tools/guild_kick_member/index.js +1 -1
  14. package/agents/qq/skills/qq/tools/guild_kick_member/index.ts +1 -1
  15. package/agents/qq/skills/qq/tools/guild_mute_member/index.js +1 -1
  16. package/agents/qq/skills/qq/tools/guild_mute_member/index.ts +1 -1
  17. package/agents/qq/skills/qq/tools/list_channels/index.js +1 -1
  18. package/agents/qq/skills/qq/tools/list_channels/index.ts +1 -1
  19. package/agents/qq/skills/qq/tools/list_roles/index.js +1 -1
  20. package/agents/qq/skills/qq/tools/list_roles/index.ts +1 -1
  21. package/agents/qq/skills/qq/tools/member_detail/index.js +1 -1
  22. package/agents/qq/skills/qq/tools/member_detail/index.ts +1 -1
  23. package/agents/qq/skills/qq/tools/remove_role/index.js +2 -1
  24. package/agents/qq/skills/qq/tools/remove_role/index.ts +2 -1
  25. package/agents/qq/skills/qq-conversation/tools/conversation_info/index.js +1 -1
  26. package/agents/qq/skills/qq-conversation/tools/conversation_info/index.ts +1 -1
  27. package/agents/qq/skills/qq-directory/tools/list_guilds/index.js +1 -1
  28. package/agents/qq/skills/qq-directory/tools/list_guilds/index.ts +1 -1
  29. package/agents/qq/skills/qq-group-admin/tools/group_kick/index.js +2 -2
  30. package/agents/qq/skills/qq-group-admin/tools/group_kick/index.ts +2 -2
  31. package/agents/qq/skills/qq-group-admin/tools/handle_join_request/index.js +1 -1
  32. package/agents/qq/skills/qq-group-admin/tools/handle_join_request/index.ts +1 -1
  33. package/agents/qq/skills/qq-group-admin/tools/set_mute/index.js +2 -2
  34. package/agents/qq/skills/qq-group-admin/tools/set_mute/index.ts +2 -2
  35. package/agents/qq/skills/qq-group-directory/tools/group_info/index.js +1 -1
  36. package/agents/qq/skills/qq-group-directory/tools/group_info/index.ts +1 -1
  37. package/agents/qq/skills/qq-group-directory/tools/group_member_info/index.js +1 -1
  38. package/agents/qq/skills/qq-group-directory/tools/group_member_info/index.ts +1 -1
  39. package/agents/qq/skills/qq-group-directory/tools/group_members/index.js +1 -1
  40. package/agents/qq/skills/qq-group-directory/tools/group_members/index.ts +1 -1
  41. package/agents/qq/skills/qq-group-state/tools/group_blacklist/index.js +1 -1
  42. package/agents/qq/skills/qq-group-state/tools/group_blacklist/index.ts +1 -1
  43. package/agents/qq/skills/qq-group-state/tools/list_join_requests/index.js +1 -1
  44. package/agents/qq/skills/qq-group-state/tools/list_join_requests/index.ts +1 -1
  45. package/agents/qq/skills/qq-group-state/tools/list_muted/index.js +1 -1
  46. package/agents/qq/skills/qq-group-state/tools/list_muted/index.ts +1 -1
  47. package/commands/qq/endpoint/definition.js +41 -36
  48. package/commands/qq/endpoint/definition.ts +41 -67
  49. package/lib/adapter.d.ts +13 -0
  50. package/lib/adapter.js +27 -0
  51. package/lib/client.d.ts +7 -9
  52. package/lib/client.js +1 -2
  53. package/lib/console.d.ts +1 -6
  54. package/lib/console.js +29 -20
  55. package/lib/endpoint.d.ts +8 -27
  56. package/lib/endpoint.js +218 -165
  57. package/lib/group-admin.d.ts +14 -9
  58. package/lib/group-admin.js +21 -20
  59. package/lib/i18n.js +4 -4
  60. package/lib/image-size.d.ts +6 -1
  61. package/lib/image-size.js +2 -3
  62. package/lib/index.d.ts +5 -4
  63. package/lib/index.js +3 -3
  64. package/lib/interaction.js +1 -1
  65. package/lib/outbound.d.ts +4 -1
  66. package/lib/outbound.js +3 -2
  67. package/lib/protocol.d.ts +2 -0
  68. package/lib/qq-console-spec.js +12 -1
  69. package/lib/qq-console.d.ts +3 -4
  70. package/lib/qq-console.js +16 -6
  71. package/lib/qq-endpoint-write.d.ts +6 -10
  72. package/lib/qq-endpoint-write.js +5 -32
  73. package/lib/qq-runtime-state.d.ts +11 -10
  74. package/lib/qq-runtime-state.js +4 -5
  75. package/lib/qq-send.d.ts +7 -14
  76. package/lib/qq-send.js +20 -6
  77. package/lib/webhook.d.ts +3 -6
  78. package/lib/webhook.js +6 -49
  79. package/lib/ws.d.ts +10 -33
  80. package/lib/ws.js +21 -70
  81. package/middlewares/bot-kind-reply/index.js +1 -2
  82. package/middlewares/bot-kind-reply/index.ts +0 -2
  83. package/package.json +19 -19
  84. package/plugin.js +5 -5
  85. package/src/adapter.ts +23 -0
  86. package/src/client.ts +4 -12
  87. package/src/console.ts +27 -20
  88. package/src/endpoint.ts +218 -160
  89. package/src/group-admin.ts +28 -26
  90. package/src/i18n.ts +4 -4
  91. package/src/image-size.ts +7 -3
  92. package/src/index.ts +5 -5
  93. package/src/interaction.ts +1 -1
  94. package/src/outbound.ts +3 -2
  95. package/src/protocol.ts +2 -0
  96. package/src/qq-console-spec.ts +12 -1
  97. package/src/qq-console.ts +14 -8
  98. package/src/qq-endpoint-write.ts +10 -38
  99. package/src/qq-runtime-state.ts +11 -16
  100. package/src/qq-send.ts +28 -12
  101. package/src/webhook.ts +7 -57
  102. package/src/ws.ts +27 -105
package/lib/endpoint.js CHANGED
@@ -1,3 +1,4 @@
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';
@@ -8,21 +9,24 @@ import { inspectQqGroupMember, inspectQqGroupMemberProfile, inspectQqGroupScene,
8
9
  /**
9
10
  * QQ endpoints — lifecycle, outbound, admit, agent tool surface.
10
11
  */
11
- import { Endpoint, createEndpointLifecycle, } from 'zhin.js/adapter';
12
+ import { createEndpointSpecification, endpointSpec, createEndpointLifecycle, createEndpointOperation, } from 'zhin.js/adapter';
12
13
  import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
13
14
  import { collectOutboundImageSizes, formatOutboundMessages } from './outbound.js';
14
15
  import { QqActiveMessageGate, QqPassiveReplyLedger } from './passive-reply.js';
15
16
  import { sendQqMessages } from './qq-send.js';
16
17
  import { formatInboundContent, parseCompoundMessageId, qqInboundConversation, qqOutboundKind, resolveOutboundMessageId, senderDisplayName, } from './protocol.js';
17
- import { defaultCreateHttpBot, registerQqWebhookRoutes, } from './webhook.js';
18
- import { bindQqBotInboundEvents, defaultCreateBot, normalizeQqMessage, } from './ws.js';
18
+ import { qqHttpBotOptions, registerQqWebhookRoutes, } from './webhook.js';
19
+ import { bindQqBotInboundEvents, qqWebsocketBotOptions, configureQqWebsocketReceiver, disposeQqAuthorization, normalizeQqMessage, } from './ws.js';
19
20
  import { bindQqBotSideEvents, receiveQqSideEvent, } from './side-event-dispatch.js';
20
- export class QqWebsocketEndpoint extends Endpoint {
21
+ export class QqWebsocketEndpoint extends Bot {
22
+ [endpointSpec];
23
+ #receiverResources;
21
24
  #logger;
22
25
  #options;
23
- #createBot;
24
- #bot = null;
26
+ #connection;
27
+ #stopping;
25
28
  #open = false;
29
+ #executionSignal;
26
30
  #offlineNoticeAt = 0;
27
31
  #seenInteractions = new Map();
28
32
  /** Recent msg_id / event_id per conversation, so replies go out as passive messages. */
@@ -30,72 +34,71 @@ export class QqWebsocketEndpoint extends Endpoint {
30
34
  #activeGate = new QqActiveMessageGate();
31
35
  #lifecycle;
32
36
  #cleanup;
33
- management = createQqEndpointManagement(() => this.#requireBot(), () => this.#open && this.#bot !== null);
34
- control = createQqEndpointControl(() => this.#requireBot(), (id) => this.recallMessage(id));
37
+ #management = createQqEndpointManagement(() => this.#requireBot(), () => this.#open && this.#connection !== undefined);
38
+ #control = createQqEndpointControl(() => this.#requireBot(), (id) => this.recallMessage(id));
35
39
  constructor(options) {
36
- super();
40
+ super(qqWebsocketBotOptions(options.config));
37
41
  this.#logger = getAdapterLogger('qq', options.config.id);
38
42
  this.#lifecycle = createEndpointLifecycle({ name: options.config.id });
39
43
  this.#options = options;
40
- this.#createBot = options.createBot ?? defaultCreateBot;
41
- }
42
- get client() {
43
- 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);
44
52
  }
45
53
  /** Live endpoint 名(Console/AdapterIndex 展示用,如 bot appid 别名)。 */
46
54
  get name() {
47
55
  return this.#options.config.id;
48
56
  }
49
- get transportState() {
57
+ get #transportState() {
50
58
  if (this.#lifecycle.state !== 'open')
51
59
  return this.#lifecycle.state;
52
- return this.#bot?.transportState ?? this.#lifecycle.state;
60
+ return this.#receiverResources.state();
53
61
  }
54
- async start() {
62
+ async #startFramework(executionSignal) {
63
+ this.#executionSignal = executionSignal;
55
64
  await this.#lifecycle.start(async (handle) => {
56
- const bot = this.#createBot(this.#options.config);
57
- this.#bot = bot;
58
- 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)));
59
69
  let cancelled = false;
60
70
  const startupAbort = new AbortController();
71
+ this.#connection = startupAbort;
72
+ let nativeStart;
61
73
  let stopping;
74
+ let transportClose;
62
75
  const stopBot = async () => {
63
- try {
64
- bot.removeAllListeners();
65
- }
66
- catch { /* continue SDK cleanup */ }
67
- try {
68
- await bot.stop();
69
- }
70
- catch { /* best-effort SDK cleanup */ }
76
+ await super.stop();
77
+ await nativeStart?.catch(() => undefined);
71
78
  };
72
79
  const cleanup = () => {
73
80
  cancelled = true;
74
81
  releaseUploadDiagnostics();
75
82
  startupAbort.abort();
76
- if (this.#bot === bot)
77
- this.#bot = null;
83
+ if (transportClose)
84
+ this.receiver.off('close', transportClose);
85
+ if (this.#connection === startupAbort)
86
+ this.#connection = undefined;
78
87
  return stopping ??= stopBot();
79
88
  };
80
89
  this.#cleanup = cleanup;
81
- handle.onForceClose(() => { void cleanup(); });
90
+ handle.onForceClose(() => { void cleanup().catch(() => undefined); });
82
91
  try {
83
- bot.on('transport.close', () => {
84
- if (cancelled || this.#bot !== bot)
92
+ transportClose = () => {
93
+ if (cancelled || this.#connection === undefined)
85
94
  return;
86
95
  this.#notifyOffline('transport_close');
87
- void cleanup();
96
+ void cleanup().catch(() => undefined);
88
97
  handle.notifyClosed();
89
- });
90
- this.#bindBot(bot);
91
- await awaitQqStartup(bot.start().then(async () => {
92
- if (cancelled)
93
- await stopBot();
94
- }, async (error) => {
95
- if (cancelled)
96
- await stopBot();
97
- throw error;
98
- }), startupAbort.signal);
98
+ };
99
+ this.receiver.on('close', transportClose);
100
+ nativeStart = super.start();
101
+ await awaitQqStartup(nativeStart.then(() => undefined), startupAbort.signal);
99
102
  if (cancelled) {
100
103
  // SDK startup may create resources after stop; close this exact old client again.
101
104
  await stopping;
@@ -115,24 +118,35 @@ export class QqWebsocketEndpoint extends Endpoint {
115
118
  }
116
119
  });
117
120
  }
118
- open() {
121
+ #openFramework() {
119
122
  this.#open = true;
120
123
  }
121
- close() {
124
+ #closeFramework() {
122
125
  this.#open = false;
123
126
  }
124
- async stop() {
125
- this.#open = false;
126
- const cleanup = this.#cleanup;
127
- this.#cleanup = undefined;
128
- await this.#lifecycle.stop();
129
- 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;
130
143
  this.#logger.debug(formatCompact({ op: 'disconnect' }));
131
144
  }
132
- async send({ conversation, payload }) {
145
+ async #sendFramework({ conversation, payload, signal }) {
133
146
  // 媒体与 markdown/按钮不能同条发送(msg_type 7 与 2 互斥):公网图片嵌入 markdown,
134
147
  // 拿不到 URL/尺寸的媒体降级为独立消息先发
135
- 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 })) });
136
150
  const body = messages[messages.length - 1];
137
151
  const kind = qqOutboundKind(conversation);
138
152
  const bot = this.#requireBot();
@@ -143,7 +157,7 @@ export class QqWebsocketEndpoint extends Endpoint {
143
157
  ledger: this.#passiveReplies,
144
158
  activeGate: this.#activeGate,
145
159
  log: fields => this.#logger.warn(formatCompact(fields)),
146
- }, conversation, messages);
160
+ }, { conversation, messages, signal });
147
161
  }
148
162
  catch (error) {
149
163
  const { failure, diagnostic } = qqDeliveryFailure(error);
@@ -159,7 +173,7 @@ export class QqWebsocketEndpoint extends Endpoint {
159
173
  async recallMessage(messageId) {
160
174
  const { kind, channelId, qqMsgId } = parseCompoundMessageId(messageId);
161
175
  const bot = this.#requireBot();
162
- await recallQqMessage(bot, kind, channelId, qqMsgId);
176
+ await recallQqMessage(bot, { kind, channelId, messageId: qqMsgId });
163
177
  this.#logger.debug(formatCompact({
164
178
  op: 'qq_recall',
165
179
  endpoint: this.#options.config.id,
@@ -180,8 +194,10 @@ export class QqWebsocketEndpoint extends Endpoint {
180
194
  const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
181
195
  if (msg.id)
182
196
  this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { id: msg.id });
183
- void this.emit('message.receive', {
197
+ void this[endpointSpec].events.emit('message.receive', {
184
198
  conversation,
199
+ event: msg.event,
200
+ platformSentAt: msg.platformSentAt,
185
201
  message: { conversation, id: msg.id },
186
202
  content,
187
203
  ...(msg.segments?.length ? { segments: msg.segments } : {}),
@@ -225,13 +241,16 @@ export class QqWebsocketEndpoint extends Endpoint {
225
241
  if (this.#seenInteractions.size > 10_000)
226
242
  this.#seenInteractions.delete(this.#seenInteractions.keys().next().value);
227
243
  // ACK owns only platform loading state, never implies business acceptance.
228
- void action.ack().then(ok => { if (ok === false)
229
- 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'); });
230
249
  const conversation = qqInboundConversation(String(this.#options.id), action.message);
231
250
  // INTERACTION_CREATE's id is an event_id the reply may answer passively.
232
251
  this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { event_id: action.message.id });
233
- void this.emit('message.receive', {
234
- 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: '',
235
254
  segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
236
255
  metadata: Object.freeze({ eventType: 'INTERACTION_CREATE', sourceMessageIdAvailable: action.sourceMessageId !== undefined,
237
256
  ...(action.sourceMessageId ? { sourceMessageId: action.sourceMessageId } : {}),
@@ -261,24 +280,28 @@ export class QqWebsocketEndpoint extends Endpoint {
261
280
  }
262
281
  #bindBot(bot) {
263
282
  bindQqBotInboundEvents(bot, (raw) => {
264
- if (this.#bot !== bot)
265
- return;
266
- this.#emitPlatformEvent('message', raw);
267
- const msg = normalizeQqMessage(raw);
268
- if (msg)
269
- 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'));
270
291
  });
271
292
  bindQqBotSideEvents(bot, (eventName, raw) => {
272
- if (this.#bot !== bot)
273
- return;
274
- this.#emitPlatformEvent(eventName, raw);
275
- if (this.admitInteraction(raw))
276
- return;
277
- 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'));
278
301
  });
279
302
  }
280
303
  #emitPlatformEvent(name, event) {
281
- void this.emitPlatform(name, event).catch((error) => {
304
+ void this[endpointSpec].events.platform(name, event).catch((error) => {
282
305
  this.#logger.warn(formatCompact({
283
306
  op: 'qq_platform_event_failed',
284
307
  event: name,
@@ -287,18 +310,20 @@ export class QqWebsocketEndpoint extends Endpoint {
287
310
  });
288
311
  }
289
312
  #requireBot() {
290
- if (!this.#bot)
313
+ if (!this.#connection)
291
314
  throw new EndpointDeliveryError('endpoint_disconnected', 'QQ bot not connected', 'not_sent');
292
- return this.#bot;
315
+ return this;
293
316
  }
294
317
  }
295
318
  /** Webhook / middleware inbound via httpHostToken POST (qq-official-bot Middleware receiver). */
296
- export class QqHttpEndpoint extends Endpoint {
319
+ export class QqHttpEndpoint extends Bot {
320
+ [endpointSpec];
297
321
  #logger;
298
322
  #options;
299
- #createBot;
300
- #bot = null;
323
+ #connection;
324
+ #stopping;
301
325
  #open = false;
326
+ #executionSignal;
302
327
  #offlineNoticeAt = 0;
303
328
  #seenInteractions = new Map();
304
329
  /** Recent msg_id / event_id per conversation, so replies go out as passive messages. */
@@ -306,70 +331,65 @@ export class QqHttpEndpoint extends Endpoint {
306
331
  #activeGate = new QqActiveMessageGate();
307
332
  #lifecycle;
308
333
  #cleanup;
309
- management = createQqEndpointManagement(() => this.#requireBot(), () => this.#open && this.#bot !== null);
310
- control = createQqEndpointControl(() => this.#requireBot(), (id) => this.recallMessage(id));
334
+ #management = createQqEndpointManagement(() => this.#requireBot(), () => this.#open && this.#connection !== undefined);
335
+ #control = createQqEndpointControl(() => this.#requireBot(), (id) => this.recallMessage(id));
311
336
  constructor(options) {
312
- super();
337
+ super(qqHttpBotOptions(options.config));
313
338
  this.#logger = getAdapterLogger('qq', options.config.id);
314
339
  this.#lifecycle = createEndpointLifecycle({ name: options.config.id, reconnect: false });
315
340
  this.#options = options;
316
- this.#createBot = options.createBot ?? defaultCreateHttpBot;
317
- }
318
- get client() {
319
- 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);
320
349
  }
321
350
  /** Live endpoint 名(Console/AdapterIndex 展示用)。 */
322
351
  get name() {
323
352
  return this.#options.config.id;
324
353
  }
325
- get transportState() {
354
+ get #transportState() {
326
355
  return this.#lifecycle.state;
327
356
  }
328
- async start() {
357
+ async #startFramework(executionSignal) {
358
+ this.#executionSignal = executionSignal;
329
359
  await this.#lifecycle.start(async (handle) => {
330
- const bot = this.#createBot(this.#options.config);
331
- this.#bot = bot;
332
- 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)));
333
364
  let cancelled = false;
334
365
  const startupAbort = new AbortController();
366
+ this.#connection = startupAbort;
367
+ let nativeStart;
335
368
  let stopping;
336
369
  const routes = [];
337
370
  const stopBot = async () => {
338
- try {
339
- bot.removeAllListeners();
340
- }
341
- catch { /* continue SDK cleanup */ }
342
- try {
343
- await bot.stop();
344
- }
345
- catch { /* best-effort SDK cleanup */ }
371
+ await super.stop();
372
+ await nativeStart?.catch(() => undefined);
346
373
  };
347
374
  const cleanup = () => {
348
375
  cancelled = true;
349
376
  releaseUploadDiagnostics();
350
377
  startupAbort.abort();
351
- if (this.#bot === bot)
352
- this.#bot = null;
378
+ if (this.#connection === startupAbort)
379
+ this.#connection = undefined;
353
380
  for (const release of routes.splice(0))
354
381
  release();
355
382
  return stopping ??= stopBot();
356
383
  };
357
384
  this.#cleanup = cleanup;
358
- handle.onForceClose(() => { void cleanup(); });
385
+ handle.onForceClose(() => { void cleanup().catch(() => undefined); });
359
386
  try {
360
- this.#bindBot(bot);
361
387
  routes.push(...registerQqWebhookRoutes(this.#options.http, {
362
388
  config: this.#options.config,
363
389
  getBot: () => cancelled ? null : bot,
364
390
  }));
365
- await awaitQqStartup(bot.start().then(async () => {
366
- if (cancelled)
367
- await stopBot();
368
- }, async (error) => {
369
- if (cancelled)
370
- await stopBot();
371
- throw error;
372
- }), startupAbort.signal);
391
+ nativeStart = super.start();
392
+ await awaitQqStartup(nativeStart.then(() => undefined), startupAbort.signal);
373
393
  if (cancelled) {
374
394
  // SDK startup may create resources after stop; close this exact old client again.
375
395
  await stopping;
@@ -388,24 +408,34 @@ export class QqHttpEndpoint extends Endpoint {
388
408
  }
389
409
  });
390
410
  }
391
- open() {
411
+ #openFramework() {
392
412
  this.#open = true;
393
413
  }
394
- close() {
414
+ #closeFramework() {
395
415
  this.#open = false;
396
416
  }
397
- async stop() {
398
- this.#open = false;
399
- const cleanup = this.#cleanup;
400
- this.#cleanup = undefined;
401
- await this.#lifecycle.stop();
402
- await cleanup?.();
403
- 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;
404
433
  }
405
- async send({ conversation, payload }) {
434
+ async #sendFramework({ conversation, payload, signal }) {
406
435
  // 媒体与 markdown/按钮不能同条发送(msg_type 7 与 2 互斥):公网图片嵌入 markdown,
407
436
  // 拿不到 URL/尺寸的媒体降级为独立消息先发
408
- 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 })) });
409
439
  const body = messages[messages.length - 1];
410
440
  const kind = qqOutboundKind(conversation);
411
441
  const bot = this.#requireBot();
@@ -416,7 +446,7 @@ export class QqHttpEndpoint extends Endpoint {
416
446
  ledger: this.#passiveReplies,
417
447
  activeGate: this.#activeGate,
418
448
  log: fields => this.#logger.warn(formatCompact(fields)),
419
- }, conversation, messages);
449
+ }, { conversation, messages, signal });
420
450
  }
421
451
  catch (error) {
422
452
  const { failure, diagnostic } = qqDeliveryFailure(error);
@@ -432,7 +462,7 @@ export class QqHttpEndpoint extends Endpoint {
432
462
  async recallMessage(messageId) {
433
463
  const { kind, channelId, qqMsgId } = parseCompoundMessageId(messageId);
434
464
  const bot = this.#requireBot();
435
- await recallQqMessage(bot, kind, channelId, qqMsgId);
465
+ await recallQqMessage(bot, { kind, channelId, messageId: qqMsgId });
436
466
  this.#logger.debug(formatCompact({
437
467
  op: 'qq_recall',
438
468
  endpoint: this.#options.config.id,
@@ -452,8 +482,10 @@ export class QqHttpEndpoint extends Endpoint {
452
482
  const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
453
483
  if (msg.id)
454
484
  this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { id: msg.id });
455
- void this.emit('message.receive', {
485
+ void this[endpointSpec].events.emit('message.receive', {
456
486
  conversation,
487
+ event: msg.event,
488
+ platformSentAt: msg.platformSentAt,
457
489
  message: { conversation, id: msg.id },
458
490
  content,
459
491
  ...(msg.segments?.length ? { segments: msg.segments } : {}),
@@ -497,13 +529,16 @@ export class QqHttpEndpoint extends Endpoint {
497
529
  if (this.#seenInteractions.size > 10_000)
498
530
  this.#seenInteractions.delete(this.#seenInteractions.keys().next().value);
499
531
  // ACK owns only platform loading state, never implies business acceptance.
500
- void action.ack().then(ok => { if (ok === false)
501
- 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'); });
502
537
  const conversation = qqInboundConversation(String(this.#options.id), action.message);
503
538
  // INTERACTION_CREATE's id is an event_id the reply may answer passively.
504
539
  this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { event_id: action.message.id });
505
- void this.emit('message.receive', {
506
- 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: '',
507
542
  segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
508
543
  metadata: Object.freeze({ eventType: 'INTERACTION_CREATE', sourceMessageIdAvailable: action.sourceMessageId !== undefined,
509
544
  ...(action.sourceMessageId ? { sourceMessageId: action.sourceMessageId } : {}),
@@ -533,24 +568,28 @@ export class QqHttpEndpoint extends Endpoint {
533
568
  }
534
569
  #bindBot(bot) {
535
570
  bindQqBotInboundEvents(bot, (raw) => {
536
- if (this.#bot !== bot)
537
- return;
538
- this.#emitPlatformEvent('message', raw);
539
- const msg = normalizeQqMessage(raw);
540
- if (msg)
541
- 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'));
542
579
  });
543
580
  bindQqBotSideEvents(bot, (eventName, raw) => {
544
- if (this.#bot !== bot)
545
- return;
546
- this.#emitPlatformEvent(eventName, raw);
547
- if (this.admitInteraction(raw))
548
- return;
549
- 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'));
550
589
  });
551
590
  }
552
591
  #emitPlatformEvent(name, event) {
553
- void this.emitPlatform(name, event).catch((error) => {
592
+ void this[endpointSpec].events.platform(name, event).catch((error) => {
554
593
  this.#logger.warn(formatCompact({
555
594
  op: 'qq_platform_event_failed',
556
595
  event: name,
@@ -559,39 +598,41 @@ export class QqHttpEndpoint extends Endpoint {
559
598
  });
560
599
  }
561
600
  #requireBot() {
562
- if (!this.#bot)
601
+ if (!this.#connection)
563
602
  throw new EndpointDeliveryError('endpoint_disconnected', 'QQ bot not connected', 'not_sent');
564
- return this.#bot;
603
+ return this;
565
604
  }
566
605
  }
567
606
  function createQqEndpointManagement(requireClient, isActive) {
568
- const api = () => requireClient().api;
607
+ const api = () => requireClient();
569
608
  return Object.freeze({
570
609
  // QQ 群(group_openid / member_openid):官方 v2 群管理接口,部分为白名单能力(11253)。
571
- inspectGroupMember: (group, user, signal) => inspectQqGroupMember(api(), group, user, signal),
572
- inspectGroupScene: (group, signal) => inspectQqGroupScene(api(), group, signal),
573
- inspectGroupMemberProfile: (group, user, signal) => inspectQqGroupMemberProfile(api(), group, user, signal),
574
- moderateGroupMember: (group, user, action, signal) => moderateQqGroupMember(api(), group, user, action, signal, isActive),
575
- async listGroupMembers(groupId) {
576
- return qqGroupService(api()).getMembers(groupId);
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));
577
617
  },
578
- async kickGroupMember(groupId, userId) {
579
- await kickQqGroupMember(api(), groupId, userId);
618
+ async kickGroupMember({ groupId, userId, signal }) {
619
+ await kickQqGroupMember(api(), { groupId, userId, signal });
580
620
  },
581
- async muteGroupMember(groupId, userId, durationSeconds) {
582
- await muteQqGroupMember(api(), groupId, userId, durationSeconds);
621
+ async muteGroupMember({ groupId, userId, durationSeconds, signal }) {
622
+ await muteQqGroupMember(api(), { groupId, userId, durationSeconds, signal });
583
623
  },
584
- async listChannels() {
624
+ async listChannels(request = {}) {
625
+ const operation = createEndpointOperation(request);
585
626
  const channels = [];
586
627
  const client = requireClient();
587
- const guilds = await client.getGuilds();
628
+ const guilds = await operation.run(() => client.guildService.getList());
588
629
  for (const guildValue of Array.isArray(guilds) ? guilds : []) {
589
630
  const guild = asRecord(guildValue);
590
631
  const guildId = String(guild.id ?? guild.guild_id ?? guildValue ?? '');
591
632
  if (!guildId)
592
633
  continue;
593
634
  const guildName = String(guild.name ?? guild.guild_name ?? guildId);
594
- const rows = await client.getChannels(guildId);
635
+ const rows = await operation.run(() => client.channelService.getList(guildId));
595
636
  for (const channelValue of Array.isArray(rows) ? rows : []) {
596
637
  const channel = asRecord(channelValue);
597
638
  const id = String(channel.id ?? channel.channel_id ?? channelValue ?? '');
@@ -620,24 +661,24 @@ function asRecord(value) {
620
661
  */
621
662
  function createQqEndpointControl(requireBot, recallById) {
622
663
  return Object.freeze({
623
- recall: async (message) => {
664
+ recall: async ({ message, signal }) => {
624
665
  if (isCompoundMessageId(message.id))
625
- return recallById(message.id);
666
+ return createEndpointOperation({ signal }).run(() => recallById(message.id));
626
667
  const kind = qqOutboundKind(message.conversation);
627
- await recallQqMessage(requireBot(), kind, message.conversation.id, message.id);
668
+ await createEndpointOperation({ signal }).run(() => recallQqMessage(requireBot(), { kind, channelId: message.conversation.id, messageId: message.id }));
628
669
  },
629
- addReaction: async (message, emoji) => {
670
+ addReaction: async ({ message, emoji, signal }) => {
630
671
  const target = qqGuildReactionTarget(message, emoji);
631
672
  if (!target)
632
673
  return null;
633
- await requireBot().api.reactionService.addGuildMessageReaction(target.channelId, target.messageId, target.type, target.id);
674
+ await createEndpointOperation({ signal }).run(() => requireBot().reactionService.addGuildMessageReaction(target.channelId, target.messageId, target.type, target.id));
634
675
  return `${target.type}:${target.id}`;
635
676
  },
636
- removeReaction: async (message, reactionId) => {
677
+ removeReaction: async ({ message, reactionId, signal }) => {
637
678
  const target = qqGuildReactionTarget(message, reactionId);
638
679
  if (!target)
639
680
  return;
640
- await requireBot().api.reactionService.deleteGuildMessageReaction(target.channelId, target.messageId, target.type, target.id);
681
+ await createEndpointOperation({ signal }).run(() => requireBot().reactionService.deleteGuildMessageReaction(target.channelId, target.messageId, target.type, target.id));
641
682
  },
642
683
  });
643
684
  }
@@ -672,7 +713,7 @@ function qqGuildReactionTarget(message, emoji) {
672
713
  const parsed = parseQqEmoji(emoji);
673
714
  return parsed ? { channelId, messageId, ...parsed } : null;
674
715
  }
675
- async function recallQqMessage(bot, kind, channelId, messageId) {
716
+ async function recallQqMessage(bot, { kind, channelId, messageId }) {
676
717
  const recall = { private: bot.recallPrivateMessage, group: bot.recallGroupMessage,
677
718
  channel: bot.recallGuildMessage, direct: bot.recallDirectMessage }[kind];
678
719
  if (!recall)
@@ -681,3 +722,15 @@ async function recallQqMessage(bot, kind, channelId, messageId) {
681
722
  throw new EndpointDeliveryError('invalid_message_id', 'QQ recall requires a nonempty message ID', 'not_sent');
682
723
  await recall.call(bot, channelId, messageId);
683
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
+ }