@zhin.js/adapter-qq 1.1.4 → 1.1.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # @zhin.js/adapter-qq
2
2
 
3
+ ## 1.1.6
4
+
5
+ ### Patch Changes
6
+
7
+ - 2cdb181: Upgrade kook-client to ^1.0.5 and qq-official-bot to ^1.3.1, which include the upstream lifecycle and upload fixes. Remove local SDK patches and validate the published SDK directly in regression fixtures.
8
+ - Updated dependencies [badcc1f]
9
+ - @zhin.js/core@1.1.41
10
+ - @zhin.js/feature-kit@1.1.3
11
+ - @zhin.js/adapter@1.1.17
12
+ - @zhin.js/agent-feature@1.1.4
13
+ - @zhin.js/skill@1.1.4
14
+ - @zhin.js/tool@1.1.4
15
+ - @zhin.js/host-http@1.1.4
16
+ - @zhin.js/permission@1.1.4
17
+
18
+ ## 1.1.5
19
+
20
+ ### Patch Changes
21
+
22
+ - b9b3953: Fix adapter connection races, transport health reporting, candidate rollback and delivery acknowledgement boundaries. Separate generation admission from locally observed connectivity, reject unconfirmed message receipts, and mark uncertain delivery results without recommending automatic replay.
23
+
24
+ Class and compact endpoints should expose a `transportState` getter to report physical connection or local listener health. Unobserved endpoints now report an unknown transport and remain offline in diagnostics. Platform stability tiers remain unchanged until real account acceptance evidence is available.
25
+
26
+ - b9b3953: Bound QQ startup to 30 seconds with cancellation and late-client cleanup, own asynchronous CLI startup failures, and patch qq-official-bot session startup so authentication/receiver failures reject the returned promise without orphan rejection or lost synchronous readiness.
27
+
28
+ Encode native QQ callback buttons with official action type 1, subscribe to interaction events by default, and map SDK action notices into canonical clicks with platform ACK and honest source-message association metadata.
29
+
30
+ Reject outbound messages containing segments excluded by an explicit adapter supported policy, and reject unimplemented QQ share cards instead of silently dropping title/description/URL while confirming neighboring text.
31
+
32
+ - b9b3953: Reject QQ outbound responses carrying a nonzero platform error even when they contain a message ID. Classify DingTalk HTTP client rejection separately from uncertain server/network outcomes and avoid exposing raw platform response text in delivery errors.
33
+ - b9b3953: Add an optional fixed loopback WSS fault proxy that preserves gateway Host/SNI and TLS peer verification. Move reconnect ownership to EndpointLifecycle and keep QQ authentication, discovery, message APIs and uploads direct. Extend the pinned SDK patch with instance-scoped WebSocket agent/factory and reconnect controls, and opt adapters out of SDK process-level error handlers to prevent listener accumulation.
34
+ - Updated dependencies [b9b3953]
35
+ - Updated dependencies [b9b3953]
36
+ - Updated dependencies [b9b3953]
37
+ - Updated dependencies [b9b3953]
38
+ - Updated dependencies [b9b3953]
39
+ - Updated dependencies [b9b3953]
40
+ - Updated dependencies [b9b3953]
41
+ - Updated dependencies [b9b3953]
42
+ - Updated dependencies [b9b3953]
43
+ - Updated dependencies [b9b3953]
44
+ - Updated dependencies [b9b3953]
45
+ - @zhin.js/adapter@1.1.16
46
+ - @zhin.js/im-contract@1.1.3
47
+ - @zhin.js/core@1.1.40
48
+ - @zhin.js/host-http@1.1.3
49
+ - @zhin.js/tool@1.1.3
50
+ - @zhin.js/skill@1.1.3
51
+ - @zhin.js/agent-feature@1.1.3
52
+
3
53
  ## 1.1.4
4
54
 
5
55
  ### Patch Changes
package/README.md CHANGED
@@ -182,3 +182,30 @@ payload 的回跳映射。图片、音频、视频和文件仍沿 canonical `Med
182
182
  ## 许可证
183
183
 
184
184
  MIT License
185
+
186
+ ### 发送回执与未知结果
187
+
188
+ 发送成功回执只接受平台真实消息 ID。网络超时、断线、缺少消息 ID 的响应会报告 `deliveryUnknown`,框架不会自动重发;手动重发可能重复发送。
189
+
190
+ QQ 返回 `message_audit.audit_id` 仅表示审核任务,不能用作消息 ID,不能据此声明消息已投递。
191
+
192
+ QQ SDK 启动须在 30 秒内完成,否则端点启动失败并清理该客户端;迟到就绪会再次清理旧客户端。超时不表示凭据错误,应结合网关、认证网络与权限检查。
193
+
194
+ `qq-official-bot` 1.3.1 已包含 SDK Session.start 的 async Promise executor 修复:认证/接收器启动异常由启动 Promise 正确拒绝,ready 监听先于启动注册,启动结束清理临时监听。已实际验证本机 dummy 403 认证失败经完整 CLI 退出 1,无未处理 rejection;不等价于真实 QQ 账号联网验收。
195
+
196
+ 原生按钮按腾讯协议编码:type=0 URL 跳转、type=1 回调、type=2 指令。默认订阅包含 INTERACTION(1<<26);用户显式 intents 保留原值,需自行加入 INTERACTION 才能收到回调。SDK notice.*.action 映射为 canonical action,并独立 PUT ACK解除loading;ACK不代表业务处理成功。私聊/群聊平台不提供源消息ID,metadata 明确 sourceMessageIdAvailable=false,以唯一payload、完整端点/会话与操作者关联;频道提供源ID时严格保留。参考:[腾讯官方消息按钮协议](https://github.com/tencent-connect/bot-docs/blob/main/docs/develop/api-v2/server-inter/message/trans/msg-btn.md)。
197
+
198
+ QQ 当前未实现 canonical `share` 的标题/描述/URL 消息卡片。适配器显式 supported 不含 share,探针及正常统一发送均整体返回 unsupported;不会仅发送旁边的样本文字并记分享成功。直接 endpoint 编码也明确 not_sent,避免绕过能力检查后静默丢失。
199
+
200
+ 分片上传兼容平台返回的完整连续 0-based 或 1-based 序号。字节偏移使用
201
+ `(index - base) * block_size`,分片完成通知保留平台原序号;重复、缺片、异常大小或空
202
+ 分片集合会在 PUT 前拒绝。官方文档示例为 0-based,实机曾返回单片 `index: 1`。
203
+ 当前官方预上传合同未说明 `parts: []` 是合法秒传响应,因此未将空集合视为已上传;
204
+ 若后续取得该合同证据,应补独立回归后支持。debug 诊断仅记录数值和匹配状态,SDK
205
+ 原始 debug 继续关闭,避免输出凭据及上传地址。
206
+
207
+ ### WebSocket 受控故障入口
208
+
209
+ 可选 `streamProxy: { port: 18443, serverName: "实际网关 hostname" }` 将 WSS 的 TCP 连接导向 `127.0.0.1:port`。需代理固定转发到相同官方网关的 443 端口;SDK 每次仍动态调用 `getWsUrl()`,hostname/443 不匹配会拒绝连接并只记录 hostname,不能自动绕过代理。原始 URL、HTTP Host、TLS SNI 保留,证书验证始终开启。不要填带 query 的 URL。
210
+
211
+ 仅 WSS 入站、心跳和会话认证经过此代理;AppSecret 换 token、网关发现 HTTP、消息 API 和媒体上传仍直连。故障恢复由 `createEndpointLifecycle` 负责,SDK 内置重连关闭,避免双重重连。此入口用于受控网络验收,不提供全局代理或 TLS 验证绕过。
@@ -0,0 +1,13 @@
1
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
2
+ /** Project SDK request failures into stable delivery evidence without exposing request data. */
3
+ export declare function qqDeliveryFailure(error: unknown): {
4
+ failure: EndpointDeliveryError;
5
+ diagnostic: {
6
+ op: string;
7
+ stage: string;
8
+ httpStatus: number | undefined;
9
+ platformCode: number | undefined;
10
+ code: string;
11
+ disposition: "not_sent" | "rejected" | "unknown";
12
+ };
13
+ };
@@ -0,0 +1,19 @@
1
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
2
+ /** Project SDK request failures into stable delivery evidence without exposing request data. */
3
+ export function qqDeliveryFailure(error) {
4
+ const value = error && typeof error === 'object' ? error : {};
5
+ const httpStatus = typeof value.response?.status === 'number' ? value.response.status : undefined;
6
+ let platformCode = typeof value.response?.data?.code === 'number' && Number.isFinite(value.response.data.code) ? value.response.data.code : undefined;
7
+ let path = typeof value.config?.url === 'string' ? value.config.url.split('?')[0] : '';
8
+ // Published SDK 1.3 flattens Axios rejection into this exact message format.
9
+ // Extract only operation suffix and numeric protocol code; never surface the message.
10
+ const flattened = error instanceof Error ? /^Request "([^"]+)" failed with code\((\d+)\):/.exec(error.message) : null;
11
+ if (flattened) {
12
+ path ||= flattened[1].split('?')[0];
13
+ platformCode ??= Number(flattened[2]);
14
+ }
15
+ const stage = /\/upload_prepare$/.test(path) ? 'upload_prepare' : /\/upload_part_finish$/.test(path) ? 'upload_part_finish' : /\/files$/.test(path) ? 'upload_complete' : /\/messages$/.test(path) ? 'send_message' : 'sdk_send';
16
+ const knownRejection = (httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500) || (!!flattened && platformCode !== undefined && ((platformCode >= 400 && platformCode < 500) || platformCode >= 1000));
17
+ const failure = error instanceof EndpointDeliveryError ? error : new EndpointDeliveryError(knownRejection ? 'platform_rejected' : 'delivery_unconfirmed', knownRejection ? 'QQ platform rejected the outbound request' : 'QQ outbound request outcome is unknown', knownRejection ? 'rejected' : 'unknown', { cause: error });
18
+ return { failure, diagnostic: { op: 'qq_delivery_failed', stage, httpStatus, platformCode, code: failure.code, disposition: failure.disposition } };
19
+ }
package/lib/endpoint.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * QQ endpoints — lifecycle, outbound, admit, agent tool surface.
3
3
  */
4
- import { Endpoint, type EndpointControl, type EndpointManagement, type EndpointSendRequest } from 'zhin.js/adapter';
4
+ import { Endpoint, type EndpointControl, type EndpointManagement, type EndpointSendRequest, type EndpointTransportState } from 'zhin.js/adapter';
5
5
  import type { HttpHost } from '@zhin.js/host-http';
6
6
  import type { CapabilityId } from 'zhin.js';
7
7
  import { type QqInboundMessage, type ResolvedQqHttpConfig, type ResolvedQqWebsocketConfig } from './protocol.js';
@@ -22,6 +22,7 @@ export declare class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
22
22
  get client(): QqBotTransport;
23
23
  /** Live endpoint 名(Console/AdapterIndex 展示用,如 bot appid 别名)。 */
24
24
  get name(): string;
25
+ get transportState(): EndpointTransportState;
25
26
  start(): Promise<void>;
26
27
  open(): void;
27
28
  close(): void;
@@ -30,6 +31,8 @@ export declare class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
30
31
  recallMessage(messageId: string): Promise<void>;
31
32
  /** Test / internal: admit a message when open. */
32
33
  admit(msg: QqInboundMessage): void;
34
+ /** Native click only; C2C/group have no platform source message ID. */
35
+ admitInteraction(raw: unknown): boolean;
33
36
  }
34
37
  export interface QqHttpEndpointOptions {
35
38
  readonly id: CapabilityId;
@@ -46,6 +49,7 @@ export declare class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
46
49
  get client(): QqHttpBotTransport;
47
50
  /** Live endpoint 名(Console/AdapterIndex 展示用)。 */
48
51
  get name(): string;
52
+ get transportState(): EndpointTransportState;
49
53
  start(): Promise<void>;
50
54
  open(): void;
51
55
  close(): void;
@@ -53,4 +57,6 @@ export declare class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
53
57
  send({ conversation, payload }: EndpointSendRequest): Promise<string>;
54
58
  recallMessage(messageId: string): Promise<void>;
55
59
  admit(msg: QqInboundMessage): void;
60
+ /** Native click only; C2C/group have no platform source message ID. */
61
+ admitInteraction(raw: unknown): boolean;
56
62
  }
package/lib/endpoint.js 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
  */
@@ -14,6 +19,7 @@ export class QqWebsocketEndpoint extends Endpoint {
14
19
  #createBot;
15
20
  #bot = null;
16
21
  #open = false;
22
+ #seenInteractions = new Map();
17
23
  #lifecycle;
18
24
  #cleanup;
19
25
  management = createQqEndpointManagement(() => this.#requireBot());
@@ -21,7 +27,7 @@ export class QqWebsocketEndpoint extends Endpoint {
21
27
  constructor(options) {
22
28
  super();
23
29
  this.#logger = getAdapterLogger('qq', options.config.id);
24
- this.#lifecycle = createEndpointLifecycle({ name: options.config.id, reconnect: false });
30
+ this.#lifecycle = createEndpointLifecycle({ name: options.config.id });
25
31
  this.#options = options;
26
32
  this.#createBot = options.createBot ?? defaultCreateBot;
27
33
  }
@@ -32,11 +38,18 @@ export class QqWebsocketEndpoint extends Endpoint {
32
38
  get name() {
33
39
  return this.#options.config.id;
34
40
  }
41
+ get transportState() {
42
+ if (this.#lifecycle.state !== 'open')
43
+ return this.#lifecycle.state;
44
+ return this.#bot?.transportState ?? this.#lifecycle.state;
45
+ }
35
46
  async start() {
36
47
  await this.#lifecycle.start(async (handle) => {
37
48
  const bot = this.#createBot(this.#options.config);
38
49
  this.#bot = bot;
50
+ const releaseUploadDiagnostics = installQqUploadDiagnostics(bot.api, metadata => this.#logger.debug(formatCompact(metadata)));
39
51
  let cancelled = false;
52
+ const startupAbort = new AbortController();
40
53
  let stopping;
41
54
  const stopBot = async () => {
42
55
  try {
@@ -50,6 +63,8 @@ export class QqWebsocketEndpoint extends Endpoint {
50
63
  };
51
64
  const cleanup = () => {
52
65
  cancelled = true;
66
+ releaseUploadDiagnostics();
67
+ startupAbort.abort();
53
68
  if (this.#bot === bot)
54
69
  this.#bot = null;
55
70
  return stopping ??= stopBot();
@@ -57,12 +72,20 @@ export class QqWebsocketEndpoint extends Endpoint {
57
72
  this.#cleanup = cleanup;
58
73
  handle.onForceClose(() => { void cleanup(); });
59
74
  try {
75
+ bot.on('transport.close', () => { if (cancelled || this.#bot !== bot)
76
+ return; void cleanup(); handle.notifyClosed(); });
60
77
  this.#bindBot(bot);
61
- await bot.start();
78
+ await awaitQqStartup(bot.start().then(async () => {
79
+ if (cancelled)
80
+ await stopBot();
81
+ }, async (error) => {
82
+ if (cancelled)
83
+ await stopBot();
84
+ throw error;
85
+ }), startupAbort.signal);
62
86
  if (cancelled) {
63
87
  // SDK startup may create resources after stop; close this exact old client again.
64
88
  await stopping;
65
- await stopBot();
66
89
  return;
67
90
  }
68
91
  this.#logger.info(`connected (websocket) | appid: ${this.#options.config.appid}`);
@@ -70,10 +93,8 @@ export class QqWebsocketEndpoint extends Endpoint {
70
93
  catch (error) {
71
94
  const wasCancelled = cancelled;
72
95
  await cleanup();
73
- if (wasCancelled) {
74
- await stopBot();
96
+ if (wasCancelled)
75
97
  return;
76
- }
77
98
  const raw = error instanceof Error ? error.message : String(error);
78
99
  throw new Error(`QQ WebSocket 连接失败:请检查 appid/secret 是否配对、网关地址(gatewayUrl/accessTokenUrl)是否可达(原始错误:${raw})`, { cause: error });
79
100
  }
@@ -98,21 +119,28 @@ export class QqWebsocketEndpoint extends Endpoint {
98
119
  const kind = qqOutboundKind(conversation);
99
120
  const bot = this.#requireBot();
100
121
  let result;
101
- switch (kind) {
102
- case 'private':
103
- result = await bot.sendPrivateMessage(conversation.id, body);
104
- break;
105
- case 'group':
106
- result = await bot.sendGroupMessage(conversation.id, body);
107
- break;
108
- case 'channel':
109
- result = await bot.sendGuildMessage(conversation.id, body);
110
- break;
111
- case 'direct':
112
- if (!bot.sendDirectMessage)
113
- throw new Error('QQ direct message not supported by transport');
114
- result = await bot.sendDirectMessage(conversation.id, body);
115
- break;
122
+ try {
123
+ switch (kind) {
124
+ case 'private':
125
+ result = await bot.sendPrivateMessage(conversation.id, body);
126
+ break;
127
+ case 'group':
128
+ result = await bot.sendGroupMessage(conversation.id, body);
129
+ break;
130
+ case 'channel':
131
+ result = await bot.sendGuildMessage(conversation.id, body);
132
+ break;
133
+ case 'direct':
134
+ if (!bot.sendDirectMessage)
135
+ throw new Error('QQ direct message not supported by transport');
136
+ result = await bot.sendDirectMessage(conversation.id, body);
137
+ break;
138
+ }
139
+ }
140
+ catch (error) {
141
+ const { failure, diagnostic } = qqDeliveryFailure(error);
142
+ this.#logger.warn(formatCompact(diagnostic));
143
+ throw failure;
116
144
  }
117
145
  const messageId = `${kind}-${conversation.id}:${resolveOutboundMessageId(result)}`;
118
146
  this.#logger.info(`send ${kind}:${conversation.id}`
@@ -123,20 +151,7 @@ export class QqWebsocketEndpoint extends Endpoint {
123
151
  async recallMessage(messageId) {
124
152
  const { kind, channelId, qqMsgId } = parseCompoundMessageId(messageId);
125
153
  const bot = this.#requireBot();
126
- switch (kind) {
127
- case 'private':
128
- await bot.recallPrivateMessage?.(channelId, qqMsgId);
129
- break;
130
- case 'group':
131
- await bot.recallGroupMessage?.(channelId, qqMsgId);
132
- break;
133
- case 'channel':
134
- await bot.recallGuildMessage?.(channelId, qqMsgId);
135
- break;
136
- case 'direct':
137
- await bot.recallDirectMessage?.(channelId, qqMsgId);
138
- break;
139
- }
154
+ await recallQqMessage(bot, kind, channelId, qqMsgId);
140
155
  this.#logger.debug(formatCompact({
141
156
  op: 'qq_recall',
142
157
  endpoint: this.#options.config.id,
@@ -180,6 +195,35 @@ export class QqWebsocketEndpoint extends Endpoint {
180
195
  }));
181
196
  });
182
197
  }
198
+ /** Native click only; C2C/group have no platform source message ID. */
199
+ admitInteraction(raw) {
200
+ if (!this.#open)
201
+ return false;
202
+ const action = normalizeQqInteraction(raw);
203
+ if (!action)
204
+ return false;
205
+ const now = Date.now();
206
+ for (const [id, time] of this.#seenInteractions)
207
+ if (now - time > 300_000)
208
+ this.#seenInteractions.delete(id);
209
+ if (this.#seenInteractions.has(action.message.id))
210
+ return true;
211
+ this.#seenInteractions.set(action.message.id, now);
212
+ if (this.#seenInteractions.size > 10_000)
213
+ this.#seenInteractions.delete(this.#seenInteractions.keys().next().value);
214
+ // ACK owns only platform loading state, never implies business acceptance.
215
+ void action.ack().then(ok => { if (ok === false)
216
+ this.#logger.warn('qq_interaction_ack_failed'); }, () => this.#logger.warn('qq_interaction_ack_failed'));
217
+ const conversation = qqInboundConversation(String(this.#options.id), action.message);
218
+ void this.emit('message.receive', {
219
+ conversation, message: { conversation, id: action.message.id }, content: '',
220
+ segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
221
+ metadata: Object.freeze({ eventType: 'INTERACTION_CREATE', sourceMessageIdAvailable: action.sourceMessageId !== undefined,
222
+ ...(action.sourceMessageId ? { sourceMessageId: action.sourceMessageId } : {}),
223
+ callbackAssociation: action.sourceMessageId ? 'source-message' : 'payload-conversation-actor' }),
224
+ }).catch(() => this.#logger.warn('qq_interaction_receive_failed'));
225
+ return true;
226
+ }
183
227
  #bindBot(bot) {
184
228
  bindQqBotInboundEvents(bot, (raw) => {
185
229
  if (this.#bot !== bot)
@@ -193,6 +237,8 @@ export class QqWebsocketEndpoint extends Endpoint {
193
237
  if (this.#bot !== bot)
194
238
  return;
195
239
  this.#emitPlatformEvent(eventName, raw);
240
+ if (this.admitInteraction(raw))
241
+ return;
196
242
  receiveQqSideEvent((name, payload) => this.emit(name, payload), this.#options.config.id, bot, eventName, raw, this.#logger);
197
243
  });
198
244
  }
@@ -207,7 +253,7 @@ export class QqWebsocketEndpoint extends Endpoint {
207
253
  }
208
254
  #requireBot() {
209
255
  if (!this.#bot)
210
- throw new Error('QQ bot not connected');
256
+ throw new EndpointDeliveryError('endpoint_disconnected', 'QQ bot not connected', 'not_sent');
211
257
  return this.#bot;
212
258
  }
213
259
  }
@@ -218,6 +264,7 @@ export class QqHttpEndpoint extends Endpoint {
218
264
  #createBot;
219
265
  #bot = null;
220
266
  #open = false;
267
+ #seenInteractions = new Map();
221
268
  #lifecycle;
222
269
  #cleanup;
223
270
  management = createQqEndpointManagement(() => this.#requireBot());
@@ -236,11 +283,16 @@ export class QqHttpEndpoint extends Endpoint {
236
283
  get name() {
237
284
  return this.#options.config.id;
238
285
  }
286
+ get transportState() {
287
+ return this.#lifecycle.state;
288
+ }
239
289
  async start() {
240
290
  await this.#lifecycle.start(async (handle) => {
241
291
  const bot = this.#createBot(this.#options.config);
242
292
  this.#bot = bot;
293
+ const releaseUploadDiagnostics = installQqUploadDiagnostics(bot.api, metadata => this.#logger.debug(formatCompact(metadata)));
243
294
  let cancelled = false;
295
+ const startupAbort = new AbortController();
244
296
  let stopping;
245
297
  const routes = [];
246
298
  const stopBot = async () => {
@@ -255,6 +307,8 @@ export class QqHttpEndpoint extends Endpoint {
255
307
  };
256
308
  const cleanup = () => {
257
309
  cancelled = true;
310
+ releaseUploadDiagnostics();
311
+ startupAbort.abort();
258
312
  if (this.#bot === bot)
259
313
  this.#bot = null;
260
314
  for (const release of routes.splice(0))
@@ -269,11 +323,17 @@ export class QqHttpEndpoint extends Endpoint {
269
323
  config: this.#options.config,
270
324
  getBot: () => cancelled ? null : bot,
271
325
  }));
272
- await bot.start();
326
+ await awaitQqStartup(bot.start().then(async () => {
327
+ if (cancelled)
328
+ await stopBot();
329
+ }, async (error) => {
330
+ if (cancelled)
331
+ await stopBot();
332
+ throw error;
333
+ }), startupAbort.signal);
273
334
  if (cancelled) {
274
335
  // SDK startup may create resources after stop; close this exact old client again.
275
336
  await stopping;
276
- await stopBot();
277
337
  return;
278
338
  }
279
339
  this.#logger.info(`connected (${this.#options.config.mode}) | path: ${this.#options.config.webhookPath}`);
@@ -281,10 +341,8 @@ export class QqHttpEndpoint extends Endpoint {
281
341
  catch (error) {
282
342
  const wasCancelled = cancelled;
283
343
  await cleanup();
284
- if (wasCancelled) {
285
- await stopBot();
344
+ if (wasCancelled)
286
345
  return;
287
- }
288
346
  throw error;
289
347
  }
290
348
  });
@@ -308,21 +366,28 @@ export class QqHttpEndpoint extends Endpoint {
308
366
  const kind = qqOutboundKind(conversation);
309
367
  const bot = this.#requireBot();
310
368
  let result;
311
- switch (kind) {
312
- case 'private':
313
- result = await bot.sendPrivateMessage(conversation.id, body);
314
- break;
315
- case 'group':
316
- result = await bot.sendGroupMessage(conversation.id, body);
317
- break;
318
- case 'channel':
319
- result = await bot.sendGuildMessage(conversation.id, body);
320
- break;
321
- case 'direct':
322
- if (!bot.sendDirectMessage)
323
- throw new Error('QQ direct message not supported by transport');
324
- result = await bot.sendDirectMessage(conversation.id, body);
325
- break;
369
+ try {
370
+ switch (kind) {
371
+ case 'private':
372
+ result = await bot.sendPrivateMessage(conversation.id, body);
373
+ break;
374
+ case 'group':
375
+ result = await bot.sendGroupMessage(conversation.id, body);
376
+ break;
377
+ case 'channel':
378
+ result = await bot.sendGuildMessage(conversation.id, body);
379
+ break;
380
+ case 'direct':
381
+ if (!bot.sendDirectMessage)
382
+ throw new Error('QQ direct message not supported by transport');
383
+ result = await bot.sendDirectMessage(conversation.id, body);
384
+ break;
385
+ }
386
+ }
387
+ catch (error) {
388
+ const { failure, diagnostic } = qqDeliveryFailure(error);
389
+ this.#logger.warn(formatCompact(diagnostic));
390
+ throw failure;
326
391
  }
327
392
  const messageId = `${kind}-${conversation.id}:${resolveOutboundMessageId(result)}`;
328
393
  this.#logger.info(`send ${kind}:${conversation.id}`
@@ -333,20 +398,7 @@ export class QqHttpEndpoint extends Endpoint {
333
398
  async recallMessage(messageId) {
334
399
  const { kind, channelId, qqMsgId } = parseCompoundMessageId(messageId);
335
400
  const bot = this.#requireBot();
336
- switch (kind) {
337
- case 'private':
338
- await bot.recallPrivateMessage?.(channelId, qqMsgId);
339
- break;
340
- case 'group':
341
- await bot.recallGroupMessage?.(channelId, qqMsgId);
342
- break;
343
- case 'channel':
344
- await bot.recallGuildMessage?.(channelId, qqMsgId);
345
- break;
346
- case 'direct':
347
- await bot.recallDirectMessage?.(channelId, qqMsgId);
348
- break;
349
- }
401
+ await recallQqMessage(bot, kind, channelId, qqMsgId);
350
402
  this.#logger.debug(formatCompact({
351
403
  op: 'qq_recall',
352
404
  endpoint: this.#options.config.id,
@@ -389,6 +441,35 @@ export class QqHttpEndpoint extends Endpoint {
389
441
  }));
390
442
  });
391
443
  }
444
+ /** Native click only; C2C/group have no platform source message ID. */
445
+ admitInteraction(raw) {
446
+ if (!this.#open)
447
+ return false;
448
+ const action = normalizeQqInteraction(raw);
449
+ if (!action)
450
+ return false;
451
+ const now = Date.now();
452
+ for (const [id, time] of this.#seenInteractions)
453
+ if (now - time > 300_000)
454
+ this.#seenInteractions.delete(id);
455
+ if (this.#seenInteractions.has(action.message.id))
456
+ return true;
457
+ this.#seenInteractions.set(action.message.id, now);
458
+ if (this.#seenInteractions.size > 10_000)
459
+ this.#seenInteractions.delete(this.#seenInteractions.keys().next().value);
460
+ // ACK owns only platform loading state, never implies business acceptance.
461
+ void action.ack().then(ok => { if (ok === false)
462
+ this.#logger.warn('qq_interaction_ack_failed'); }, () => this.#logger.warn('qq_interaction_ack_failed'));
463
+ const conversation = qqInboundConversation(String(this.#options.id), action.message);
464
+ void this.emit('message.receive', {
465
+ conversation, message: { conversation, id: action.message.id }, content: '',
466
+ segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
467
+ metadata: Object.freeze({ eventType: 'INTERACTION_CREATE', sourceMessageIdAvailable: action.sourceMessageId !== undefined,
468
+ ...(action.sourceMessageId ? { sourceMessageId: action.sourceMessageId } : {}),
469
+ callbackAssociation: action.sourceMessageId ? 'source-message' : 'payload-conversation-actor' }),
470
+ }).catch(() => this.#logger.warn('qq_interaction_receive_failed'));
471
+ return true;
472
+ }
392
473
  #bindBot(bot) {
393
474
  bindQqBotInboundEvents(bot, (raw) => {
394
475
  if (this.#bot !== bot)
@@ -402,6 +483,8 @@ export class QqHttpEndpoint extends Endpoint {
402
483
  if (this.#bot !== bot)
403
484
  return;
404
485
  this.#emitPlatformEvent(eventName, raw);
486
+ if (this.admitInteraction(raw))
487
+ return;
405
488
  receiveQqSideEvent((name, payload) => this.emit(name, payload), this.#options.config.id, bot, eventName, raw, this.#logger);
406
489
  });
407
490
  }
@@ -416,7 +499,7 @@ export class QqHttpEndpoint extends Endpoint {
416
499
  }
417
500
  #requireBot() {
418
501
  if (!this.#bot)
419
- throw new Error('QQ bot not connected');
502
+ throw new EndpointDeliveryError('endpoint_disconnected', 'QQ bot not connected', 'not_sent');
420
503
  return this.#bot;
421
504
  }
422
505
  }
@@ -454,3 +537,12 @@ function asRecord(value) {
454
537
  ? value
455
538
  : {};
456
539
  }
540
+ async function recallQqMessage(bot, kind, channelId, messageId) {
541
+ const recall = { private: bot.recallPrivateMessage, group: bot.recallGroupMessage,
542
+ channel: bot.recallGuildMessage, direct: bot.recallDirectMessage }[kind];
543
+ if (!recall)
544
+ throw new EndpointDeliveryError('recall_unsupported', 'QQ transport does not support recall for this conversation', 'not_sent');
545
+ if (!messageId)
546
+ throw new EndpointDeliveryError('invalid_message_id', 'QQ recall requires a nonempty message ID', 'not_sent');
547
+ await recall.call(bot, channelId, messageId);
548
+ }
@@ -0,0 +1,7 @@
1
+ import type { QqInboundMessage } from './protocol.js';
2
+ /** qq-official-bot 1.3 normalizes INTERACTION_CREATE to notice.*.action. */
3
+ export declare function normalizeQqInteraction(raw: unknown): {
4
+ message: QqInboundMessage;
5
+ sourceMessageId?: string;
6
+ ack: () => Promise<unknown>;
7
+ } | null;
@@ -0,0 +1,16 @@
1
+ /** qq-official-bot 1.3 normalizes INTERACTION_CREATE to notice.*.action. */
2
+ export function normalizeQqInteraction(raw) {
3
+ if (!raw || typeof raw !== 'object')
4
+ return null;
5
+ const event = raw;
6
+ const resolved = event.data?.resolved;
7
+ if (event.sub_type !== 'action' || !event.notice_id || typeof resolved?.button_data !== 'string' || typeof event.reply !== 'function')
8
+ 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))
13
+ return null;
14
+ const sourceMessageId = typeof resolved.message_id === 'string' && resolved.message_id ? resolved.message_id : undefined;
15
+ 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) };
16
+ }
package/lib/outbound.js 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
  const logger = getLogger('qq');
18
19
  /**
19
20
  * 解析 image/audio/video/file 段的投递源(canonical MediaRef 唯一来源):
@@ -82,9 +83,9 @@ function normalizeMarkdownSegment(seg) {
82
83
  function coreButtonToQq(btn) {
83
84
  const isCommand = btn.mode === 'command';
84
85
  const action = {
85
- type: isCommand ? 2 : 0,
86
+ type: isCommand ? 2 : btn.url ? 0 : 1,
86
87
  permission: { type: 2 },
87
- data: btn.payload ?? '',
88
+ data: btn.url ?? btn.payload ?? '',
88
89
  click_limit: btn.disabled ? 0 : 10,
89
90
  unsupport_tips: btn.disabled ? '该按钮不可用' : '',
90
91
  };
@@ -173,6 +174,8 @@ export function formatOutbound(payload) {
173
174
  }
174
175
  const data = item.data ?? {};
175
176
  switch (item.type) {
177
+ case 'share':
178
+ throw new EndpointDeliveryError('unsupported_operation', 'QQ canonical share cards are not implemented', 'not_sent');
176
179
  case 'text':
177
180
  textBuf += String(data.text ?? data.content ?? '');
178
181
  break;
@@ -207,7 +210,7 @@ export function formatOutbound(payload) {
207
210
  break;
208
211
  }
209
212
  case 'reply': {
210
- const id = data.id ?? data.event_id;
213
+ const id = data.message_id ?? data.id ?? data.event_id;
211
214
  if (id == null || id === '')
212
215
  break;
213
216
  flushText();
package/lib/protocol.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** QQ Official Bot protocol helpers. Canonicalization is owned by Core. */
2
- import type { ConversationRef } from '@zhin.js/im-contract';
2
+ import { type ConversationRef } from '@zhin.js/im-contract';
3
3
  /** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
4
4
  export interface QqEndpointConfig {
5
5
  readonly id: string;
@@ -14,6 +14,7 @@ export interface QqEndpointConfig {
14
14
  readonly botKind?: string;
15
15
  readonly accessTokenUrl?: string;
16
16
  readonly gatewayUrl?: string;
17
+ readonly streamProxy?: import('./stream-proxy.js').QqStreamProxy;
17
18
  readonly webhookPath?: string;
18
19
  }
19
20
  export interface ResolvedQqWebsocketConfig {
@@ -26,6 +27,7 @@ export interface ResolvedQqWebsocketConfig {
26
27
  readonly intents?: readonly string[];
27
28
  readonly accessTokenUrl?: string;
28
29
  readonly gatewayUrl?: string;
30
+ readonly streamProxy?: import('./stream-proxy.js').QqStreamProxy;
29
31
  }
30
32
  export interface ResolvedQqHttpConfig {
31
33
  readonly context: 'qq';