@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +25 -1
  3. package/agents/qq/skills/qq/SKILL.md +16 -10
  4. package/agents/qq/skills/qq/tools/add_role/index.js +1 -0
  5. package/agents/qq/skills/qq/tools/add_role/index.ts +1 -0
  6. package/agents/qq/skills/qq/tools/channel_info/index.js +1 -0
  7. package/agents/qq/skills/qq/tools/channel_info/index.ts +1 -0
  8. package/agents/qq/skills/qq/tools/create_role/index.js +1 -0
  9. package/agents/qq/skills/qq/tools/create_role/index.ts +1 -0
  10. package/agents/qq/skills/qq/tools/list_channels/index.js +1 -0
  11. package/agents/qq/skills/qq/tools/list_channels/index.ts +1 -0
  12. package/agents/qq/skills/qq/tools/list_roles/index.js +1 -0
  13. package/agents/qq/skills/qq/tools/list_roles/index.ts +1 -0
  14. package/agents/qq/skills/qq/tools/member_detail/index.js +1 -0
  15. package/agents/qq/skills/qq/tools/member_detail/index.ts +1 -0
  16. package/agents/qq/skills/qq/tools/remove_role/index.js +1 -0
  17. package/agents/qq/skills/qq/tools/remove_role/index.ts +1 -0
  18. package/agents/qq/skills/qq-directory/SKILL.md +14 -0
  19. package/lib/endpoint.js +28 -36
  20. package/lib/passive-reply.d.ts +61 -0
  21. package/lib/passive-reply.js +122 -0
  22. package/lib/qq-send.d.ts +28 -0
  23. package/lib/qq-send.js +109 -0
  24. package/lib/webhook.js +4 -4
  25. package/lib/ws.d.ts +10 -4
  26. package/lib/ws.js +4 -4
  27. package/package.json +18 -18
  28. package/src/endpoint.ts +26 -34
  29. package/src/passive-reply.ts +159 -0
  30. package/src/qq-send.ts +138 -0
  31. package/src/webhook.ts +4 -4
  32. package/src/ws.ts +15 -8
  33. /package/agents/qq/skills/{qq → qq-directory}/tools/list_guilds/index.js +0 -0
  34. /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
- sendPrivateMessage(userId: string, message: QqOutboundMessage): Promise<unknown>;
17
- sendGroupMessage(groupId: string, message: QqOutboundMessage): Promise<unknown>;
18
- sendGuildMessage(channelId: string, message: QqOutboundMessage): Promise<unknown>;
19
- sendDirectMessage?(guildId: string, message: QqOutboundMessage): Promise<unknown>;
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.14",
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.1",
37
- "@zhin.js/adapter": "1.1.21",
38
- "@zhin.js/agent-feature": "1.1.8",
39
- "@zhin.js/core": "1.1.49",
40
- "@zhin.js/feature-kit": "1.1.7",
41
- "@zhin.js/host-http": "1.1.8",
42
- "@zhin.js/im-contract": "1.1.5",
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.7",
45
- "@zhin.js/skill": "1.1.8",
46
- "@zhin.js/tool": "1.1.8"
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.21",
51
- "@zhin.js/command": "^1.1.9",
52
- "@zhin.js/core": "^1.1.49",
53
- "@zhin.js/middleware": "^1.1.8",
54
- "zhin.js": "^1.1.14"
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.8",
76
- "zhin.js": "1.1.14"
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
- for (const message of messages) {
173
- switch (kind) {
174
- case 'private':
175
- result = await bot.sendPrivateMessage(conversation.id, message);
176
- break;
177
- case 'group':
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
- for (const message of messages) {
436
- switch (kind) {
437
- case 'private':
438
- result = await bot.sendPrivateMessage(conversation.id, message);
439
- break;
440
- case 'group':
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
+ }