@zhin.js/adapter-qq 1.1.18 → 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 (102) hide show
  1. package/CHANGELOG.md +39 -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/src/endpoint.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { Bot, ReceiverMode } 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';
@@ -17,13 +18,23 @@ import {
17
18
  * QQ endpoints — lifecycle, outbound, admit, agent tool surface.
18
19
  */
19
20
  import {
20
- Endpoint, createEndpointLifecycle, type EndpointLifecycle,
21
+ createEndpointSpecification,
22
+ endpointSpec,
23
+ type Endpoint,
24
+ type EndpointSpecification,
25
+ createEndpointLifecycle,
26
+ type EndpointLifecycle,
21
27
  type EndpointControl,
22
28
  type EndpointChannel,
23
29
  type EndpointManagement,
24
30
  type EndpointSendRequest,
25
31
  type EndpointTransportState,
26
32
  type AdapterOutageNotifier,
33
+ createEndpointOperation,
34
+ type EndpointGroupRequest,
35
+ type EndpointMemberRequest,
36
+ type EndpointMuteRequest,
37
+ type EndpointManagementRequest,
27
38
  } from 'zhin.js/adapter';
28
39
  import type { HttpHost } from '@zhin.js/host-http';
29
40
  import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
@@ -43,16 +54,14 @@ import {
43
54
  type ResolvedQqWebsocketConfig,
44
55
  } from './protocol.js';
45
56
  import {
46
- defaultCreateHttpBot,
57
+ qqHttpBotOptions,
47
58
  registerQqWebhookRoutes,
48
- type CreateQqHttpBot,
49
59
  type QqHttpBotTransport,
50
60
  } from './webhook.js';
51
61
  import {
52
62
  bindQqBotInboundEvents,
53
- defaultCreateBot,
63
+ qqWebsocketBotOptions, configureQqWebsocketReceiver, disposeQqAuthorization,
54
64
  normalizeQqMessage,
55
- type CreateQqBot,
56
65
  type QqBotTransport,
57
66
  } from './ws.js';
58
67
  import {
@@ -61,24 +70,26 @@ import {
61
70
  type QqSideEventCaller,
62
71
  } from './side-event-dispatch.js';
63
72
 
64
- export type { CreateQqBot, QqBotTransport, QqOfficialApi } from './ws.js';
65
- export type { CreateQqHttpBot, QqHttpBotTransport } from './webhook.js';
73
+ export type { QqBotTransport, QqOfficialApi } from './ws.js';
74
+ export type { QqHttpBotTransport } from './webhook.js';
66
75
 
67
76
  export interface QqEndpointOptions {
68
77
  readonly id: CapabilityId;
69
78
  readonly config: ResolvedQqWebsocketConfig;
70
- readonly createBot?: CreateQqBot;
71
79
  /** Out-of-band outage notifier; absent in tests/hosts without outbound. */
72
80
  readonly notifier?: AdapterOutageNotifier;
73
81
  }
74
82
 
75
- export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
83
+ export class QqWebsocketEndpoint extends Bot<ReceiverMode.WEBSOCKET> implements Endpoint {
84
+ readonly [endpointSpec]: EndpointSpecification;
85
+ readonly #receiverResources: ReturnType<typeof configureQqWebsocketReceiver>;
76
86
  readonly #logger!: ReturnType<typeof getAdapterLogger>;
77
87
 
78
88
  readonly #options: QqEndpointOptions;
79
- readonly #createBot: CreateQqBot;
80
- #bot: QqBotTransport | null = null;
89
+ #connection?: AbortController;
90
+ #stopping?: Promise<void>;
81
91
  #open = false;
92
+ #executionSignal?: AbortSignal;
82
93
  #offlineNoticeAt = 0;
83
94
  readonly #seenInteractions = new Map<string, number>();
84
95
  /** Recent msg_id / event_id per conversation, so replies go out as passive messages. */
@@ -86,19 +97,22 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
86
97
  readonly #activeGate = new QqActiveMessageGate();
87
98
  readonly #lifecycle: EndpointLifecycle;
88
99
  #cleanup?: () => Promise<void>;
89
- readonly management: EndpointManagement = createQqEndpointManagement(() => this.#requireBot(), () => this.#open && this.#bot !== null);
90
- readonly control: EndpointControl = createQqEndpointControl(() => this.#requireBot(), (id) => this.recallMessage(id));
100
+ readonly #management: EndpointManagement = createQqEndpointManagement(() => this.#requireBot(), () => this.#open && this.#connection !== undefined);
101
+ readonly #control: EndpointControl = createQqEndpointControl(() => this.#requireBot(), (id) => this.recallMessage(id));
91
102
 
92
103
  constructor(options: QqEndpointOptions) {
93
- super();
104
+ super(qqWebsocketBotOptions(options.config));
94
105
  this.#logger = getAdapterLogger('qq', options.config.id);
95
106
  this.#lifecycle = createEndpointLifecycle({ name: options.config.id });
96
107
  this.#options = options;
97
- this.#createBot = options.createBot ?? defaultCreateBot;
98
- }
99
-
100
- get client(): QqBotTransport {
101
- return this.#requireBot();
108
+ this.#receiverResources = configureQqWebsocketReceiver(this, options.config);
109
+ const endpoint = this;
110
+ this[endpointSpec] = createEndpointSpecification(this, {
111
+ start: signal => this.#startFramework(signal), open: () => this.#openFramework(), close: () => this.#closeFramework(),
112
+ stop: () => this.#stopFramework(), send: request => this.#sendFramework(request), name: options.config.id,
113
+ get transportState() { return endpoint.#transportState; }, management: this.#management, control: this.#control,
114
+ });
115
+ this.#bindBot(this);
102
116
  }
103
117
 
104
118
  /** Live endpoint 名(Console/AdapterIndex 展示用,如 bot appid 别名)。 */
@@ -106,46 +120,48 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
106
120
  return this.#options.config.id;
107
121
  }
108
122
 
109
- get transportState(): EndpointTransportState {
123
+ get #transportState(): EndpointTransportState {
110
124
  if (this.#lifecycle.state !== 'open') return this.#lifecycle.state;
111
- return this.#bot?.transportState ?? this.#lifecycle.state;
125
+ return this.#receiverResources.state();
112
126
  }
113
127
 
114
- async start(): Promise<void> {
128
+ async #startFramework(executionSignal: AbortSignal): Promise<void> {
129
+ this.#executionSignal = executionSignal;
115
130
  await this.#lifecycle.start(async (handle) => {
116
- const bot = this.#createBot(this.#options.config);
117
- this.#bot = bot;
118
- const releaseUploadDiagnostics = installQqUploadDiagnostics(bot.api, metadata => this.#logger.debug(formatCompact(metadata)));
131
+ const bot = this;
132
+ const previousCleanup = this.#cleanup;
133
+ await previousCleanup?.();
134
+ const releaseUploadDiagnostics = installQqUploadDiagnostics(bot, metadata => this.#logger.debug(formatCompact(metadata)));
119
135
  let cancelled = false;
120
136
  const startupAbort = new AbortController();
137
+ this.#connection = startupAbort;
138
+ let nativeStart: Promise<unknown> | undefined;
121
139
  let stopping: Promise<void> | undefined;
140
+ let transportClose: (() => void) | undefined;
122
141
  const stopBot = async () => {
123
- try { bot.removeAllListeners(); } catch { /* continue SDK cleanup */ }
124
- try { await bot.stop(); } catch { /* best-effort SDK cleanup */ }
142
+ await super.stop();
143
+ await nativeStart?.catch(() => undefined);
125
144
  };
126
145
  const cleanup = () => {
127
146
  cancelled = true;
128
147
  releaseUploadDiagnostics();
129
148
  startupAbort.abort();
130
- if (this.#bot === bot) this.#bot = null;
149
+ if (transportClose) this.receiver.off('close', transportClose);
150
+ if (this.#connection === startupAbort) this.#connection = undefined;
131
151
  return stopping ??= stopBot();
132
152
  };
133
153
  this.#cleanup = cleanup;
134
- handle.onForceClose(() => { void cleanup(); });
154
+ handle.onForceClose(() => { void cleanup().catch(() => undefined); });
135
155
  try {
136
- bot.on('transport.close', () => {
137
- if (cancelled || this.#bot !== bot) return;
156
+ transportClose = () => {
157
+ if (cancelled || this.#connection === undefined) return;
138
158
  this.#notifyOffline('transport_close');
139
- void cleanup();
159
+ void cleanup().catch(() => undefined);
140
160
  handle.notifyClosed();
141
- });
142
- this.#bindBot(bot);
143
- await awaitQqStartup(bot.start().then(async () => {
144
- if (cancelled) await stopBot();
145
- }, async (error) => {
146
- if (cancelled) await stopBot();
147
- throw error;
148
- }), startupAbort.signal);
161
+ };
162
+ this.receiver.on('close', transportClose);
163
+ nativeStart = super.start();
164
+ await awaitQqStartup(nativeStart.then(() => undefined), startupAbort.signal);
149
165
  if (cancelled) {
150
166
  // SDK startup may create resources after stop; close this exact old client again.
151
167
  await stopping;
@@ -167,27 +183,31 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
167
183
  });
168
184
  }
169
185
 
170
- open(): void {
186
+ #openFramework(): void {
171
187
  this.#open = true;
172
188
  }
173
189
 
174
- close(): void {
190
+ #closeFramework(): void {
175
191
  this.#open = false;
176
192
  }
177
193
 
178
- async stop(): Promise<void> {
179
- this.#open = false;
180
- const cleanup = this.#cleanup;
181
- this.#cleanup = undefined;
182
- await this.#lifecycle.stop();
183
- await cleanup?.();
194
+ async #stopFramework(): Promise<void> {
195
+ this.#stopping ??= Promise.resolve().then(async () => {
196
+ this.#open = false;
197
+ const cleanup = this.#cleanup;
198
+ this.#cleanup = undefined;
199
+ try { await this.#lifecycle.stop(); await cleanup?.(); }
200
+ finally { this.removeAllListeners(undefined as never); this.#receiverResources.dispose(); disposeQqAuthorization(this); }
201
+ });
202
+ await this.#stopping;
184
203
  this.#logger.debug(formatCompact({ op: 'disconnect' }));
185
204
  }
186
205
 
187
- async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
206
+ async #sendFramework({ conversation, payload, signal }: EndpointSendRequest): Promise<string> {
188
207
  // 媒体与 markdown/按钮不能同条发送(msg_type 7 与 2 互斥):公网图片嵌入 markdown,
189
208
  // 拿不到 URL/尺寸的媒体降级为独立消息先发
190
- const messages = formatOutboundMessages(payload, { imageSizes: await collectOutboundImageSizes(payload) });
209
+ const operation = createEndpointOperation({ signal });
210
+ const messages = formatOutboundMessages(payload, { imageSizes: await operation.prepare(() => collectOutboundImageSizes(payload, { signal })) });
191
211
  const body = messages[messages.length - 1]!;
192
212
  const kind = qqOutboundKind(conversation);
193
213
  const bot = this.#requireBot();
@@ -198,7 +218,7 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
198
218
  ledger: this.#passiveReplies,
199
219
  activeGate: this.#activeGate,
200
220
  log: fields => this.#logger.warn(formatCompact(fields)),
201
- }, conversation, messages);
221
+ }, { conversation, messages, signal });
202
222
  } catch (error) {
203
223
  const { failure, diagnostic } = qqDeliveryFailure(error);
204
224
  this.#logger.warn(formatCompact(diagnostic));
@@ -216,7 +236,7 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
216
236
  async recallMessage(messageId: string): Promise<void> {
217
237
  const { kind, channelId, qqMsgId } = parseCompoundMessageId(messageId);
218
238
  const bot = this.#requireBot();
219
- await recallQqMessage(bot, kind, channelId, qqMsgId);
239
+ await recallQqMessage(bot, { kind, channelId, messageId: qqMsgId });
220
240
  this.#logger.debug(formatCompact({
221
241
  op: 'qq_recall',
222
242
  endpoint: this.#options.config.id,
@@ -238,8 +258,10 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
238
258
  );
239
259
  const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
240
260
  if (msg.id) this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { id: msg.id });
241
- void this.emit('message.receive', {
261
+ void this[endpointSpec].events.emit('message.receive', {
242
262
  conversation,
263
+ event: msg.event,
264
+ platformSentAt: msg.platformSentAt,
243
265
  message: { conversation, id: msg.id },
244
266
  content,
245
267
  ...(msg.segments?.length ? { segments: msg.segments } : {}),
@@ -278,12 +300,15 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
278
300
  this.#seenInteractions.set(action.message.id, now);
279
301
  if (this.#seenInteractions.size > 10_000) this.#seenInteractions.delete(this.#seenInteractions.keys().next().value!);
280
302
  // ACK owns only platform loading state, never implies business acceptance.
281
- void action.ack().then(ok => { if (ok === false) this.#logger.warn('qq_interaction_ack_failed'); }, () => this.#logger.warn('qq_interaction_ack_failed'));
303
+ void this[endpointSpec].events.run(() => awaitOwnedAck(action.ack(), this.#executionSignal)).then(
304
+ ok => { if (ok === false) this.#logger.warn('qq_interaction_ack_failed'); },
305
+ () => { if (this.#executionSignal?.aborted) this.#logger.debug('QQ ACK wait cancelled'); else this.#logger.warn('qq_interaction_ack_failed'); },
306
+ );
282
307
  const conversation = qqInboundConversation(String(this.#options.id), action.message);
283
308
  // INTERACTION_CREATE's id is an event_id the reply may answer passively.
284
309
  this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { event_id: action.message.id });
285
- void this.emit('message.receive', {
286
- conversation, message: { conversation, id: action.message.id }, content: '',
310
+ void this[endpointSpec].events.emit('message.receive', {
311
+ event: action.message.event, conversation, message: { conversation, id: action.message.id }, content: '',
287
312
  segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
288
313
  metadata: Object.freeze({ eventType: 'INTERACTION_CREATE', sourceMessageIdAvailable: action.sourceMessageId !== undefined,
289
314
  ...(action.sourceMessageId ? { sourceMessageId: action.sourceMessageId } : {}),
@@ -312,28 +337,32 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
312
337
 
313
338
  #bindBot(bot: QqBotTransport): void {
314
339
  bindQqBotInboundEvents(bot, (raw) => {
315
- if (this.#bot !== bot) return;
316
- this.#emitPlatformEvent('message', raw);
317
- const msg = normalizeQqMessage(raw);
318
- if (msg) this.admit(msg);
340
+ void this[endpointSpec].events.run(() => {
341
+ if (this.#connection === undefined) return;
342
+ this.#emitPlatformEvent('message', raw);
343
+ const msg = normalizeQqMessage(raw);
344
+ if (msg) this.admit(msg);
345
+ }).catch(() => this.#logger.debug('Native callback was not admitted'));
319
346
  });
320
347
  bindQqBotSideEvents(bot, (eventName, raw) => {
321
- if (this.#bot !== bot) return;
322
- this.#emitPlatformEvent(eventName, raw);
323
- if (this.admitInteraction(raw)) return;
324
- receiveQqSideEvent(
325
- (name, payload) => this.emit(name, payload),
326
- this.#options.config.id,
327
- bot as QqSideEventCaller,
328
- eventName,
329
- raw,
330
- this.#logger,
331
- );
348
+ void this[endpointSpec].events.run(() => {
349
+ if (this.#connection === undefined) return;
350
+ this.#emitPlatformEvent(eventName, raw);
351
+ if (this.admitInteraction(raw)) return;
352
+ receiveQqSideEvent(
353
+ (name, payload) => this[endpointSpec].events.emit(name, payload),
354
+ this.#options.config.id,
355
+ bot,
356
+ eventName,
357
+ raw,
358
+ this.#logger,
359
+ );
360
+ }).catch(() => this.#logger.debug('Native callback was not admitted'));
332
361
  });
333
362
  }
334
363
 
335
364
  #emitPlatformEvent(name: string, event: unknown): void {
336
- void this.emitPlatform(name, event).catch((error) => {
365
+ void this[endpointSpec].events.platform(name, event).catch((error) => {
337
366
  this.#logger.warn(formatCompact({
338
367
  op: 'qq_platform_event_failed',
339
368
  event: name,
@@ -343,8 +372,8 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
343
372
  }
344
373
 
345
374
  #requireBot(): QqBotTransport {
346
- if (!this.#bot) throw new EndpointDeliveryError('endpoint_disconnected', 'QQ bot not connected', 'not_sent');
347
- return this.#bot;
375
+ if (!this.#connection) throw new EndpointDeliveryError('endpoint_disconnected', 'QQ bot not connected', 'not_sent');
376
+ return this;
348
377
  }
349
378
  }
350
379
 
@@ -352,19 +381,20 @@ export interface QqHttpEndpointOptions {
352
381
  readonly id: CapabilityId;
353
382
  readonly http: HttpHost;
354
383
  readonly config: ResolvedQqHttpConfig;
355
- readonly createBot?: CreateQqHttpBot;
356
384
  /** Out-of-band outage notifier; absent in tests/hosts without outbound. */
357
385
  readonly notifier?: AdapterOutageNotifier;
358
386
  }
359
387
 
360
388
  /** Webhook / middleware inbound via httpHostToken POST (qq-official-bot Middleware receiver). */
361
- export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
389
+ export class QqHttpEndpoint extends Bot<ReceiverMode.MIDDLEWARE, 'koa'> implements Endpoint {
390
+ readonly [endpointSpec]: EndpointSpecification;
362
391
  readonly #logger!: ReturnType<typeof getAdapterLogger>;
363
392
 
364
393
  readonly #options: QqHttpEndpointOptions;
365
- readonly #createBot: CreateQqHttpBot;
366
- #bot: QqHttpBotTransport | null = null;
394
+ #connection?: AbortController;
395
+ #stopping?: Promise<void>;
367
396
  #open = false;
397
+ #executionSignal?: AbortSignal;
368
398
  #offlineNoticeAt = 0;
369
399
  readonly #seenInteractions = new Map<string, number>();
370
400
  /** Recent msg_id / event_id per conversation, so replies go out as passive messages. */
@@ -372,19 +402,22 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
372
402
  readonly #activeGate = new QqActiveMessageGate();
373
403
  readonly #lifecycle: EndpointLifecycle;
374
404
  #cleanup?: () => Promise<void>;
375
- readonly management: EndpointManagement = createQqEndpointManagement(() => this.#requireBot(), () => this.#open && this.#bot !== null);
376
- readonly control: EndpointControl = createQqEndpointControl(() => this.#requireBot(), (id) => this.recallMessage(id));
405
+ readonly #management: EndpointManagement = createQqEndpointManagement(() => this.#requireBot(), () => this.#open && this.#connection !== undefined);
406
+ readonly #control: EndpointControl = createQqEndpointControl(() => this.#requireBot(), (id) => this.recallMessage(id));
377
407
 
378
408
  constructor(options: QqHttpEndpointOptions) {
379
- super();
409
+ super(qqHttpBotOptions(options.config));
380
410
  this.#logger = getAdapterLogger('qq', options.config.id);
381
411
  this.#lifecycle = createEndpointLifecycle({ name: options.config.id, reconnect: false });
382
412
  this.#options = options;
383
- this.#createBot = options.createBot ?? defaultCreateHttpBot;
384
- }
385
-
386
- get client(): QqHttpBotTransport {
387
- return this.#requireBot();
413
+ const endpoint = this;
414
+ this[endpointSpec] = createEndpointSpecification(this, {
415
+ start: signal => this.#startFramework(signal), open: () => this.#openFramework(), close: () => this.#closeFramework(),
416
+ stop: () => this.#stopFramework(), send: request => this.#sendFramework(request), name: options.config.id,
417
+ get transportState() { return endpoint.#transportState; },
418
+ management: this.#management, control: this.#control,
419
+ });
420
+ this.#bindBot(this);
388
421
  }
389
422
 
390
423
  /** Live endpoint 名(Console/AdapterIndex 展示用)。 */
@@ -392,45 +425,44 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
392
425
  return this.#options.config.id;
393
426
  }
394
427
 
395
- get transportState(): EndpointTransportState {
428
+ get #transportState(): EndpointTransportState {
396
429
  return this.#lifecycle.state;
397
430
  }
398
431
 
399
- async start(): Promise<void> {
432
+ async #startFramework(executionSignal: AbortSignal): Promise<void> {
433
+ this.#executionSignal = executionSignal;
400
434
  await this.#lifecycle.start(async (handle) => {
401
- const bot = this.#createBot(this.#options.config);
402
- this.#bot = bot;
403
- const releaseUploadDiagnostics = installQqUploadDiagnostics(bot.api, metadata => this.#logger.debug(formatCompact(metadata)));
435
+ const bot = this;
436
+ const previousCleanup = this.#cleanup;
437
+ await previousCleanup?.();
438
+ const releaseUploadDiagnostics = installQqUploadDiagnostics(bot, metadata => this.#logger.debug(formatCompact(metadata)));
404
439
  let cancelled = false;
405
440
  const startupAbort = new AbortController();
441
+ this.#connection = startupAbort;
442
+ let nativeStart: Promise<unknown> | undefined;
406
443
  let stopping: Promise<void> | undefined;
407
444
  const routes: Array<() => void> = [];
408
445
  const stopBot = async () => {
409
- try { bot.removeAllListeners(); } catch { /* continue SDK cleanup */ }
410
- try { await bot.stop(); } catch { /* best-effort SDK cleanup */ }
446
+ await super.stop();
447
+ await nativeStart?.catch(() => undefined);
411
448
  };
412
449
  const cleanup = () => {
413
450
  cancelled = true;
414
451
  releaseUploadDiagnostics();
415
452
  startupAbort.abort();
416
- if (this.#bot === bot) this.#bot = null;
453
+ if (this.#connection === startupAbort) this.#connection = undefined;
417
454
  for (const release of routes.splice(0)) release();
418
455
  return stopping ??= stopBot();
419
456
  };
420
457
  this.#cleanup = cleanup;
421
- handle.onForceClose(() => { void cleanup(); });
458
+ handle.onForceClose(() => { void cleanup().catch(() => undefined); });
422
459
  try {
423
- this.#bindBot(bot);
424
460
  routes.push(...registerQqWebhookRoutes(this.#options.http, {
425
461
  config: this.#options.config,
426
462
  getBot: () => cancelled ? null : bot,
427
463
  }));
428
- await awaitQqStartup(bot.start().then(async () => {
429
- if (cancelled) await stopBot();
430
- }, async (error) => {
431
- if (cancelled) await stopBot();
432
- throw error;
433
- }), startupAbort.signal);
464
+ nativeStart = super.start();
465
+ await awaitQqStartup(nativeStart.then(() => undefined), startupAbort.signal);
434
466
  if (cancelled) {
435
467
  // SDK startup may create resources after stop; close this exact old client again.
436
468
  await stopping;
@@ -448,27 +480,31 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
448
480
  });
449
481
  }
450
482
 
451
- open(): void {
483
+ #openFramework(): void {
452
484
  this.#open = true;
453
485
  }
454
486
 
455
- close(): void {
487
+ #closeFramework(): void {
456
488
  this.#open = false;
457
489
  }
458
490
 
459
- async stop(): Promise<void> {
460
- this.#open = false;
461
- const cleanup = this.#cleanup;
462
- this.#cleanup = undefined;
463
- await this.#lifecycle.stop();
464
- await cleanup?.();
465
- this.#logger.debug(formatCompact({ op: 'disconnect' }));
491
+ async #stopFramework(): Promise<void> {
492
+ this.#stopping ??= Promise.resolve().then(async () => {
493
+ this.#open = false;
494
+ const cleanup = this.#cleanup;
495
+ this.#cleanup = undefined;
496
+ try { await this.#lifecycle.stop(); await cleanup?.(); }
497
+ finally { this.removeAllListeners(undefined as never); disposeQqAuthorization(this); }
498
+ this.#logger.debug(formatCompact({ op: 'disconnect' }));
499
+ });
500
+ await this.#stopping;
466
501
  }
467
502
 
468
- async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
503
+ async #sendFramework({ conversation, payload, signal }: EndpointSendRequest): Promise<string> {
469
504
  // 媒体与 markdown/按钮不能同条发送(msg_type 7 与 2 互斥):公网图片嵌入 markdown,
470
505
  // 拿不到 URL/尺寸的媒体降级为独立消息先发
471
- const messages = formatOutboundMessages(payload, { imageSizes: await collectOutboundImageSizes(payload) });
506
+ const operation = createEndpointOperation({ signal });
507
+ const messages = formatOutboundMessages(payload, { imageSizes: await operation.prepare(() => collectOutboundImageSizes(payload, { signal })) });
472
508
  const body = messages[messages.length - 1]!;
473
509
  const kind = qqOutboundKind(conversation);
474
510
  const bot = this.#requireBot();
@@ -479,7 +515,7 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
479
515
  ledger: this.#passiveReplies,
480
516
  activeGate: this.#activeGate,
481
517
  log: fields => this.#logger.warn(formatCompact(fields)),
482
- }, conversation, messages);
518
+ }, { conversation, messages, signal });
483
519
  } catch (error) {
484
520
  const { failure, diagnostic } = qqDeliveryFailure(error);
485
521
  this.#logger.warn(formatCompact(diagnostic));
@@ -497,7 +533,7 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
497
533
  async recallMessage(messageId: string): Promise<void> {
498
534
  const { kind, channelId, qqMsgId } = parseCompoundMessageId(messageId);
499
535
  const bot = this.#requireBot();
500
- await recallQqMessage(bot, kind, channelId, qqMsgId);
536
+ await recallQqMessage(bot, { kind, channelId, messageId: qqMsgId });
501
537
  this.#logger.debug(formatCompact({
502
538
  op: 'qq_recall',
503
539
  endpoint: this.#options.config.id,
@@ -518,8 +554,10 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
518
554
  );
519
555
  const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
520
556
  if (msg.id) this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { id: msg.id });
521
- void this.emit('message.receive', {
557
+ void this[endpointSpec].events.emit('message.receive', {
522
558
  conversation,
559
+ event: msg.event,
560
+ platformSentAt: msg.platformSentAt,
523
561
  message: { conversation, id: msg.id },
524
562
  content,
525
563
  ...(msg.segments?.length ? { segments: msg.segments } : {}),
@@ -558,12 +596,15 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
558
596
  this.#seenInteractions.set(action.message.id, now);
559
597
  if (this.#seenInteractions.size > 10_000) this.#seenInteractions.delete(this.#seenInteractions.keys().next().value!);
560
598
  // ACK owns only platform loading state, never implies business acceptance.
561
- void action.ack().then(ok => { if (ok === false) this.#logger.warn('qq_interaction_ack_failed'); }, () => this.#logger.warn('qq_interaction_ack_failed'));
599
+ void this[endpointSpec].events.run(() => awaitOwnedAck(action.ack(), this.#executionSignal)).then(
600
+ ok => { if (ok === false) this.#logger.warn('qq_interaction_ack_failed'); },
601
+ () => { if (this.#executionSignal?.aborted) this.#logger.debug('QQ ACK wait cancelled'); else this.#logger.warn('qq_interaction_ack_failed'); },
602
+ );
562
603
  const conversation = qqInboundConversation(String(this.#options.id), action.message);
563
604
  // INTERACTION_CREATE's id is an event_id the reply may answer passively.
564
605
  this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { event_id: action.message.id });
565
- void this.emit('message.receive', {
566
- conversation, message: { conversation, id: action.message.id }, content: '',
606
+ void this[endpointSpec].events.emit('message.receive', {
607
+ event: action.message.event, conversation, message: { conversation, id: action.message.id }, content: '',
567
608
  segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
568
609
  metadata: Object.freeze({ eventType: 'INTERACTION_CREATE', sourceMessageIdAvailable: action.sourceMessageId !== undefined,
569
610
  ...(action.sourceMessageId ? { sourceMessageId: action.sourceMessageId } : {}),
@@ -592,28 +633,32 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
592
633
 
593
634
  #bindBot(bot: QqBotTransport): void {
594
635
  bindQqBotInboundEvents(bot, (raw) => {
595
- if (this.#bot !== bot) return;
596
- this.#emitPlatformEvent('message', raw);
597
- const msg = normalizeQqMessage(raw);
598
- if (msg) this.admit(msg);
636
+ void this[endpointSpec].events.run(() => {
637
+ if (this.#connection === undefined) return;
638
+ this.#emitPlatformEvent('message', raw);
639
+ const msg = normalizeQqMessage(raw);
640
+ if (msg) this.admit(msg);
641
+ }).catch(() => this.#logger.debug('Native callback was not admitted'));
599
642
  });
600
643
  bindQqBotSideEvents(bot, (eventName, raw) => {
601
- if (this.#bot !== bot) return;
602
- this.#emitPlatformEvent(eventName, raw);
603
- if (this.admitInteraction(raw)) return;
604
- receiveQqSideEvent(
605
- (name, payload) => this.emit(name, payload),
606
- this.#options.config.id,
607
- bot as QqSideEventCaller,
608
- eventName,
609
- raw,
610
- this.#logger,
611
- );
644
+ void this[endpointSpec].events.run(() => {
645
+ if (this.#connection === undefined) return;
646
+ this.#emitPlatformEvent(eventName, raw);
647
+ if (this.admitInteraction(raw)) return;
648
+ receiveQqSideEvent(
649
+ (name, payload) => this[endpointSpec].events.emit(name, payload),
650
+ this.#options.config.id,
651
+ bot,
652
+ eventName,
653
+ raw,
654
+ this.#logger,
655
+ );
656
+ }).catch(() => this.#logger.debug('Native callback was not admitted'));
612
657
  });
613
658
  }
614
659
 
615
660
  #emitPlatformEvent(name: string, event: unknown): void {
616
- void this.emitPlatform(name, event).catch((error) => {
661
+ void this[endpointSpec].events.platform(name, event).catch((error) => {
617
662
  this.#logger.warn(formatCompact({
618
663
  op: 'qq_platform_event_failed',
619
664
  event: name,
@@ -623,41 +668,43 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
623
668
  }
624
669
 
625
670
  #requireBot(): QqHttpBotTransport {
626
- if (!this.#bot) throw new EndpointDeliveryError('endpoint_disconnected', 'QQ bot not connected', 'not_sent');
627
- return this.#bot;
671
+ if (!this.#connection) throw new EndpointDeliveryError('endpoint_disconnected', 'QQ bot not connected', 'not_sent');
672
+ return this;
628
673
  }
629
674
  }
630
675
 
631
676
  function createQqEndpointManagement(
632
- requireClient: () => Pick<QqBotTransport, 'getGuilds' | 'getChannels' | 'api'>,
677
+ requireClient: () => QqBotTransport,
633
678
  isActive: () => boolean,
634
679
  ): EndpointManagement {
635
- const api = () => requireClient().api;
680
+ const api = () => requireClient();
636
681
  return Object.freeze<EndpointManagement>({
637
682
  // QQ 群(group_openid / member_openid):官方 v2 群管理接口,部分为白名单能力(11253)。
638
- inspectGroupMember: (group, user, signal) => inspectQqGroupMember(api(), group, user, signal),
639
- inspectGroupScene: (group, signal) => inspectQqGroupScene(api(), group, signal),
640
- inspectGroupMemberProfile: (group, user, signal) => inspectQqGroupMemberProfile(api(), group, user, signal),
641
- moderateGroupMember: (group, user, action, signal) => moderateQqGroupMember(api(), group, user, action, signal, isActive),
642
- async listGroupMembers(groupId: string): Promise<readonly unknown[]> {
643
- return qqGroupService(api()).getMembers(groupId);
683
+ inspectGroupMember: request => inspectQqGroupMember(api(), request),
684
+ inspectGroupScene: request => inspectQqGroupScene(api(), request),
685
+ inspectGroupMemberProfile: request => inspectQqGroupMemberProfile(api(), request),
686
+ moderateGroupMember: request => moderateQqGroupMember({ api: api(), isActive }, request),
687
+ async listGroupMembers({ groupId, signal }: EndpointGroupRequest): Promise<readonly unknown[]> {
688
+ const operation = createEndpointOperation({ signal });
689
+ return operation.run(() => qqGroupService(api()).getMembers(groupId));
644
690
  },
645
- async kickGroupMember(groupId: string, userId: string): Promise<void> {
646
- await kickQqGroupMember(api(), groupId, userId);
691
+ async kickGroupMember({ groupId, userId, signal }: EndpointMemberRequest): Promise<void> {
692
+ await kickQqGroupMember(api(), { groupId, userId, signal });
647
693
  },
648
- async muteGroupMember(groupId: string, userId: string, durationSeconds: number): Promise<void> {
649
- await muteQqGroupMember(api(), groupId, userId, durationSeconds);
694
+ async muteGroupMember({ groupId, userId, durationSeconds, signal }: EndpointMuteRequest): Promise<void> {
695
+ await muteQqGroupMember(api(), { groupId, userId, durationSeconds, signal });
650
696
  },
651
- async listChannels(): Promise<readonly EndpointChannel[]> {
697
+ async listChannels(request: EndpointManagementRequest = {}): Promise<readonly EndpointChannel[]> {
698
+ const operation = createEndpointOperation(request);
652
699
  const channels: EndpointChannel[] = [];
653
700
  const client = requireClient();
654
- const guilds = await client.getGuilds();
701
+ const guilds = await operation.run(() => client.guildService.getList());
655
702
  for (const guildValue of Array.isArray(guilds) ? guilds : []) {
656
703
  const guild = asRecord(guildValue);
657
704
  const guildId = String(guild.id ?? guild.guild_id ?? guildValue ?? '');
658
705
  if (!guildId) continue;
659
706
  const guildName = String(guild.name ?? guild.guild_name ?? guildId);
660
- const rows = await client.getChannels(guildId);
707
+ const rows = await operation.run(() => client.channelService.getList(guildId));
661
708
  for (const channelValue of Array.isArray(rows) ? rows : []) {
662
709
  const channel = asRecord(channelValue);
663
710
  const id = String(channel.id ?? channel.channel_id ?? channelValue ?? '');
@@ -690,21 +737,21 @@ function createQqEndpointControl(
690
737
  recallById: (messageId: string) => Promise<void>,
691
738
  ): EndpointControl {
692
739
  return Object.freeze<EndpointControl>({
693
- recall: async (message: MessageRef) => {
694
- if (isCompoundMessageId(message.id)) return recallById(message.id);
740
+ recall: async ({ message, signal }) => {
741
+ if (isCompoundMessageId(message.id)) return createEndpointOperation({ signal }).run(() => recallById(message.id));
695
742
  const kind = qqOutboundKind(message.conversation);
696
- await recallQqMessage(requireBot(), kind, message.conversation.id, message.id);
743
+ await createEndpointOperation({ signal }).run(() => recallQqMessage(requireBot(), { kind, channelId: message.conversation.id, messageId: message.id }));
697
744
  },
698
- addReaction: async (message: MessageRef, emoji: string) => {
745
+ addReaction: async ({ message, emoji, signal }) => {
699
746
  const target = qqGuildReactionTarget(message, emoji);
700
747
  if (!target) return null;
701
- await requireBot().api.reactionService.addGuildMessageReaction(target.channelId, target.messageId, target.type, target.id);
748
+ await createEndpointOperation({ signal }).run(() => requireBot().reactionService.addGuildMessageReaction(target.channelId, target.messageId, target.type, target.id));
702
749
  return `${target.type}:${target.id}`;
703
750
  },
704
- removeReaction: async (message: MessageRef, reactionId: string) => {
751
+ removeReaction: async ({ message, reactionId, signal }) => {
705
752
  const target = qqGuildReactionTarget(message, reactionId);
706
753
  if (!target) return;
707
- await requireBot().api.reactionService.deleteGuildMessageReaction(target.channelId, target.messageId, target.type, target.id);
754
+ await createEndpointOperation({ signal }).run(() => requireBot().reactionService.deleteGuildMessageReaction(target.channelId, target.messageId, target.type, target.id));
708
755
  },
709
756
  });
710
757
  }
@@ -733,10 +780,21 @@ function qqGuildReactionTarget(message: MessageRef, emoji: string) {
733
780
  return parsed ? { channelId, messageId, ...parsed } : null;
734
781
  }
735
782
 
736
- async function recallQqMessage(bot: QqBotTransport, kind: ReturnType<typeof qqOutboundKind>, channelId: string, messageId: string): Promise<void> {
783
+ async function recallQqMessage(bot: QqBotTransport, { kind, channelId, messageId }: { readonly kind: ReturnType<typeof qqOutboundKind>; readonly channelId: string; readonly messageId: string }): Promise<void> {
737
784
  const recall = { private: bot.recallPrivateMessage, group: bot.recallGroupMessage,
738
785
  channel: bot.recallGuildMessage, direct: bot.recallDirectMessage }[kind];
739
786
  if (!recall) throw new EndpointDeliveryError('recall_unsupported', 'QQ transport does not support recall for this conversation', 'not_sent');
740
787
  if (!messageId) throw new EndpointDeliveryError('invalid_message_id', 'QQ recall requires a nonempty message ID', 'not_sent');
741
788
  await recall.call(bot, channelId, messageId);
742
789
  }
790
+
791
+ /** Logical cancellation only; the native SDK still owns its already-started ACK. */
792
+ function awaitOwnedAck<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T> {
793
+ if (!signal) return operation;
794
+ return new Promise((resolve, reject) => {
795
+ const abort = () => reject(signal.reason);
796
+ signal.addEventListener('abort', abort, { once: true });
797
+ void operation.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
798
+ if (signal.aborted) abort();
799
+ });
800
+ }