@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/src/endpoint.ts CHANGED
@@ -1,20 +1,40 @@
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';
4
5
  import { normalizeQqInteraction } from './interaction.js';
5
6
  import { awaitQqStartup } from './start-deadline.js';
6
- import { EndpointDeliveryError } from '@zhin.js/im-contract';
7
+ import { EndpointDeliveryError, type MessageRef } from '@zhin.js/im-contract';
8
+ import {
9
+ inspectQqGroupMember,
10
+ inspectQqGroupMemberProfile,
11
+ inspectQqGroupScene,
12
+ kickQqGroupMember,
13
+ moderateQqGroupMember,
14
+ muteQqGroupMember,
15
+ qqGroupService,
16
+ } from './group-admin.js';
7
17
  /**
8
18
  * QQ endpoints — lifecycle, outbound, admit, agent tool surface.
9
19
  */
10
20
  import {
11
- Endpoint, createEndpointLifecycle, type EndpointLifecycle,
12
- createRecallEndpointControl,
21
+ createEndpointSpecification,
22
+ endpointSpec,
23
+ type Endpoint,
24
+ type EndpointSpecification,
25
+ createEndpointLifecycle,
26
+ type EndpointLifecycle,
13
27
  type EndpointControl,
14
28
  type EndpointChannel,
15
29
  type EndpointManagement,
16
30
  type EndpointSendRequest,
17
31
  type EndpointTransportState,
32
+ type AdapterOutageNotifier,
33
+ createEndpointOperation,
34
+ type EndpointGroupRequest,
35
+ type EndpointMemberRequest,
36
+ type EndpointMuteRequest,
37
+ type EndpointManagementRequest,
18
38
  } from 'zhin.js/adapter';
19
39
  import type { HttpHost } from '@zhin.js/host-http';
20
40
  import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
@@ -34,16 +54,14 @@ import {
34
54
  type ResolvedQqWebsocketConfig,
35
55
  } from './protocol.js';
36
56
  import {
37
- defaultCreateHttpBot,
57
+ qqHttpBotOptions,
38
58
  registerQqWebhookRoutes,
39
- type CreateQqHttpBot,
40
59
  type QqHttpBotTransport,
41
60
  } from './webhook.js';
42
61
  import {
43
62
  bindQqBotInboundEvents,
44
- defaultCreateBot,
63
+ qqWebsocketBotOptions, configureQqWebsocketReceiver, disposeQqAuthorization,
45
64
  normalizeQqMessage,
46
- type CreateQqBot,
47
65
  type QqBotTransport,
48
66
  } from './ws.js';
49
67
  import {
@@ -52,41 +70,49 @@ import {
52
70
  type QqSideEventCaller,
53
71
  } from './side-event-dispatch.js';
54
72
 
55
- export type { CreateQqBot, QqBotTransport, QqOfficialApi } from './ws.js';
56
- export type { CreateQqHttpBot, QqHttpBotTransport } from './webhook.js';
73
+ export type { QqBotTransport, QqOfficialApi } from './ws.js';
74
+ export type { QqHttpBotTransport } from './webhook.js';
57
75
 
58
76
  export interface QqEndpointOptions {
59
77
  readonly id: CapabilityId;
60
78
  readonly config: ResolvedQqWebsocketConfig;
61
- readonly createBot?: CreateQqBot;
79
+ /** Out-of-band outage notifier; absent in tests/hosts without outbound. */
80
+ readonly notifier?: AdapterOutageNotifier;
62
81
  }
63
82
 
64
- 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>;
65
86
  readonly #logger!: ReturnType<typeof getAdapterLogger>;
66
87
 
67
88
  readonly #options: QqEndpointOptions;
68
- readonly #createBot: CreateQqBot;
69
- #bot: QqBotTransport | null = null;
89
+ #connection?: AbortController;
90
+ #stopping?: Promise<void>;
70
91
  #open = false;
92
+ #executionSignal?: AbortSignal;
93
+ #offlineNoticeAt = 0;
71
94
  readonly #seenInteractions = new Map<string, number>();
72
95
  /** Recent msg_id / event_id per conversation, so replies go out as passive messages. */
73
96
  readonly #passiveReplies = new QqPassiveReplyLedger();
74
97
  readonly #activeGate = new QqActiveMessageGate();
75
98
  readonly #lifecycle: EndpointLifecycle;
76
99
  #cleanup?: () => Promise<void>;
77
- readonly management: EndpointManagement = createQqEndpointManagement(() => this.#requireBot());
78
- readonly control: EndpointControl = createRecallEndpointControl((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));
79
102
 
80
103
  constructor(options: QqEndpointOptions) {
81
- super();
104
+ super(qqWebsocketBotOptions(options.config));
82
105
  this.#logger = getAdapterLogger('qq', options.config.id);
83
106
  this.#lifecycle = createEndpointLifecycle({ name: options.config.id });
84
107
  this.#options = options;
85
- this.#createBot = options.createBot ?? defaultCreateBot;
86
- }
87
-
88
- get client(): QqBotTransport {
89
- 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);
90
116
  }
91
117
 
92
118
  /** Live endpoint 名(Console/AdapterIndex 展示用,如 bot appid 别名)。 */
@@ -94,51 +120,60 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
94
120
  return this.#options.config.id;
95
121
  }
96
122
 
97
- get transportState(): EndpointTransportState {
123
+ get #transportState(): EndpointTransportState {
98
124
  if (this.#lifecycle.state !== 'open') return this.#lifecycle.state;
99
- return this.#bot?.transportState ?? this.#lifecycle.state;
125
+ return this.#receiverResources.state();
100
126
  }
101
127
 
102
- async start(): Promise<void> {
128
+ async #startFramework(executionSignal: AbortSignal): Promise<void> {
129
+ this.#executionSignal = executionSignal;
103
130
  await this.#lifecycle.start(async (handle) => {
104
- const bot = this.#createBot(this.#options.config);
105
- this.#bot = bot;
106
- 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)));
107
135
  let cancelled = false;
108
136
  const startupAbort = new AbortController();
137
+ this.#connection = startupAbort;
138
+ let nativeStart: Promise<unknown> | undefined;
109
139
  let stopping: Promise<void> | undefined;
140
+ let transportClose: (() => void) | undefined;
110
141
  const stopBot = async () => {
111
- try { bot.removeAllListeners(); } catch { /* continue SDK cleanup */ }
112
- try { await bot.stop(); } catch { /* best-effort SDK cleanup */ }
142
+ await super.stop();
143
+ await nativeStart?.catch(() => undefined);
113
144
  };
114
145
  const cleanup = () => {
115
146
  cancelled = true;
116
147
  releaseUploadDiagnostics();
117
148
  startupAbort.abort();
118
- if (this.#bot === bot) this.#bot = null;
149
+ if (transportClose) this.receiver.off('close', transportClose);
150
+ if (this.#connection === startupAbort) this.#connection = undefined;
119
151
  return stopping ??= stopBot();
120
152
  };
121
153
  this.#cleanup = cleanup;
122
- handle.onForceClose(() => { void cleanup(); });
154
+ handle.onForceClose(() => { void cleanup().catch(() => undefined); });
123
155
  try {
124
- bot.on('transport.close', () => { if (cancelled || this.#bot !== bot) return; void cleanup(); handle.notifyClosed(); });
125
- this.#bindBot(bot);
126
- await awaitQqStartup(bot.start().then(async () => {
127
- if (cancelled) await stopBot();
128
- }, async (error) => {
129
- if (cancelled) await stopBot();
130
- throw error;
131
- }), startupAbort.signal);
156
+ transportClose = () => {
157
+ if (cancelled || this.#connection === undefined) return;
158
+ this.#notifyOffline('transport_close');
159
+ void cleanup().catch(() => undefined);
160
+ handle.notifyClosed();
161
+ };
162
+ this.receiver.on('close', transportClose);
163
+ nativeStart = super.start();
164
+ await awaitQqStartup(nativeStart.then(() => undefined), startupAbort.signal);
132
165
  if (cancelled) {
133
166
  // SDK startup may create resources after stop; close this exact old client again.
134
167
  await stopping;
135
168
  return;
136
169
  }
170
+ this.#offlineNoticeAt = 0;
137
171
  this.#logger.info(`connected (websocket) | appid: ${this.#options.config.appid}`);
138
172
  } catch (error) {
139
173
  const wasCancelled = cancelled;
140
174
  await cleanup();
141
175
  if (wasCancelled) return;
176
+ this.#notifyOffline('start_failed');
142
177
  const raw = error instanceof Error ? error.message : String(error);
143
178
  throw new Error(
144
179
  `QQ WebSocket 连接失败:请检查 appid/secret 是否配对、网关地址(gatewayUrl/accessTokenUrl)是否可达(原始错误:${raw})`,
@@ -148,27 +183,31 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
148
183
  });
149
184
  }
150
185
 
151
- open(): void {
186
+ #openFramework(): void {
152
187
  this.#open = true;
153
188
  }
154
189
 
155
- close(): void {
190
+ #closeFramework(): void {
156
191
  this.#open = false;
157
192
  }
158
193
 
159
- async stop(): Promise<void> {
160
- this.#open = false;
161
- const cleanup = this.#cleanup;
162
- this.#cleanup = undefined;
163
- await this.#lifecycle.stop();
164
- 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;
165
203
  this.#logger.debug(formatCompact({ op: 'disconnect' }));
166
204
  }
167
205
 
168
- async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
206
+ async #sendFramework({ conversation, payload, signal }: EndpointSendRequest): Promise<string> {
169
207
  // 媒体与 markdown/按钮不能同条发送(msg_type 7 与 2 互斥):公网图片嵌入 markdown,
170
208
  // 拿不到 URL/尺寸的媒体降级为独立消息先发
171
- 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 })) });
172
211
  const body = messages[messages.length - 1]!;
173
212
  const kind = qqOutboundKind(conversation);
174
213
  const bot = this.#requireBot();
@@ -179,7 +218,7 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
179
218
  ledger: this.#passiveReplies,
180
219
  activeGate: this.#activeGate,
181
220
  log: fields => this.#logger.warn(formatCompact(fields)),
182
- }, conversation, messages);
221
+ }, { conversation, messages, signal });
183
222
  } catch (error) {
184
223
  const { failure, diagnostic } = qqDeliveryFailure(error);
185
224
  this.#logger.warn(formatCompact(diagnostic));
@@ -197,7 +236,7 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
197
236
  async recallMessage(messageId: string): Promise<void> {
198
237
  const { kind, channelId, qqMsgId } = parseCompoundMessageId(messageId);
199
238
  const bot = this.#requireBot();
200
- await recallQqMessage(bot, kind, channelId, qqMsgId);
239
+ await recallQqMessage(bot, { kind, channelId, messageId: qqMsgId });
201
240
  this.#logger.debug(formatCompact({
202
241
  op: 'qq_recall',
203
242
  endpoint: this.#options.config.id,
@@ -219,8 +258,10 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
219
258
  );
220
259
  const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
221
260
  if (msg.id) this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { id: msg.id });
222
- void this.emit('message.receive', {
261
+ void this[endpointSpec].events.emit('message.receive', {
223
262
  conversation,
263
+ event: msg.event,
264
+ platformSentAt: msg.platformSentAt,
224
265
  message: { conversation, id: msg.id },
225
266
  content,
226
267
  ...(msg.segments?.length ? { segments: msg.segments } : {}),
@@ -259,12 +300,15 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
259
300
  this.#seenInteractions.set(action.message.id, now);
260
301
  if (this.#seenInteractions.size > 10_000) this.#seenInteractions.delete(this.#seenInteractions.keys().next().value!);
261
302
  // ACK owns only platform loading state, never implies business acceptance.
262
- 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
+ );
263
307
  const conversation = qqInboundConversation(String(this.#options.id), action.message);
264
308
  // INTERACTION_CREATE's id is an event_id the reply may answer passively.
265
309
  this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { event_id: action.message.id });
266
- void this.emit('message.receive', {
267
- 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: '',
268
312
  segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
269
313
  metadata: Object.freeze({ eventType: 'INTERACTION_CREATE', sourceMessageIdAvailable: action.sourceMessageId !== undefined,
270
314
  ...(action.sourceMessageId ? { sourceMessageId: action.sourceMessageId } : {}),
@@ -273,30 +317,52 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
273
317
  return true;
274
318
  }
275
319
 
320
+ #notifyOffline(reason: string): void {
321
+ const notifier = this.#options.notifier;
322
+ if (!notifier?.configured) return;
323
+ const now = Date.now();
324
+ if (now - this.#offlineNoticeAt < 60_000) return;
325
+ this.#offlineNoticeAt = now;
326
+ const text = `[Zhin] QQ 机器人 ${this.#options.config.id} 掉线(${reason})。正在自动重连。\n`
327
+ + '恢复:在 QQ 回复 `qq endpoint add` 重新扫码绑定,或打开 Console 的 QQ 页面。';
328
+ void notifier.send(text).then((sent) => {
329
+ if (sent) return;
330
+ this.#logger.warn(formatCompact({
331
+ op: 'qq_notify_failed',
332
+ endpoint: this.#options.config.id,
333
+ reason,
334
+ }));
335
+ });
336
+ }
337
+
276
338
  #bindBot(bot: QqBotTransport): void {
277
339
  bindQqBotInboundEvents(bot, (raw) => {
278
- if (this.#bot !== bot) return;
279
- this.#emitPlatformEvent('message', raw);
280
- const msg = normalizeQqMessage(raw);
281
- 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'));
282
346
  });
283
347
  bindQqBotSideEvents(bot, (eventName, raw) => {
284
- if (this.#bot !== bot) return;
285
- this.#emitPlatformEvent(eventName, raw);
286
- if (this.admitInteraction(raw)) return;
287
- receiveQqSideEvent(
288
- (name, payload) => this.emit(name, payload),
289
- this.#options.config.id,
290
- bot as QqSideEventCaller,
291
- eventName,
292
- raw,
293
- this.#logger,
294
- );
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'));
295
361
  });
296
362
  }
297
363
 
298
364
  #emitPlatformEvent(name: string, event: unknown): void {
299
- void this.emitPlatform(name, event).catch((error) => {
365
+ void this[endpointSpec].events.platform(name, event).catch((error) => {
300
366
  this.#logger.warn(formatCompact({
301
367
  op: 'qq_platform_event_failed',
302
368
  event: name,
@@ -306,8 +372,8 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
306
372
  }
307
373
 
308
374
  #requireBot(): QqBotTransport {
309
- if (!this.#bot) throw new EndpointDeliveryError('endpoint_disconnected', 'QQ bot not connected', 'not_sent');
310
- return this.#bot;
375
+ if (!this.#connection) throw new EndpointDeliveryError('endpoint_disconnected', 'QQ bot not connected', 'not_sent');
376
+ return this;
311
377
  }
312
378
  }
313
379
 
@@ -315,36 +381,43 @@ export interface QqHttpEndpointOptions {
315
381
  readonly id: CapabilityId;
316
382
  readonly http: HttpHost;
317
383
  readonly config: ResolvedQqHttpConfig;
318
- readonly createBot?: CreateQqHttpBot;
384
+ /** Out-of-band outage notifier; absent in tests/hosts without outbound. */
385
+ readonly notifier?: AdapterOutageNotifier;
319
386
  }
320
387
 
321
388
  /** Webhook / middleware inbound via httpHostToken POST (qq-official-bot Middleware receiver). */
322
- export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
389
+ export class QqHttpEndpoint extends Bot<ReceiverMode.MIDDLEWARE, 'koa'> implements Endpoint {
390
+ readonly [endpointSpec]: EndpointSpecification;
323
391
  readonly #logger!: ReturnType<typeof getAdapterLogger>;
324
392
 
325
393
  readonly #options: QqHttpEndpointOptions;
326
- readonly #createBot: CreateQqHttpBot;
327
- #bot: QqHttpBotTransport | null = null;
394
+ #connection?: AbortController;
395
+ #stopping?: Promise<void>;
328
396
  #open = false;
397
+ #executionSignal?: AbortSignal;
398
+ #offlineNoticeAt = 0;
329
399
  readonly #seenInteractions = new Map<string, number>();
330
400
  /** Recent msg_id / event_id per conversation, so replies go out as passive messages. */
331
401
  readonly #passiveReplies = new QqPassiveReplyLedger();
332
402
  readonly #activeGate = new QqActiveMessageGate();
333
403
  readonly #lifecycle: EndpointLifecycle;
334
404
  #cleanup?: () => Promise<void>;
335
- readonly management: EndpointManagement = createQqEndpointManagement(() => this.#requireBot());
336
- readonly control: EndpointControl = createRecallEndpointControl((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));
337
407
 
338
408
  constructor(options: QqHttpEndpointOptions) {
339
- super();
409
+ super(qqHttpBotOptions(options.config));
340
410
  this.#logger = getAdapterLogger('qq', options.config.id);
341
411
  this.#lifecycle = createEndpointLifecycle({ name: options.config.id, reconnect: false });
342
412
  this.#options = options;
343
- this.#createBot = options.createBot ?? defaultCreateHttpBot;
344
- }
345
-
346
- get client(): QqHttpBotTransport {
347
- 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);
348
421
  }
349
422
 
350
423
  /** Live endpoint 名(Console/AdapterIndex 展示用)。 */
@@ -352,81 +425,86 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
352
425
  return this.#options.config.id;
353
426
  }
354
427
 
355
- get transportState(): EndpointTransportState {
428
+ get #transportState(): EndpointTransportState {
356
429
  return this.#lifecycle.state;
357
430
  }
358
431
 
359
- async start(): Promise<void> {
432
+ async #startFramework(executionSignal: AbortSignal): Promise<void> {
433
+ this.#executionSignal = executionSignal;
360
434
  await this.#lifecycle.start(async (handle) => {
361
- const bot = this.#createBot(this.#options.config);
362
- this.#bot = bot;
363
- 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)));
364
439
  let cancelled = false;
365
440
  const startupAbort = new AbortController();
441
+ this.#connection = startupAbort;
442
+ let nativeStart: Promise<unknown> | undefined;
366
443
  let stopping: Promise<void> | undefined;
367
444
  const routes: Array<() => void> = [];
368
445
  const stopBot = async () => {
369
- try { bot.removeAllListeners(); } catch { /* continue SDK cleanup */ }
370
- try { await bot.stop(); } catch { /* best-effort SDK cleanup */ }
446
+ await super.stop();
447
+ await nativeStart?.catch(() => undefined);
371
448
  };
372
449
  const cleanup = () => {
373
450
  cancelled = true;
374
451
  releaseUploadDiagnostics();
375
452
  startupAbort.abort();
376
- if (this.#bot === bot) this.#bot = null;
453
+ if (this.#connection === startupAbort) this.#connection = undefined;
377
454
  for (const release of routes.splice(0)) release();
378
455
  return stopping ??= stopBot();
379
456
  };
380
457
  this.#cleanup = cleanup;
381
- handle.onForceClose(() => { void cleanup(); });
458
+ handle.onForceClose(() => { void cleanup().catch(() => undefined); });
382
459
  try {
383
- this.#bindBot(bot);
384
460
  routes.push(...registerQqWebhookRoutes(this.#options.http, {
385
461
  config: this.#options.config,
386
462
  getBot: () => cancelled ? null : bot,
387
463
  }));
388
- await awaitQqStartup(bot.start().then(async () => {
389
- if (cancelled) await stopBot();
390
- }, async (error) => {
391
- if (cancelled) await stopBot();
392
- throw error;
393
- }), startupAbort.signal);
464
+ nativeStart = super.start();
465
+ await awaitQqStartup(nativeStart.then(() => undefined), startupAbort.signal);
394
466
  if (cancelled) {
395
467
  // SDK startup may create resources after stop; close this exact old client again.
396
468
  await stopping;
397
469
  return;
398
470
  }
471
+ this.#offlineNoticeAt = 0;
399
472
  this.#logger.info(`connected (${this.#options.config.mode}) | path: ${this.#options.config.webhookPath}`);
400
473
  } catch (error) {
401
474
  const wasCancelled = cancelled;
402
475
  await cleanup();
403
476
  if (wasCancelled) return;
477
+ this.#notifyOffline('start_failed');
404
478
  throw error;
405
479
  }
406
480
  });
407
481
  }
408
482
 
409
- open(): void {
483
+ #openFramework(): void {
410
484
  this.#open = true;
411
485
  }
412
486
 
413
- close(): void {
487
+ #closeFramework(): void {
414
488
  this.#open = false;
415
489
  }
416
490
 
417
- async stop(): Promise<void> {
418
- this.#open = false;
419
- const cleanup = this.#cleanup;
420
- this.#cleanup = undefined;
421
- await this.#lifecycle.stop();
422
- await cleanup?.();
423
- 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;
424
501
  }
425
502
 
426
- async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
503
+ async #sendFramework({ conversation, payload, signal }: EndpointSendRequest): Promise<string> {
427
504
  // 媒体与 markdown/按钮不能同条发送(msg_type 7 与 2 互斥):公网图片嵌入 markdown,
428
505
  // 拿不到 URL/尺寸的媒体降级为独立消息先发
429
- 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 })) });
430
508
  const body = messages[messages.length - 1]!;
431
509
  const kind = qqOutboundKind(conversation);
432
510
  const bot = this.#requireBot();
@@ -437,7 +515,7 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
437
515
  ledger: this.#passiveReplies,
438
516
  activeGate: this.#activeGate,
439
517
  log: fields => this.#logger.warn(formatCompact(fields)),
440
- }, conversation, messages);
518
+ }, { conversation, messages, signal });
441
519
  } catch (error) {
442
520
  const { failure, diagnostic } = qqDeliveryFailure(error);
443
521
  this.#logger.warn(formatCompact(diagnostic));
@@ -455,7 +533,7 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
455
533
  async recallMessage(messageId: string): Promise<void> {
456
534
  const { kind, channelId, qqMsgId } = parseCompoundMessageId(messageId);
457
535
  const bot = this.#requireBot();
458
- await recallQqMessage(bot, kind, channelId, qqMsgId);
536
+ await recallQqMessage(bot, { kind, channelId, messageId: qqMsgId });
459
537
  this.#logger.debug(formatCompact({
460
538
  op: 'qq_recall',
461
539
  endpoint: this.#options.config.id,
@@ -476,8 +554,10 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
476
554
  );
477
555
  const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
478
556
  if (msg.id) this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { id: msg.id });
479
- void this.emit('message.receive', {
557
+ void this[endpointSpec].events.emit('message.receive', {
480
558
  conversation,
559
+ event: msg.event,
560
+ platformSentAt: msg.platformSentAt,
481
561
  message: { conversation, id: msg.id },
482
562
  content,
483
563
  ...(msg.segments?.length ? { segments: msg.segments } : {}),
@@ -516,12 +596,15 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
516
596
  this.#seenInteractions.set(action.message.id, now);
517
597
  if (this.#seenInteractions.size > 10_000) this.#seenInteractions.delete(this.#seenInteractions.keys().next().value!);
518
598
  // ACK owns only platform loading state, never implies business acceptance.
519
- 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
+ );
520
603
  const conversation = qqInboundConversation(String(this.#options.id), action.message);
521
604
  // INTERACTION_CREATE's id is an event_id the reply may answer passively.
522
605
  this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { event_id: action.message.id });
523
- void this.emit('message.receive', {
524
- 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: '',
525
608
  segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
526
609
  metadata: Object.freeze({ eventType: 'INTERACTION_CREATE', sourceMessageIdAvailable: action.sourceMessageId !== undefined,
527
610
  ...(action.sourceMessageId ? { sourceMessageId: action.sourceMessageId } : {}),
@@ -530,30 +613,52 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
530
613
  return true;
531
614
  }
532
615
 
616
+ #notifyOffline(reason: string): void {
617
+ const notifier = this.#options.notifier;
618
+ if (!notifier?.configured) return;
619
+ const now = Date.now();
620
+ if (now - this.#offlineNoticeAt < 60_000) return;
621
+ this.#offlineNoticeAt = now;
622
+ const text = `[Zhin] QQ 机器人 ${this.#options.config.id} 掉线(${reason})。正在自动重连。\n`
623
+ + '恢复:在 QQ 回复 `qq endpoint add` 重新扫码绑定,或打开 Console 的 QQ 页面。';
624
+ void notifier.send(text).then((sent) => {
625
+ if (sent) return;
626
+ this.#logger.warn(formatCompact({
627
+ op: 'qq_notify_failed',
628
+ endpoint: this.#options.config.id,
629
+ reason,
630
+ }));
631
+ });
632
+ }
633
+
533
634
  #bindBot(bot: QqBotTransport): void {
534
635
  bindQqBotInboundEvents(bot, (raw) => {
535
- if (this.#bot !== bot) return;
536
- this.#emitPlatformEvent('message', raw);
537
- const msg = normalizeQqMessage(raw);
538
- 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'));
539
642
  });
540
643
  bindQqBotSideEvents(bot, (eventName, raw) => {
541
- if (this.#bot !== bot) return;
542
- this.#emitPlatformEvent(eventName, raw);
543
- if (this.admitInteraction(raw)) return;
544
- receiveQqSideEvent(
545
- (name, payload) => this.emit(name, payload),
546
- this.#options.config.id,
547
- bot as QqSideEventCaller,
548
- eventName,
549
- raw,
550
- this.#logger,
551
- );
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'));
552
657
  });
553
658
  }
554
659
 
555
660
  #emitPlatformEvent(name: string, event: unknown): void {
556
- void this.emitPlatform(name, event).catch((error) => {
661
+ void this[endpointSpec].events.platform(name, event).catch((error) => {
557
662
  this.#logger.warn(formatCompact({
558
663
  op: 'qq_platform_event_failed',
559
664
  event: name,
@@ -563,25 +668,43 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
563
668
  }
564
669
 
565
670
  #requireBot(): QqHttpBotTransport {
566
- if (!this.#bot) throw new EndpointDeliveryError('endpoint_disconnected', 'QQ bot not connected', 'not_sent');
567
- return this.#bot;
671
+ if (!this.#connection) throw new EndpointDeliveryError('endpoint_disconnected', 'QQ bot not connected', 'not_sent');
672
+ return this;
568
673
  }
569
674
  }
570
675
 
571
676
  function createQqEndpointManagement(
572
- requireClient: () => Pick<QqBotTransport, 'getGuilds' | 'getChannels'>,
677
+ requireClient: () => QqBotTransport,
678
+ isActive: () => boolean,
573
679
  ): EndpointManagement {
680
+ const api = () => requireClient();
574
681
  return Object.freeze<EndpointManagement>({
575
- async listChannels(): Promise<readonly EndpointChannel[]> {
682
+ // QQ 群(group_openid / member_openid):官方 v2 群管理接口,部分为白名单能力(11253)。
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));
690
+ },
691
+ async kickGroupMember({ groupId, userId, signal }: EndpointMemberRequest): Promise<void> {
692
+ await kickQqGroupMember(api(), { groupId, userId, signal });
693
+ },
694
+ async muteGroupMember({ groupId, userId, durationSeconds, signal }: EndpointMuteRequest): Promise<void> {
695
+ await muteQqGroupMember(api(), { groupId, userId, durationSeconds, signal });
696
+ },
697
+ async listChannels(request: EndpointManagementRequest = {}): Promise<readonly EndpointChannel[]> {
698
+ const operation = createEndpointOperation(request);
576
699
  const channels: EndpointChannel[] = [];
577
700
  const client = requireClient();
578
- const guilds = await client.getGuilds();
701
+ const guilds = await operation.run(() => client.guildService.getList());
579
702
  for (const guildValue of Array.isArray(guilds) ? guilds : []) {
580
703
  const guild = asRecord(guildValue);
581
704
  const guildId = String(guild.id ?? guild.guild_id ?? guildValue ?? '');
582
705
  if (!guildId) continue;
583
706
  const guildName = String(guild.name ?? guild.guild_name ?? guildId);
584
- const rows = await client.getChannels(guildId);
707
+ const rows = await operation.run(() => client.channelService.getList(guildId));
585
708
  for (const channelValue of Array.isArray(rows) ? rows : []) {
586
709
  const channel = asRecord(channelValue);
587
710
  const id = String(channel.id ?? channel.channel_id ?? channelValue ?? '');
@@ -604,10 +727,74 @@ function asRecord(value: unknown): Record<string, unknown> {
604
727
  : {};
605
728
  }
606
729
 
607
- async function recallQqMessage(bot: QqBotTransport, kind: ReturnType<typeof qqOutboundKind>, channelId: string, messageId: string): Promise<void> {
730
+ /**
731
+ * Recall + 频道表情回应。recall 同时接受 send() 的复合 ID 与入站原生消息 ID(目标取自 MessageRef.conversation),
732
+ * 因此群管理员机器人可撤回普通成员 2 分钟内的消息(官方限制由平台执行)。
733
+ * 表情回应只有频道子频道消息支持(官方无群 / 单聊表情回应接口)。
734
+ */
735
+ function createQqEndpointControl(
736
+ requireBot: () => QqBotTransport,
737
+ recallById: (messageId: string) => Promise<void>,
738
+ ): EndpointControl {
739
+ return Object.freeze<EndpointControl>({
740
+ recall: async ({ message, signal }) => {
741
+ if (isCompoundMessageId(message.id)) return createEndpointOperation({ signal }).run(() => recallById(message.id));
742
+ const kind = qqOutboundKind(message.conversation);
743
+ await createEndpointOperation({ signal }).run(() => recallQqMessage(requireBot(), { kind, channelId: message.conversation.id, messageId: message.id }));
744
+ },
745
+ addReaction: async ({ message, emoji, signal }) => {
746
+ const target = qqGuildReactionTarget(message, emoji);
747
+ if (!target) return null;
748
+ await createEndpointOperation({ signal }).run(() => requireBot().reactionService.addGuildMessageReaction(target.channelId, target.messageId, target.type, target.id));
749
+ return `${target.type}:${target.id}`;
750
+ },
751
+ removeReaction: async ({ message, reactionId, signal }) => {
752
+ const target = qqGuildReactionTarget(message, reactionId);
753
+ if (!target) return;
754
+ await createEndpointOperation({ signal }).run(() => requireBot().reactionService.deleteGuildMessageReaction(target.channelId, target.messageId, target.type, target.id));
755
+ },
756
+ });
757
+ }
758
+
759
+ function isCompoundMessageId(id: string): boolean {
760
+ try { parseCompoundMessageId(id); return true; } catch { return false; }
761
+ }
762
+
763
+ /** `type:id`(1 系统表情 / 2 emoji)或单个 Unicode emoji(→ type 2 + 码点)。 */
764
+ export function parseQqEmoji(value: string): { type: 1 | 2; id: `${number}` } | null {
765
+ const explicit = /^([12]):(\d+)$/.exec(value.trim());
766
+ if (explicit) return { type: Number(explicit[1]) as 1 | 2, id: explicit[2] as `${number}` };
767
+ const chars = [...value.trim().replace(/\uFE0F/g, '')];
768
+ if (chars.length !== 1) return null;
769
+ const code = chars[0]!.codePointAt(0)!;
770
+ return code > 0x7f ? { type: 2, id: String(code) as `${number}` } : null;
771
+ }
772
+
773
+ function qqGuildReactionTarget(message: MessageRef, emoji: string) {
774
+ let kind = qqOutboundKind(message.conversation);
775
+ let channelId = message.conversation.id;
776
+ let messageId = message.id;
777
+ if (isCompoundMessageId(message.id)) ({ kind, channelId, qqMsgId: messageId } = parseCompoundMessageId(message.id));
778
+ if (kind !== 'channel' || !channelId || !messageId) return null;
779
+ const parsed = parseQqEmoji(emoji);
780
+ return parsed ? { channelId, messageId, ...parsed } : null;
781
+ }
782
+
783
+ async function recallQqMessage(bot: QqBotTransport, { kind, channelId, messageId }: { readonly kind: ReturnType<typeof qqOutboundKind>; readonly channelId: string; readonly messageId: string }): Promise<void> {
608
784
  const recall = { private: bot.recallPrivateMessage, group: bot.recallGroupMessage,
609
785
  channel: bot.recallGuildMessage, direct: bot.recallDirectMessage }[kind];
610
786
  if (!recall) throw new EndpointDeliveryError('recall_unsupported', 'QQ transport does not support recall for this conversation', 'not_sent');
611
787
  if (!messageId) throw new EndpointDeliveryError('invalid_message_id', 'QQ recall requires a nonempty message ID', 'not_sent');
612
788
  await recall.call(bot, channelId, messageId);
613
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
+ }