@zhin.js/adapter-qq 1.1.20 → 1.1.22
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 +86 -0
- package/README.md +13 -0
- package/adapters/qq/index.js +27 -5
- package/adapters/qq/index.ts +27 -5
- package/agents/qq/skills/qq/SKILL.md +1 -1
- package/agents/qq/skills/qq/tools/guild_mute_member/index.js +1 -1
- package/agents/qq/skills/qq/tools/guild_mute_member/index.ts +1 -1
- package/agents/qq/skills/qq-conversation/SKILL.md +3 -3
- package/agents/qq/skills/qq-conversation/tools/conversation_info/index.js +1 -1
- package/agents/qq/skills/qq-conversation/tools/conversation_info/index.ts +1 -1
- package/agents/qq/skills/qq-group-admin/SKILL.md +3 -3
- package/agents/qq/skills/qq-group-admin/tools/handle_join_request/index.js +1 -1
- package/agents/qq/skills/qq-group-admin/tools/handle_join_request/index.ts +1 -1
- package/agents/qq/skills/qq-group-admin/tools/recall_member_message/index.js +1 -1
- package/agents/qq/skills/qq-group-admin/tools/recall_member_message/index.ts +1 -1
- package/agents/qq/skills/qq-group-admin/tools/set_mute/index.js +1 -1
- package/agents/qq/skills/qq-group-admin/tools/set_mute/index.ts +1 -1
- package/agents/qq/skills/qq-group-directory/SKILL.md +3 -3
- package/agents/qq/skills/qq-group-state/SKILL.md +1 -1
- package/agents/qq/skills/qq-messages/SKILL.md +1 -1
- package/agents/qq/skills/qq-messages/tools/recall_own_message/index.js +1 -1
- package/agents/qq/skills/qq-messages/tools/recall_own_message/index.ts +1 -1
- package/lib/adapter.js +1 -1
- package/lib/delivery-error.d.ts +1 -1
- package/lib/endpoint.js +6 -0
- package/lib/outbound.d.ts +4 -10
- package/lib/outbound.js +51 -60
- package/lib/passive-reply.d.ts +2 -0
- package/lib/passive-reply.js +12 -5
- package/lib/protocol.d.ts +9 -1
- package/lib/protocol.js +7 -0
- package/lib/qq-console-spec.js +2 -0
- package/lib/self-profile.d.ts +9 -0
- package/lib/self-profile.js +14 -0
- package/lib/side-event-dispatch.js +1 -1
- package/lib/stream-markdown.d.ts +5 -0
- package/lib/stream-markdown.js +41 -0
- package/lib/streaming.d.ts +5 -0
- package/lib/streaming.js +105 -0
- package/package.json +22 -21
- package/plugin.js +1 -0
- package/schema.json +146 -1
- package/src/adapter.ts +1 -1
- package/src/endpoint.ts +6 -0
- package/src/outbound.ts +46 -55
- package/src/passive-reply.ts +13 -4
- package/src/protocol.ts +15 -1
- package/src/qq-console-spec.ts +2 -0
- package/src/self-profile.ts +20 -0
- package/src/side-event-dispatch.ts +1 -1
- package/src/stream-markdown.ts +41 -0
- package/src/streaming.ts +98 -0
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
|
|
306
|
-
|
|
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
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
* 1. 公网 URL 图片且已知尺寸:按 QQ markdown 语法 `` 嵌入同一
|
|
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
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
|
|
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
|
|
329
|
-
const
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
if (
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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: `` } });
|
|
350
|
+
} else {
|
|
351
|
+
flush();
|
|
352
|
+
append([...replies, item]);
|
|
353
|
+
}
|
|
361
354
|
} else {
|
|
362
|
-
|
|
355
|
+
pending.push(item);
|
|
363
356
|
}
|
|
364
357
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
rest.length === 1 ? rest[0]! : rest,
|
|
368
|
-
];
|
|
358
|
+
flush();
|
|
359
|
+
return requireKeyboardMarkdown(messages);
|
|
369
360
|
}
|
|
370
361
|
|
|
371
362
|
/**
|
package/src/passive-reply.ts
CHANGED
|
@@ -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
|
-
|
|
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). */
|
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,
|
package/src/qq-console-spec.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { receiveOneBotLikeSideEvent, type OneBotLikeRawEvent } from '@zhin.js/
|
|
1
|
+
import { receiveOneBotLikeSideEvent, type OneBotLikeRawEvent } from '@zhin.js/adapter-onebot-events';
|
|
2
2
|
import type { EndpointEventEmitter } from 'zhin.js/adapter';
|
|
3
3
|
import { formatCompact, type getAdapterLogger } from '@zhin.js/logger';
|
|
4
4
|
import type { ApproveJoinRequestOptions } from 'qq-official-bot';
|
|
@@ -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
|
+
}
|
package/src/streaming.ts
ADDED
|
@@ -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
|
+
}
|