@zhin.js/adapter-qq 1.1.4 → 1.1.5

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.
package/src/endpoint.ts CHANGED
@@ -1,3 +1,8 @@
1
+ import { installQqUploadDiagnostics } from './upload-diagnostics.js';
2
+ import { qqDeliveryFailure } from './delivery-error.js';
3
+ import { normalizeQqInteraction } from './interaction.js';
4
+ import { awaitQqStartup } from './start-deadline.js';
5
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
1
6
  /**
2
7
  * QQ endpoints — lifecycle, outbound, admit, agent tool surface.
3
8
  */
@@ -8,6 +13,7 @@ import {
8
13
  type EndpointChannel,
9
14
  type EndpointManagement,
10
15
  type EndpointSendRequest,
16
+ type EndpointTransportState,
11
17
  } from 'zhin.js/adapter';
12
18
  import type { HttpHost } from '@zhin.js/host-http';
13
19
  import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
@@ -59,6 +65,7 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
59
65
  readonly #createBot: CreateQqBot;
60
66
  #bot: QqBotTransport | null = null;
61
67
  #open = false;
68
+ readonly #seenInteractions = new Map<string, number>();
62
69
  readonly #lifecycle: EndpointLifecycle;
63
70
  #cleanup?: () => Promise<void>;
64
71
  readonly management: EndpointManagement = createQqEndpointManagement(() => this.#requireBot());
@@ -67,7 +74,7 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
67
74
  constructor(options: QqEndpointOptions) {
68
75
  super();
69
76
  this.#logger = getAdapterLogger('qq', options.config.id);
70
- this.#lifecycle = createEndpointLifecycle({ name: options.config.id, reconnect: false });
77
+ this.#lifecycle = createEndpointLifecycle({ name: options.config.id });
71
78
  this.#options = options;
72
79
  this.#createBot = options.createBot ?? defaultCreateBot;
73
80
  }
@@ -81,11 +88,18 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
81
88
  return this.#options.config.id;
82
89
  }
83
90
 
91
+ get transportState(): EndpointTransportState {
92
+ if (this.#lifecycle.state !== 'open') return this.#lifecycle.state;
93
+ return this.#bot?.transportState ?? this.#lifecycle.state;
94
+ }
95
+
84
96
  async start(): Promise<void> {
85
97
  await this.#lifecycle.start(async (handle) => {
86
98
  const bot = this.#createBot(this.#options.config);
87
99
  this.#bot = bot;
100
+ const releaseUploadDiagnostics = installQqUploadDiagnostics(bot.api, metadata => this.#logger.debug(formatCompact(metadata)));
88
101
  let cancelled = false;
102
+ const startupAbort = new AbortController();
89
103
  let stopping: Promise<void> | undefined;
90
104
  const stopBot = async () => {
91
105
  try { bot.removeAllListeners(); } catch { /* continue SDK cleanup */ }
@@ -93,28 +107,32 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
93
107
  };
94
108
  const cleanup = () => {
95
109
  cancelled = true;
110
+ releaseUploadDiagnostics();
111
+ startupAbort.abort();
96
112
  if (this.#bot === bot) this.#bot = null;
97
113
  return stopping ??= stopBot();
98
114
  };
99
115
  this.#cleanup = cleanup;
100
116
  handle.onForceClose(() => { void cleanup(); });
101
117
  try {
118
+ bot.on('transport.close', () => { if (cancelled || this.#bot !== bot) return; void cleanup(); handle.notifyClosed(); });
102
119
  this.#bindBot(bot);
103
- await bot.start();
120
+ await awaitQqStartup(bot.start().then(async () => {
121
+ if (cancelled) await stopBot();
122
+ }, async (error) => {
123
+ if (cancelled) await stopBot();
124
+ throw error;
125
+ }), startupAbort.signal);
104
126
  if (cancelled) {
105
127
  // SDK startup may create resources after stop; close this exact old client again.
106
128
  await stopping;
107
- await stopBot();
108
129
  return;
109
130
  }
110
131
  this.#logger.info(`connected (websocket) | appid: ${this.#options.config.appid}`);
111
132
  } catch (error) {
112
133
  const wasCancelled = cancelled;
113
134
  await cleanup();
114
- if (wasCancelled) {
115
- await stopBot();
116
- return;
117
- }
135
+ if (wasCancelled) return;
118
136
  const raw = error instanceof Error ? error.message : String(error);
119
137
  throw new Error(
120
138
  `QQ WebSocket 连接失败:请检查 appid/secret 是否配对、网关地址(gatewayUrl/accessTokenUrl)是否可达(原始错误:${raw})`,
@@ -146,20 +164,26 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
146
164
  const kind = qqOutboundKind(conversation);
147
165
  const bot = this.#requireBot();
148
166
  let result: unknown;
149
- switch (kind) {
150
- case 'private':
151
- result = await bot.sendPrivateMessage(conversation.id, body);
152
- break;
153
- case 'group':
154
- result = await bot.sendGroupMessage(conversation.id, body);
155
- break;
156
- case 'channel':
157
- result = await bot.sendGuildMessage(conversation.id, body);
158
- break;
159
- case 'direct':
160
- if (!bot.sendDirectMessage) throw new Error('QQ direct message not supported by transport');
161
- result = await bot.sendDirectMessage(conversation.id, body);
162
- break;
167
+ try {
168
+ switch (kind) {
169
+ case 'private':
170
+ result = await bot.sendPrivateMessage(conversation.id, body);
171
+ break;
172
+ case 'group':
173
+ result = await bot.sendGroupMessage(conversation.id, body);
174
+ break;
175
+ case 'channel':
176
+ result = await bot.sendGuildMessage(conversation.id, body);
177
+ break;
178
+ case 'direct':
179
+ if (!bot.sendDirectMessage) throw new Error('QQ direct message not supported by transport');
180
+ result = await bot.sendDirectMessage(conversation.id, body);
181
+ break;
182
+ }
183
+ } catch (error) {
184
+ const { failure, diagnostic } = qqDeliveryFailure(error);
185
+ this.#logger.warn(formatCompact(diagnostic));
186
+ throw failure;
163
187
  }
164
188
  const messageId = `${kind}-${conversation.id}:${resolveOutboundMessageId(result)}`;
165
189
  this.#logger.info(
@@ -173,20 +197,7 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
173
197
  async recallMessage(messageId: string): Promise<void> {
174
198
  const { kind, channelId, qqMsgId } = parseCompoundMessageId(messageId);
175
199
  const bot = this.#requireBot();
176
- switch (kind) {
177
- case 'private':
178
- await bot.recallPrivateMessage?.(channelId, qqMsgId);
179
- break;
180
- case 'group':
181
- await bot.recallGroupMessage?.(channelId, qqMsgId);
182
- break;
183
- case 'channel':
184
- await bot.recallGuildMessage?.(channelId, qqMsgId);
185
- break;
186
- case 'direct':
187
- await bot.recallDirectMessage?.(channelId, qqMsgId);
188
- break;
189
- }
200
+ await recallQqMessage(bot, kind, channelId, qqMsgId);
190
201
  this.#logger.debug(formatCompact({
191
202
  op: 'qq_recall',
192
203
  endpoint: this.#options.config.id,
@@ -233,6 +244,29 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
233
244
  });
234
245
  }
235
246
 
247
+ /** Native click only; C2C/group have no platform source message ID. */
248
+ admitInteraction(raw: unknown): boolean {
249
+ if (!this.#open) return false;
250
+ const action = normalizeQqInteraction(raw);
251
+ if (!action) return false;
252
+ const now = Date.now();
253
+ for (const [id, time] of this.#seenInteractions) if (now - time > 300_000) this.#seenInteractions.delete(id);
254
+ if (this.#seenInteractions.has(action.message.id)) return true;
255
+ this.#seenInteractions.set(action.message.id, now);
256
+ if (this.#seenInteractions.size > 10_000) this.#seenInteractions.delete(this.#seenInteractions.keys().next().value!);
257
+ // ACK owns only platform loading state, never implies business acceptance.
258
+ void action.ack().then(ok => { if (ok === false) this.#logger.warn('qq_interaction_ack_failed'); }, () => this.#logger.warn('qq_interaction_ack_failed'));
259
+ const conversation = qqInboundConversation(String(this.#options.id), action.message);
260
+ void this.emit('message.receive', {
261
+ conversation, message: { conversation, id: action.message.id }, content: '',
262
+ segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
263
+ metadata: Object.freeze({ eventType: 'INTERACTION_CREATE', sourceMessageIdAvailable: action.sourceMessageId !== undefined,
264
+ ...(action.sourceMessageId ? { sourceMessageId: action.sourceMessageId } : {}),
265
+ callbackAssociation: action.sourceMessageId ? 'source-message' : 'payload-conversation-actor' }),
266
+ }).catch(() => this.#logger.warn('qq_interaction_receive_failed'));
267
+ return true;
268
+ }
269
+
236
270
  #bindBot(bot: QqBotTransport): void {
237
271
  bindQqBotInboundEvents(bot, (raw) => {
238
272
  if (this.#bot !== bot) return;
@@ -243,6 +277,7 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
243
277
  bindQqBotSideEvents(bot, (eventName, raw) => {
244
278
  if (this.#bot !== bot) return;
245
279
  this.#emitPlatformEvent(eventName, raw);
280
+ if (this.admitInteraction(raw)) return;
246
281
  receiveQqSideEvent(
247
282
  (name, payload) => this.emit(name, payload),
248
283
  this.#options.config.id,
@@ -265,7 +300,7 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
265
300
  }
266
301
 
267
302
  #requireBot(): QqBotTransport {
268
- if (!this.#bot) throw new Error('QQ bot not connected');
303
+ if (!this.#bot) throw new EndpointDeliveryError('endpoint_disconnected', 'QQ bot not connected', 'not_sent');
269
304
  return this.#bot;
270
305
  }
271
306
  }
@@ -285,6 +320,7 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
285
320
  readonly #createBot: CreateQqHttpBot;
286
321
  #bot: QqHttpBotTransport | null = null;
287
322
  #open = false;
323
+ readonly #seenInteractions = new Map<string, number>();
288
324
  readonly #lifecycle: EndpointLifecycle;
289
325
  #cleanup?: () => Promise<void>;
290
326
  readonly management: EndpointManagement = createQqEndpointManagement(() => this.#requireBot());
@@ -307,11 +343,17 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
307
343
  return this.#options.config.id;
308
344
  }
309
345
 
346
+ get transportState(): EndpointTransportState {
347
+ return this.#lifecycle.state;
348
+ }
349
+
310
350
  async start(): Promise<void> {
311
351
  await this.#lifecycle.start(async (handle) => {
312
352
  const bot = this.#createBot(this.#options.config);
313
353
  this.#bot = bot;
354
+ const releaseUploadDiagnostics = installQqUploadDiagnostics(bot.api, metadata => this.#logger.debug(formatCompact(metadata)));
314
355
  let cancelled = false;
356
+ const startupAbort = new AbortController();
315
357
  let stopping: Promise<void> | undefined;
316
358
  const routes: Array<() => void> = [];
317
359
  const stopBot = async () => {
@@ -320,6 +362,8 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
320
362
  };
321
363
  const cleanup = () => {
322
364
  cancelled = true;
365
+ releaseUploadDiagnostics();
366
+ startupAbort.abort();
323
367
  if (this.#bot === bot) this.#bot = null;
324
368
  for (const release of routes.splice(0)) release();
325
369
  return stopping ??= stopBot();
@@ -332,21 +376,22 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
332
376
  config: this.#options.config,
333
377
  getBot: () => cancelled ? null : bot,
334
378
  }));
335
- await bot.start();
379
+ await awaitQqStartup(bot.start().then(async () => {
380
+ if (cancelled) await stopBot();
381
+ }, async (error) => {
382
+ if (cancelled) await stopBot();
383
+ throw error;
384
+ }), startupAbort.signal);
336
385
  if (cancelled) {
337
386
  // SDK startup may create resources after stop; close this exact old client again.
338
387
  await stopping;
339
- await stopBot();
340
388
  return;
341
389
  }
342
390
  this.#logger.info(`connected (${this.#options.config.mode}) | path: ${this.#options.config.webhookPath}`);
343
391
  } catch (error) {
344
392
  const wasCancelled = cancelled;
345
393
  await cleanup();
346
- if (wasCancelled) {
347
- await stopBot();
348
- return;
349
- }
394
+ if (wasCancelled) return;
350
395
  throw error;
351
396
  }
352
397
  });
@@ -374,20 +419,26 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
374
419
  const kind = qqOutboundKind(conversation);
375
420
  const bot = this.#requireBot();
376
421
  let result: unknown;
377
- switch (kind) {
378
- case 'private':
379
- result = await bot.sendPrivateMessage(conversation.id, body);
380
- break;
381
- case 'group':
382
- result = await bot.sendGroupMessage(conversation.id, body);
383
- break;
384
- case 'channel':
385
- result = await bot.sendGuildMessage(conversation.id, body);
386
- break;
387
- case 'direct':
388
- if (!bot.sendDirectMessage) throw new Error('QQ direct message not supported by transport');
389
- result = await bot.sendDirectMessage(conversation.id, body);
390
- break;
422
+ try {
423
+ switch (kind) {
424
+ case 'private':
425
+ result = await bot.sendPrivateMessage(conversation.id, body);
426
+ break;
427
+ case 'group':
428
+ result = await bot.sendGroupMessage(conversation.id, body);
429
+ break;
430
+ case 'channel':
431
+ result = await bot.sendGuildMessage(conversation.id, body);
432
+ break;
433
+ case 'direct':
434
+ if (!bot.sendDirectMessage) throw new Error('QQ direct message not supported by transport');
435
+ result = await bot.sendDirectMessage(conversation.id, body);
436
+ break;
437
+ }
438
+ } catch (error) {
439
+ const { failure, diagnostic } = qqDeliveryFailure(error);
440
+ this.#logger.warn(formatCompact(diagnostic));
441
+ throw failure;
391
442
  }
392
443
  const messageId = `${kind}-${conversation.id}:${resolveOutboundMessageId(result)}`;
393
444
  this.#logger.info(
@@ -401,20 +452,7 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
401
452
  async recallMessage(messageId: string): Promise<void> {
402
453
  const { kind, channelId, qqMsgId } = parseCompoundMessageId(messageId);
403
454
  const bot = this.#requireBot();
404
- switch (kind) {
405
- case 'private':
406
- await bot.recallPrivateMessage?.(channelId, qqMsgId);
407
- break;
408
- case 'group':
409
- await bot.recallGroupMessage?.(channelId, qqMsgId);
410
- break;
411
- case 'channel':
412
- await bot.recallGuildMessage?.(channelId, qqMsgId);
413
- break;
414
- case 'direct':
415
- await bot.recallDirectMessage?.(channelId, qqMsgId);
416
- break;
417
- }
455
+ await recallQqMessage(bot, kind, channelId, qqMsgId);
418
456
  this.#logger.debug(formatCompact({
419
457
  op: 'qq_recall',
420
458
  endpoint: this.#options.config.id,
@@ -460,6 +498,29 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
460
498
  });
461
499
  }
462
500
 
501
+ /** Native click only; C2C/group have no platform source message ID. */
502
+ admitInteraction(raw: unknown): boolean {
503
+ if (!this.#open) return false;
504
+ const action = normalizeQqInteraction(raw);
505
+ if (!action) return false;
506
+ const now = Date.now();
507
+ for (const [id, time] of this.#seenInteractions) if (now - time > 300_000) this.#seenInteractions.delete(id);
508
+ if (this.#seenInteractions.has(action.message.id)) return true;
509
+ this.#seenInteractions.set(action.message.id, now);
510
+ if (this.#seenInteractions.size > 10_000) this.#seenInteractions.delete(this.#seenInteractions.keys().next().value!);
511
+ // ACK owns only platform loading state, never implies business acceptance.
512
+ void action.ack().then(ok => { if (ok === false) this.#logger.warn('qq_interaction_ack_failed'); }, () => this.#logger.warn('qq_interaction_ack_failed'));
513
+ const conversation = qqInboundConversation(String(this.#options.id), action.message);
514
+ void this.emit('message.receive', {
515
+ conversation, message: { conversation, id: action.message.id }, content: '',
516
+ segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
517
+ metadata: Object.freeze({ eventType: 'INTERACTION_CREATE', sourceMessageIdAvailable: action.sourceMessageId !== undefined,
518
+ ...(action.sourceMessageId ? { sourceMessageId: action.sourceMessageId } : {}),
519
+ callbackAssociation: action.sourceMessageId ? 'source-message' : 'payload-conversation-actor' }),
520
+ }).catch(() => this.#logger.warn('qq_interaction_receive_failed'));
521
+ return true;
522
+ }
523
+
463
524
  #bindBot(bot: QqBotTransport): void {
464
525
  bindQqBotInboundEvents(bot, (raw) => {
465
526
  if (this.#bot !== bot) return;
@@ -470,6 +531,7 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
470
531
  bindQqBotSideEvents(bot, (eventName, raw) => {
471
532
  if (this.#bot !== bot) return;
472
533
  this.#emitPlatformEvent(eventName, raw);
534
+ if (this.admitInteraction(raw)) return;
473
535
  receiveQqSideEvent(
474
536
  (name, payload) => this.emit(name, payload),
475
537
  this.#options.config.id,
@@ -492,7 +554,7 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
492
554
  }
493
555
 
494
556
  #requireBot(): QqHttpBotTransport {
495
- if (!this.#bot) throw new Error('QQ bot not connected');
557
+ if (!this.#bot) throw new EndpointDeliveryError('endpoint_disconnected', 'QQ bot not connected', 'not_sent');
496
558
  return this.#bot;
497
559
  }
498
560
  }
@@ -532,3 +594,11 @@ function asRecord(value: unknown): Record<string, unknown> {
532
594
  ? value as Record<string, unknown>
533
595
  : {};
534
596
  }
597
+
598
+ async function recallQqMessage(bot: QqBotTransport, kind: ReturnType<typeof qqOutboundKind>, channelId: string, messageId: string): Promise<void> {
599
+ const recall = { private: bot.recallPrivateMessage, group: bot.recallGroupMessage,
600
+ channel: bot.recallGuildMessage, direct: bot.recallDirectMessage }[kind];
601
+ if (!recall) throw new EndpointDeliveryError('recall_unsupported', 'QQ transport does not support recall for this conversation', 'not_sent');
602
+ if (!messageId) throw new EndpointDeliveryError('invalid_message_id', 'QQ recall requires a nonempty message ID', 'not_sent');
603
+ await recall.call(bot, channelId, messageId);
604
+ }
@@ -0,0 +1,15 @@
1
+ import type { QqInboundMessage } from './protocol.js';
2
+
3
+ /** qq-official-bot 1.3 normalizes INTERACTION_CREATE to notice.*.action. */
4
+ export function normalizeQqInteraction(raw: unknown): { message: QqInboundMessage; sourceMessageId?: string; ack: () => Promise<unknown> } | null {
5
+ if (!raw || typeof raw !== 'object') return null;
6
+ const event = raw as { sub_type?: string; notice_type?: string; notice_id?: string; operator_id?: string; group_id?: string; channel_id?: string; guild_id?: string; data?: { resolved?: { button_data?: unknown; message_id?: unknown; user_id?: string } }; reply?: (code: number) => Promise<unknown> };
7
+ const resolved = event.data?.resolved;
8
+ if (event.sub_type !== 'action' || !event.notice_id || typeof resolved?.button_data !== 'string' || typeof event.reply !== 'function') return null;
9
+ const actor = event.operator_id ?? resolved.user_id;
10
+ const kind = event.notice_type === 'friend' ? 'private' : event.notice_type === 'group' ? 'group' : event.notice_type === 'guild' ? 'channel' : undefined;
11
+ const id = kind === 'private' ? actor : kind === 'group' ? event.group_id : event.channel_id;
12
+ if (!kind || !id || !actor || (kind === 'channel' && !event.guild_id)) return null;
13
+ const sourceMessageId = typeof resolved.message_id === 'string' && resolved.message_id ? resolved.message_id : undefined;
14
+ return { message: { id: event.notice_id, content: '', channelKind: kind, channelId: id, authorId: actor, authorName: '', timestamp: Date.now(), ...(event.guild_id ? { guildId: event.guild_id } : {}), segments: [{ type: 'action', data: { payload: resolved.button_data } }] }, ...(sourceMessageId ? { sourceMessageId } : {}), ack: () => event.reply!(0) };
15
+ }
package/src/outbound.ts CHANGED
@@ -10,10 +10,11 @@
10
10
  * - keyboard:data.rows 按钮矩阵展开为 button 段(SDK processButtons 组装
11
11
  * keyboard 载荷);按钮消息必须带 markdown,故同行文本合并为 markdown 段。
12
12
  * data.id 模板键盘原样透传。
13
- * 不可映射的段一律降级为文本并 warn(保持既有 fallback 行为)。
13
+ * canonical share 当前明确 not_sent/unsupported,避免静默丢标题/描述/URL。
14
+ * 其他未识别段保持既有 data.text fallback;正常发送另受声明的 supported 白名单约束。
14
15
  */
15
16
  import { formatCompact, getLogger } from '@zhin.js/logger';
16
- import { isMediaRef } from '@zhin.js/im-contract';
17
+ import { EndpointDeliveryError, isMediaRef } from '@zhin.js/im-contract';
17
18
  import type { QqWireSegment } from './protocol.js';
18
19
 
19
20
  const logger = getLogger('qq');
@@ -30,6 +31,7 @@ interface QqOutboundButtonSpec {
30
31
  readonly id?: string;
31
32
  readonly label?: string;
32
33
  readonly payload?: string;
34
+ readonly url?: string;
33
35
  readonly disabled?: boolean;
34
36
  readonly style?: 'primary' | 'danger' | 'secondary';
35
37
  readonly mode?: string;
@@ -104,9 +106,9 @@ function normalizeMarkdownSegment(seg: QqWireSegment): QqOutboundElem | null {
104
106
  function coreButtonToQq(btn: QqOutboundButtonSpec): Record<string, unknown> {
105
107
  const isCommand = btn.mode === 'command';
106
108
  const action: Record<string, unknown> = {
107
- type: isCommand ? 2 : 0,
109
+ type: isCommand ? 2 : btn.url ? 0 : 1,
108
110
  permission: { type: 2 },
109
- data: btn.payload ?? '',
111
+ data: btn.url ?? btn.payload ?? '',
110
112
  click_limit: btn.disabled ? 0 : 10,
111
113
  unsupport_tips: btn.disabled ? '该按钮不可用' : '',
112
114
  };
@@ -199,6 +201,8 @@ export function formatOutbound(payload: unknown): QqOutboundMessage {
199
201
  }
200
202
  const data = item.data ?? {};
201
203
  switch (item.type) {
204
+ case 'share':
205
+ throw new EndpointDeliveryError('unsupported_operation', 'QQ canonical share cards are not implemented', 'not_sent');
202
206
  case 'text':
203
207
  textBuf += String(data.text ?? data.content ?? '');
204
208
  break;
@@ -230,7 +234,7 @@ export function formatOutbound(payload: unknown): QqOutboundMessage {
230
234
  break;
231
235
  }
232
236
  case 'reply': {
233
- const id = data.id ?? data.event_id;
237
+ const id = data.message_id ?? data.id ?? data.event_id;
234
238
  if (id == null || id === '') break;
235
239
  flushText();
236
240
  parts.push({
package/src/protocol.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /** QQ Official Bot protocol helpers. Canonicalization is owned by Core. */
2
2
 
3
- import type { ConversationKind, ConversationRef } from '@zhin.js/im-contract';
3
+ import { EndpointDeliveryError, type ConversationKind, type ConversationRef } from '@zhin.js/im-contract';
4
4
  import { formatCompact, getLogger } from '@zhin.js/logger';
5
5
  import { resolveQqIntents } from './qq-intents.js';
6
6
 
@@ -20,6 +20,7 @@ export interface QqEndpointConfig {
20
20
  readonly botKind?: string;
21
21
  readonly accessTokenUrl?: string;
22
22
  readonly gatewayUrl?: string;
23
+ readonly streamProxy?: import('./stream-proxy.js').QqStreamProxy;
23
24
  readonly webhookPath?: string;
24
25
  }
25
26
 
@@ -33,6 +34,7 @@ export interface ResolvedQqWebsocketConfig {
33
34
  readonly intents?: readonly string[];
34
35
  readonly accessTokenUrl?: string;
35
36
  readonly gatewayUrl?: string;
37
+ readonly streamProxy?: import('./stream-proxy.js').QqStreamProxy;
36
38
  }
37
39
 
38
40
  export interface ResolvedQqHttpConfig {
@@ -96,6 +98,8 @@ export function resolveQqConfig(config: QqEndpointConfig): ResolvedQqConfig {
96
98
  };
97
99
  }
98
100
 
101
+ if (config.streamProxy && (!Number.isInteger(config.streamProxy.port) || config.streamProxy.port < 1 || config.streamProxy.port > 65535
102
+ || typeof config.streamProxy.serverName !== 'string' || !config.streamProxy.serverName || /[\s/:@?#]/.test(config.streamProxy.serverName))) throw new TypeError('Invalid QQ streamProxy port/serverName');
99
103
  return {
100
104
  context: 'qq',
101
105
  mode: 'websocket',
@@ -109,6 +113,7 @@ export function resolveQqConfig(config: QqEndpointConfig): ResolvedQqConfig {
109
113
  }),
110
114
  accessTokenUrl: config.accessTokenUrl,
111
115
  gatewayUrl: config.gatewayUrl,
116
+ ...(config.streamProxy ? { streamProxy: config.streamProxy } : {}),
112
117
  };
113
118
  }
114
119
 
@@ -228,21 +233,25 @@ export function parseCompoundMessageId(messageId: string): {
228
233
  /** 从 QQ API SendResult / 审核回包中解析出站消息 ID */
229
234
  export function resolveOutboundMessageId(result: unknown): string {
230
235
  if (!result || typeof result !== 'object') {
231
- throw new Error('QQ 发送消息失败:响应为空');
236
+ throw new EndpointDeliveryError('delivery_unconfirmed', 'QQ 发送消息失败:响应为空', 'unknown');
232
237
  }
233
238
  const row = result as Record<string, unknown>;
239
+ if (typeof row.code === 'number' && Number.isFinite(row.code) && row.code !== 0) {
240
+ throw new EndpointDeliveryError('platform_rejected', `QQ platform rejected the outbound request (${row.code})`, 'rejected');
241
+ }
234
242
  const nested = row.data && typeof row.data === 'object'
235
243
  ? row.data as Record<string, unknown>
236
244
  : undefined;
237
- const audit = (row.message_audit ?? nested?.message_audit) as Record<string, unknown> | undefined;
238
- const id = row.id ?? row.message_id ?? audit?.audit_id;
239
- if (id == null || id === '') {
245
+ const id = row.id ?? row.message_id ?? nested?.id ?? nested?.message_id;
246
+ if ((typeof id !== 'string' && typeof id !== 'number') || String(id).trim() === '' || (typeof id === 'number' && !Number.isFinite(id))) {
240
247
  const code = row.code;
241
- const msg = row.message;
242
- throw new Error(
248
+ const rejected = typeof code === 'number' && Number.isFinite(code) && code !== 0;
249
+ throw new EndpointDeliveryError(
250
+ rejected ? 'platform_rejected' : 'delivery_unconfirmed',
243
251
  code != null
244
- ? `QQ 发送消息失败(${String(code)})${msg ? `: ${String(msg)}` : ''}`
252
+ ? 'QQ outbound response is unconfirmed'
245
253
  : 'QQ 发送消息失败:响应缺少消息 ID',
254
+ rejected ? 'rejected' : 'unknown',
246
255
  );
247
256
  }
248
257
  return String(id);
package/src/qq-intents.ts CHANGED
@@ -29,6 +29,7 @@ export const DEFAULT_QQ_BOT_KIND: QqBotKind = 'public';
29
29
  /** 公/私域共用:群/C2C、群成员变更、频道基础事件、频道私信。 */
30
30
  const QQ_SHARED_INTENTS = Object.freeze([
31
31
  'GROUP_AND_C2C_EVENT',
32
+ 'INTERACTION',
32
33
  'GROUP_MEMBER',
33
34
  'GUILDS',
34
35
  'GUILD_MEMBERS',
@@ -0,0 +1,18 @@
1
+ /** SDK startup must settle; serial endpoint readiness must never wait forever. */
2
+ export async function awaitQqStartup(starting: Promise<void>, signal: AbortSignal): Promise<void> {
3
+ let timer: ReturnType<typeof setTimeout> | undefined;
4
+ let aborted!: () => void;
5
+ const cancelled = new Promise<never>((_resolve, reject) => {
6
+ aborted = () => reject(new Error('QQ startup stopped'));
7
+ signal.addEventListener('abort', aborted, { once: true });
8
+ if (signal.aborted) aborted();
9
+ });
10
+ try {
11
+ await Promise.race([starting, cancelled, new Promise<never>((_resolve, reject) => {
12
+ timer = setTimeout(() => reject(new Error('QQ startup timed out after 30000ms')), 30_000);
13
+ })]);
14
+ } finally {
15
+ if (timer) clearTimeout(timer);
16
+ signal.removeEventListener('abort', aborted);
17
+ }
18
+ }
@@ -0,0 +1,20 @@
1
+ import { Agent } from 'node:https';
2
+ import tls from 'node:tls';
3
+ import { getLogger } from '@zhin.js/logger';
4
+
5
+ export interface QqStreamProxy { readonly port: number; readonly serverName: string }
6
+
7
+ /** QQ receiver uses this agent only for WSS; discovery/OpenAPI stay unchanged. */
8
+ export function createQqStreamAgent(proxy: QqStreamProxy): Agent {
9
+ const agent = new Agent({ keepAlive: false });
10
+ agent.createConnection = options => {
11
+ const hostname = String(options.hostname ?? options.host ?? '');
12
+ if (hostname !== proxy.serverName || Number(options.port ?? 443) !== 443) {
13
+ getLogger('qq').warn(`Stream proxy rejected gateway hostname: ${hostname}`);
14
+ throw new Error('Qq Stream proxy gateway identity changed; update fixed upstream before retrying');
15
+ }
16
+ return tls.connect({ ...options as tls.ConnectionOptions, path: undefined, host: '127.0.0.1', port: proxy.port,
17
+ servername: hostname, rejectUnauthorized: true });
18
+ };
19
+ return agent;
20
+ }
@@ -0,0 +1,70 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { createHash } from 'node:crypto';
3
+
4
+ type Row = Record<string, unknown>;
5
+ type Metadata = Record<string, string | number | boolean | undefined>;
6
+ interface Interceptors {
7
+ use(fulfilled: (value: unknown) => unknown): number;
8
+ eject(id: number): void;
9
+ }
10
+ interface UploadContext { buffer: Buffer; blockSize?: number; indexBase?: number; finishes: number }
11
+ const row = (value: unknown): Row => value !== null && (typeof value === 'object' || typeof value === 'function') ? value as Row : {};
12
+ const numeric = (value: unknown): number | undefined => { const n = typeof value === 'number' || typeof value === 'string' ? Number(value) : NaN; return Number.isSafeInteger(n) && n >= 0 ? n : undefined; };
13
+ const hash = (buffer: Buffer, algorithm = 'md5') => createHash(algorithm).update(buffer).digest('hex');
14
+ const body = (value: unknown): Row => { if (typeof value !== 'string') return row(value); try { return row(JSON.parse(value)); } catch { return {}; } };
15
+ const stage = (value: unknown): string => typeof value === 'string' ? value.split('?')[0].split('/').at(-1) ?? '' : '';
16
+
17
+ /** Same complete-set rule as the SDK patch; preserve platform index in ACKs. */
18
+ export function inferQqUploadIndexBase(parts: unknown, fileSize: number, blockSize: number): 0 | 1 | undefined {
19
+ if (!Number.isSafeInteger(blockSize) || blockSize <= 0 || !fileSize || !Array.isArray(parts)) return undefined;
20
+ const count = Math.ceil(fileSize / blockSize);
21
+ if (!parts.length || parts.length !== count) return undefined;
22
+ let base = Infinity; const indices = new Set<number>();
23
+ for (const part of parts) { const index = row(part).index; if (typeof index !== 'number' || !Number.isSafeInteger(index)) return undefined; base = Math.min(base, index); indices.add(index); }
24
+ if ((base !== 0 && base !== 1) || indices.size !== count) return undefined;
25
+ for (const part of parts) { const item = row(part); const index = item.index as number; if (index < base || index >= base + count || numeric(item.block_size) !== Math.min(blockSize, fileSize - (index - base) * blockSize)) return undefined; }
26
+ return base;
27
+ }
28
+
29
+ /** Endpoint-owned observer. Emits only whitelisted numeric/boolean metadata. */
30
+ export function installQqUploadDiagnostics(api: unknown, debug: (metadata: Metadata) => void): () => void {
31
+ const bot = row(api); const request = row(bot.request); const interceptors = row(request.interceptors);
32
+ const requests = interceptors.request as Interceptors | undefined; const responses = interceptors.response as Interceptors | undefined;
33
+ const processor = row(bot.fileProcessor);
34
+ const original = processor.uploadByChunks;
35
+ if (!requests?.use || !responses?.use || typeof original !== 'function') return () => {};
36
+ const storage = new AsyncLocalStorage<UploadContext>(); let active = true;
37
+ const emit = (metadata: Metadata) => { if (active) { try { debug(metadata); } catch { /* diagnostics never affect delivery */ } } };
38
+ const wrapped = function(this: unknown, buffer: Buffer, ...args: unknown[]) {
39
+ return storage.run({ buffer, finishes: 0 }, () => original.call(this, buffer, ...args));
40
+ };
41
+ processor.uploadByChunks = wrapped;
42
+ // Axios request hooks run LIFO, before the SDK authentication/body transformer;
43
+ // success response hooks run FIFO, after the SDK's identity success hook.
44
+ const requestId = requests.use(value => {
45
+ const config = row(value); const data = body(config.data); const context = storage.getStore();
46
+ if (stage(config.url) === 'upload_prepare') {
47
+ emit({ op: 'qq_upload_prepare', fileType: numeric(data.file_type), fileSize: numeric(data.file_size), actualSize: context?.buffer.length,
48
+ ...(context ? { sizeMatch: numeric(data.file_size) === context.buffer.length, hashMatch: data.md5 === hash(context.buffer) && data.sha1 === hash(context.buffer, 'sha1') && data.md5_10m === hash(context.buffer.subarray(0, 10002432)) } : {}) });
49
+ } else if (stage(config.url) === 'upload_part_finish' && context && context.finishes++ < 8) {
50
+ const index = numeric(data.part_index); const length = numeric(data.block_size); const blockSize = context.blockSize; const base = context.indexBase;
51
+ const expected = index !== undefined && blockSize && base !== undefined ? context.buffer.subarray((index - base) * blockSize, (index - base + 1) * blockSize) : undefined;
52
+ emit({ op: 'qq_upload_part_finish', index, blockSize: length, ...(expected ? { sizeMatch: length === expected.length, hashMatch: data.md5 === hash(expected) } : {}) });
53
+ }
54
+ return value;
55
+ });
56
+ const responseId = responses.use(value => {
57
+ const response = row(value); const config = row(response.config);
58
+ if (stage(config.url) !== 'upload_prepare') return value;
59
+ const data = row(response.data); const parts = Array.isArray(data.parts) ? data.parts.slice(0, 128).map(row) : undefined;
60
+ const blockSize = numeric(data.block_size); const context = storage.getStore(); if (context) { context.blockSize = blockSize; context.indexBase = blockSize ? inferQqUploadIndexBase(data.parts, context.buffer.length, blockSize) : undefined; }
61
+ const indices = parts?.map(part => numeric(part.index)).filter((index): index is number => index !== undefined) ?? [];
62
+ const sizes = parts?.map(part => numeric(part.block_size)).filter((size): size is number => size !== undefined) ?? [];
63
+ emit({ op: 'qq_upload_prepared', blockSize, partsPresent: parts !== undefined, partsCount: Array.isArray(data.parts) ? data.parts.length : undefined, inspectedCount: parts?.length,
64
+ validIndices: indices.length, ...(indices.length ? { minIndex: Math.min(...indices), maxIndex: Math.max(...indices) } : {}),
65
+ validSizes: sizes.length, ...(sizes.length ? { minPartSize: Math.min(...sizes), maxPartSize: Math.max(...sizes) } : {}),
66
+ ...(context && blockSize ? { coverageMatch: context.indexBase !== undefined, indexBase: context.indexBase } : {}) });
67
+ return value;
68
+ });
69
+ return () => { active = false; requests.eject(requestId); responses.eject(responseId); if (processor.uploadByChunks === wrapped) processor.uploadByChunks = original; storage.disable(); };
70
+ }