@zhin.js/adapter-qq 1.1.13 → 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 +33 -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/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # @zhin.js/adapter-qq
2
2
 
3
+ ## 1.1.15
4
+
5
+ ### Patch Changes
6
+
7
+ - 7d68df6: 精简 Agent 内置固定提示词与消息说明,保留工具、审批和结构化回复能力。适配器 Skill 按会话场景拆分,群/频道管理仅向对应场景披露;提示段与模型消息段支持场景声明,沿用当前回合权限和 generation 投影。
8
+ - e684ade: Upgrade `qq-official-bot` to 1.3.3 and send the passive-reply `msg_seq` the Endpoint assigns per source, so the platform's `msg_id + msg_seq` deduplication protects a retried passive reply.
9
+ - 34c536b: Send QQ replies as passive messages. The Endpoint never passed the user message's `msg_id`, so every reply went out as an active message (quota-limited, blocked when the user turns off active messages). It now records each conversation's recent `msg_id` and interaction `event_id` and replies passively within the platform limits (group 5 min × 5, C2C 60 min × 4, guild 5 min), preferring an explicit reply segment. An expired or exhausted source falls back to one active message; an unknown outcome or a content rejection never does. Conversations that refuse active messages are skipped for an hour, and Markdown the platform does not allow is resent as plain text. Requires `qq-official-bot` 1.3.3, which accepts the Endpoint's per-source `msg_seq` so a retried passive reply is deduplicated by the platform.
10
+ - Updated dependencies [f98025b]
11
+ - Updated dependencies [66ec1a5]
12
+ - Updated dependencies [7d68df6]
13
+ - Updated dependencies [bf0fa9f]
14
+ - Updated dependencies [3326db3]
15
+ - Updated dependencies [e91235e]
16
+ - Updated dependencies [6da2bad]
17
+ - Updated dependencies [3781f1c]
18
+ - @zhin.js/core@1.1.50
19
+ - @zhin.js/adapter@1.1.22
20
+ - @zhin.js/im-contract@1.1.6
21
+ - @zhin.js/tool@1.1.9
22
+ - @zhin.js/host-http@1.1.9
23
+ - @zhin.js/agent-feature@1.1.9
24
+ - @zhin.js/feature-kit@1.1.8
25
+ - @zhin.js/permission@1.1.8
26
+ - @zhin.js/skill@1.1.9
27
+
28
+ ## 1.1.14
29
+
30
+ ### Patch Changes
31
+
32
+ - Updated dependencies [974fa85]
33
+ - Updated dependencies [1e9b3d6]
34
+ - @zhin.js/core@1.1.49
35
+
3
36
  ## 1.1.13
4
37
 
5
38
  ### Patch Changes
package/README.md CHANGED
@@ -174,6 +174,28 @@ Markdown + 指令按钮组。按钮值与手动输入共用同一解析入口,
174
174
  不支持原生按钮的 Adapter 会在 Core 中把同一 keyboard 语义降级为编号列表,并保存数字到
175
175
  payload 的回跳映射。图片、音频、视频和文件仍沿 canonical `MediaRef` 出站链投递。
176
176
 
177
+ ## 被动回复与主动消息
178
+
179
+ QQ 开放平台按有没有带上用户消息的 `msg_id`(或事件的 `event_id`)区分被动回复和主动消息。
180
+ 主动消息有额度限制,用户还可以在客户端关闭"允许主动发送"。Endpoint 会记录每个会话最近
181
+ 收到的 `msg_id`,以及按钮回调(`INTERACTION_CREATE`)的 `event_id`,发送时自动带上:
182
+
183
+ | 场景 | 被动回复有效期 | 每个来源最多回复 |
184
+ | --- | --- | --- |
185
+ | 群聊 | 5 分钟 | 5 次 |
186
+ | 单聊(C2C) | 60 分钟 | 4 次 |
187
+ | 频道 / 频道私信 | 5 分钟 | 5 次(平台未公布次数) |
188
+
189
+ - **怎么选来源**:每条消息取会话里最新的可用来源。payload 里有 `reply` 段时,优先用它指向的消息。快到有效期时(提前 15 秒)就不再使用,`msg_seq` 按来源递增,经 `qq-official-bot` 的 `SendOptions.msgSeq` 传给平台。
190
+ - **来源不可用**:平台返回来源过期、次数用完或 msg_id 错误(`40034128`、`40034005`、`304027`、`304026` 等)时,这个来源作废,这条消息改发一次主动消息。
191
+ - **结果未知不重发**:超时、断线这类结果未知的情况,不会改发主动消息,因为第一次请求可能已经送达;会报告 `deliveryUnknown`。
192
+ - **内容被拒不改发主动**:审核、敏感词等内容类错误照常报失败,不改发主动消息,以免消耗主动额度。
193
+ - **主动消息被拒**:平台拒绝主动消息时(无权限 `40034105`、私信已关闭、额度用完等),这个会话 1 小时内不再尝试主动发送,直接报失败。
194
+ - **Markdown 没有权限**:平台拒绝 Markdown 时(`304036`、`304014`、`50056`),同一条消息改成纯文本重发,按钮会被去掉。
195
+
196
+ 公域机器人在群里只能收到 @ 它的消息,所以群聊旁听(`ai.trigger.ambient`)只对拥有全量
197
+ 消息权限的私域机器人有意义。
198
+
177
199
  ## 故障排查
178
200
 
179
201
  | 现象 | 排查 |
@@ -181,7 +203,9 @@ payload 的回跳映射。图片、音频、视频和文件仍沿 canonical `Med
181
203
  | Gateway Identify 后断线 | 检查 `botKind` 与 intents;公域机器人不要订阅受限 intent |
182
204
  | 二维码绑定后仍离线 | 在 Console 登录待办确认结果,并检查 App ID、Secret 与 Token |
183
205
  | Webhook 无事件 | 检查 HTTP Host、公网路径、平台回调与签名参数 |
184
- | Markdown/按钮发送失败 | 检查平台模板与权限,并按 Endpoint 能力降级 |
206
+ | Markdown/按钮发送失败 | 检查平台模板与权限;无权限时 Endpoint 会自动改发纯文本 |
207
+ | 日志出现 `qq_active_unavailable` | 该会话拒绝主动消息:让用户打开"允许主动发送",或等用户再发消息后以被动回复送达 |
208
+ | 定时任务、超时通知发不出 | 触发时会话里没有有效期内的 `msg_id`,只能发主动消息;检查主动消息权限与额度 |
185
209
 
186
210
  ## 许可证
187
211
 
@@ -1,11 +1,18 @@
1
1
  ---
2
2
  name: qq
3
- platforms: [qq]
4
- description: QQ 官方机器人频道、角色和成员详情能力。
5
- keywords: [qq, guild, channel, role, member]
6
- tags: [adapter, community]
3
+ platforms:
4
+ - qq
5
+ description: QQ 官方机器人频道、角色和成员详情管理。
6
+ keywords:
7
+ - qq
8
+ - guild
9
+ - channel
10
+ - role
11
+ - member
12
+ tags:
13
+ - adapter
14
+ - community
7
15
  tools:
8
- - list_guilds
9
16
  - list_channels
10
17
  - list_roles
11
18
  - create_role
@@ -13,10 +20,9 @@ tools:
13
20
  - remove_role
14
21
  - channel_info
15
22
  - member_detail
23
+ scopes:
24
+ - channel
16
25
  ---
26
+ # QQ 频道
17
27
 
18
- # QQ 官方机器人
19
-
20
- 先用 `list_guilds` 和 `list_channels` 确认频道上下文。角色操作前用 `list_roles` 获取角色 ID;成员操作前用 `member_detail` 确认目标。创建、授予和移除角色属于写操作,应遵守对应 Tool 的审批策略。
21
-
22
- 当前 Agent Tool 不提供群踢人、禁言或成员列表能力。
28
+ 确认频道和成员 ID 后查询详情或维护角色;权限与审批由各 Tool 执行。可访问频道列表使用 qq-directory。
@@ -11,6 +11,7 @@ export default defineAgentTool({
11
11
  role_id: z.string().describe('角色 ID'),
12
12
  }),
13
13
  adapter: 'qq',
14
+ scopes: ["channel"],
14
15
  tags: ['qq'],
15
16
  permissions: [platformPermission('qq', 'manage_roles')],
16
17
  async execute({ guild_id, channel_id, user_id, role_id }, context) {
@@ -11,6 +11,7 @@ export default defineAgentTool<{ guild_id: string; channel_id: string; user_id:
11
11
  role_id: z.string().describe('角色 ID'),
12
12
  }),
13
13
  adapter: 'qq',
14
+ scopes: ["channel"],
14
15
  tags: ['qq'],
15
16
  permissions: [platformPermission('qq', 'manage_roles')],
16
17
  async execute({ guild_id, channel_id, user_id, role_id }: { guild_id: string; channel_id: string; user_id: string; role_id: string }, context) {
@@ -8,6 +8,7 @@ export default defineAgentTool({
8
8
  channel_id: z.string().describe('子频道 ID'),
9
9
  }),
10
10
  adapter: 'qq',
11
+ scopes: ["channel"],
11
12
  tags: ['qq'],
12
13
  permissions: [platformPermission('qq', 'guild_admin')],
13
14
  async execute({ channel_id }, context) {
@@ -8,6 +8,7 @@ export default defineAgentTool<{ channel_id: string }>({
8
8
  channel_id: z.string().describe('子频道 ID'),
9
9
  }),
10
10
  adapter: 'qq',
11
+ scopes: ["channel"],
11
12
  tags: ['qq'],
12
13
  permissions: [platformPermission('qq', 'guild_admin')],
13
14
  async execute({ channel_id }: { channel_id: string }, context) {
@@ -10,6 +10,7 @@ export default defineAgentTool({
10
10
  color: z.number().optional().describe('颜色(RGB 十进制数值)'),
11
11
  }),
12
12
  adapter: 'qq',
13
+ scopes: ["channel"],
13
14
  tags: ['qq'],
14
15
  permissions: [platformPermission('qq', 'guild_owner')],
15
16
  async execute({ guild_id, name, color }, context) {
@@ -10,6 +10,7 @@ export default defineAgentTool<{ guild_id: string; name: string; color?: number
10
10
  color: z.number().optional().describe('颜色(RGB 十进制数值)'),
11
11
  }),
12
12
  adapter: 'qq',
13
+ scopes: ["channel"],
13
14
  tags: ['qq'],
14
15
  permissions: [platformPermission('qq', 'guild_owner')],
15
16
  async execute({ guild_id, name, color }: { guild_id: string; name: string; color?: number }, context) {
@@ -8,6 +8,7 @@ export default defineAgentTool({
8
8
  guild_id: z.string().describe('频道 ID'),
9
9
  }),
10
10
  adapter: 'qq',
11
+ scopes: ["channel"],
11
12
  tags: ['qq'],
12
13
  permissions: [platformPermission('qq', 'manage_channels')],
13
14
  async execute({ guild_id }, context) {
@@ -8,6 +8,7 @@ export default defineAgentTool<{ guild_id: string }>({
8
8
  guild_id: z.string().describe('频道 ID'),
9
9
  }),
10
10
  adapter: 'qq',
11
+ scopes: ["channel"],
11
12
  tags: ['qq'],
12
13
  permissions: [platformPermission('qq', 'manage_channels')],
13
14
  async execute({ guild_id }: { guild_id: string }, context) {
@@ -8,6 +8,7 @@ export default defineAgentTool({
8
8
  guild_id: z.string().describe('频道 ID'),
9
9
  }),
10
10
  adapter: 'qq',
11
+ scopes: ["channel"],
11
12
  tags: ['qq'],
12
13
  permissions: [platformPermission('qq', 'manage_roles')],
13
14
  async execute({ guild_id }, context) {
@@ -8,6 +8,7 @@ export default defineAgentTool<{ guild_id: string }>({
8
8
  guild_id: z.string().describe('频道 ID'),
9
9
  }),
10
10
  adapter: 'qq',
11
+ scopes: ["channel"],
11
12
  tags: ['qq'],
12
13
  permissions: [platformPermission('qq', 'manage_roles')],
13
14
  async execute({ guild_id }: { guild_id: string }, context) {
@@ -9,6 +9,7 @@ export default defineAgentTool({
9
9
  user_id: z.string().describe('用户 ID'),
10
10
  }),
11
11
  adapter: 'qq',
12
+ scopes: ["channel"],
12
13
  tags: ['qq'],
13
14
  permissions: [platformPermission('qq', 'guild_admin')],
14
15
  async execute({ guild_id, user_id }, context) {
@@ -9,6 +9,7 @@ export default defineAgentTool<{ guild_id: string; user_id: string }>({
9
9
  user_id: z.string().describe('用户 ID'),
10
10
  }),
11
11
  adapter: 'qq',
12
+ scopes: ["channel"],
12
13
  tags: ['qq'],
13
14
  permissions: [platformPermission('qq', 'guild_admin')],
14
15
  async execute({ guild_id, user_id }: { guild_id: string; user_id: string }, context) {
@@ -11,6 +11,7 @@ export default defineAgentTool({
11
11
  role_id: z.string().describe('角色 ID'),
12
12
  }),
13
13
  adapter: 'qq',
14
+ scopes: ["channel"],
14
15
  tags: ['qq'],
15
16
  permissions: [platformPermission('qq', 'manage_roles')],
16
17
  async execute({ guild_id, channel_id, user_id, role_id }, context) {
@@ -11,6 +11,7 @@ export default defineAgentTool<{ guild_id: string; channel_id: string; user_id:
11
11
  role_id: z.string().describe('角色 ID'),
12
12
  }),
13
13
  adapter: 'qq',
14
+ scopes: ["channel"],
14
15
  tags: ['qq'],
15
16
  permissions: [platformPermission('qq', 'manage_roles')],
16
17
  async execute({ guild_id, channel_id, user_id, role_id }: { guild_id: string; channel_id: string; user_id: string; role_id: string }, context) {
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: qq-directory
3
+ platforms:
4
+ - qq
5
+ description: QQ 官方机器人可访问的频道列表查询。
6
+ keywords:
7
+ - qq
8
+ - list_guilds
9
+ tools:
10
+ - list_guilds
11
+ ---
12
+ # qq-directory
13
+
14
+ 先查询可访问的频道列表,使用工具返回的真实 ID。
package/lib/endpoint.js CHANGED
@@ -10,6 +10,8 @@ import { EndpointDeliveryError } from '@zhin.js/im-contract';
10
10
  import { Endpoint, createEndpointLifecycle, createRecallEndpointControl, } from 'zhin.js/adapter';
11
11
  import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
12
12
  import { collectOutboundImageSizes, formatOutboundMessages } from './outbound.js';
13
+ import { QqActiveMessageGate, QqPassiveReplyLedger } from './passive-reply.js';
14
+ import { sendQqMessages } from './qq-send.js';
13
15
  import { formatInboundContent, parseCompoundMessageId, qqInboundConversation, qqOutboundKind, resolveOutboundMessageId, senderDisplayName, } from './protocol.js';
14
16
  import { defaultCreateHttpBot, registerQqWebhookRoutes, } from './webhook.js';
15
17
  import { bindQqBotInboundEvents, defaultCreateBot, normalizeQqMessage, } from './ws.js';
@@ -21,6 +23,9 @@ export class QqWebsocketEndpoint extends Endpoint {
21
23
  #bot = null;
22
24
  #open = false;
23
25
  #seenInteractions = new Map();
26
+ /** Recent msg_id / event_id per conversation, so replies go out as passive messages. */
27
+ #passiveReplies = new QqPassiveReplyLedger();
28
+ #activeGate = new QqActiveMessageGate();
24
29
  #lifecycle;
25
30
  #cleanup;
26
31
  management = createQqEndpointManagement(() => this.#requireBot());
@@ -124,24 +129,12 @@ export class QqWebsocketEndpoint extends Endpoint {
124
129
  const bot = this.#requireBot();
125
130
  let result;
126
131
  try {
127
- for (const message of messages) {
128
- switch (kind) {
129
- case 'private':
130
- result = await bot.sendPrivateMessage(conversation.id, message);
131
- break;
132
- case 'group':
133
- result = await bot.sendGroupMessage(conversation.id, message);
134
- break;
135
- case 'channel':
136
- result = await bot.sendGuildMessage(conversation.id, message);
137
- break;
138
- case 'direct':
139
- if (!bot.sendDirectMessage)
140
- throw new Error('QQ direct message not supported by transport');
141
- result = await bot.sendDirectMessage(conversation.id, message);
142
- break;
143
- }
144
- }
132
+ result = await sendQqMessages({
133
+ bot,
134
+ ledger: this.#passiveReplies,
135
+ activeGate: this.#activeGate,
136
+ log: fields => this.#logger.warn(formatCompact(fields)),
137
+ }, conversation, messages);
145
138
  }
146
139
  catch (error) {
147
140
  const { failure, diagnostic } = qqDeliveryFailure(error);
@@ -176,6 +169,8 @@ export class QqWebsocketEndpoint extends Endpoint {
176
169
  + (msg.mentioned ? ' (mentioned)' : '')
177
170
  + ` | ${truncatePreview(content, 80)}`);
178
171
  const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
172
+ if (msg.id)
173
+ this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { id: msg.id });
179
174
  void this.emit('message.receive', {
180
175
  conversation,
181
176
  message: { conversation, id: msg.id },
@@ -224,6 +219,8 @@ export class QqWebsocketEndpoint extends Endpoint {
224
219
  void action.ack().then(ok => { if (ok === false)
225
220
  this.#logger.warn('qq_interaction_ack_failed'); }, () => this.#logger.warn('qq_interaction_ack_failed'));
226
221
  const conversation = qqInboundConversation(String(this.#options.id), action.message);
222
+ // INTERACTION_CREATE's id is an event_id the reply may answer passively.
223
+ this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { event_id: action.message.id });
227
224
  void this.emit('message.receive', {
228
225
  conversation, message: { conversation, id: action.message.id }, content: '',
229
226
  segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
@@ -274,6 +271,9 @@ export class QqHttpEndpoint extends Endpoint {
274
271
  #bot = null;
275
272
  #open = false;
276
273
  #seenInteractions = new Map();
274
+ /** Recent msg_id / event_id per conversation, so replies go out as passive messages. */
275
+ #passiveReplies = new QqPassiveReplyLedger();
276
+ #activeGate = new QqActiveMessageGate();
277
277
  #lifecycle;
278
278
  #cleanup;
279
279
  management = createQqEndpointManagement(() => this.#requireBot());
@@ -379,24 +379,12 @@ export class QqHttpEndpoint extends Endpoint {
379
379
  const bot = this.#requireBot();
380
380
  let result;
381
381
  try {
382
- for (const message of messages) {
383
- switch (kind) {
384
- case 'private':
385
- result = await bot.sendPrivateMessage(conversation.id, message);
386
- break;
387
- case 'group':
388
- result = await bot.sendGroupMessage(conversation.id, message);
389
- break;
390
- case 'channel':
391
- result = await bot.sendGuildMessage(conversation.id, message);
392
- break;
393
- case 'direct':
394
- if (!bot.sendDirectMessage)
395
- throw new Error('QQ direct message not supported by transport');
396
- result = await bot.sendDirectMessage(conversation.id, message);
397
- break;
398
- }
399
- }
382
+ result = await sendQqMessages({
383
+ bot,
384
+ ledger: this.#passiveReplies,
385
+ activeGate: this.#activeGate,
386
+ log: fields => this.#logger.warn(formatCompact(fields)),
387
+ }, conversation, messages);
400
388
  }
401
389
  catch (error) {
402
390
  const { failure, diagnostic } = qqDeliveryFailure(error);
@@ -430,6 +418,8 @@ export class QqHttpEndpoint extends Endpoint {
430
418
  + (msg.mentioned ? ' (mentioned)' : '')
431
419
  + ` | ${truncatePreview(content, 80)}`);
432
420
  const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
421
+ if (msg.id)
422
+ this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { id: msg.id });
433
423
  void this.emit('message.receive', {
434
424
  conversation,
435
425
  message: { conversation, id: msg.id },
@@ -478,6 +468,8 @@ export class QqHttpEndpoint extends Endpoint {
478
468
  void action.ack().then(ok => { if (ok === false)
479
469
  this.#logger.warn('qq_interaction_ack_failed'); }, () => this.#logger.warn('qq_interaction_ack_failed'));
480
470
  const conversation = qqInboundConversation(String(this.#options.id), action.message);
471
+ // INTERACTION_CREATE's id is an event_id the reply may answer passively.
472
+ this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { event_id: action.message.id });
481
473
  void this.emit('message.receive', {
482
474
  conversation, message: { conversation, id: action.message.id }, content: '',
483
475
  segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
@@ -0,0 +1,61 @@
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
+ /** Reply-to source for the SDK: a user message id or an event id. */
14
+ export type QqReplySource = {
15
+ readonly id: string;
16
+ } | {
17
+ readonly event_id: string;
18
+ };
19
+ export interface QqPassiveLimit {
20
+ readonly windowMs: number;
21
+ readonly maxReplies: number;
22
+ }
23
+ /** C2C: 60 min, 4 replies. Group: 5 min, 5 replies. Guild channel and DM: 5 min (count unpublished; 5 assumed). */
24
+ export declare const QQ_PASSIVE_LIMITS: Readonly<Record<QqOutboundKind, QqPassiveLimit>>;
25
+ export interface PassiveReplyGrant {
26
+ readonly source: QqReplySource;
27
+ readonly msgSeq: number;
28
+ }
29
+ export declare class QqPassiveReplyLedger {
30
+ #private;
31
+ private readonly now;
32
+ constructor(now?: () => number);
33
+ /** A user message or event in `conversation` that later replies may answer. */
34
+ record(kind: QqOutboundKind, conversationId: string, source: QqReplySource, receivedAt?: number): void;
35
+ /**
36
+ * Claim one reply: `preferred` (an explicit reply segment) when it is still usable, otherwise the
37
+ * newest usable source in the conversation. Undefined when only an active message is possible.
38
+ */
39
+ acquire(kind: QqOutboundKind, conversationId: string, preferred?: QqReplySource): PassiveReplyGrant | undefined;
40
+ /** The platform said this source can no longer be replied to (expired, over the limit, invalid id). */
41
+ exhaust(kind: QqOutboundKind, conversationId: string, source: QqReplySource): void;
42
+ /** Drop conversations whose every source is past its window. */
43
+ prune(): void;
44
+ }
45
+ /** Platform codes meaning the reply source is unusable: switch to an active message. */
46
+ export declare const QQ_PASSIVE_SOURCE_CODES: ReadonlySet<number>;
47
+ /** Platform codes meaning active messages are unavailable for this conversation for now. */
48
+ export declare const QQ_ACTIVE_UNAVAILABLE_CODES: ReadonlySet<number>;
49
+ /** Platform codes meaning Markdown is not allowed: resend as plain text. */
50
+ export declare const QQ_MARKDOWN_DENIED_CODES: ReadonlySet<number>;
51
+ /** How long a conversation is not offered active messages after the platform refused them. */
52
+ export declare const ACTIVE_UNAVAILABLE_BACKOFF_MS: number;
53
+ /** Conversations where the platform recently refused active messages. */
54
+ export declare class QqActiveMessageGate {
55
+ #private;
56
+ private readonly now;
57
+ constructor(now?: () => number);
58
+ blocked(kind: QqOutboundKind, conversationId: string): boolean;
59
+ /** Returns true the first time a conversation is blocked, so the caller can log it once. */
60
+ block(kind: QqOutboundKind, conversationId: string): boolean;
61
+ }
@@ -0,0 +1,122 @@
1
+ /** C2C: 60 min, 4 replies. Group: 5 min, 5 replies. Guild channel and DM: 5 min (count unpublished; 5 assumed). */
2
+ export const QQ_PASSIVE_LIMITS = Object.freeze({
3
+ private: { windowMs: 60 * 60_000, maxReplies: 4 },
4
+ group: { windowMs: 5 * 60_000, maxReplies: 5 },
5
+ channel: { windowMs: 5 * 60_000, maxReplies: 5 },
6
+ direct: { windowMs: 5 * 60_000, maxReplies: 5 },
7
+ });
8
+ /** Stop using a source this long before the platform window closes: the request itself takes time. */
9
+ const SAFETY_MARGIN_MS = 15_000;
10
+ /** Sources remembered per conversation; older ones are long expired in practice. */
11
+ const MAX_SOURCES_PER_CONVERSATION = 8;
12
+ function sourceKey(source) {
13
+ return 'id' in source ? `msg:${source.id}` : `event:${source.event_id}`;
14
+ }
15
+ /** Expired conversations are dropped every this many records. */
16
+ const PRUNE_EVERY_RECORDS = 256;
17
+ export class QqPassiveReplyLedger {
18
+ now;
19
+ #conversations = new Map();
20
+ #records = 0;
21
+ constructor(now = Date.now) {
22
+ this.now = now;
23
+ }
24
+ /** A user message or event in `conversation` that later replies may answer. */
25
+ record(kind, conversationId, source, receivedAt = this.now()) {
26
+ const key = `${kind}:${conversationId}`;
27
+ const entries = (this.#conversations.get(key) ?? []).filter(entry => sourceKey(entry.source) !== sourceKey(source));
28
+ entries.push({ source, receivedAt, replies: 0, exhausted: false });
29
+ entries.sort((a, b) => a.receivedAt - b.receivedAt);
30
+ this.#conversations.set(key, entries.slice(-MAX_SOURCES_PER_CONVERSATION));
31
+ if (++this.#records % PRUNE_EVERY_RECORDS === 0)
32
+ this.prune();
33
+ }
34
+ /**
35
+ * Claim one reply: `preferred` (an explicit reply segment) when it is still usable, otherwise the
36
+ * newest usable source in the conversation. Undefined when only an active message is possible.
37
+ */
38
+ acquire(kind, conversationId, preferred) {
39
+ const limit = QQ_PASSIVE_LIMITS[kind];
40
+ const now = this.now();
41
+ const entries = this.#conversations.get(`${kind}:${conversationId}`) ?? [];
42
+ const usable = (entry) => !entry.exhausted
43
+ && entry.replies < limit.maxReplies
44
+ && now - entry.receivedAt < limit.windowMs - SAFETY_MARGIN_MS;
45
+ const preferredKey = preferred ? sourceKey(preferred) : undefined;
46
+ const entry = (preferredKey ? entries.find(item => sourceKey(item.source) === preferredKey && usable(item)) : undefined)
47
+ ?? [...entries].reverse().find(usable);
48
+ if (!entry)
49
+ return undefined;
50
+ entry.replies += 1;
51
+ return { source: entry.source, msgSeq: entry.replies };
52
+ }
53
+ /** The platform said this source can no longer be replied to (expired, over the limit, invalid id). */
54
+ exhaust(kind, conversationId, source) {
55
+ const key = sourceKey(source);
56
+ const entry = this.#conversations.get(`${kind}:${conversationId}`)?.find(item => sourceKey(item.source) === key);
57
+ if (entry)
58
+ entry.exhausted = true;
59
+ }
60
+ /** Drop conversations whose every source is past its window. */
61
+ prune() {
62
+ const now = this.now();
63
+ for (const [key, entries] of this.#conversations) {
64
+ const kind = key.slice(0, key.indexOf(':'));
65
+ const windowMs = QQ_PASSIVE_LIMITS[kind]?.windowMs ?? 0;
66
+ const live = entries.filter(entry => now - entry.receivedAt < windowMs);
67
+ if (live.length === 0)
68
+ this.#conversations.delete(key);
69
+ else
70
+ this.#conversations.set(key, live);
71
+ }
72
+ }
73
+ }
74
+ /** Platform codes meaning the reply source is unusable: switch to an active message. */
75
+ export const QQ_PASSIVE_SOURCE_CODES = new Set([
76
+ 40034128, // 被动回复时间或次数超限
77
+ 40034005, // 回复消息 msg_id 已过期
78
+ 304027, // MSG_EXPIRE 回复的消息过期
79
+ 304026, // MSG_ID 回复的消息 id 错误
80
+ 306002, // msgid error
81
+ 501005, 501008, 501009, // MessageID 错误 / 不存在 / 解析失败
82
+ ]);
83
+ /** Platform codes meaning active messages are unavailable for this conversation for now. */
84
+ export const QQ_ACTIVE_UNAVAILABLE_CODES = new Set([
85
+ 40034105, // 主动消息发送失败,无权限
86
+ 304031, 304032, 304034, // 私信已关闭 / 不存在 / 不是私信成员
87
+ 304035, 304045, 304047, 304049, 304050, // 主动消息额度、频控
88
+ 304046, 304048, // 不允许在此(子)频道发主动消息
89
+ ]);
90
+ /** Platform codes meaning Markdown is not allowed: resend as plain text. */
91
+ export const QQ_MARKDOWN_DENIED_CODES = new Set([
92
+ 304036, // 没有 markdown 模板的权限
93
+ 304014, // TEMPLATE_PRIVILEGE 没有模板权限
94
+ 50056, // 不允许发送 markdown content
95
+ ]);
96
+ /** How long a conversation is not offered active messages after the platform refused them. */
97
+ export const ACTIVE_UNAVAILABLE_BACKOFF_MS = 60 * 60_000;
98
+ /** Conversations where the platform recently refused active messages. */
99
+ export class QqActiveMessageGate {
100
+ now;
101
+ #blockedUntil = new Map();
102
+ constructor(now = Date.now) {
103
+ this.now = now;
104
+ }
105
+ blocked(kind, conversationId) {
106
+ const key = `${kind}:${conversationId}`;
107
+ const until = this.#blockedUntil.get(key);
108
+ if (until === undefined)
109
+ return false;
110
+ if (this.now() < until)
111
+ return true;
112
+ this.#blockedUntil.delete(key);
113
+ return false;
114
+ }
115
+ /** Returns true the first time a conversation is blocked, so the caller can log it once. */
116
+ block(kind, conversationId) {
117
+ const key = `${kind}:${conversationId}`;
118
+ const fresh = !this.blocked(kind, conversationId);
119
+ this.#blockedUntil.set(key, this.now() + ACTIVE_UNAVAILABLE_BACKOFF_MS);
120
+ return fresh;
121
+ }
122
+ }
@@ -0,0 +1,28 @@
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 type { QqOutboundMessage } from './outbound.js';
15
+ import { type QqActiveMessageGate, type QqPassiveReplyLedger, type QqReplySource } from './passive-reply.js';
16
+ import type { QqBotTransport } from './ws.js';
17
+ export interface QqSendContext {
18
+ readonly bot: QqBotTransport;
19
+ readonly ledger: QqPassiveReplyLedger;
20
+ readonly activeGate: QqActiveMessageGate;
21
+ readonly log: (fields: Record<string, string | number | undefined>) => void;
22
+ }
23
+ /** The reply segment the payload already names, as a passive source. */
24
+ export declare function explicitReplySource(message: QqOutboundMessage): QqReplySource | undefined;
25
+ /** Plain-text version of a Markdown message (buttons dropped); undefined when it has no Markdown. */
26
+ export declare function markdownAsText(message: QqOutboundMessage): QqOutboundMessage | undefined;
27
+ /** Sends every message of one delivery in order; returns the last platform result. */
28
+ export declare function sendQqMessages(context: QqSendContext, conversation: ConversationRef, messages: readonly QqOutboundMessage[]): Promise<unknown>;