@zhin.js/core 1.0.37 → 1.0.39

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 (204) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +57 -3
  3. package/lib/adapter.d.ts +11 -0
  4. package/lib/adapter.d.ts.map +1 -1
  5. package/lib/adapter.js +61 -0
  6. package/lib/adapter.js.map +1 -1
  7. package/lib/ai/index.d.ts +3 -39
  8. package/lib/ai/index.d.ts.map +1 -1
  9. package/lib/ai/index.js +2 -44
  10. package/lib/ai/index.js.map +1 -1
  11. package/lib/ai/providers/anthropic.d.ts.map +1 -1
  12. package/lib/ai/providers/anthropic.js +2 -0
  13. package/lib/ai/providers/anthropic.js.map +1 -1
  14. package/lib/ai/providers/openai.d.ts.map +1 -1
  15. package/lib/ai/providers/openai.js +8 -0
  16. package/lib/ai/providers/openai.js.map +1 -1
  17. package/lib/ai/types.d.ts +5 -3
  18. package/lib/ai/types.d.ts.map +1 -1
  19. package/lib/built/ai-trigger.js.map +1 -1
  20. package/lib/built/common-adapter-tools.d.ts +55 -0
  21. package/lib/built/common-adapter-tools.d.ts.map +1 -0
  22. package/lib/built/common-adapter-tools.js +158 -0
  23. package/lib/built/common-adapter-tools.js.map +1 -0
  24. package/lib/built/dispatcher.d.ts.map +1 -1
  25. package/lib/built/dispatcher.js +50 -46
  26. package/lib/built/dispatcher.js.map +1 -1
  27. package/lib/built/skill.d.ts.map +1 -1
  28. package/lib/built/skill.js +0 -1
  29. package/lib/built/skill.js.map +1 -1
  30. package/lib/built/tool.d.ts +3 -3
  31. package/lib/built/tool.d.ts.map +1 -1
  32. package/lib/built/tool.js.map +1 -1
  33. package/lib/feature.d.ts +16 -1
  34. package/lib/feature.d.ts.map +1 -1
  35. package/lib/feature.js +41 -2
  36. package/lib/feature.js.map +1 -1
  37. package/lib/index.d.ts +1 -0
  38. package/lib/index.d.ts.map +1 -1
  39. package/lib/index.js +2 -0
  40. package/lib/index.js.map +1 -1
  41. package/lib/plugin.d.ts +38 -1
  42. package/lib/plugin.d.ts.map +1 -1
  43. package/lib/plugin.js +73 -22
  44. package/lib/plugin.js.map +1 -1
  45. package/lib/scheduler/scheduler.js +1 -1
  46. package/lib/scheduler/scheduler.js.map +1 -1
  47. package/lib/types.d.ts +43 -28
  48. package/lib/types.d.ts.map +1 -1
  49. package/lib/utils.d.ts +12 -3
  50. package/lib/utils.d.ts.map +1 -1
  51. package/lib/utils.js +64 -54
  52. package/lib/utils.js.map +1 -1
  53. package/package.json +5 -5
  54. package/src/adapter.ts +85 -5
  55. package/src/ai/index.ts +8 -186
  56. package/src/ai/providers/anthropic.ts +1 -0
  57. package/src/ai/providers/openai.ts +5 -1
  58. package/src/ai/types.ts +6 -4
  59. package/src/built/ai-trigger.ts +2 -2
  60. package/src/built/common-adapter-tools.ts +207 -0
  61. package/src/built/dispatcher.ts +51 -52
  62. package/src/built/skill.ts +3 -4
  63. package/src/built/tool.ts +3 -3
  64. package/src/feature.ts +45 -2
  65. package/src/index.ts +2 -0
  66. package/src/plugin.ts +92 -31
  67. package/src/scheduler/scheduler.ts +1 -1
  68. package/src/types.ts +39 -28
  69. package/src/utils.ts +63 -52
  70. package/tests/ai/setup.ts +2 -2
  71. package/tests/utils.test.ts +1 -3
  72. package/lib/ai/agent.d.ts +0 -130
  73. package/lib/ai/agent.d.ts.map +0 -1
  74. package/lib/ai/agent.js +0 -702
  75. package/lib/ai/agent.js.map +0 -1
  76. package/lib/ai/bootstrap.d.ts +0 -91
  77. package/lib/ai/bootstrap.d.ts.map +0 -1
  78. package/lib/ai/bootstrap.js +0 -243
  79. package/lib/ai/bootstrap.js.map +0 -1
  80. package/lib/ai/builtin-tools.d.ts +0 -59
  81. package/lib/ai/builtin-tools.d.ts.map +0 -1
  82. package/lib/ai/builtin-tools.js +0 -777
  83. package/lib/ai/builtin-tools.js.map +0 -1
  84. package/lib/ai/compaction.d.ts +0 -132
  85. package/lib/ai/compaction.d.ts.map +0 -1
  86. package/lib/ai/compaction.js +0 -370
  87. package/lib/ai/compaction.js.map +0 -1
  88. package/lib/ai/context-manager.d.ts +0 -213
  89. package/lib/ai/context-manager.d.ts.map +0 -1
  90. package/lib/ai/context-manager.js +0 -313
  91. package/lib/ai/context-manager.js.map +0 -1
  92. package/lib/ai/conversation-memory.d.ts +0 -181
  93. package/lib/ai/conversation-memory.d.ts.map +0 -1
  94. package/lib/ai/conversation-memory.js +0 -581
  95. package/lib/ai/conversation-memory.js.map +0 -1
  96. package/lib/ai/cron-engine.d.ts +0 -92
  97. package/lib/ai/cron-engine.d.ts.map +0 -1
  98. package/lib/ai/cron-engine.js +0 -278
  99. package/lib/ai/cron-engine.js.map +0 -1
  100. package/lib/ai/follow-up.d.ts +0 -131
  101. package/lib/ai/follow-up.d.ts.map +0 -1
  102. package/lib/ai/follow-up.js +0 -265
  103. package/lib/ai/follow-up.js.map +0 -1
  104. package/lib/ai/hooks.d.ts +0 -143
  105. package/lib/ai/hooks.d.ts.map +0 -1
  106. package/lib/ai/hooks.js +0 -108
  107. package/lib/ai/hooks.js.map +0 -1
  108. package/lib/ai/init.d.ts +0 -30
  109. package/lib/ai/init.d.ts.map +0 -1
  110. package/lib/ai/init.js +0 -686
  111. package/lib/ai/init.js.map +0 -1
  112. package/lib/ai/output.d.ts +0 -93
  113. package/lib/ai/output.d.ts.map +0 -1
  114. package/lib/ai/output.js +0 -176
  115. package/lib/ai/output.js.map +0 -1
  116. package/lib/ai/rate-limiter.d.ts +0 -38
  117. package/lib/ai/rate-limiter.d.ts.map +0 -1
  118. package/lib/ai/rate-limiter.js +0 -86
  119. package/lib/ai/rate-limiter.js.map +0 -1
  120. package/lib/ai/service.d.ts +0 -88
  121. package/lib/ai/service.d.ts.map +0 -1
  122. package/lib/ai/service.js +0 -285
  123. package/lib/ai/service.js.map +0 -1
  124. package/lib/ai/session.d.ts +0 -186
  125. package/lib/ai/session.d.ts.map +0 -1
  126. package/lib/ai/session.js +0 -443
  127. package/lib/ai/session.js.map +0 -1
  128. package/lib/ai/subagent.d.ts +0 -50
  129. package/lib/ai/subagent.d.ts.map +0 -1
  130. package/lib/ai/subagent.js +0 -144
  131. package/lib/ai/subagent.js.map +0 -1
  132. package/lib/ai/tone-detector.d.ts +0 -19
  133. package/lib/ai/tone-detector.d.ts.map +0 -1
  134. package/lib/ai/tone-detector.js +0 -72
  135. package/lib/ai/tone-detector.js.map +0 -1
  136. package/lib/ai/tools.d.ts +0 -45
  137. package/lib/ai/tools.d.ts.map +0 -1
  138. package/lib/ai/tools.js +0 -206
  139. package/lib/ai/tools.js.map +0 -1
  140. package/lib/ai/user-profile.d.ts +0 -56
  141. package/lib/ai/user-profile.d.ts.map +0 -1
  142. package/lib/ai/user-profile.js +0 -130
  143. package/lib/ai/user-profile.js.map +0 -1
  144. package/lib/ai/zhin-agent/builtin-tools.d.ts +0 -17
  145. package/lib/ai/zhin-agent/builtin-tools.d.ts.map +0 -1
  146. package/lib/ai/zhin-agent/builtin-tools.js +0 -220
  147. package/lib/ai/zhin-agent/builtin-tools.js.map +0 -1
  148. package/lib/ai/zhin-agent/config.d.ts +0 -54
  149. package/lib/ai/zhin-agent/config.d.ts.map +0 -1
  150. package/lib/ai/zhin-agent/config.js +0 -76
  151. package/lib/ai/zhin-agent/config.js.map +0 -1
  152. package/lib/ai/zhin-agent/exec-policy.d.ts +0 -20
  153. package/lib/ai/zhin-agent/exec-policy.d.ts.map +0 -1
  154. package/lib/ai/zhin-agent/exec-policy.js +0 -71
  155. package/lib/ai/zhin-agent/exec-policy.js.map +0 -1
  156. package/lib/ai/zhin-agent/index.d.ts +0 -70
  157. package/lib/ai/zhin-agent/index.d.ts.map +0 -1
  158. package/lib/ai/zhin-agent/index.js +0 -404
  159. package/lib/ai/zhin-agent/index.js.map +0 -1
  160. package/lib/ai/zhin-agent/prompt.d.ts +0 -21
  161. package/lib/ai/zhin-agent/prompt.d.ts.map +0 -1
  162. package/lib/ai/zhin-agent/prompt.js +0 -111
  163. package/lib/ai/zhin-agent/prompt.js.map +0 -1
  164. package/lib/ai/zhin-agent/tool-collector.d.ts +0 -22
  165. package/lib/ai/zhin-agent/tool-collector.d.ts.map +0 -1
  166. package/lib/ai/zhin-agent/tool-collector.js +0 -218
  167. package/lib/ai/zhin-agent/tool-collector.js.map +0 -1
  168. package/src/ai/agent.ts +0 -831
  169. package/src/ai/bootstrap.ts +0 -309
  170. package/src/ai/builtin-tools.ts +0 -849
  171. package/src/ai/compaction.ts +0 -529
  172. package/src/ai/context-manager.ts +0 -440
  173. package/src/ai/conversation-memory.ts +0 -774
  174. package/src/ai/cron-engine.ts +0 -337
  175. package/src/ai/follow-up.ts +0 -357
  176. package/src/ai/hooks.ts +0 -223
  177. package/src/ai/init.ts +0 -762
  178. package/src/ai/output.ts +0 -261
  179. package/src/ai/rate-limiter.ts +0 -129
  180. package/src/ai/service.ts +0 -331
  181. package/src/ai/session.ts +0 -544
  182. package/src/ai/subagent.ts +0 -209
  183. package/src/ai/tone-detector.ts +0 -89
  184. package/src/ai/tools.ts +0 -218
  185. package/src/ai/user-profile.ts +0 -181
  186. package/src/ai/zhin-agent/builtin-tools.ts +0 -247
  187. package/src/ai/zhin-agent/config.ts +0 -113
  188. package/src/ai/zhin-agent/exec-policy.ts +0 -78
  189. package/src/ai/zhin-agent/index.ts +0 -512
  190. package/src/ai/zhin-agent/prompt.ts +0 -131
  191. package/src/ai/zhin-agent/tool-collector.ts +0 -243
  192. package/tests/ai/agent.test.ts +0 -614
  193. package/tests/ai/context-manager.test.ts +0 -413
  194. package/tests/ai/conversation-memory.test.ts +0 -128
  195. package/tests/ai/follow-up.test.ts +0 -175
  196. package/tests/ai/integration.test.ts +0 -584
  197. package/tests/ai/output.test.ts +0 -128
  198. package/tests/ai/rate-limiter.test.ts +0 -108
  199. package/tests/ai/session.test.ts +0 -375
  200. package/tests/ai/subagent.test.ts +0 -270
  201. package/tests/ai/tone-detector.test.ts +0 -80
  202. package/tests/ai/tools-builtin.test.ts +0 -346
  203. package/tests/ai/user-profile.test.ts +0 -73
  204. package/tests/ai/zhin-agent.test.ts +0 -177
package/src/ai/output.ts DELETED
@@ -1,261 +0,0 @@
1
- /**
2
- * @zhin.js/ai - Multi-modal Output Pipeline
3
- *
4
- * 统一输出类型系统:AI 回复 → OutputElement[] → Adapter 渲染
5
- *
6
- * 支持的输出类型:
7
- * - text: 纯文本 / markdown
8
- * - image: 图片 URL 或 base64
9
- * - audio: 音频 URL 或 base64
10
- * - video: 视频 URL
11
- * - card: 结构化卡片 (标题 + 正文 + 字段 + 按钮)
12
- * - file: 文件附件
13
- */
14
-
15
- // ============================================================================
16
- // OutputElement 类型定义
17
- // ============================================================================
18
-
19
- export interface TextElement {
20
- type: 'text';
21
- content: string;
22
- /** markdown / plain */
23
- format?: 'markdown' | 'plain';
24
- }
25
-
26
- export interface ImageElement {
27
- type: 'image';
28
- url: string;
29
- /** base64 数据 (url 不可用时的后备) */
30
- base64?: string;
31
- alt?: string;
32
- width?: number;
33
- height?: number;
34
- }
35
-
36
- export interface AudioElement {
37
- type: 'audio';
38
- url: string;
39
- base64?: string;
40
- duration?: number;
41
- /** 语音转文字的后备文本 */
42
- fallbackText?: string;
43
- }
44
-
45
- export interface VideoElement {
46
- type: 'video';
47
- url: string;
48
- coverUrl?: string;
49
- duration?: number;
50
- fallbackText?: string;
51
- }
52
-
53
- export interface CardField {
54
- label: string;
55
- value: string;
56
- inline?: boolean;
57
- }
58
-
59
- export interface CardButton {
60
- text: string;
61
- /** 点击后发送的命令 */
62
- command?: string;
63
- /** 点击后打开的 URL */
64
- url?: string;
65
- }
66
-
67
- export interface CardElement {
68
- type: 'card';
69
- title: string;
70
- description?: string;
71
- fields?: CardField[];
72
- imageUrl?: string;
73
- buttons?: CardButton[];
74
- color?: string;
75
- }
76
-
77
- export interface FileElement {
78
- type: 'file';
79
- url: string;
80
- name: string;
81
- size?: number;
82
- mimeType?: string;
83
- }
84
-
85
- export type OutputElement =
86
- | TextElement
87
- | ImageElement
88
- | AudioElement
89
- | VideoElement
90
- | CardElement
91
- | FileElement;
92
-
93
- // ============================================================================
94
- // 输出解析器 — 将 AI 原始回复转换为 OutputElement[]
95
- // ============================================================================
96
-
97
- /**
98
- * 从 AI 回复文本中解析出结构化的 OutputElement[]
99
- *
100
- * 识别规则:
101
- * - ![alt](url) → ImageElement
102
- * - [audio](url) → AudioElement
103
- * - [video](url) → VideoElement
104
- * - ```card ... ``` → CardElement (JSON)
105
- * - [file:name](url) → FileElement
106
- * - 其余文本 → TextElement
107
- */
108
- export function parseOutput(raw: string): OutputElement[] {
109
- if (!raw || !raw.trim()) return [{ type: 'text', content: '', format: 'plain' }];
110
-
111
- const elements: OutputElement[] = [];
112
- let remaining = raw;
113
-
114
- // eslint-disable-next-line no-constant-condition
115
- while (remaining.length > 0) {
116
- // ── 尝试匹配 card 代码块 ──
117
- const cardMatch = remaining.match(/^([\s\S]*?)```card\s*\n([\s\S]*?)```/);
118
- if (cardMatch) {
119
- // 前缀文本
120
- if (cardMatch[1].trim()) {
121
- elements.push({ type: 'text', content: cardMatch[1].trim(), format: 'markdown' });
122
- }
123
- // 解析卡片 JSON
124
- try {
125
- const cardData = JSON.parse(cardMatch[2]);
126
- elements.push({ ...cardData, type: 'card' });
127
- } catch {
128
- elements.push({ type: 'text', content: cardMatch[2], format: 'plain' });
129
- }
130
- remaining = remaining.slice(cardMatch[0].length);
131
- continue;
132
- }
133
-
134
- // ── 尝试匹配 image: ![alt](url) ──
135
- const imgMatch = remaining.match(/^([\s\S]*?)!\[([^\]]*)\]\(([^)]+)\)/);
136
- if (imgMatch && imgMatch[1].length < 500) {
137
- if (imgMatch[1].trim()) {
138
- elements.push({ type: 'text', content: imgMatch[1].trim(), format: 'markdown' });
139
- }
140
- elements.push({ type: 'image', url: imgMatch[3], alt: imgMatch[2] || undefined });
141
- remaining = remaining.slice(imgMatch[0].length);
142
- continue;
143
- }
144
-
145
- // ── 尝试匹配 audio: [audio](url) ──
146
- const audioMatch = remaining.match(/^([\s\S]*?)\[audio\]\(([^)]+)\)/i);
147
- if (audioMatch && audioMatch[1].length < 500) {
148
- if (audioMatch[1].trim()) {
149
- elements.push({ type: 'text', content: audioMatch[1].trim(), format: 'markdown' });
150
- }
151
- elements.push({ type: 'audio', url: audioMatch[2] });
152
- remaining = remaining.slice(audioMatch[0].length);
153
- continue;
154
- }
155
-
156
- // ── 尝试匹配 video: [video](url) ──
157
- const videoMatch = remaining.match(/^([\s\S]*?)\[video\]\(([^)]+)\)/i);
158
- if (videoMatch && videoMatch[1].length < 500) {
159
- if (videoMatch[1].trim()) {
160
- elements.push({ type: 'text', content: videoMatch[1].trim(), format: 'markdown' });
161
- }
162
- elements.push({ type: 'video', url: videoMatch[2] });
163
- remaining = remaining.slice(videoMatch[0].length);
164
- continue;
165
- }
166
-
167
- // ── 尝试匹配 file: [file:name](url) ──
168
- const fileMatch = remaining.match(/^([\s\S]*?)\[file:([^\]]+)\]\(([^)]+)\)/i);
169
- if (fileMatch && fileMatch[1].length < 500) {
170
- if (fileMatch[1].trim()) {
171
- elements.push({ type: 'text', content: fileMatch[1].trim(), format: 'markdown' });
172
- }
173
- elements.push({ type: 'file', url: fileMatch[3], name: fileMatch[2] });
174
- remaining = remaining.slice(fileMatch[0].length);
175
- continue;
176
- }
177
-
178
- // ── 无匹配 → 全部作为文本 ──
179
- elements.push({ type: 'text', content: remaining.trim(), format: 'markdown' });
180
- break;
181
- }
182
-
183
- return elements.length > 0 ? elements : [{ type: 'text', content: raw, format: 'plain' }];
184
- }
185
-
186
- // ============================================================================
187
- // OutputElement 渲染工具
188
- // ============================================================================
189
-
190
- /**
191
- * 将 OutputElement[] 降级为纯文本(用于不支持富媒体的平台)
192
- */
193
- export function renderToPlainText(elements: OutputElement[]): string {
194
- return elements.map(el => {
195
- switch (el.type) {
196
- case 'text':
197
- return el.content;
198
- case 'image':
199
- return el.alt ? `[图片: ${el.alt}]` : `[图片: ${el.url}]`;
200
- case 'audio':
201
- return el.fallbackText || `[音频: ${el.url}]`;
202
- case 'video':
203
- return el.fallbackText || `[视频: ${el.url}]`;
204
- case 'card': {
205
- const parts = [el.title];
206
- if (el.description) parts.push(el.description);
207
- if (el.fields?.length) {
208
- for (const f of el.fields) parts.push(`${f.label}: ${f.value}`);
209
- }
210
- return parts.join('\n');
211
- }
212
- case 'file':
213
- return `[文件: ${el.name}] ${el.url}`;
214
- default:
215
- return '';
216
- }
217
- }).filter(Boolean).join('\n');
218
- }
219
-
220
- /**
221
- * 将 OutputElement[] 渲染为 Satori 兼容的 HTML 片段
222
- */
223
- export function renderToSatori(elements: OutputElement[]): string {
224
- return elements.map(el => {
225
- switch (el.type) {
226
- case 'text':
227
- return `<p>${escapeHtml(el.content)}</p>`;
228
- case 'image':
229
- return `<img src="${escapeHtml(el.url)}"${el.alt ? ` alt="${escapeHtml(el.alt)}"` : ''}/>`;
230
- case 'audio':
231
- return el.fallbackText
232
- ? `<p>${escapeHtml(el.fallbackText)}</p>`
233
- : `<audio src="${escapeHtml(el.url)}"/>`;
234
- case 'video':
235
- return el.fallbackText
236
- ? `<p>${escapeHtml(el.fallbackText)}</p>`
237
- : `<video src="${escapeHtml(el.url)}"/>`;
238
- case 'card': {
239
- let html = `<div class="card">`;
240
- html += `<h3>${escapeHtml(el.title)}</h3>`;
241
- if (el.description) html += `<p>${escapeHtml(el.description)}</p>`;
242
- if (el.imageUrl) html += `<img src="${escapeHtml(el.imageUrl)}"/>`;
243
- if (el.fields?.length) {
244
- for (const f of el.fields) {
245
- html += `<p><b>${escapeHtml(f.label)}</b>: ${escapeHtml(f.value)}</p>`;
246
- }
247
- }
248
- html += `</div>`;
249
- return html;
250
- }
251
- case 'file':
252
- return `<a href="${escapeHtml(el.url)}">${escapeHtml(el.name)}</a>`;
253
- default:
254
- return '';
255
- }
256
- }).filter(Boolean).join('\n');
257
- }
258
-
259
- function escapeHtml(s: string): string {
260
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
261
- }
@@ -1,129 +0,0 @@
1
- /**
2
- * RateLimiter — AI 请求频率限制
3
- *
4
- * 防止单用户过度消耗 GPU/API 资源。
5
- *
6
- * 策略:
7
- * 1. 滑动窗口速率限制 — 每分钟 N 次请求
8
- * 2. 优雅降级 — 超限时返回友好提示而非静默丢弃
9
- */
10
-
11
- import { Logger } from '@zhin.js/logger';
12
-
13
- const logger = new Logger(null, 'RateLimiter');
14
-
15
- // ============================================================================
16
- // 配置
17
- // ============================================================================
18
-
19
- export interface RateLimitConfig {
20
- /** 每分钟最大请求数(默认 20) */
21
- maxRequestsPerMinute?: number;
22
- /** 冷却时间(秒),超限后需等待(默认 10) */
23
- cooldownSeconds?: number;
24
- }
25
-
26
- const DEFAULTS: Required<RateLimitConfig> = {
27
- maxRequestsPerMinute: 20,
28
- cooldownSeconds: 10,
29
- };
30
-
31
- // ============================================================================
32
- // 类型
33
- // ============================================================================
34
-
35
- interface UserBucket {
36
- /** 最近请求的时间戳列表(ms) */
37
- timestamps: number[];
38
- /** 冷却结束时间(ms),0 表示无冷却 */
39
- cooldownUntil: number;
40
- }
41
-
42
- export interface RateLimitResult {
43
- allowed: boolean;
44
- /** 如果被拒绝,返回友好提示 */
45
- message?: string;
46
- /** 需等待的秒数 */
47
- retryAfterSeconds?: number;
48
- }
49
-
50
- // ============================================================================
51
- // RateLimiter
52
- // ============================================================================
53
-
54
- export class RateLimiter {
55
- private config: Required<RateLimitConfig>;
56
- private buckets: Map<string, UserBucket> = new Map();
57
- private cleanupTimer?: ReturnType<typeof setInterval>;
58
-
59
- constructor(config?: RateLimitConfig) {
60
- this.config = { ...DEFAULTS, ...config };
61
- // 定期清理过期的 bucket(每 5 分钟)
62
- this.cleanupTimer = setInterval(() => this.cleanup(), 5 * 60 * 1000);
63
- }
64
-
65
- /**
66
- * 检查请求是否被允许
67
- */
68
- check(userId: string): RateLimitResult {
69
- const now = Date.now();
70
- let bucket = this.buckets.get(userId);
71
-
72
- if (!bucket) {
73
- bucket = { timestamps: [], cooldownUntil: 0 };
74
- this.buckets.set(userId, bucket);
75
- }
76
-
77
- // 1. 检查冷却期
78
- if (bucket.cooldownUntil > now) {
79
- const waitSec = Math.ceil((bucket.cooldownUntil - now) / 1000);
80
- return {
81
- allowed: false,
82
- message: `请稍等 ${waitSec} 秒后再发消息哦~消息太频繁啦 😊`,
83
- retryAfterSeconds: waitSec,
84
- };
85
- }
86
-
87
- // 2. 滑动窗口:清理 1 分钟前的时间戳
88
- const windowStart = now - 60_000;
89
- bucket.timestamps = bucket.timestamps.filter(t => t > windowStart);
90
-
91
- // 3. 检查速率
92
- if (bucket.timestamps.length >= this.config.maxRequestsPerMinute) {
93
- bucket.cooldownUntil = now + this.config.cooldownSeconds * 1000;
94
- const waitSec = this.config.cooldownSeconds;
95
- logger.warn(`User ${userId} rate limited: ${bucket.timestamps.length} requests in 1 min`);
96
- return {
97
- allowed: false,
98
- message: `你发消息太快啦,请等 ${waitSec} 秒后再试~`,
99
- retryAfterSeconds: waitSec,
100
- };
101
- }
102
-
103
- // 4. 记录这次请求
104
- bucket.timestamps.push(now);
105
- return { allowed: true };
106
- }
107
-
108
- /**
109
- * 清理长期不活跃的 bucket
110
- */
111
- private cleanup(): void {
112
- const now = Date.now();
113
- const staleThreshold = 10 * 60 * 1000; // 10 分钟
114
- for (const [userId, bucket] of this.buckets) {
115
- const latest = bucket.timestamps[bucket.timestamps.length - 1] ?? 0;
116
- if (now - latest > staleThreshold && bucket.cooldownUntil < now) {
117
- this.buckets.delete(userId);
118
- }
119
- }
120
- }
121
-
122
- dispose(): void {
123
- if (this.cleanupTimer) {
124
- clearInterval(this.cleanupTimer);
125
- this.cleanupTimer = undefined;
126
- }
127
- this.buckets.clear();
128
- }
129
- }