@jerryliang122/openclaw-qqbot 1.0.0

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 (120) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +967 -0
  3. package/README.zh.md +790 -0
  4. package/dist/index.cjs +18072 -0
  5. package/dist/index.cjs.map +1 -0
  6. package/dist/index.d.cts +1222 -0
  7. package/index.ts +92 -0
  8. package/openclaw.plugin.json +38 -0
  9. package/package.json +69 -0
  10. package/preload.cjs +19 -0
  11. package/scripts/link-sdk-core.cjs +268 -0
  12. package/scripts/proactive-api-server.ts +369 -0
  13. package/scripts/send-proactive.ts +293 -0
  14. package/scripts/test-sendmedia.ts +116 -0
  15. package/skills/qqbot-channel/SKILL.md +285 -0
  16. package/skills/qqbot-channel/references/api_references.md +521 -0
  17. package/skills/qqbot-remind/SKILL.md +159 -0
  18. package/skills/qqbot-upgrade/SKILL.md +56 -0
  19. package/src/adapter/contract.ts +63 -0
  20. package/src/adapter/lint.ts +144 -0
  21. package/src/adapter/media.ts +40 -0
  22. package/src/adapter/pairing.ts +95 -0
  23. package/src/adapter/resolve.ts +255 -0
  24. package/src/adapter/setup.ts +13 -0
  25. package/src/adapter/webhook.ts +248 -0
  26. package/src/adapter/workspace.ts +21 -0
  27. package/src/agent-prompt-adapter.ts +26 -0
  28. package/src/bot-instance.ts +60 -0
  29. package/src/channel.ts +230 -0
  30. package/src/commands/bot-approve.ts +143 -0
  31. package/src/commands/bot-clear-storage.ts +114 -0
  32. package/src/commands/bot-group-always.ts +62 -0
  33. package/src/commands/bot-group-info.ts +48 -0
  34. package/src/commands/bot-help.ts +40 -0
  35. package/src/commands/bot-logs.ts +248 -0
  36. package/src/commands/bot-me.ts +18 -0
  37. package/src/commands/bot-pairing.ts +50 -0
  38. package/src/commands/bot-ping.ts +33 -0
  39. package/src/commands/bot-streaming.ts +55 -0
  40. package/src/commands/bot-upgrade.ts +56 -0
  41. package/src/commands/bot-version.ts +41 -0
  42. package/src/commands/config-util.ts +96 -0
  43. package/src/commands/index.ts +51 -0
  44. package/src/config.ts +403 -0
  45. package/src/constants.ts +6 -0
  46. package/src/dispatch/body-assembler.ts +308 -0
  47. package/src/dispatch/ctx-builder.ts +127 -0
  48. package/src/dispatch/dispatch.ts +667 -0
  49. package/src/dispatch/envelope-builder.ts +112 -0
  50. package/src/dispatch/index.ts +2 -0
  51. package/src/features/approval-capability.ts +302 -0
  52. package/src/features/approval-helpers.ts +271 -0
  53. package/src/features/approval-utils.ts +21 -0
  54. package/src/features/command-panel.ts +301 -0
  55. package/src/features/credential-backup.ts +74 -0
  56. package/src/features/group-mode-store.ts +79 -0
  57. package/src/features/history-store.ts +75 -0
  58. package/src/features/msgid-cache.ts +55 -0
  59. package/src/features/outbound-echo-store.ts +46 -0
  60. package/src/features/proactive-budget.ts +57 -0
  61. package/src/features/proactive.ts +549 -0
  62. package/src/features/question-helpers.ts +771 -0
  63. package/src/features/quota-manager.ts +173 -0
  64. package/src/features/ref-index-store.ts +289 -0
  65. package/src/features/secret-input-store.ts +118 -0
  66. package/src/features/secret-store-cli.ts +324 -0
  67. package/src/features/typing-refresh.ts +51 -0
  68. package/src/features/update-checker.ts +166 -0
  69. package/src/gateway/event-handlers.ts +456 -0
  70. package/src/gateway/index.ts +3 -0
  71. package/src/gateway/lifecycle.ts +236 -0
  72. package/src/gateway/middleware-setup.ts +173 -0
  73. package/src/gateway/qqbot-gateway.ts +458 -0
  74. package/src/gateway-adapter.ts +44 -0
  75. package/src/heartbeat-adapter.ts +57 -0
  76. package/src/message-adapter.ts +40 -0
  77. package/src/messaging-adapter.ts +78 -0
  78. package/src/middleware/access-control.ts +125 -0
  79. package/src/middleware/attachment.ts +373 -0
  80. package/src/middleware/inbound-guard.ts +102 -0
  81. package/src/middleware/policy-injector.ts +71 -0
  82. package/src/middleware/secret-capture.ts +161 -0
  83. package/src/middleware/typing.ts +110 -0
  84. package/src/openclaw-plugin-sdk.d.ts +543 -0
  85. package/src/outbound/chunker.ts +80 -0
  86. package/src/outbound/debounce.ts +102 -0
  87. package/src/outbound/deliver-pipeline.ts +235 -0
  88. package/src/outbound/index.ts +3 -0
  89. package/src/outbound/local-file-router.ts +145 -0
  90. package/src/outbound/media-send.ts +408 -0
  91. package/src/outbound/outbound-service.ts +298 -0
  92. package/src/outbound/reply-limiter.ts +139 -0
  93. package/src/outbound/sanitize.ts +32 -0
  94. package/src/outbound/streaming-controller.ts +332 -0
  95. package/src/outbound/target.ts +109 -0
  96. package/src/outbound-adapter.ts +323 -0
  97. package/src/plugin-base.ts +42 -0
  98. package/src/request-context.ts +50 -0
  99. package/src/runtime.ts +42 -0
  100. package/src/setup/account-key.ts +41 -0
  101. package/src/setup/finalize.ts +110 -0
  102. package/src/setup/login.ts +197 -0
  103. package/src/setup/surface.ts +40 -0
  104. package/src/status-adapter.ts +56 -0
  105. package/src/tools/platform.ts +149 -0
  106. package/src/tools/remind.ts +308 -0
  107. package/src/tools/secret-input.ts +185 -0
  108. package/src/types-augment.d.ts +54 -0
  109. package/src/types-plugin.ts +82 -0
  110. package/src/types.ts +620 -0
  111. package/src/typing-lifecycle.ts +182 -0
  112. package/src/utils/mention.ts +52 -0
  113. package/src/utils/pkg-version.ts +23 -0
  114. package/src/utils/platform.ts +459 -0
  115. package/src/utils/plugin-logger.ts +104 -0
  116. package/src/utils/ssrf-guard.ts +132 -0
  117. package/src/utils/stt.ts +150 -0
  118. package/src/utils/voice-text.ts +61 -0
  119. package/tsconfig.json +17 -0
  120. package/tsup.config.ts +64 -0
@@ -0,0 +1,102 @@
1
+ /**
2
+ * 入站事件守卫
3
+ *
4
+ * 拦截三类「非真实用户输入」的入站推送(均有平台文档/SDK 注释背书,
5
+ * 详见 AGENTS.md「入站事件守卫」条目):
6
+ * 1. 出站回声 —— QQ 会把 bot 自己发出的消息回投为入站事件
7
+ * (SDK message-filter 注释;c2c 事件无 author.bot,SDK 过滤不到),
8
+ * 按 outbound-echo-store 登记的近期出站消息 id 比对。
9
+ * 2. 重复推送 —— 平台事件文档要求按 msg_seq / message_scene.ext 的
10
+ * msg_idx 去重(「为确保消息可达,相同 msg_id 可能重复推送」),
11
+ * SDK 内置去重窗口仅 5s,这里补 30 分钟长窗口。
12
+ * 3. 空内容事件 —— content 空白且无附件、无 msg_elements 的推送
13
+ * (重推副本 / 结构化回执形态)。注意:带 msg_elements 的 103
14
+ * 引用/转发消息是真实用户操作,必须放行(quoteRef 会渲染引用块)。
15
+ *
16
+ * 每次拦截打 INFO 日志(含 msgType / scene / payload 摘要)——这是
17
+ * 入站事件唯一的非 DEBUG 级痕迹,用于事后取证定位事件真身。
18
+ *
19
+ * 挂载位置:messageFilter 之后、policyInjector 之前(链条最前端,
20
+ * 避免污染群历史、烧 typing 被动回复配额)。
21
+ */
22
+ import type { Middleware } from '@tencent-connect/qqbot-nodejs';
23
+ import { isOutboundEcho } from '../features/outbound-echo-store.js';
24
+
25
+ const DEDUP_TTL_MS = 30 * 60 * 1000; // 覆盖 c2c msg_id 30 分钟被动回复有效期
26
+ const DEDUP_MAX = 5000;
27
+ const PAYLOAD_PREVIEW_CHARS = 300;
28
+
29
+ const dedupSeen = new Map<string, number>(); // dedupKey -> 首次见到的时间
30
+
31
+ function truncateText(text: string, max: number): string {
32
+ return text.length > max ? `${text.slice(0, max)}…` : text;
33
+ }
34
+
35
+ function payloadPreview(raw: unknown): string {
36
+ try {
37
+ return truncateText(JSON.stringify(raw ?? {}), PAYLOAD_PREVIEW_CHARS);
38
+ } catch {
39
+ return '<unserializable>';
40
+ }
41
+ }
42
+
43
+ export interface InboundGuardParams {
44
+ accountId: string;
45
+ }
46
+
47
+ export function inboundGuard(params: InboundGuardParams): Middleware {
48
+ return async (ctx, next) => {
49
+ const msg = ctx.message as Record<string, any> | undefined;
50
+ const accountId = params.accountId;
51
+ const messageId: string | undefined = msg?.messageId;
52
+ const kind: string = msg?.kind ?? '?';
53
+
54
+ // 1. 出站回声:入站 id 命中近期出站消息 id
55
+ if (isOutboundEcho(accountId, messageId)) {
56
+ ctx.log.info(`[guard] dropped outbound-echo msgId=${messageId ?? '?'} kind=${kind}`);
57
+ ctx.stop('outbound-echo');
58
+ return;
59
+ }
60
+
61
+ // 2. 重复推送:msgId + msgIdx 长窗口去重
62
+ const msgIdx: string | undefined = msg?.msgIdx;
63
+ const dedupKey = `${accountId}:${kind}:${messageId ?? ''}:${msgIdx ?? ''}`;
64
+ const now = Date.now();
65
+ const firstSeen = dedupSeen.get(dedupKey);
66
+ if (firstSeen !== undefined && now - firstSeen <= DEDUP_TTL_MS) {
67
+ ctx.log.info(
68
+ `[guard] dropped duplicate-push msgId=${messageId ?? '?'} msgIdx=${msgIdx ?? ''} kind=${kind} ageMs=${now - firstSeen}`,
69
+ );
70
+ ctx.stop('duplicate-push');
71
+ return;
72
+ }
73
+ dedupSeen.set(dedupKey, now);
74
+ if (dedupSeen.size > DEDUP_MAX) {
75
+ for (const [key, ts] of dedupSeen) {
76
+ if (dedupSeen.size <= DEDUP_MAX && now - ts <= DEDUP_TTL_MS) break;
77
+ dedupSeen.delete(key);
78
+ }
79
+ }
80
+
81
+ // 3. 空内容事件:无文本、无附件、无消息元素
82
+ const contentBlank = !String(msg?.content ?? '').trim();
83
+ const hasAttachments = Array.isArray(msg?.attachments) && msg.attachments.length > 0;
84
+ const hasElements = Array.isArray(msg?.msgElements) && msg.msgElements.length > 0;
85
+ if (contentBlank && !hasAttachments && !hasElements) {
86
+ ctx.log.info(
87
+ `[guard] dropped contentless msgId=${messageId ?? '?'} kind=${kind}` +
88
+ ` msgType=${msg?.msgType ?? '?'} scene=${msg?.messageScene?.source ?? '?'}` +
89
+ ` payload=${payloadPreview(msg?.raw)}`,
90
+ );
91
+ ctx.stop('contentless');
92
+ return;
93
+ }
94
+
95
+ await next();
96
+ };
97
+ }
98
+
99
+ /** 测试用:清空去重表 */
100
+ export function clearInboundGuardDedup(): void {
101
+ dedupSeen.clear();
102
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * 动态策略注入中间件
3
+ *
4
+ * 每条消息到达时,按 groupId 解析群配置并注入 `ctx.state.policy`。
5
+ * SDK 内置中间件(mentionGate、historyBuffer 等)自动从 `ctx.state.policy`
6
+ * 读取动态策略作为 fallback,无需各自注册 `resolveConfig`。
7
+ *
8
+ * 优先级链(与旧版一致):
9
+ * 具体群配置 > 通配符 "*" > defaultRequireMention > 硬编码默认值
10
+ */
11
+ import type { Middleware } from '@tencent-connect/qqbot-nodejs';
12
+ import type { ResolvedQQBotAccount } from '../types.js';
13
+ import { resolveGroupConfigFromAccount } from '../config.js';
14
+
15
+ /**
16
+ * 创建 policy injector 中间件。
17
+ *
18
+ * 注入的 `ctx.state.policy` 结构:
19
+ * ```
20
+ * {
21
+ * scope: "c2c" | "group",
22
+ * group: {
23
+ * requireMention: boolean,
24
+ * ignoreOtherMentions: boolean,
25
+ * historyLimit: number,
26
+ * // ... 可扩展任意字段
27
+ * }
28
+ * }
29
+ * ```
30
+ *
31
+ * 使用者可在 `policy.group` 上添加自定义字段,如:
32
+ * ```
33
+ * ctx.state.policy.toolPolicy = "full";
34
+ * ```
35
+ * 自定义中间件通过 `ctx.state.policy` 读取即可。
36
+ */
37
+ export function createPolicyInjector(account: ResolvedQQBotAccount): Middleware {
38
+ return async (ctx, next) => {
39
+ const msg = ctx.message as any;
40
+ const scope = msg.kind as 'c2c' | 'group' | 'dm' | 'channel';
41
+
42
+ const policy: Record<string, unknown> = {
43
+ scope,
44
+ accountId: account.accountId,
45
+ // 访问控制:dmPolicy(c2c) / groupPolicy(group),默认 allowlist
46
+ c2cMode: account.config?.dmPolicy ?? 'allowlist',
47
+ groupMode: account.config?.groupPolicy ?? 'allowlist',
48
+ allowFrom: account.config?.allowFrom ?? [],
49
+ groupAllowFrom: account.config?.groupAllowFrom ?? [],
50
+ };
51
+
52
+ if (scope === 'group') {
53
+ const groupOpenid = msg.groupOpenid ?? '';
54
+ const groupCfg = resolveGroupConfigFromAccount(account, groupOpenid);
55
+ // room_event 群:门控放行全部消息(mentionGate 以 requireMention=false
56
+ // 通过并保留 wasMentioned 标记),未 @/称呼/引用的消息在 dispatch 侧
57
+ // 分类为 room_event 被动房间事件(仅全量模式群实际会有这类消息到达)
58
+ const roomEvent = groupCfg.unmentionedInbound === 'room_event';
59
+ policy.group = {
60
+ requireMention: roomEvent ? false : groupCfg.requireMention,
61
+ ignoreOtherMentions: groupCfg.ignoreOtherMentions,
62
+ historyLimit: groupCfg.historyLimit,
63
+ prompt: groupCfg.prompt,
64
+ unmentionedInbound: groupCfg.unmentionedInbound,
65
+ };
66
+ }
67
+
68
+ ctx.state.policy = policy;
69
+ await next();
70
+ };
71
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * 密钥捕获中间件
3
+ *
4
+ * 当会话存在 pending 密钥输入(secret-input-store)时,拦截该 c2c 用户的
5
+ * 下一条文本消息:就地执行 `openclaw secrets store set` 并把结果直接回复
6
+ * 给用户,消息本身不进入框架 —— 密钥值绝不进入 AI 转录 / 框架日志。
7
+ *
8
+ * 挂载位置:slashCommand 之后、groupMessageCoalescer 之前
9
+ * (/bot-* 命令优先可用;拦截后不再触发 typing 与 envelope 组装)。
10
+ *
11
+ * 红线:多问题 ask_user 答案 / 确认卡消息必须放行 —— 吞掉真实用户消息
12
+ * 会导致挂起的 ask_user 永不成单(AGENTS.md 2026-08-24 事故)。
13
+ */
14
+ import type { Middleware } from '@tencent-connect/qqbot-nodejs';
15
+ import { findPendingMultiQuestionByConversation } from '../features/question-helpers.js';
16
+ import { checkAndConsumePassiveReplyQuota } from '../features/quota-manager.js';
17
+ import {
18
+ runSecretsReload,
19
+ runSecretsStoreSet,
20
+ maskSecret,
21
+ type SecretsStoreSetResult,
22
+ } from '../features/secret-store-cli.js';
23
+ import {
24
+ cancelPendingSecretInput,
25
+ findPendingSecretInput,
26
+ takePendingSecretInput,
27
+ } from '../features/secret-input-store.js';
28
+
29
+ const CANCEL_KEYWORDS = new Set(['取消', 'cancel', '#cancel', '/cancel']);
30
+
31
+ export function isSecretInputCancelKeyword(text: string): boolean {
32
+ return CANCEL_KEYWORDS.has(text.trim().toLowerCase());
33
+ }
34
+
35
+ /** 配额感知的直接回复:优先被动(msg_id),配额耗尽/发送失败降级主动 */
36
+ async function replyToUser(
37
+ ctx: any,
38
+ accountId: string,
39
+ text: string,
40
+ ): Promise<void> {
41
+ const msgId = ctx.replyTarget?.msgId as string | undefined;
42
+ if (msgId) {
43
+ const { canReply, rollback } = checkAndConsumePassiveReplyQuota({
44
+ accountId,
45
+ msgId,
46
+ scope: 'c2c',
47
+ });
48
+ if (canReply) {
49
+ try {
50
+ await ctx.bot.sendText(ctx.replyTarget, text);
51
+ return;
52
+ } catch {
53
+ rollback();
54
+ // 落到主动降级
55
+ }
56
+ }
57
+ }
58
+ const proactiveTarget = { scope: 'c2c' as const, targetId: ctx.message.senderId as string };
59
+ await ctx.bot.sendText(proactiveTarget, text).catch(() => {/* 尽力而为 */});
60
+ }
61
+
62
+ function formatSetFailure(name: string, result: SecretsStoreSetResult): string {
63
+ const reason = result.timedOut
64
+ ? '执行超时'
65
+ : result.error
66
+ ? result.error
67
+ : `退出码 ${result.exitCode ?? '无'}`;
68
+ const lines = [`❌ 保存 \`${name}\` 失败(${reason})。`];
69
+ if (!result.error && result.output) {
70
+ lines.push('', '```', result.output, '```');
71
+ }
72
+ lines.push(
73
+ '',
74
+ '可能原因:openclaw 版本过旧(需支持 `openclaw secrets store set`),或本机 CLI 不可用。',
75
+ '密钥未被保存,可稍后重试或改在终端执行。',
76
+ );
77
+ return lines.join('\n');
78
+ }
79
+
80
+ export interface SecretCaptureParams {
81
+ accountId: string;
82
+ /** 测试注入:替换 secrets store set 执行器 */
83
+ runSet?: typeof runSecretsStoreSet;
84
+ /** 测试注入:替换 secrets reload */
85
+ runReload?: typeof runSecretsReload;
86
+ }
87
+
88
+ export function secretCapture(params: SecretCaptureParams): Middleware {
89
+ const { accountId, runSet = runSecretsStoreSet, runReload = runSecretsReload } = params;
90
+
91
+ return async (ctx, next) => {
92
+ // 仅 c2c:密钥不应出现在群聊(工具侧发卡时已限制,此处双保险)
93
+ if (ctx.message.kind !== 'c2c') {
94
+ await next();
95
+ return;
96
+ }
97
+ const senderId = ctx.message.senderId as string;
98
+ const pending = findPendingSecretInput(accountId, senderId);
99
+ if (!pending) {
100
+ await next();
101
+ return;
102
+ }
103
+
104
+ // 红线:多问题 ask_user 进行中 → 其答案/确认卡消息优先,绝不拦截
105
+ if (findPendingMultiQuestionByConversation('c2c', senderId)) {
106
+ await next();
107
+ return;
108
+ }
109
+
110
+ const text = ((ctx.message.content as string | undefined) ?? '').trim();
111
+
112
+ if (isSecretInputCancelKeyword(text)) {
113
+ cancelPendingSecretInput(accountId, senderId);
114
+ ctx.log?.info?.(`[secret-input] cancelled by user (${pending.name})`);
115
+ await replyToUser(ctx, accountId, `已取消 \`${pending.name}\` 的密钥输入。需要时请重新发起。`);
116
+ ctx.stop('secret-capture:cancelled');
117
+ return;
118
+ }
119
+
120
+ // 空文本 / 纯附件:提示重发,保留 pending 继续等待
121
+ if (!text) {
122
+ await replyToUser(
123
+ ctx,
124
+ accountId,
125
+ `请直接以文本形式发送 **${pending.name}** 的值(可发送「取消」退出)。`,
126
+ );
127
+ ctx.stop('secret-capture:empty_input');
128
+ return;
129
+ }
130
+
131
+ // 一次性消费,防重复处理
132
+ const entry = takePendingSecretInput(accountId, senderId);
133
+ if (!entry) {
134
+ await next();
135
+ return;
136
+ }
137
+
138
+ const result = await runSet({ name: entry.name, kind: entry.kind, value: text });
139
+ let reloadOk = false;
140
+ if (result.ok) {
141
+ reloadOk = await runReload();
142
+ }
143
+ const masked = maskSecret(text);
144
+ const kindLabel = entry.kind === 'env' ? '代理可读环境变量' : `kind: ${entry.kind}`;
145
+ const reply = result.ok
146
+ ? [
147
+ `✅ 已保存 \`${entry.name}\`(${kindLabel},值已隐藏:\`${masked}\`)`,
148
+ reloadOk
149
+ ? '已触发 `secrets reload`。'
150
+ : '提示:若该名称被 SecretRef 引用,可执行 `openclaw secrets reload` 使其立即生效。',
151
+ ].join('\n')
152
+ : formatSetFailure(entry.name, result);
153
+
154
+ ctx.log?.info?.(
155
+ `[secret-input] store set ${entry.name} kind=${entry.kind} ok=${result.ok}` +
156
+ ` exit=${result.exitCode ?? 'n/a'}${result.timedOut ? ' (timeout)' : ''}`,
157
+ );
158
+ await replyToUser(ctx, accountId, reply);
159
+ ctx.stop('secret-capture:consumed');
160
+ };
161
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * C2C "正在输入"指示器中间件(替代 SDK 的 typingIndicator)。
3
+ *
4
+ * 与 SDK 版的三点差异:
5
+ * 1. 续期间隔默认 20s 且强制不低于 20s(QPS 约束,事件触发的续期
6
+ * 同样受此间距保护)
7
+ * 2. 配额感知:typing 通知携带入站 msg_id 时属于被动回复、消耗该
8
+ * msg_id 的被动配额,与真正的回复消息共享额度(经同一 limiter
9
+ * 记账)。被动配额耗尽后与回复消息一样自动降级为主动发送
10
+ * (不带 msg_id),续期不会中断
11
+ * 3. 出站消息后续期:QQ 客户端收到机器人消息(如思维链中间输出)
12
+ * 会自动终止"正在输入"显示。若框架任务仍在进行,本中间件在
13
+ * 每条消息发出 POST_MESSAGE_REFRESH_DELAY_MS 后补发一次续期;
14
+ * 若该消息是最终回复(处理链完成),会话已结束,自然不再续期
15
+ *
16
+ * QQ 客户端行为:退出聊天界面再进入后指示器会消失,只有收到新的
17
+ * input_notify 推送才会重新显示 —— 这是需要周期性续期的原因。
18
+ */
19
+ import type { Middleware } from '@tencent-connect/qqbot-nodejs';
20
+ import { rollbackPassiveSlot, tryAcquirePassiveSlot } from '../outbound/outbound-service.js';
21
+ import { subscribeOutboundMessage } from '../features/typing-refresh.js';
22
+
23
+ /** QPS 约束:续期间隔不得低于 20s */
24
+ export const MIN_TYPING_INTERVAL_MS = 20_000;
25
+ const DEFAULT_TYPING_INTERVAL_MS = 20_000;
26
+ /** 服务端输入状态窗口(秒),QQ 平台窗口上限约 60s */
27
+ const TYPING_DURATION_SEC = 60;
28
+ /** 出站消息后延迟多久补发续期 */
29
+ export const POST_MESSAGE_REFRESH_DELAY_MS = 5_000;
30
+
31
+ export function resolveTypingIntervalMs(input: number | undefined): number {
32
+ return Math.max(input ?? DEFAULT_TYPING_INTERVAL_MS, MIN_TYPING_INTERVAL_MS);
33
+ }
34
+
35
+ export interface TypingIndicatorMiddlewareOptions {
36
+ accountId: string;
37
+ /** 续期间隔 ms,默认 20_000,低于 20_000 会被钳制到 20_000 */
38
+ intervalMs?: number;
39
+ }
40
+
41
+ export function c2cTypingIndicator(opts: TypingIndicatorMiddlewareOptions): Middleware {
42
+ const accountId = opts.accountId;
43
+ const intervalMs = resolveTypingIntervalMs(opts.intervalMs);
44
+
45
+ return async (ctx, next) => {
46
+ // QQ 开放平台限制:input_notify 仅支持 C2C(私聊)
47
+ if (ctx.message.kind !== 'c2c') {
48
+ await next();
49
+ return;
50
+ }
51
+
52
+ let periodic: NodeJS.Timeout | null = null;
53
+ let refresh: NodeJS.Timeout | null = null;
54
+ let lastSentAt = 0;
55
+ let active = true;
56
+
57
+ const sendNow = (): void => {
58
+ if (!active) return;
59
+ // 被动配额可用 → 占用并带 msg_id 发送;耗尽 → 不带 msg_id 主动发送,
60
+ // 与回复消息的降级策略一致(见 outbound-service 的统一配额预留)
61
+ const passive = tryAcquirePassiveSlot(accountId, ctx.replyTarget.msgId);
62
+ const target = passive
63
+ ? ctx.replyTarget
64
+ : { scope: 'c2c' as const, targetId: ctx.replyTarget.targetId };
65
+ ctx.bot.sendTyping(target, TYPING_DURATION_SEC).catch((err: unknown) => {
66
+ if (passive) rollbackPassiveSlot(accountId, ctx.replyTarget.msgId);
67
+ ctx.log?.debug?.(`[typing] failed: ${err instanceof Error ? err.message : String(err)}`);
68
+ });
69
+ lastSentAt = Date.now();
70
+ // 以本次发送为锚点重置周期续期,避免与事件续期撞车
71
+ if (periodic) clearInterval(periodic);
72
+ periodic = setInterval(sendNow, intervalMs);
73
+ };
74
+
75
+ // 出站消息会终止客户端的输入状态显示 → 5s 后补发;
76
+ // 与上次 typing 发送不足 intervalMs 时顺延(QPS 间距保护)
77
+ const schedulePostMessageRefresh = (): void => {
78
+ if (!active) return;
79
+ if (refresh) clearTimeout(refresh);
80
+ refresh = setTimeout(() => {
81
+ refresh = null;
82
+ const wait = intervalMs - (Date.now() - lastSentAt);
83
+ if (wait > 0) {
84
+ refresh = setTimeout(() => {
85
+ refresh = null;
86
+ sendNow();
87
+ }, wait);
88
+ } else {
89
+ sendNow();
90
+ }
91
+ }, POST_MESSAGE_REFRESH_DELAY_MS);
92
+ };
93
+
94
+ // 订阅本会话的出站消息信号( accountId + targetId 对应同一 C2C 用户)
95
+ const unsubscribe = subscribeOutboundMessage(
96
+ accountId, 'c2c', ctx.replyTarget.targetId, schedulePostMessageRefresh,
97
+ );
98
+
99
+ sendNow();
100
+
101
+ try {
102
+ await next();
103
+ } finally {
104
+ active = false;
105
+ unsubscribe();
106
+ if (periodic) clearInterval(periodic);
107
+ if (refresh) clearTimeout(refresh);
108
+ }
109
+ };
110
+ }