@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
package/src/protocol.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { EndpointProfileConfiguration } from 'zhin.js/adapter';
1
2
  /** QQ Official Bot protocol helpers. Canonicalization is owned by Core. */
2
3
 
3
4
  import { EndpointDeliveryError, type ConversationKind, type ConversationRef } from '@zhin.js/im-contract';
@@ -8,13 +9,16 @@ import { resolveAdapterNotifyTarget, type AdapterNotifyTarget, type ResolvedAdap
8
9
  const logger = getLogger('qq');
9
10
 
10
11
  /** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
11
- export interface QqEndpointConfig {
12
+ export interface QqEndpointConfig extends EndpointProfileConfiguration {
12
13
  readonly id: string;
13
14
  readonly appid: string;
14
15
  readonly secret: string;
15
16
  /** Default `websocket`. `webhook` / `middleware` use httpHostToken POST. */
16
17
  readonly mode?: 'websocket' | 'webhook' | 'middleware';
17
18
  readonly sandbox?: boolean;
19
+ /** Publish supported C2C replies while generated; false keeps complete delivery. */
20
+ readonly streaming?: boolean;
21
+ readonly streamingIntervalMs?: number;
18
22
  /** Explicit intents; when omitted, derived from `botKind` (default public). */
19
23
  readonly intents?: readonly string[];
20
24
  /** public | private — drives default intents when `intents` omitted. */
@@ -34,6 +38,8 @@ export interface ResolvedQqWebsocketConfig {
34
38
  readonly appid: string;
35
39
  readonly secret: string;
36
40
  readonly sandbox: boolean;
41
+ readonly streaming?: boolean;
42
+ readonly streamingIntervalMs?: number;
37
43
  readonly intents?: readonly string[];
38
44
  readonly accessTokenUrl?: string;
39
45
  readonly gatewayUrl?: string;
@@ -49,6 +55,8 @@ export interface ResolvedQqHttpConfig {
49
55
  readonly secret: string;
50
56
  readonly webhookPath: string;
51
57
  readonly sandbox: boolean;
58
+ readonly streaming?: boolean;
59
+ readonly streamingIntervalMs?: number;
52
60
  readonly notify?: ResolvedAdapterNotifyTarget;
53
61
  }
54
62
 
@@ -90,6 +98,8 @@ export function resolveQqConfig(config: QqEndpointConfig): ResolvedQqConfig {
90
98
  const appid = requiredEndpointField(config.appid, 'appid');
91
99
  const secret = requiredEndpointField(config.secret, 'secret');
92
100
  const mode = config.mode ?? 'websocket';
101
+ const streamingIntervalMs = config.streamingIntervalMs ?? 500;
102
+ if (!Number.isFinite(streamingIntervalMs) || streamingIntervalMs < 0) throw new TypeError('streamingIntervalMs must be a non-negative finite number');
93
103
  const notify = resolveAdapterNotifyTarget(config.notify);
94
104
 
95
105
  if (mode === 'webhook' || mode === 'middleware') {
@@ -103,6 +113,8 @@ export function resolveQqConfig(config: QqEndpointConfig): ResolvedQqConfig {
103
113
  secret,
104
114
  webhookPath,
105
115
  sandbox: config.sandbox === true,
116
+ streaming: config.streaming !== false,
117
+ streamingIntervalMs,
106
118
  ...(notify ? { notify } : {}),
107
119
  };
108
120
  }
@@ -116,6 +128,8 @@ export function resolveQqConfig(config: QqEndpointConfig): ResolvedQqConfig {
116
128
  appid,
117
129
  secret,
118
130
  sandbox: config.sandbox === true,
131
+ streaming: config.streaming !== false,
132
+ streamingIntervalMs,
119
133
  intents: resolveQqIntents({
120
134
  intents: config.intents,
121
135
  botKind: config.botKind,
@@ -14,6 +14,8 @@ export const qqConsoleSpec = {
14
14
  { key: 'secret', required: true, env: true, description: 'QQ app secret' },
15
15
  { key: 'mode', description: 'websocket, webhook or middleware' },
16
16
  { key: 'sandbox', type: 'boolean', description: 'QQ sandbox environment' },
17
+ { key: 'streaming', type: 'boolean', description: 'Publish supported C2C replies while generating (default true)' },
18
+ { key: 'streamingIntervalMs', type: 'number', description: 'Delay between reply updates in milliseconds (default 500)' },
17
19
  { key: 'botKind', description: 'public or private; existing explicit intents remain authoritative' },
18
20
  { key: 'accessTokenUrl', description: 'Configured access-token API URL' },
19
21
  { key: 'gatewayUrl', description: 'Configured Gateway discovery URL' },
@@ -0,0 +1,20 @@
1
+ import { createEndpointOperation, type EndpointProfile } from 'zhin.js/adapter';
2
+ import type { Bot } from 'qq-official-bot';
3
+
4
+ interface QqSelfProfileOptions {
5
+ readonly client: Pick<Bot, 'getSelfInfo'>;
6
+ readonly reportAccount: (id: string) => void;
7
+ }
8
+
9
+ /** QQ official Bot.Info has an actual bot id, display username and public avatar URL. */
10
+ export function createQqSelfProfile({ client, reportAccount }: QqSelfProfileOptions): EndpointProfile {
11
+ return Object.freeze<EndpointProfile>({
12
+ writableFields: Object.freeze([]),
13
+ async read({ signal }) {
14
+ const self = await createEndpointOperation({ signal }).prepare(() => client.getSelfInfo());
15
+ if (!self?.id || typeof self.username !== 'string') throw new Error('QQ returned an incomplete bot profile');
16
+ reportAccount(self.id);
17
+ return { nickname: self.username, ...(typeof self.avatar === 'string' ? { avatar: self.avatar } : {}) };
18
+ },
19
+ });
20
+ }
@@ -0,0 +1,41 @@
1
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
2
+
3
+ interface Fence { readonly marker: string; readonly info: string; }
4
+
5
+ /** Only source-level fenced context is portable to a fresh native Markdown message. */
6
+ export function qqMarkdownContinuation(precedingText: string): { readonly opening: string } {
7
+ if (!precedingText) return { opening: '' };
8
+ const fence = openFence(precedingText);
9
+ if (fence) {
10
+ if (!precedingText.includes('\n', fence.offset)) throw unsupported();
11
+ return { opening: `${fence.marker}${fence.info}\n` };
12
+ }
13
+ if (/(?:^|\n) {0,3}(?:`{3,}|~{3,})[ \t]*\n?$/.test(precedingText)) 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)) throw unsupported();
19
+ return { opening: '' };
20
+ }
21
+
22
+ export function qqMarkdownClosing(source: string): string {
23
+ const fence = openFence(source);
24
+ return fence ? `${source.endsWith('\n') ? '' : '\n'}${fence.marker}` : '';
25
+ }
26
+
27
+ function openFence(source: string): (Fence & { readonly offset: number }) | undefined {
28
+ let active: (Fence & { readonly offset: number }) | undefined;
29
+ for (const line of source.matchAll(/^ {0,3}(`{3,}|~{3,})([^\n]*)/gm)) {
30
+ const marker = line[1], info = line[2];
31
+ if (active) {
32
+ if (marker[0] === active.marker[0] && marker.length >= active.marker.length && !info.trim()) active = undefined;
33
+ } else if (marker[0] !== '`' || !info.includes('`')) active = { marker, info, offset: line.index };
34
+ }
35
+ return active;
36
+ }
37
+
38
+ function unsupported(): EndpointDeliveryError {
39
+ return new EndpointDeliveryError('unsupported_operation',
40
+ 'QQ Markdown continuation requires a plain block boundary or a complete fenced-code header', 'not_sent');
41
+ }
@@ -0,0 +1,98 @@
1
+ import type { Bot, PrivateMessageStream } from 'qq-official-bot';
2
+ import { createEndpointOperation, type EndpointMessageStream, type EndpointStreaming, type EndpointStreamRequest,
3
+ type EndpointStreamAcknowledgement } from 'zhin.js/adapter';
4
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
5
+ import { qqDeliveryFailure } from './delivery-error.js';
6
+ import { QQ_PASSIVE_SOURCE_CODES, type PassiveReplyGrant, type QqPassiveReplyLedger, type QqReplySource } from './passive-reply.js';
7
+ import { qqOutboundKind, resolveOutboundMessageId } from './protocol.js';
8
+ import { qqMarkdownClosing, qqMarkdownContinuation } from './stream-markdown.js';
9
+
10
+ function supportsQqStream(request: Pick<EndpointStreamRequest, 'conversation' | 'source'>): boolean {
11
+ return qqOutboundKind(request.conversation) === 'private';
12
+ }
13
+
14
+ /** C2C native streams share the ordinary passive ledger, source and sequence. */
15
+ export function createQqStreaming(getBot: () => Bot, ledger: QqPassiveReplyLedger, minIntervalMs = 500): EndpointStreaming {
16
+ return {
17
+ mode: 'native',
18
+ minIntervalMs,
19
+ supports: supportsQqStream,
20
+ open(request): EndpointMessageStream | undefined {
21
+ if (!supportsQqStream(request)) return undefined;
22
+ // stream_messages has no verified canonical mention/keyboard projection.
23
+ // Keep these combinations on the complete-message codec instead of guessing.
24
+ if (request.prefix?.some(segment => segment.type !== 'reply')) return undefined;
25
+ const reply = request.prefix?.find(segment => segment.type === 'reply');
26
+ const explicitId = reply && 'message_id' in reply.data ? reply.data.message_id : undefined;
27
+ const preferred: QqReplySource | undefined = typeof explicitId === 'string' && explicitId
28
+ ? { id: explicitId }
29
+ : request.source?.messageId ? { id: request.source.messageId } : undefined;
30
+ if (!ledger.hasUsableSource('private', request.conversation.id, preferred)) return undefined;
31
+ const precedingText = request.continuation?.precedingText ?? '';
32
+ const opening = request.format === 'markdown' ? qqMarkdownContinuation(precedingText).opening : '';
33
+ const cancellation = new AbortController();
34
+ const signal = request.signal ? AbortSignal.any([request.signal, cancellation.signal]) : cancellation.signal;
35
+ const operation = createEndpointOperation({ signal });
36
+ let native: PrivateMessageStream | undefined;
37
+ let grant: PassiveReplyGrant | undefined;
38
+ let acceptedText = '';
39
+ let messageId: string | undefined;
40
+ let exhausted = false;
41
+ let contentUnavailable = Boolean(opening);
42
+ let openingSent = false;
43
+ let failure: unknown;
44
+ let completion: Promise<EndpointStreamAcknowledgement> | undefined;
45
+ const acknowledgement = (): EndpointStreamAcknowledgement => ({
46
+ status: messageId ? 'sent' : 'buffered', acceptedText, ...(messageId ? { messageId } : {}),
47
+ ...(exhausted ? { exhausted: true as const } : {}),
48
+ ...(contentUnavailable ? { contentUnavailable: true as const } : {}),
49
+ });
50
+ const send = async (text: string, final: boolean): Promise<EndpointStreamAcknowledgement> => {
51
+ if (failure) throw failure;
52
+ if (signal.aborted) throw new EndpointDeliveryError('endpoint_inactive', 'QQ stream cancelled', 'not_sent');
53
+ if (!text.startsWith(acceptedText)) throw new EndpointDeliveryError('invalid_payload', 'QQ stream cannot replace its accepted prefix', 'not_sent');
54
+ if (!final && (text === acceptedText || exhausted)) return acknowledgement();
55
+ if (final && exhausted && text !== acceptedText) throw new EndpointDeliveryError('stream_capacity', 'QQ exhausted stream must finish its accepted prefix', 'not_sent');
56
+ if (!native) {
57
+ if (!text) return acknowledgement();
58
+ grant = ledger.acquire('private', request.conversation.id, preferred);
59
+ if (!grant) throw new EndpointDeliveryError('unsupported_operation', 'QQ stream has no usable passive reply source', 'not_sent');
60
+ native = getBot().createPrivateStream(request.conversation.id, {
61
+ source: grant.source, msgSeq: grant.msgSeq,
62
+ contentType: request.format, inputMode: 'append',
63
+ });
64
+ }
65
+ try {
66
+ const closing = final && request.format === 'markdown' ? qqMarkdownClosing(precedingText + text) : '';
67
+ // An exhausted container permits only the terminating state frame;
68
+ // no extra formatting characters may exceed its confirmed capacity.
69
+ const suffix = exhausted ? '' : closing;
70
+ const delta = (openingSent ? '' : opening) + text.slice(acceptedText.length) + suffix;
71
+ const result = await operation.run(() => final ? native!.end(delta) : native!.write(delta));
72
+ const id = resolveOutboundMessageId(result);
73
+ messageId = `private-${request.conversation.id}:${id}`;
74
+ acceptedText = text;
75
+ openingSent = true;
76
+ contentUnavailable ||= Boolean(closing);
77
+ // Zero is exhausted regardless of the platform's otherwise unspecified unit.
78
+ exhausted = result.remain_msg_len === 0;
79
+ return acknowledgement();
80
+ } catch (error) {
81
+ const result = qqDeliveryFailure(error);
82
+ if (grant && result.diagnostic.platformCode !== undefined && QQ_PASSIVE_SOURCE_CODES.has(result.diagnostic.platformCode)) {
83
+ ledger.exhaust('private', request.conversation.id, grant.source);
84
+ }
85
+ failure = result.failure;
86
+ throw failure;
87
+ }
88
+ };
89
+ return {
90
+ write: text => completion
91
+ ? Promise.reject(new EndpointDeliveryError('stream_closed', 'QQ stream is finishing', 'not_sent'))
92
+ : send(text, false),
93
+ finish: text => completion ??= send(text, true),
94
+ cancel: () => { cancellation.abort(); },
95
+ };
96
+ },
97
+ };
98
+ }