@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 +50 -0
- package/README.md +27 -0
- package/lib/delivery-error.d.ts +13 -0
- package/lib/delivery-error.js +19 -0
- package/lib/endpoint.d.ts +7 -1
- package/lib/endpoint.js +163 -71
- package/lib/interaction.d.ts +7 -0
- package/lib/interaction.js +16 -0
- package/lib/outbound.js +8 -5
- package/lib/protocol.d.ts +3 -1
- package/lib/protocol.js +15 -8
- package/lib/qq-intents.js +1 -0
- package/lib/start-deadline.d.ts +2 -0
- package/lib/start-deadline.js +21 -0
- package/lib/stream-proxy.d.ts +7 -0
- package/lib/stream-proxy.js +17 -0
- package/lib/upload-diagnostics.d.ts +6 -0
- package/lib/upload-diagnostics.js +106 -0
- package/lib/ws.d.ts +2 -0
- package/lib/ws.js +37 -2
- package/package.json +18 -18
- package/schema.json +40 -0
- package/src/delivery-error.ts +20 -0
- package/src/endpoint.ts +141 -71
- package/src/interaction.ts +15 -0
- package/src/outbound.ts +9 -5
- package/src/protocol.ts +17 -8
- package/src/qq-intents.ts +1 -0
- package/src/start-deadline.ts +18 -0
- package/src/stream-proxy.ts +20 -0
- package/src/upload-diagnostics.ts +70 -0
- package/src/ws.ts +27 -2
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
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
238
|
-
|
|
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
|
|
242
|
-
throw new
|
|
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
|
-
?
|
|
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
|
@@ -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
|
+
}
|