@zhin.js/adapter-qq 1.1.19 → 1.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +13 -0
  3. package/agents/qq/skills/qq/SKILL.md +1 -1
  4. package/agents/qq/skills/qq/tools/guild_mute_member/index.js +1 -1
  5. package/agents/qq/skills/qq/tools/guild_mute_member/index.ts +1 -1
  6. package/agents/qq/skills/qq-conversation/SKILL.md +3 -3
  7. package/agents/qq/skills/qq-conversation/tools/conversation_info/index.js +1 -1
  8. package/agents/qq/skills/qq-conversation/tools/conversation_info/index.ts +1 -1
  9. package/agents/qq/skills/qq-group-admin/SKILL.md +3 -3
  10. package/agents/qq/skills/qq-group-admin/tools/handle_join_request/index.js +1 -1
  11. package/agents/qq/skills/qq-group-admin/tools/handle_join_request/index.ts +1 -1
  12. package/agents/qq/skills/qq-group-admin/tools/recall_member_message/index.js +1 -1
  13. package/agents/qq/skills/qq-group-admin/tools/recall_member_message/index.ts +1 -1
  14. package/agents/qq/skills/qq-group-admin/tools/set_mute/index.js +1 -1
  15. package/agents/qq/skills/qq-group-admin/tools/set_mute/index.ts +1 -1
  16. package/agents/qq/skills/qq-group-directory/SKILL.md +3 -3
  17. package/agents/qq/skills/qq-group-state/SKILL.md +1 -1
  18. package/agents/qq/skills/qq-messages/SKILL.md +1 -1
  19. package/agents/qq/skills/qq-messages/tools/recall_own_message/index.js +1 -1
  20. package/agents/qq/skills/qq-messages/tools/recall_own_message/index.ts +1 -1
  21. package/lib/adapter.js +1 -1
  22. package/lib/delivery-error.d.ts +1 -1
  23. package/lib/endpoint.js +6 -0
  24. package/lib/outbound.d.ts +4 -10
  25. package/lib/outbound.js +51 -60
  26. package/lib/passive-reply.d.ts +2 -0
  27. package/lib/passive-reply.js +12 -5
  28. package/lib/protocol.d.ts +9 -1
  29. package/lib/protocol.js +7 -0
  30. package/lib/qq-console-spec.js +2 -0
  31. package/lib/self-profile.d.ts +9 -0
  32. package/lib/self-profile.js +14 -0
  33. package/lib/stream-markdown.d.ts +5 -0
  34. package/lib/stream-markdown.js +41 -0
  35. package/lib/streaming.d.ts +5 -0
  36. package/lib/streaming.js +105 -0
  37. package/package.json +21 -21
  38. package/schema.json +146 -1
  39. package/src/adapter.ts +1 -1
  40. package/src/endpoint.ts +6 -0
  41. package/src/outbound.ts +46 -55
  42. package/src/passive-reply.ts +13 -4
  43. package/src/protocol.ts +15 -1
  44. package/src/qq-console-spec.ts +2 -0
  45. package/src/self-profile.ts +20 -0
  46. package/src/stream-markdown.ts +41 -0
  47. package/src/streaming.ts +98 -0
@@ -0,0 +1,14 @@
1
+ import { createEndpointOperation } from 'zhin.js/adapter';
2
+ /** QQ official Bot.Info has an actual bot id, display username and public avatar URL. */
3
+ export function createQqSelfProfile({ client, reportAccount }) {
4
+ return Object.freeze({
5
+ writableFields: Object.freeze([]),
6
+ async read({ signal }) {
7
+ const self = await createEndpointOperation({ signal }).prepare(() => client.getSelfInfo());
8
+ if (!self?.id || typeof self.username !== 'string')
9
+ throw new Error('QQ returned an incomplete bot profile');
10
+ reportAccount(self.id);
11
+ return { nickname: self.username, ...(typeof self.avatar === 'string' ? { avatar: self.avatar } : {}) };
12
+ },
13
+ });
14
+ }
@@ -0,0 +1,5 @@
1
+ /** Only source-level fenced context is portable to a fresh native Markdown message. */
2
+ export declare function qqMarkdownContinuation(precedingText: string): {
3
+ readonly opening: string;
4
+ };
5
+ export declare function qqMarkdownClosing(source: string): string;
@@ -0,0 +1,41 @@
1
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
2
+ /** Only source-level fenced context is portable to a fresh native Markdown message. */
3
+ export function qqMarkdownContinuation(precedingText) {
4
+ if (!precedingText)
5
+ return { opening: '' };
6
+ const fence = openFence(precedingText);
7
+ if (fence) {
8
+ if (!precedingText.includes('\n', fence.offset))
9
+ throw unsupported();
10
+ return { opening: `${fence.marker}${fence.info}\n` };
11
+ }
12
+ if (/(?:^|\n) {0,3}(?:`{3,}|~{3,})[ \t]*\n?$/.test(precedingText))
13
+ return { opening: '' };
14
+ const boundary = precedingText.lastIndexOf('\n\n');
15
+ const block = precedingText.slice(boundary >= 0 ? boundary + 2 : 0);
16
+ // No native contract permits retroactively recreating an unfinished inline
17
+ // construct in another message. Admit plain continuations and closed blocks.
18
+ if (/[`*_\[\]~]/.test(block))
19
+ throw unsupported();
20
+ return { opening: '' };
21
+ }
22
+ export function qqMarkdownClosing(source) {
23
+ const fence = openFence(source);
24
+ return fence ? `${source.endsWith('\n') ? '' : '\n'}${fence.marker}` : '';
25
+ }
26
+ function openFence(source) {
27
+ let active;
28
+ for (const line of source.matchAll(/^ {0,3}(`{3,}|~{3,})([^\n]*)/gm)) {
29
+ const marker = line[1], info = line[2];
30
+ if (active) {
31
+ if (marker[0] === active.marker[0] && marker.length >= active.marker.length && !info.trim())
32
+ active = undefined;
33
+ }
34
+ else if (marker[0] !== '`' || !info.includes('`'))
35
+ active = { marker, info, offset: line.index };
36
+ }
37
+ return active;
38
+ }
39
+ function unsupported() {
40
+ return new EndpointDeliveryError('unsupported_operation', 'QQ Markdown continuation requires a plain block boundary or a complete fenced-code header', 'not_sent');
41
+ }
@@ -0,0 +1,5 @@
1
+ import type { Bot } from 'qq-official-bot';
2
+ import { type EndpointStreaming } from 'zhin.js/adapter';
3
+ import { type QqPassiveReplyLedger } from './passive-reply.js';
4
+ /** C2C native streams share the ordinary passive ledger, source and sequence. */
5
+ export declare function createQqStreaming(getBot: () => Bot, ledger: QqPassiveReplyLedger, minIntervalMs?: number): EndpointStreaming;
@@ -0,0 +1,105 @@
1
+ import { createEndpointOperation } from 'zhin.js/adapter';
2
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
3
+ import { qqDeliveryFailure } from './delivery-error.js';
4
+ import { QQ_PASSIVE_SOURCE_CODES } from './passive-reply.js';
5
+ import { qqOutboundKind, resolveOutboundMessageId } from './protocol.js';
6
+ import { qqMarkdownClosing, qqMarkdownContinuation } from './stream-markdown.js';
7
+ function supportsQqStream(request) {
8
+ return qqOutboundKind(request.conversation) === 'private';
9
+ }
10
+ /** C2C native streams share the ordinary passive ledger, source and sequence. */
11
+ export function createQqStreaming(getBot, ledger, minIntervalMs = 500) {
12
+ return {
13
+ mode: 'native',
14
+ minIntervalMs,
15
+ supports: supportsQqStream,
16
+ open(request) {
17
+ if (!supportsQqStream(request))
18
+ return undefined;
19
+ // stream_messages has no verified canonical mention/keyboard projection.
20
+ // Keep these combinations on the complete-message codec instead of guessing.
21
+ if (request.prefix?.some(segment => segment.type !== 'reply'))
22
+ return undefined;
23
+ const reply = request.prefix?.find(segment => segment.type === 'reply');
24
+ const explicitId = reply && 'message_id' in reply.data ? reply.data.message_id : undefined;
25
+ const preferred = typeof explicitId === 'string' && explicitId
26
+ ? { id: explicitId }
27
+ : request.source?.messageId ? { id: request.source.messageId } : undefined;
28
+ if (!ledger.hasUsableSource('private', request.conversation.id, preferred))
29
+ return undefined;
30
+ const precedingText = request.continuation?.precedingText ?? '';
31
+ const opening = request.format === 'markdown' ? qqMarkdownContinuation(precedingText).opening : '';
32
+ const cancellation = new AbortController();
33
+ const signal = request.signal ? AbortSignal.any([request.signal, cancellation.signal]) : cancellation.signal;
34
+ const operation = createEndpointOperation({ signal });
35
+ let native;
36
+ let grant;
37
+ let acceptedText = '';
38
+ let messageId;
39
+ let exhausted = false;
40
+ let contentUnavailable = Boolean(opening);
41
+ let openingSent = false;
42
+ let failure;
43
+ let completion;
44
+ const acknowledgement = () => ({
45
+ status: messageId ? 'sent' : 'buffered', acceptedText, ...(messageId ? { messageId } : {}),
46
+ ...(exhausted ? { exhausted: true } : {}),
47
+ ...(contentUnavailable ? { contentUnavailable: true } : {}),
48
+ });
49
+ const send = async (text, final) => {
50
+ if (failure)
51
+ throw failure;
52
+ if (signal.aborted)
53
+ throw new EndpointDeliveryError('endpoint_inactive', 'QQ stream cancelled', 'not_sent');
54
+ if (!text.startsWith(acceptedText))
55
+ throw new EndpointDeliveryError('invalid_payload', 'QQ stream cannot replace its accepted prefix', 'not_sent');
56
+ if (!final && (text === acceptedText || exhausted))
57
+ return acknowledgement();
58
+ if (final && exhausted && text !== acceptedText)
59
+ throw new EndpointDeliveryError('stream_capacity', 'QQ exhausted stream must finish its accepted prefix', 'not_sent');
60
+ if (!native) {
61
+ if (!text)
62
+ return acknowledgement();
63
+ grant = ledger.acquire('private', request.conversation.id, preferred);
64
+ if (!grant)
65
+ throw new EndpointDeliveryError('unsupported_operation', 'QQ stream has no usable passive reply source', 'not_sent');
66
+ native = getBot().createPrivateStream(request.conversation.id, {
67
+ source: grant.source, msgSeq: grant.msgSeq,
68
+ contentType: request.format, inputMode: 'append',
69
+ });
70
+ }
71
+ try {
72
+ const closing = final && request.format === 'markdown' ? qqMarkdownClosing(precedingText + text) : '';
73
+ // An exhausted container permits only the terminating state frame;
74
+ // no extra formatting characters may exceed its confirmed capacity.
75
+ const suffix = exhausted ? '' : closing;
76
+ const delta = (openingSent ? '' : opening) + text.slice(acceptedText.length) + suffix;
77
+ const result = await operation.run(() => final ? native.end(delta) : native.write(delta));
78
+ const id = resolveOutboundMessageId(result);
79
+ messageId = `private-${request.conversation.id}:${id}`;
80
+ acceptedText = text;
81
+ openingSent = true;
82
+ contentUnavailable ||= Boolean(closing);
83
+ // Zero is exhausted regardless of the platform's otherwise unspecified unit.
84
+ exhausted = result.remain_msg_len === 0;
85
+ return acknowledgement();
86
+ }
87
+ catch (error) {
88
+ const result = qqDeliveryFailure(error);
89
+ if (grant && result.diagnostic.platformCode !== undefined && QQ_PASSIVE_SOURCE_CODES.has(result.diagnostic.platformCode)) {
90
+ ledger.exhaust('private', request.conversation.id, grant.source);
91
+ }
92
+ failure = result.failure;
93
+ throw failure;
94
+ }
95
+ };
96
+ return {
97
+ write: text => completion
98
+ ? Promise.reject(new EndpointDeliveryError('stream_closed', 'QQ stream is finishing', 'not_sent'))
99
+ : send(text, false),
100
+ finish: text => completion ??= send(text, true),
101
+ cancel: () => { cancellation.abort(); },
102
+ };
103
+ },
104
+ };
105
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-qq",
3
- "version": "1.1.19",
3
+ "version": "1.1.21",
4
4
  "description": "Zhin.js QQ Official adapter for Plugin Runtime (WebSocket gateway)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -34,27 +34,27 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "qq-official-bot": "^1.3.3",
37
- "@zhin.js/adapter": "1.1.26",
38
- "@zhin.js/agent-feature": "1.1.11",
39
- "@zhin.js/client": "1.1.22",
37
+ "@zhin.js/adapter": "1.1.27",
38
+ "@zhin.js/agent-feature": "1.1.12",
39
+ "@zhin.js/client": "1.1.23",
40
40
  "@zhin.js/console-contract": "1.1.3",
41
- "@zhin.js/core": "1.1.54",
42
- "@zhin.js/feature-kit": "1.1.10",
43
- "@zhin.js/host-http": "1.1.12",
44
- "@zhin.js/im-contract": "1.1.9",
41
+ "@zhin.js/core": "1.1.55",
42
+ "@zhin.js/feature-kit": "1.1.11",
43
+ "@zhin.js/host-http": "1.1.13",
44
+ "@zhin.js/im-contract": "1.1.10",
45
45
  "@zhin.js/logger": "1.1.2",
46
- "@zhin.js/page": "1.1.11",
47
- "@zhin.js/permission": "1.1.10",
48
- "@zhin.js/skill": "1.1.11",
49
- "@zhin.js/tool": "1.1.13"
46
+ "@zhin.js/page": "1.1.13",
47
+ "@zhin.js/permission": "1.1.11",
48
+ "@zhin.js/skill": "1.1.12",
49
+ "@zhin.js/tool": "1.1.14"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "zod": "^4.0.0",
53
- "@zhin.js/adapter": "^1.1.26",
54
- "@zhin.js/command": "^1.1.12",
55
- "@zhin.js/core": "^1.1.54",
56
- "@zhin.js/middleware": "^1.1.11",
57
- "zhin.js": "^1.1.19"
53
+ "@zhin.js/adapter": "^1.1.27",
54
+ "@zhin.js/command": "^1.1.13",
55
+ "@zhin.js/core": "^1.1.55",
56
+ "@zhin.js/middleware": "^1.1.12",
57
+ "zhin.js": "^1.1.20"
58
58
  },
59
59
  "peerDependenciesMeta": {
60
60
  "@zhin.js/command": {
@@ -75,8 +75,8 @@
75
75
  "typescript": "^6.0.3",
76
76
  "vitest": "^4.1.10",
77
77
  "zod": "^4.4.3",
78
- "@zhin.js/host-http": "1.1.12",
79
- "zhin.js": "1.1.19"
78
+ "@zhin.js/host-http": "1.1.13",
79
+ "zhin.js": "1.1.20"
80
80
  },
81
81
  "files": [
82
82
  "adapters",
@@ -109,7 +109,7 @@
109
109
  "features": [
110
110
  {
111
111
  "package": "@zhin.js/adapter",
112
- "api": "^2.0.0"
112
+ "api": "^2.1.0"
113
113
  },
114
114
  {
115
115
  "package": "@zhin.js/command",
@@ -121,7 +121,7 @@
121
121
  },
122
122
  {
123
123
  "package": "@zhin.js/tool",
124
- "api": "^1.0.0"
124
+ "api": "^1.1.0"
125
125
  },
126
126
  {
127
127
  "package": "@zhin.js/skill",
package/schema.json CHANGED
@@ -284,6 +284,46 @@
284
284
  "endpoint",
285
285
  "id"
286
286
  ]
287
+ },
288
+ "streaming": {
289
+ "type": "boolean",
290
+ "description": "在支持的会话中渐进发送回复。设为 false 时仅发送完整回复。",
291
+ "x-i18n": {
292
+ "en": {
293
+ "description": "Publish supported replies while generating. Set false to send only complete replies."
294
+ }
295
+ }
296
+ },
297
+ "streamingIntervalMs": {
298
+ "type": "number",
299
+ "minimum": 0,
300
+ "description": "后续回复更新的最小间隔(毫秒);首段立即发送。",
301
+ "x-i18n": {
302
+ "en": {
303
+ "description": "Minimum delay in milliseconds between reply updates; the first update is immediate."
304
+ }
305
+ }
306
+ },
307
+ "nickname": {
308
+ "type": [
309
+ "string",
310
+ "null"
311
+ ],
312
+ "description": "账号昵称兜底",
313
+ "x-i18n": {
314
+ "en": {
315
+ "description": "Fallback account nickname"
316
+ }
317
+ }
318
+ },
319
+ "profile": {
320
+ "$ref": "#/$defs/selfProfileFallback",
321
+ "description": "账号资料兜底",
322
+ "x-i18n": {
323
+ "en": {
324
+ "description": "Fallback account profile"
325
+ }
326
+ }
287
327
  }
288
328
  },
289
329
  "required": [
@@ -414,9 +454,114 @@
414
454
  "endpoint",
415
455
  "id"
416
456
  ]
457
+ },
458
+ "streaming": {
459
+ "type": "boolean",
460
+ "default": true,
461
+ "description": "在支持的会话中渐进发送回复。设为 false 时仅发送完整回复。",
462
+ "x-i18n": {
463
+ "en": {
464
+ "description": "Publish supported replies while generating. Set false to send only complete replies."
465
+ }
466
+ }
467
+ },
468
+ "streamingIntervalMs": {
469
+ "type": "number",
470
+ "minimum": 0,
471
+ "default": 500,
472
+ "description": "后续回复更新的最小间隔(毫秒);首段立即发送。",
473
+ "x-i18n": {
474
+ "en": {
475
+ "description": "Minimum delay in milliseconds between reply updates; the first update is immediate."
476
+ }
477
+ }
478
+ },
479
+ "nickname": {
480
+ "type": [
481
+ "string",
482
+ "null"
483
+ ],
484
+ "description": "账号昵称兜底",
485
+ "x-i18n": {
486
+ "en": {
487
+ "description": "Fallback account nickname"
488
+ }
489
+ }
490
+ },
491
+ "profile": {
492
+ "$ref": "#/$defs/selfProfileFallback",
493
+ "description": "账号资料兜底",
494
+ "x-i18n": {
495
+ "en": {
496
+ "description": "Fallback account profile"
497
+ }
498
+ }
417
499
  }
418
500
  },
419
501
  "required": [
420
502
  "endpoints"
421
- ]
503
+ ],
504
+ "$defs": {
505
+ "selfProfileFallback": {
506
+ "type": "object",
507
+ "additionalProperties": false,
508
+ "properties": {
509
+ "gender": {
510
+ "type": [
511
+ "string",
512
+ "null"
513
+ ],
514
+ "enum": [
515
+ "male",
516
+ "female",
517
+ "other",
518
+ "unknown",
519
+ null
520
+ ]
521
+ },
522
+ "age": {
523
+ "type": [
524
+ "integer",
525
+ "null"
526
+ ],
527
+ "minimum": 0
528
+ },
529
+ "birthday": {
530
+ "type": [
531
+ "string",
532
+ "null"
533
+ ],
534
+ "pattern": "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$"
535
+ },
536
+ "signature": {
537
+ "type": [
538
+ "string",
539
+ "null"
540
+ ]
541
+ },
542
+ "avatar": {
543
+ "type": [
544
+ "string",
545
+ "null"
546
+ ],
547
+ "pattern": "^(https?://[^\\s/?#]+(?:[/?#].*)?|)$"
548
+ },
549
+ "interests": {
550
+ "type": [
551
+ "array",
552
+ "null"
553
+ ],
554
+ "items": {
555
+ "type": "string"
556
+ }
557
+ },
558
+ "bio": {
559
+ "type": [
560
+ "string",
561
+ "null"
562
+ ]
563
+ }
564
+ }
565
+ }
566
+ }
422
567
  }
package/src/adapter.ts CHANGED
@@ -9,7 +9,7 @@ export class QqAdapter extends Adapter<QqHttpEndpoint | QqWebsocketEndpoint, QqE
9
9
  constructor(private readonly context: AdapterContext<QqEndpointConfig>) { super(); }
10
10
  override get configurationIdentityFields(): readonly string[] { return ['id', 'appid']; }
11
11
  override get configurationIdentityProbeFields(): readonly string[] { return ['accessTokenUrl', 'gatewayUrl']; }
12
- override get configurationUpdateFields(): readonly string[] { return ['secret', 'mode', 'sandbox', 'intents', 'botKind',
12
+ override get configurationUpdateFields(): readonly string[] { return ['secret', 'mode', 'sandbox', 'streaming', 'streamingIntervalMs', 'intents', 'botKind',
13
13
  'accessTokenUrl', 'gatewayUrl', 'streamProxy', 'streamProxy.port', 'streamProxy.serverName', 'webhookPath']; }
14
14
  protected override validateConfiguration(input: QqEndpointConfig): void { resolveQqConfig(input); }
15
15
  protected createEndpoint(input: QqEndpointConfig, execution: EndpointCreationContext): QqHttpEndpoint | QqWebsocketEndpoint {
package/src/endpoint.ts CHANGED
@@ -1,9 +1,11 @@
1
+ import { createQqSelfProfile } from './self-profile.js';
1
2
  import { Bot, ReceiverMode } from 'qq-official-bot';
2
3
  import { installQqUploadDiagnostics } from './upload-diagnostics.js';
3
4
  import { projectQqGuildSenderRoles } from './platform-permit.js';
4
5
  import { qqDeliveryFailure } from './delivery-error.js';
5
6
  import { normalizeQqInteraction } from './interaction.js';
6
7
  import { awaitQqStartup } from './start-deadline.js';
8
+ import { createQqStreaming } from './streaming.js';
7
9
  import { EndpointDeliveryError, type MessageRef } from '@zhin.js/im-contract';
8
10
  import {
9
11
  inspectQqGroupMember,
@@ -111,6 +113,8 @@ export class QqWebsocketEndpoint extends Bot<ReceiverMode.WEBSOCKET> implements
111
113
  start: signal => this.#startFramework(signal), open: () => this.#openFramework(), close: () => this.#closeFramework(),
112
114
  stop: () => this.#stopFramework(), send: request => this.#sendFramework(request), name: options.config.id,
113
115
  get transportState() { return endpoint.#transportState; }, management: this.#management, control: this.#control,
116
+ streaming: options.config.streaming !== false ? createQqStreaming(() => this.#requireBot(), this.#passiveReplies, options.config.streamingIntervalMs) : undefined,
117
+ profile: createQqSelfProfile({ client: this, reportAccount: id => this[endpointSpec].reportSelfUserId(id) }),
114
118
  });
115
119
  this.#bindBot(this);
116
120
  }
@@ -416,6 +420,8 @@ export class QqHttpEndpoint extends Bot<ReceiverMode.MIDDLEWARE, 'koa'> implemen
416
420
  stop: () => this.#stopFramework(), send: request => this.#sendFramework(request), name: options.config.id,
417
421
  get transportState() { return endpoint.#transportState; },
418
422
  management: this.#management, control: this.#control,
423
+ streaming: options.config.streaming !== false ? createQqStreaming(() => this.#requireBot(), this.#passiveReplies, options.config.streamingIntervalMs) : undefined,
424
+ profile: createQqSelfProfile({ client: this, reportAccount: id => this[endpointSpec].reportSelfUserId(id) }),
419
425
  });
420
426
  this.#bindBot(this);
421
427
  }
package/src/outbound.ts CHANGED
@@ -302,70 +302,61 @@ export interface FormatOutboundMessagesOptions {
302
302
  readonly imageSizes?: ReadonlyMap<string, QqImageSize>;
303
303
  }
304
304
 
305
- function isElemOf(types: Set<string>) {
306
- return (part: string | QqOutboundElem): part is QqOutboundElem => typeof part !== 'string' && types.has(part.type);
305
+ function requireKeyboardMarkdown(messages: QqOutboundMessage[]): QqOutboundMessage[] {
306
+ if (!messages.length) throw new EndpointDeliveryError('unsupported_operation', 'QQ payload has no sendable content', 'not_sent');
307
+ for (const message of messages) {
308
+ const parts: readonly (string | QqOutboundElem)[] = Array.isArray(message) ? message : [message as string | QqOutboundElem];
309
+ if (parts.some(part => typeof part !== 'string' && (part.type === 'keyboard' || part.type === 'button'))
310
+ && !parts.some(part => typeof part !== 'string' && part.type === 'markdown')) {
311
+ throw new EndpointDeliveryError('unsupported_operation', 'QQ keyboard requires Markdown in the same physical message', 'not_sent');
312
+ }
313
+ }
314
+ return messages;
307
315
  }
308
316
 
309
317
  /**
310
- * 渲染后 payload → 按序发送的 QQ Sendable 列表。
311
- *
312
- * QQ 单条消息的 msg_type 只能是一种:富媒体(7,media.file_info)与 markdown/按钮(2)
313
- * 互斥,SDK 后处理的段会覆盖 msg_type,导致图片被静默丢弃。处理顺序:
314
- * 1. 公网 URL 图片且已知尺寸:按 QQ markdown 语法 `![图片 #宽px #高px](url)` 嵌入同一
315
- * markdown 段(开放平台会下载转存),仍为一条消息;
316
- * 2. 其余媒体(base64 / 本地渲染图:v2 /files 上传只返回 file_info,拿不到 URL;或尺寸
317
- * 未知、模板 markdown 无法拼接)降级为独立富媒体消息先发,markdown + 按钮作为最后一条
318
- * (返回其消息 ID)。reply 段随每条消息携带,保持被动回复。
319
- * 不含媒体与 markdown/按钮冲突时与 formatOutbound 一致(单条)。
318
+ * Preserve the original segment order before formatOutbound merges Markdown.
319
+ * Public images with known dimensions become inline Markdown at their original
320
+ * position. Other media form physical-message barriers; reply routing is copied
321
+ * to every resulting message, without moving text across a media barrier.
320
322
  */
321
323
  export function formatOutboundMessages(payload: unknown, options: FormatOutboundMessagesOptions = {}): QqOutboundMessage[] {
322
- const body = formatOutbound(payload);
323
- if (!Array.isArray(body)) return [body as QqOutboundMessage];
324
- const elems = body as ReadonlyArray<string | QqOutboundElem>;
325
- const isMedia = isElemOf(SPLIT_MEDIA_TYPES);
326
- if (!elems.some(isMedia) || !elems.some(isElemOf(MARKDOWN_MESSAGE_TYPES))) return [body];
324
+ const items = toSegmentList(payload);
325
+ const rich = items.some(item => typeof item !== 'string' && MARKDOWN_MESSAGE_TYPES.has(item.type));
326
+ const media = items.some(item => typeof item !== 'string' && SPLIT_MEDIA_TYPES.has(item.type));
327
+ if (!rich || !media) return requireKeyboardMarkdown([formatOutbound(payload)]);
327
328
 
328
- const markdownIndex = elems.findIndex(isElemOf(new Set(['markdown'])));
329
- const markdownElem = markdownIndex >= 0 ? elems[markdownIndex] as QqOutboundElem : undefined;
330
- const markdownContent = markdownElem ? markdownElem.data.content : '';
331
- const embedded = new Set<QqOutboundElem>();
332
- let merged: QqOutboundElem | undefined;
333
- if (typeof markdownContent === 'string') {
334
- const before: string[] = [];
335
- const after: string[] = [];
336
- elems.forEach((part, index) => {
337
- if (!isMedia(part) || part.type !== 'image' || typeof part.data.url !== 'string') return;
338
- const size = options.imageSizes?.get(part.data.url);
339
- if (!size) return;
340
- (markdownIndex < 0 || index < markdownIndex ? before : after)
341
- .push(`![图片 #${size.width}px #${size.height}px](${part.data.url})`);
342
- embedded.add(part);
343
- });
344
- if (embedded.size > 0) {
345
- merged = { type: 'markdown', data: { content: [...before, markdownContent, ...after].filter((piece) => piece.trim()).join('\n\n') } };
346
- }
347
- }
348
-
349
- const replies = elems.filter(isElemOf(new Set(['reply'])));
350
- const separate: QqOutboundElem[] = [];
351
- const rest: Array<string | QqOutboundElem> = [];
352
- let mergedPlaced = false;
353
- for (const part of elems) {
354
- if (merged && (part === markdownElem || (!markdownElem && embedded.has(part as QqOutboundElem)))) {
355
- if (!mergedPlaced) rest.push(merged);
356
- mergedPlaced = true;
357
- } else if (embedded.has(part as QqOutboundElem)) {
358
- continue;
359
- } else if (isMedia(part)) {
360
- separate.push(part);
329
+ const replies = items.filter(item => typeof item !== 'string' && item.type === 'reply');
330
+ const templates = items.some(item => typeof item !== 'string' && item.type === 'markdown' && markdownTemplateId(item.data ?? {}));
331
+ const messages: QqOutboundMessage[] = [];
332
+ let pending: Array<string | QqWireSegment> = [];
333
+ const append = (items: Array<string | QqWireSegment>): void => {
334
+ const message = formatOutbound(items);
335
+ const parts = Array.isArray(message) ? message : [message];
336
+ if (parts.some(part => typeof part === 'string' ? part.length > 0 : part.type !== 'reply')) messages.push(message);
337
+ };
338
+ const flush = (): void => {
339
+ if (!pending.length) return;
340
+ append([...replies, ...pending]);
341
+ pending = [];
342
+ };
343
+ for (const item of items) {
344
+ if (typeof item !== 'string' && item.type === 'reply') continue;
345
+ if (typeof item !== 'string' && SPLIT_MEDIA_TYPES.has(item.type)) {
346
+ const source = item.type === 'image' && isMediaRef(item.data?.media) ? item.data.media : undefined;
347
+ const size = !templates && source?.kind === 'url' ? options.imageSizes?.get(source.value) : undefined;
348
+ if (source?.kind === 'url' && size) {
349
+ pending.push({ type: 'markdown', data: { content: `![图片 #${size.width}px #${size.height}px](${source.value})` } });
350
+ } else {
351
+ flush();
352
+ append([...replies, item]);
353
+ }
361
354
  } else {
362
- rest.push(part);
355
+ pending.push(item);
363
356
  }
364
357
  }
365
- return [
366
- ...separate.map((item): QqOutboundMessage => (replies.length ? [...replies, item] : item)),
367
- rest.length === 1 ? rest[0]! : rest,
368
- ];
358
+ flush();
359
+ return requireKeyboardMarkdown(messages);
369
360
  }
370
361
 
371
362
  /**
@@ -72,6 +72,18 @@ export class QqPassiveReplyLedger {
72
72
  * newest usable source in the conversation. Undefined when only an active message is possible.
73
73
  */
74
74
  acquire(kind: QqOutboundKind, conversationId: string, preferred?: QqReplySource): PassiveReplyGrant | undefined {
75
+ const entry = this.#usableSource(kind, conversationId, preferred);
76
+ if (!entry) return undefined;
77
+ entry.replies += 1;
78
+ return { source: entry.source, msgSeq: entry.replies };
79
+ }
80
+
81
+ /** Local stream preflight must not reserve a reply before any content exists. */
82
+ hasUsableSource(kind: QqOutboundKind, conversationId: string, preferred?: QqReplySource): boolean {
83
+ return this.#usableSource(kind, conversationId, preferred) !== undefined;
84
+ }
85
+
86
+ #usableSource(kind: QqOutboundKind, conversationId: string, preferred?: QqReplySource): SourceEntry | undefined {
75
87
  const limit = QQ_PASSIVE_LIMITS[kind];
76
88
  const now = this.now();
77
89
  const entries = this.#conversations.get(`${kind}:${conversationId}`) ?? [];
@@ -79,11 +91,8 @@ export class QqPassiveReplyLedger {
79
91
  && entry.replies < limit.maxReplies
80
92
  && now - entry.receivedAt < limit.windowMs - SAFETY_MARGIN_MS;
81
93
  const preferredKey = preferred ? sourceKey(preferred) : undefined;
82
- const entry = (preferredKey ? entries.find(item => sourceKey(item.source) === preferredKey && usable(item)) : undefined)
94
+ return (preferredKey ? entries.find(item => sourceKey(item.source) === preferredKey && usable(item)) : undefined)
83
95
  ?? [...entries].reverse().find(usable);
84
- if (!entry) return undefined;
85
- entry.replies += 1;
86
- return { source: entry.source, msgSeq: entry.replies };
87
96
  }
88
97
 
89
98
  /** The platform said this source can no longer be replied to (expired, over the limit, invalid id). */