@zhin.js/adapter-qq 1.1.14 → 1.1.15
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 +25 -0
- package/README.md +25 -1
- package/agents/qq/skills/qq/SKILL.md +16 -10
- package/agents/qq/skills/qq/tools/add_role/index.js +1 -0
- package/agents/qq/skills/qq/tools/add_role/index.ts +1 -0
- package/agents/qq/skills/qq/tools/channel_info/index.js +1 -0
- package/agents/qq/skills/qq/tools/channel_info/index.ts +1 -0
- package/agents/qq/skills/qq/tools/create_role/index.js +1 -0
- package/agents/qq/skills/qq/tools/create_role/index.ts +1 -0
- package/agents/qq/skills/qq/tools/list_channels/index.js +1 -0
- package/agents/qq/skills/qq/tools/list_channels/index.ts +1 -0
- package/agents/qq/skills/qq/tools/list_roles/index.js +1 -0
- package/agents/qq/skills/qq/tools/list_roles/index.ts +1 -0
- package/agents/qq/skills/qq/tools/member_detail/index.js +1 -0
- package/agents/qq/skills/qq/tools/member_detail/index.ts +1 -0
- package/agents/qq/skills/qq/tools/remove_role/index.js +1 -0
- package/agents/qq/skills/qq/tools/remove_role/index.ts +1 -0
- package/agents/qq/skills/qq-directory/SKILL.md +14 -0
- package/lib/endpoint.js +28 -36
- package/lib/passive-reply.d.ts +61 -0
- package/lib/passive-reply.js +122 -0
- package/lib/qq-send.d.ts +28 -0
- package/lib/qq-send.js +109 -0
- package/lib/webhook.js +4 -4
- package/lib/ws.d.ts +10 -4
- package/lib/ws.js +4 -4
- package/package.json +18 -18
- package/src/endpoint.ts +26 -34
- package/src/passive-reply.ts +159 -0
- package/src/qq-send.ts +138 -0
- package/src/webhook.ts +4 -4
- package/src/ws.ts +15 -8
- /package/agents/qq/skills/{qq → qq-directory}/tools/list_guilds/index.js +0 -0
- /package/agents/qq/skills/{qq → qq-directory}/tools/list_guilds/index.ts +0 -0
package/lib/qq-send.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
2
|
+
import { QQ_ACTIVE_UNAVAILABLE_CODES, QQ_MARKDOWN_DENIED_CODES, QQ_PASSIVE_SOURCE_CODES, } from './passive-reply.js';
|
|
3
|
+
import { qqDeliveryFailure } from './delivery-error.js';
|
|
4
|
+
import { qqOutboundKind } from './protocol.js';
|
|
5
|
+
function elements(message) {
|
|
6
|
+
return Array.isArray(message) ? [...message] : [message];
|
|
7
|
+
}
|
|
8
|
+
/** The reply segment the payload already names, as a passive source. */
|
|
9
|
+
export function explicitReplySource(message) {
|
|
10
|
+
for (const part of elements(message)) {
|
|
11
|
+
if (typeof part === 'string' || part.type !== 'reply')
|
|
12
|
+
continue;
|
|
13
|
+
if (typeof part.data.event_id === 'string' && part.data.event_id)
|
|
14
|
+
return { event_id: part.data.event_id };
|
|
15
|
+
if (typeof part.data.id === 'string' && part.data.id)
|
|
16
|
+
return { id: part.data.id };
|
|
17
|
+
}
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
/** Plain-text version of a Markdown message (buttons dropped); undefined when it has no Markdown. */
|
|
21
|
+
export function markdownAsText(message) {
|
|
22
|
+
const parts = elements(message);
|
|
23
|
+
if (!parts.some(part => typeof part !== 'string' && (part.type === 'markdown' || part.type === 'keyboard' || part.type === 'button')))
|
|
24
|
+
return undefined;
|
|
25
|
+
const text = parts.map(part => {
|
|
26
|
+
if (typeof part === 'string')
|
|
27
|
+
return part;
|
|
28
|
+
if (part.type === 'text')
|
|
29
|
+
return String(part.data.text ?? '');
|
|
30
|
+
if (part.type === 'markdown') {
|
|
31
|
+
return String(part.data.content ?? '')
|
|
32
|
+
.replace(/!\[[^\]\n]{0,500}\]\(([^)\s]{1,2000})\)/g, '$1')
|
|
33
|
+
.replace(/\[([^\]\n]{1,500})\]\(([^)\s]{1,2000})\)/g, '$1 $2')
|
|
34
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
35
|
+
.replace(/(\*\*|__|~~|`)/g, '');
|
|
36
|
+
}
|
|
37
|
+
return '';
|
|
38
|
+
}).join('').trim();
|
|
39
|
+
const kept = parts.filter((part) => typeof part !== 'string' && (part.type === 'reply' || part.type === 'at'));
|
|
40
|
+
return text ? [...kept, text] : undefined;
|
|
41
|
+
}
|
|
42
|
+
function dispatch(bot, kind, id, message, source, options) {
|
|
43
|
+
switch (kind) {
|
|
44
|
+
case 'private': return bot.sendPrivateMessage(id, message, source, options);
|
|
45
|
+
case 'group': return bot.sendGroupMessage(id, message, source, options);
|
|
46
|
+
case 'channel': return bot.sendGuildMessage(id, message, source, options);
|
|
47
|
+
case 'direct':
|
|
48
|
+
if (!bot.sendDirectMessage)
|
|
49
|
+
throw new Error('QQ direct message not supported by transport');
|
|
50
|
+
return bot.sendDirectMessage(id, message, source, options);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function platformCode(error) {
|
|
54
|
+
return qqDeliveryFailure(error).diagnostic.platformCode;
|
|
55
|
+
}
|
|
56
|
+
/** Sends one message, passive if possible; returns the platform result. */
|
|
57
|
+
async function sendOne(context, kind, id, message) {
|
|
58
|
+
const passive = (body, grant) => dispatch(context.bot, kind, id, body, grant.source, { msgSeq: grant.msgSeq });
|
|
59
|
+
const active = async (body) => {
|
|
60
|
+
if (context.activeGate.blocked(kind, id)) {
|
|
61
|
+
throw new EndpointDeliveryError('platform_rejected', 'QQ active messages are unavailable for this conversation', 'not_sent');
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
return await dispatch(context.bot, kind, id, body);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
const code = platformCode(error);
|
|
68
|
+
if (code !== undefined && QQ_ACTIVE_UNAVAILABLE_CODES.has(code) && context.activeGate.block(kind, id)) {
|
|
69
|
+
context.log({ op: 'qq_active_unavailable', target: `${kind}:${id}`, platformCode: code });
|
|
70
|
+
}
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
/** Markdown refused: the same message once more as plain text, under a fresh msg_seq when passive. */
|
|
75
|
+
const asText = (error, previous) => {
|
|
76
|
+
const code = platformCode(error);
|
|
77
|
+
const text = code !== undefined && QQ_MARKDOWN_DENIED_CODES.has(code) ? markdownAsText(message) : undefined;
|
|
78
|
+
if (!text)
|
|
79
|
+
throw error;
|
|
80
|
+
context.log({ op: 'qq_markdown_denied_as_text', target: `${kind}:${id}`, platformCode: code });
|
|
81
|
+
const retry = previous ? context.ledger.acquire(kind, id, previous.source) : undefined;
|
|
82
|
+
return retry ? passive(text, retry) : active(text);
|
|
83
|
+
};
|
|
84
|
+
const activeOrText = (body) => active(body).catch((error) => asText(error, undefined));
|
|
85
|
+
const grant = context.ledger.acquire(kind, id, explicitReplySource(message));
|
|
86
|
+
if (!grant)
|
|
87
|
+
return activeOrText(message);
|
|
88
|
+
try {
|
|
89
|
+
return await passive(message, grant);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
const code = platformCode(error);
|
|
93
|
+
if (code !== undefined && QQ_PASSIVE_SOURCE_CODES.has(code)) {
|
|
94
|
+
context.ledger.exhaust(kind, id, grant.source);
|
|
95
|
+
context.log({ op: 'qq_passive_source_unusable', target: `${kind}:${id}`, platformCode: code });
|
|
96
|
+
return activeOrText(message);
|
|
97
|
+
}
|
|
98
|
+
// Anything else, including a timeout (outcome unknown), is not retried as an active message.
|
|
99
|
+
return asText(error, grant);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Sends every message of one delivery in order; returns the last platform result. */
|
|
103
|
+
export async function sendQqMessages(context, conversation, messages) {
|
|
104
|
+
const kind = qqOutboundKind(conversation);
|
|
105
|
+
let result;
|
|
106
|
+
for (const message of messages)
|
|
107
|
+
result = await sendOne(context, kind, conversation.id, message);
|
|
108
|
+
return result;
|
|
109
|
+
}
|
package/lib/webhook.js
CHANGED
|
@@ -73,10 +73,10 @@ export function defaultCreateHttpBot(config) {
|
|
|
73
73
|
removeAllListeners: () => {
|
|
74
74
|
bot.removeAllListeners(undefined);
|
|
75
75
|
},
|
|
76
|
-
sendPrivateMessage: (userId, message) => bot.sendPrivateMessage(userId, message),
|
|
77
|
-
sendGroupMessage: (groupId, message) => bot.sendGroupMessage(groupId, message),
|
|
78
|
-
sendGuildMessage: (channelId, message) => bot.sendGuildMessage(channelId, message),
|
|
79
|
-
sendDirectMessage: (guildId, message) => bot.sendDirectMessage(guildId, message),
|
|
76
|
+
sendPrivateMessage: (userId, message, source, options) => bot.sendPrivateMessage(userId, message, source, options),
|
|
77
|
+
sendGroupMessage: (groupId, message, source, options) => bot.sendGroupMessage(groupId, message, source, options),
|
|
78
|
+
sendGuildMessage: (channelId, message, source, options) => bot.sendGuildMessage(channelId, message, source, options),
|
|
79
|
+
sendDirectMessage: (guildId, message, source, options) => bot.sendDirectMessage(guildId, message, source, options),
|
|
80
80
|
recallPrivateMessage: (userId, messageId) => bot.recallPrivateMessage(userId, messageId),
|
|
81
81
|
recallGroupMessage: (groupId, messageId) => bot.recallGroupMessage(groupId, messageId),
|
|
82
82
|
recallGuildMessage: (channelId, messageId) => bot.recallGuildMessage(channelId, messageId),
|
package/lib/ws.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { Bot, type ApproveJoinRequestOptions } from 'qq-official-bot';
|
|
2
2
|
import type { EndpointTransportState } from 'zhin.js/adapter';
|
|
3
3
|
import type { QqOutboundMessage } from './outbound.js';
|
|
4
|
+
import type { QqReplySource } from './passive-reply.js';
|
|
5
|
+
/** Per-send options. `msgSeq` numbers replies to one passive source (qq-official-bot ≥ 1.3.3). */
|
|
6
|
+
export interface QqSendOptions {
|
|
7
|
+
readonly msgSeq?: number;
|
|
8
|
+
}
|
|
4
9
|
import type { QqInboundMessage, ResolvedQqWebsocketConfig } from './protocol.js';
|
|
5
10
|
/** SDK business surface exposed to plugins without bypassing Endpoint lifecycle ownership. */
|
|
6
11
|
export type QqOfficialApi = Pick<Bot, 'messageService' | 'fileProcessor' | 'guildService' | 'channelService' | 'memberService' | 'permissionService' | 'reactionService' | 'scheduleService' | 'threadService' | 'audioService' | 'botService' | 'groupService' | 'menuPanelService' | 'group' | 'user' | 'channel' | 'direct' | 'guild'>;
|
|
@@ -13,10 +18,11 @@ export interface QqBotTransport {
|
|
|
13
18
|
stop(): Promise<void>;
|
|
14
19
|
on(event: string, listener: (...args: unknown[]) => void): void;
|
|
15
20
|
removeAllListeners(): void;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
/** `source` makes it a passive reply; without it the platform treats the message as active. */
|
|
22
|
+
sendPrivateMessage(userId: string, message: QqOutboundMessage, source?: QqReplySource, options?: QqSendOptions): Promise<unknown>;
|
|
23
|
+
sendGroupMessage(groupId: string, message: QqOutboundMessage, source?: QqReplySource, options?: QqSendOptions): Promise<unknown>;
|
|
24
|
+
sendGuildMessage(channelId: string, message: QqOutboundMessage, source?: QqReplySource, options?: QqSendOptions): Promise<unknown>;
|
|
25
|
+
sendDirectMessage?(guildId: string, message: QqOutboundMessage, source?: QqReplySource, options?: QqSendOptions): Promise<unknown>;
|
|
20
26
|
recallPrivateMessage?(userId: string, messageId: string): Promise<unknown>;
|
|
21
27
|
recallGroupMessage?(groupId: string, messageId: string): Promise<unknown>;
|
|
22
28
|
recallGuildMessage?(channelId: string, messageId: string): Promise<unknown>;
|
package/lib/ws.js
CHANGED
|
@@ -240,10 +240,10 @@ export function defaultCreateBot(config) {
|
|
|
240
240
|
receiver.removeListener(entry.event, entry.listener);
|
|
241
241
|
bot.removeAllListeners(undefined);
|
|
242
242
|
},
|
|
243
|
-
sendPrivateMessage: (userId, message) => bot.sendPrivateMessage(userId, message),
|
|
244
|
-
sendGroupMessage: (groupId, message) => bot.sendGroupMessage(groupId, message),
|
|
245
|
-
sendGuildMessage: (channelId, message) => bot.sendGuildMessage(channelId, message),
|
|
246
|
-
sendDirectMessage: (guildId, message) => bot.sendDirectMessage(guildId, message),
|
|
243
|
+
sendPrivateMessage: (userId, message, source, options) => bot.sendPrivateMessage(userId, message, source, options),
|
|
244
|
+
sendGroupMessage: (groupId, message, source, options) => bot.sendGroupMessage(groupId, message, source, options),
|
|
245
|
+
sendGuildMessage: (channelId, message, source, options) => bot.sendGuildMessage(channelId, message, source, options),
|
|
246
|
+
sendDirectMessage: (guildId, message, source, options) => bot.sendDirectMessage(guildId, message, source, options),
|
|
247
247
|
recallPrivateMessage: (userId, messageId) => bot.recallPrivateMessage(userId, messageId),
|
|
248
248
|
recallGroupMessage: (groupId, messageId) => bot.recallGroupMessage(groupId, messageId),
|
|
249
249
|
recallGuildMessage: (channelId, messageId) => bot.recallGuildMessage(channelId, messageId),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-qq",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.15",
|
|
4
4
|
"description": "Zhin.js QQ Official adapter for Plugin Runtime (WebSocket gateway)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -33,25 +33,25 @@
|
|
|
33
33
|
"directory": "plugins/adapters/qq"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"qq-official-bot": "^1.3.
|
|
37
|
-
"@zhin.js/adapter": "1.1.
|
|
38
|
-
"@zhin.js/agent-feature": "1.1.
|
|
39
|
-
"@zhin.js/core": "1.1.
|
|
40
|
-
"@zhin.js/feature-kit": "1.1.
|
|
41
|
-
"@zhin.js/host-http": "1.1.
|
|
42
|
-
"@zhin.js/im-contract": "1.1.
|
|
36
|
+
"qq-official-bot": "^1.3.3",
|
|
37
|
+
"@zhin.js/adapter": "1.1.22",
|
|
38
|
+
"@zhin.js/agent-feature": "1.1.9",
|
|
39
|
+
"@zhin.js/core": "1.1.50",
|
|
40
|
+
"@zhin.js/feature-kit": "1.1.8",
|
|
41
|
+
"@zhin.js/host-http": "1.1.9",
|
|
42
|
+
"@zhin.js/im-contract": "1.1.6",
|
|
43
43
|
"@zhin.js/logger": "1.1.2",
|
|
44
|
-
"@zhin.js/permission": "1.1.
|
|
45
|
-
"@zhin.js/skill": "1.1.
|
|
46
|
-
"@zhin.js/tool": "1.1.
|
|
44
|
+
"@zhin.js/permission": "1.1.8",
|
|
45
|
+
"@zhin.js/skill": "1.1.9",
|
|
46
|
+
"@zhin.js/tool": "1.1.9"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"zod": "^4.0.0",
|
|
50
|
-
"@zhin.js/adapter": "^1.1.
|
|
51
|
-
"@zhin.js/command": "^1.1.
|
|
52
|
-
"@zhin.js/core": "^1.1.
|
|
53
|
-
"@zhin.js/middleware": "^1.1.
|
|
54
|
-
"zhin.js": "^1.1.
|
|
50
|
+
"@zhin.js/adapter": "^1.1.22",
|
|
51
|
+
"@zhin.js/command": "^1.1.10",
|
|
52
|
+
"@zhin.js/core": "^1.1.50",
|
|
53
|
+
"@zhin.js/middleware": "^1.1.9",
|
|
54
|
+
"zhin.js": "^1.1.15"
|
|
55
55
|
},
|
|
56
56
|
"peerDependenciesMeta": {
|
|
57
57
|
"@zhin.js/command": {
|
|
@@ -72,8 +72,8 @@
|
|
|
72
72
|
"typescript": "^6.0.3",
|
|
73
73
|
"vitest": "^4.1.10",
|
|
74
74
|
"zod": "^4.4.3",
|
|
75
|
-
"@zhin.js/host-http": "1.1.
|
|
76
|
-
"zhin.js": "1.1.
|
|
75
|
+
"@zhin.js/host-http": "1.1.9",
|
|
76
|
+
"zhin.js": "1.1.15"
|
|
77
77
|
},
|
|
78
78
|
"files": [
|
|
79
79
|
"adapters",
|
package/src/endpoint.ts
CHANGED
|
@@ -20,6 +20,8 @@ import type { HttpHost } from '@zhin.js/host-http';
|
|
|
20
20
|
import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
|
|
21
21
|
import type { CapabilityId } from 'zhin.js';
|
|
22
22
|
import { collectOutboundImageSizes, formatOutboundMessages } from './outbound.js';
|
|
23
|
+
import { QqActiveMessageGate, QqPassiveReplyLedger } from './passive-reply.js';
|
|
24
|
+
import { sendQqMessages } from './qq-send.js';
|
|
23
25
|
import {
|
|
24
26
|
formatInboundContent,
|
|
25
27
|
parseCompoundMessageId,
|
|
@@ -67,6 +69,9 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
|
|
|
67
69
|
#bot: QqBotTransport | null = null;
|
|
68
70
|
#open = false;
|
|
69
71
|
readonly #seenInteractions = new Map<string, number>();
|
|
72
|
+
/** Recent msg_id / event_id per conversation, so replies go out as passive messages. */
|
|
73
|
+
readonly #passiveReplies = new QqPassiveReplyLedger();
|
|
74
|
+
readonly #activeGate = new QqActiveMessageGate();
|
|
70
75
|
readonly #lifecycle: EndpointLifecycle;
|
|
71
76
|
#cleanup?: () => Promise<void>;
|
|
72
77
|
readonly management: EndpointManagement = createQqEndpointManagement(() => this.#requireBot());
|
|
@@ -169,23 +174,12 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
|
|
|
169
174
|
const bot = this.#requireBot();
|
|
170
175
|
let result: unknown;
|
|
171
176
|
try {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
result = await bot.sendGroupMessage(conversation.id, message);
|
|
179
|
-
break;
|
|
180
|
-
case 'channel':
|
|
181
|
-
result = await bot.sendGuildMessage(conversation.id, message);
|
|
182
|
-
break;
|
|
183
|
-
case 'direct':
|
|
184
|
-
if (!bot.sendDirectMessage) throw new Error('QQ direct message not supported by transport');
|
|
185
|
-
result = await bot.sendDirectMessage(conversation.id, message);
|
|
186
|
-
break;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
177
|
+
result = await sendQqMessages({
|
|
178
|
+
bot,
|
|
179
|
+
ledger: this.#passiveReplies,
|
|
180
|
+
activeGate: this.#activeGate,
|
|
181
|
+
log: fields => this.#logger.warn(formatCompact(fields)),
|
|
182
|
+
}, conversation, messages);
|
|
189
183
|
} catch (error) {
|
|
190
184
|
const { failure, diagnostic } = qqDeliveryFailure(error);
|
|
191
185
|
this.#logger.warn(formatCompact(diagnostic));
|
|
@@ -224,6 +218,7 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
|
|
|
224
218
|
+ ` | ${truncatePreview(content, 80)}`,
|
|
225
219
|
);
|
|
226
220
|
const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
|
|
221
|
+
if (msg.id) this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { id: msg.id });
|
|
227
222
|
void this.emit('message.receive', {
|
|
228
223
|
conversation,
|
|
229
224
|
message: { conversation, id: msg.id },
|
|
@@ -266,6 +261,8 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
|
|
|
266
261
|
// ACK owns only platform loading state, never implies business acceptance.
|
|
267
262
|
void action.ack().then(ok => { if (ok === false) this.#logger.warn('qq_interaction_ack_failed'); }, () => this.#logger.warn('qq_interaction_ack_failed'));
|
|
268
263
|
const conversation = qqInboundConversation(String(this.#options.id), action.message);
|
|
264
|
+
// INTERACTION_CREATE's id is an event_id the reply may answer passively.
|
|
265
|
+
this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { event_id: action.message.id });
|
|
269
266
|
void this.emit('message.receive', {
|
|
270
267
|
conversation, message: { conversation, id: action.message.id }, content: '',
|
|
271
268
|
segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
|
|
@@ -330,6 +327,9 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
|
|
|
330
327
|
#bot: QqHttpBotTransport | null = null;
|
|
331
328
|
#open = false;
|
|
332
329
|
readonly #seenInteractions = new Map<string, number>();
|
|
330
|
+
/** Recent msg_id / event_id per conversation, so replies go out as passive messages. */
|
|
331
|
+
readonly #passiveReplies = new QqPassiveReplyLedger();
|
|
332
|
+
readonly #activeGate = new QqActiveMessageGate();
|
|
333
333
|
readonly #lifecycle: EndpointLifecycle;
|
|
334
334
|
#cleanup?: () => Promise<void>;
|
|
335
335
|
readonly management: EndpointManagement = createQqEndpointManagement(() => this.#requireBot());
|
|
@@ -432,23 +432,12 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
|
|
|
432
432
|
const bot = this.#requireBot();
|
|
433
433
|
let result: unknown;
|
|
434
434
|
try {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
result = await bot.sendGroupMessage(conversation.id, message);
|
|
442
|
-
break;
|
|
443
|
-
case 'channel':
|
|
444
|
-
result = await bot.sendGuildMessage(conversation.id, message);
|
|
445
|
-
break;
|
|
446
|
-
case 'direct':
|
|
447
|
-
if (!bot.sendDirectMessage) throw new Error('QQ direct message not supported by transport');
|
|
448
|
-
result = await bot.sendDirectMessage(conversation.id, message);
|
|
449
|
-
break;
|
|
450
|
-
}
|
|
451
|
-
}
|
|
435
|
+
result = await sendQqMessages({
|
|
436
|
+
bot,
|
|
437
|
+
ledger: this.#passiveReplies,
|
|
438
|
+
activeGate: this.#activeGate,
|
|
439
|
+
log: fields => this.#logger.warn(formatCompact(fields)),
|
|
440
|
+
}, conversation, messages);
|
|
452
441
|
} catch (error) {
|
|
453
442
|
const { failure, diagnostic } = qqDeliveryFailure(error);
|
|
454
443
|
this.#logger.warn(formatCompact(diagnostic));
|
|
@@ -486,6 +475,7 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
|
|
|
486
475
|
+ ` | ${truncatePreview(content, 80)}`,
|
|
487
476
|
);
|
|
488
477
|
const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
|
|
478
|
+
if (msg.id) this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { id: msg.id });
|
|
489
479
|
void this.emit('message.receive', {
|
|
490
480
|
conversation,
|
|
491
481
|
message: { conversation, id: msg.id },
|
|
@@ -528,6 +518,8 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
|
|
|
528
518
|
// ACK owns only platform loading state, never implies business acceptance.
|
|
529
519
|
void action.ack().then(ok => { if (ok === false) this.#logger.warn('qq_interaction_ack_failed'); }, () => this.#logger.warn('qq_interaction_ack_failed'));
|
|
530
520
|
const conversation = qqInboundConversation(String(this.#options.id), action.message);
|
|
521
|
+
// INTERACTION_CREATE's id is an event_id the reply may answer passively.
|
|
522
|
+
this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { event_id: action.message.id });
|
|
531
523
|
void this.emit('message.receive', {
|
|
532
524
|
conversation, message: { conversation, id: action.message.id }, content: '',
|
|
533
525
|
segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passive replies on the QQ open platform.
|
|
3
|
+
*
|
|
4
|
+
* A message sent with the `msg_id` of a recent user message (or the `event_id` of a recent event)
|
|
5
|
+
* is a passive reply; without one it is an active message, which has quotas and can be switched
|
|
6
|
+
* off by the user. Each source may be replied to a limited number of times within a window, and
|
|
7
|
+
* `msg_seq` must differ for every reply to the same source. The ledger remembers recent sources
|
|
8
|
+
* per conversation and hands out the newest one still usable, with its next `msg_seq`.
|
|
9
|
+
*
|
|
10
|
+
* Limits from https://bot.q.qq.com/wiki/develop/api-v2/server-inter/message/overview.html
|
|
11
|
+
*/
|
|
12
|
+
import type { QqChannelKind as QqOutboundKind } from './protocol.js';
|
|
13
|
+
|
|
14
|
+
/** Reply-to source for the SDK: a user message id or an event id. */
|
|
15
|
+
export type QqReplySource = { readonly id: string } | { readonly event_id: string };
|
|
16
|
+
|
|
17
|
+
export interface QqPassiveLimit {
|
|
18
|
+
readonly windowMs: number;
|
|
19
|
+
readonly maxReplies: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** C2C: 60 min, 4 replies. Group: 5 min, 5 replies. Guild channel and DM: 5 min (count unpublished; 5 assumed). */
|
|
23
|
+
export const QQ_PASSIVE_LIMITS: Readonly<Record<QqOutboundKind, QqPassiveLimit>> = Object.freeze({
|
|
24
|
+
private: { windowMs: 60 * 60_000, maxReplies: 4 },
|
|
25
|
+
group: { windowMs: 5 * 60_000, maxReplies: 5 },
|
|
26
|
+
channel: { windowMs: 5 * 60_000, maxReplies: 5 },
|
|
27
|
+
direct: { windowMs: 5 * 60_000, maxReplies: 5 },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** Stop using a source this long before the platform window closes: the request itself takes time. */
|
|
31
|
+
const SAFETY_MARGIN_MS = 15_000;
|
|
32
|
+
/** Sources remembered per conversation; older ones are long expired in practice. */
|
|
33
|
+
const MAX_SOURCES_PER_CONVERSATION = 8;
|
|
34
|
+
|
|
35
|
+
interface SourceEntry {
|
|
36
|
+
readonly source: QqReplySource;
|
|
37
|
+
readonly receivedAt: number;
|
|
38
|
+
replies: number;
|
|
39
|
+
exhausted: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PassiveReplyGrant {
|
|
43
|
+
readonly source: QqReplySource;
|
|
44
|
+
readonly msgSeq: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sourceKey(source: QqReplySource): string {
|
|
48
|
+
return 'id' in source ? `msg:${source.id}` : `event:${source.event_id}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Expired conversations are dropped every this many records. */
|
|
52
|
+
const PRUNE_EVERY_RECORDS = 256;
|
|
53
|
+
|
|
54
|
+
export class QqPassiveReplyLedger {
|
|
55
|
+
readonly #conversations = new Map<string, SourceEntry[]>();
|
|
56
|
+
#records = 0;
|
|
57
|
+
|
|
58
|
+
constructor(private readonly now: () => number = Date.now) {}
|
|
59
|
+
|
|
60
|
+
/** A user message or event in `conversation` that later replies may answer. */
|
|
61
|
+
record(kind: QqOutboundKind, conversationId: string, source: QqReplySource, receivedAt = this.now()): void {
|
|
62
|
+
const key = `${kind}:${conversationId}`;
|
|
63
|
+
const entries = (this.#conversations.get(key) ?? []).filter(entry => sourceKey(entry.source) !== sourceKey(source));
|
|
64
|
+
entries.push({ source, receivedAt, replies: 0, exhausted: false });
|
|
65
|
+
entries.sort((a, b) => a.receivedAt - b.receivedAt);
|
|
66
|
+
this.#conversations.set(key, entries.slice(-MAX_SOURCES_PER_CONVERSATION));
|
|
67
|
+
if (++this.#records % PRUNE_EVERY_RECORDS === 0) this.prune();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Claim one reply: `preferred` (an explicit reply segment) when it is still usable, otherwise the
|
|
72
|
+
* newest usable source in the conversation. Undefined when only an active message is possible.
|
|
73
|
+
*/
|
|
74
|
+
acquire(kind: QqOutboundKind, conversationId: string, preferred?: QqReplySource): PassiveReplyGrant | undefined {
|
|
75
|
+
const limit = QQ_PASSIVE_LIMITS[kind];
|
|
76
|
+
const now = this.now();
|
|
77
|
+
const entries = this.#conversations.get(`${kind}:${conversationId}`) ?? [];
|
|
78
|
+
const usable = (entry: SourceEntry) => !entry.exhausted
|
|
79
|
+
&& entry.replies < limit.maxReplies
|
|
80
|
+
&& now - entry.receivedAt < limit.windowMs - SAFETY_MARGIN_MS;
|
|
81
|
+
const preferredKey = preferred ? sourceKey(preferred) : undefined;
|
|
82
|
+
const entry = (preferredKey ? entries.find(item => sourceKey(item.source) === preferredKey && usable(item)) : undefined)
|
|
83
|
+
?? [...entries].reverse().find(usable);
|
|
84
|
+
if (!entry) return undefined;
|
|
85
|
+
entry.replies += 1;
|
|
86
|
+
return { source: entry.source, msgSeq: entry.replies };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The platform said this source can no longer be replied to (expired, over the limit, invalid id). */
|
|
90
|
+
exhaust(kind: QqOutboundKind, conversationId: string, source: QqReplySource): void {
|
|
91
|
+
const key = sourceKey(source);
|
|
92
|
+
const entry = this.#conversations.get(`${kind}:${conversationId}`)?.find(item => sourceKey(item.source) === key);
|
|
93
|
+
if (entry) entry.exhausted = true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Drop conversations whose every source is past its window. */
|
|
97
|
+
prune(): void {
|
|
98
|
+
const now = this.now();
|
|
99
|
+
for (const [key, entries] of this.#conversations) {
|
|
100
|
+
const kind = key.slice(0, key.indexOf(':')) as QqOutboundKind;
|
|
101
|
+
const windowMs = QQ_PASSIVE_LIMITS[kind]?.windowMs ?? 0;
|
|
102
|
+
const live = entries.filter(entry => now - entry.receivedAt < windowMs);
|
|
103
|
+
if (live.length === 0) this.#conversations.delete(key);
|
|
104
|
+
else this.#conversations.set(key, live);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Platform codes meaning the reply source is unusable: switch to an active message. */
|
|
110
|
+
export const QQ_PASSIVE_SOURCE_CODES: ReadonlySet<number> = new Set([
|
|
111
|
+
40034128, // 被动回复时间或次数超限
|
|
112
|
+
40034005, // 回复消息 msg_id 已过期
|
|
113
|
+
304027, // MSG_EXPIRE 回复的消息过期
|
|
114
|
+
304026, // MSG_ID 回复的消息 id 错误
|
|
115
|
+
306002, // msgid error
|
|
116
|
+
501005, 501008, 501009, // MessageID 错误 / 不存在 / 解析失败
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
/** Platform codes meaning active messages are unavailable for this conversation for now. */
|
|
120
|
+
export const QQ_ACTIVE_UNAVAILABLE_CODES: ReadonlySet<number> = new Set([
|
|
121
|
+
40034105, // 主动消息发送失败,无权限
|
|
122
|
+
304031, 304032, 304034, // 私信已关闭 / 不存在 / 不是私信成员
|
|
123
|
+
304035, 304045, 304047, 304049, 304050, // 主动消息额度、频控
|
|
124
|
+
304046, 304048, // 不允许在此(子)频道发主动消息
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
/** Platform codes meaning Markdown is not allowed: resend as plain text. */
|
|
128
|
+
export const QQ_MARKDOWN_DENIED_CODES: ReadonlySet<number> = new Set([
|
|
129
|
+
304036, // 没有 markdown 模板的权限
|
|
130
|
+
304014, // TEMPLATE_PRIVILEGE 没有模板权限
|
|
131
|
+
50056, // 不允许发送 markdown content
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
/** How long a conversation is not offered active messages after the platform refused them. */
|
|
135
|
+
export const ACTIVE_UNAVAILABLE_BACKOFF_MS = 60 * 60_000;
|
|
136
|
+
|
|
137
|
+
/** Conversations where the platform recently refused active messages. */
|
|
138
|
+
export class QqActiveMessageGate {
|
|
139
|
+
readonly #blockedUntil = new Map<string, number>();
|
|
140
|
+
|
|
141
|
+
constructor(private readonly now: () => number = Date.now) {}
|
|
142
|
+
|
|
143
|
+
blocked(kind: QqOutboundKind, conversationId: string): boolean {
|
|
144
|
+
const key = `${kind}:${conversationId}`;
|
|
145
|
+
const until = this.#blockedUntil.get(key);
|
|
146
|
+
if (until === undefined) return false;
|
|
147
|
+
if (this.now() < until) return true;
|
|
148
|
+
this.#blockedUntil.delete(key);
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Returns true the first time a conversation is blocked, so the caller can log it once. */
|
|
153
|
+
block(kind: QqOutboundKind, conversationId: string): boolean {
|
|
154
|
+
const key = `${kind}:${conversationId}`;
|
|
155
|
+
const fresh = !this.blocked(kind, conversationId);
|
|
156
|
+
this.#blockedUntil.set(key, this.now() + ACTIVE_UNAVAILABLE_BACKOFF_MS);
|
|
157
|
+
return fresh;
|
|
158
|
+
}
|
|
159
|
+
}
|
package/src/qq-send.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One QQ outbound send: passive reply when a usable source exists, active message otherwise, with
|
|
3
|
+
* the fallbacks the platform's error codes allow. Shared by the websocket and webhook Endpoints.
|
|
4
|
+
*
|
|
5
|
+
* 1. Each message claims a passive source (explicit reply segment first, else the conversation's
|
|
6
|
+
* newest usable msg_id / event_id) with its own msg_seq.
|
|
7
|
+
* 2. Source unusable (expired, over the limit, invalid id): mark it and send that message once as
|
|
8
|
+
* an active message. Never after a timeout: the first request may have been delivered.
|
|
9
|
+
* 3. Markdown not allowed: resend the same message as plain text.
|
|
10
|
+
* 4. Active message refused (no permission, quota, user switched it off): remember the conversation
|
|
11
|
+
* for a while and fail fast instead of calling the platform again.
|
|
12
|
+
*/
|
|
13
|
+
import type { ConversationRef } from '@zhin.js/im-contract';
|
|
14
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
15
|
+
import type { QqOutboundElem, QqOutboundMessage } from './outbound.js';
|
|
16
|
+
import {
|
|
17
|
+
QQ_ACTIVE_UNAVAILABLE_CODES,
|
|
18
|
+
QQ_MARKDOWN_DENIED_CODES,
|
|
19
|
+
QQ_PASSIVE_SOURCE_CODES,
|
|
20
|
+
type PassiveReplyGrant,
|
|
21
|
+
type QqActiveMessageGate,
|
|
22
|
+
type QqPassiveReplyLedger,
|
|
23
|
+
type QqReplySource,
|
|
24
|
+
} from './passive-reply.js';
|
|
25
|
+
import { qqDeliveryFailure } from './delivery-error.js';
|
|
26
|
+
import { qqOutboundKind } from './protocol.js';
|
|
27
|
+
import type { QqBotTransport, QqSendOptions } from './ws.js';
|
|
28
|
+
|
|
29
|
+
export interface QqSendContext {
|
|
30
|
+
readonly bot: QqBotTransport;
|
|
31
|
+
readonly ledger: QqPassiveReplyLedger;
|
|
32
|
+
readonly activeGate: QqActiveMessageGate;
|
|
33
|
+
readonly log: (fields: Record<string, string | number | undefined>) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Kind = ReturnType<typeof qqOutboundKind>;
|
|
37
|
+
|
|
38
|
+
function elements(message: QqOutboundMessage): Array<string | QqOutboundElem> {
|
|
39
|
+
return Array.isArray(message) ? [...message] : [message as string | QqOutboundElem];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The reply segment the payload already names, as a passive source. */
|
|
43
|
+
export function explicitReplySource(message: QqOutboundMessage): QqReplySource | undefined {
|
|
44
|
+
for (const part of elements(message)) {
|
|
45
|
+
if (typeof part === 'string' || part.type !== 'reply') continue;
|
|
46
|
+
if (typeof part.data.event_id === 'string' && part.data.event_id) return { event_id: part.data.event_id };
|
|
47
|
+
if (typeof part.data.id === 'string' && part.data.id) return { id: part.data.id };
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Plain-text version of a Markdown message (buttons dropped); undefined when it has no Markdown. */
|
|
53
|
+
export function markdownAsText(message: QqOutboundMessage): QqOutboundMessage | undefined {
|
|
54
|
+
const parts = elements(message);
|
|
55
|
+
if (!parts.some(part => typeof part !== 'string' && (part.type === 'markdown' || part.type === 'keyboard' || part.type === 'button'))) return undefined;
|
|
56
|
+
const text = parts.map(part => {
|
|
57
|
+
if (typeof part === 'string') return part;
|
|
58
|
+
if (part.type === 'text') return String(part.data.text ?? '');
|
|
59
|
+
if (part.type === 'markdown') {
|
|
60
|
+
return String(part.data.content ?? '')
|
|
61
|
+
.replace(/!\[[^\]\n]{0,500}\]\(([^)\s]{1,2000})\)/g, '$1')
|
|
62
|
+
.replace(/\[([^\]\n]{1,500})\]\(([^)\s]{1,2000})\)/g, '$1 $2')
|
|
63
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
64
|
+
.replace(/(\*\*|__|~~|`)/g, '');
|
|
65
|
+
}
|
|
66
|
+
return '';
|
|
67
|
+
}).join('').trim();
|
|
68
|
+
const kept = parts.filter((part): part is QqOutboundElem => typeof part !== 'string' && (part.type === 'reply' || part.type === 'at'));
|
|
69
|
+
return text ? [...kept, text] : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function dispatch(bot: QqBotTransport, kind: Kind, id: string, message: QqOutboundMessage, source?: QqReplySource, options?: QqSendOptions): Promise<unknown> {
|
|
73
|
+
switch (kind) {
|
|
74
|
+
case 'private': return bot.sendPrivateMessage(id, message, source, options);
|
|
75
|
+
case 'group': return bot.sendGroupMessage(id, message, source, options);
|
|
76
|
+
case 'channel': return bot.sendGuildMessage(id, message, source, options);
|
|
77
|
+
case 'direct':
|
|
78
|
+
if (!bot.sendDirectMessage) throw new Error('QQ direct message not supported by transport');
|
|
79
|
+
return bot.sendDirectMessage(id, message, source, options);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function platformCode(error: unknown): number | undefined {
|
|
84
|
+
return qqDeliveryFailure(error).diagnostic.platformCode;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Sends one message, passive if possible; returns the platform result. */
|
|
88
|
+
async function sendOne(context: QqSendContext, kind: Kind, id: string, message: QqOutboundMessage): Promise<unknown> {
|
|
89
|
+
const passive = (body: QqOutboundMessage, grant: PassiveReplyGrant) =>
|
|
90
|
+
dispatch(context.bot, kind, id, body, grant.source, { msgSeq: grant.msgSeq });
|
|
91
|
+
const active = async (body: QqOutboundMessage): Promise<unknown> => {
|
|
92
|
+
if (context.activeGate.blocked(kind, id)) {
|
|
93
|
+
throw new EndpointDeliveryError('platform_rejected', 'QQ active messages are unavailable for this conversation', 'not_sent');
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
return await dispatch(context.bot, kind, id, body);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
const code = platformCode(error);
|
|
99
|
+
if (code !== undefined && QQ_ACTIVE_UNAVAILABLE_CODES.has(code) && context.activeGate.block(kind, id)) {
|
|
100
|
+
context.log({ op: 'qq_active_unavailable', target: `${kind}:${id}`, platformCode: code });
|
|
101
|
+
}
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
/** Markdown refused: the same message once more as plain text, under a fresh msg_seq when passive. */
|
|
106
|
+
const asText = (error: unknown, previous: PassiveReplyGrant | undefined): Promise<unknown> => {
|
|
107
|
+
const code = platformCode(error);
|
|
108
|
+
const text = code !== undefined && QQ_MARKDOWN_DENIED_CODES.has(code) ? markdownAsText(message) : undefined;
|
|
109
|
+
if (!text) throw error;
|
|
110
|
+
context.log({ op: 'qq_markdown_denied_as_text', target: `${kind}:${id}`, platformCode: code });
|
|
111
|
+
const retry = previous ? context.ledger.acquire(kind, id, previous.source) : undefined;
|
|
112
|
+
return retry ? passive(text, retry) : active(text);
|
|
113
|
+
};
|
|
114
|
+
const activeOrText = (body: QqOutboundMessage) => active(body).catch((error: unknown) => asText(error, undefined));
|
|
115
|
+
|
|
116
|
+
const grant = context.ledger.acquire(kind, id, explicitReplySource(message));
|
|
117
|
+
if (!grant) return activeOrText(message);
|
|
118
|
+
try {
|
|
119
|
+
return await passive(message, grant);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
const code = platformCode(error);
|
|
122
|
+
if (code !== undefined && QQ_PASSIVE_SOURCE_CODES.has(code)) {
|
|
123
|
+
context.ledger.exhaust(kind, id, grant.source);
|
|
124
|
+
context.log({ op: 'qq_passive_source_unusable', target: `${kind}:${id}`, platformCode: code });
|
|
125
|
+
return activeOrText(message);
|
|
126
|
+
}
|
|
127
|
+
// Anything else, including a timeout (outcome unknown), is not retried as an active message.
|
|
128
|
+
return asText(error, grant);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Sends every message of one delivery in order; returns the last platform result. */
|
|
133
|
+
export async function sendQqMessages(context: QqSendContext, conversation: ConversationRef, messages: readonly QqOutboundMessage[]): Promise<unknown> {
|
|
134
|
+
const kind = qqOutboundKind(conversation);
|
|
135
|
+
let result: unknown;
|
|
136
|
+
for (const message of messages) result = await sendOne(context, kind, conversation.id, message);
|
|
137
|
+
return result;
|
|
138
|
+
}
|