@zhin.js/core 1.0.36 → 1.0.38

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 (196) 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/types.d.ts +4 -3
  12. package/lib/ai/types.d.ts.map +1 -1
  13. package/lib/built/ai-trigger.js.map +1 -1
  14. package/lib/built/common-adapter-tools.d.ts +55 -0
  15. package/lib/built/common-adapter-tools.d.ts.map +1 -0
  16. package/lib/built/common-adapter-tools.js +158 -0
  17. package/lib/built/common-adapter-tools.js.map +1 -0
  18. package/lib/built/dispatcher.d.ts.map +1 -1
  19. package/lib/built/dispatcher.js +50 -46
  20. package/lib/built/dispatcher.js.map +1 -1
  21. package/lib/built/skill.d.ts.map +1 -1
  22. package/lib/built/skill.js +0 -1
  23. package/lib/built/skill.js.map +1 -1
  24. package/lib/built/tool.d.ts +3 -3
  25. package/lib/built/tool.d.ts.map +1 -1
  26. package/lib/built/tool.js.map +1 -1
  27. package/lib/feature.d.ts +16 -1
  28. package/lib/feature.d.ts.map +1 -1
  29. package/lib/feature.js +41 -2
  30. package/lib/feature.js.map +1 -1
  31. package/lib/index.d.ts +1 -0
  32. package/lib/index.d.ts.map +1 -1
  33. package/lib/index.js +2 -0
  34. package/lib/index.js.map +1 -1
  35. package/lib/plugin.d.ts +38 -1
  36. package/lib/plugin.d.ts.map +1 -1
  37. package/lib/plugin.js +73 -22
  38. package/lib/plugin.js.map +1 -1
  39. package/lib/scheduler/scheduler.js +1 -1
  40. package/lib/scheduler/scheduler.js.map +1 -1
  41. package/lib/types.d.ts +43 -28
  42. package/lib/types.d.ts.map +1 -1
  43. package/lib/utils.d.ts +12 -3
  44. package/lib/utils.d.ts.map +1 -1
  45. package/lib/utils.js +64 -54
  46. package/lib/utils.js.map +1 -1
  47. package/package.json +5 -5
  48. package/src/adapter.ts +85 -5
  49. package/src/ai/index.ts +8 -186
  50. package/src/ai/types.ts +5 -4
  51. package/src/built/ai-trigger.ts +2 -2
  52. package/src/built/common-adapter-tools.ts +207 -0
  53. package/src/built/dispatcher.ts +51 -52
  54. package/src/built/skill.ts +3 -4
  55. package/src/built/tool.ts +3 -3
  56. package/src/feature.ts +45 -2
  57. package/src/index.ts +2 -0
  58. package/src/plugin.ts +92 -31
  59. package/src/scheduler/scheduler.ts +1 -1
  60. package/src/types.ts +39 -28
  61. package/src/utils.ts +63 -52
  62. package/tests/ai/setup.ts +2 -2
  63. package/tests/utils.test.ts +1 -3
  64. package/lib/ai/agent.d.ts +0 -130
  65. package/lib/ai/agent.d.ts.map +0 -1
  66. package/lib/ai/agent.js +0 -684
  67. package/lib/ai/agent.js.map +0 -1
  68. package/lib/ai/bootstrap.d.ts +0 -91
  69. package/lib/ai/bootstrap.d.ts.map +0 -1
  70. package/lib/ai/bootstrap.js +0 -243
  71. package/lib/ai/bootstrap.js.map +0 -1
  72. package/lib/ai/builtin-tools.d.ts +0 -59
  73. package/lib/ai/builtin-tools.d.ts.map +0 -1
  74. package/lib/ai/builtin-tools.js +0 -777
  75. package/lib/ai/builtin-tools.js.map +0 -1
  76. package/lib/ai/compaction.d.ts +0 -132
  77. package/lib/ai/compaction.d.ts.map +0 -1
  78. package/lib/ai/compaction.js +0 -370
  79. package/lib/ai/compaction.js.map +0 -1
  80. package/lib/ai/context-manager.d.ts +0 -213
  81. package/lib/ai/context-manager.d.ts.map +0 -1
  82. package/lib/ai/context-manager.js +0 -313
  83. package/lib/ai/context-manager.js.map +0 -1
  84. package/lib/ai/conversation-memory.d.ts +0 -181
  85. package/lib/ai/conversation-memory.d.ts.map +0 -1
  86. package/lib/ai/conversation-memory.js +0 -581
  87. package/lib/ai/conversation-memory.js.map +0 -1
  88. package/lib/ai/cron-engine.d.ts +0 -92
  89. package/lib/ai/cron-engine.d.ts.map +0 -1
  90. package/lib/ai/cron-engine.js +0 -278
  91. package/lib/ai/cron-engine.js.map +0 -1
  92. package/lib/ai/follow-up.d.ts +0 -131
  93. package/lib/ai/follow-up.d.ts.map +0 -1
  94. package/lib/ai/follow-up.js +0 -265
  95. package/lib/ai/follow-up.js.map +0 -1
  96. package/lib/ai/hooks.d.ts +0 -143
  97. package/lib/ai/hooks.d.ts.map +0 -1
  98. package/lib/ai/hooks.js +0 -108
  99. package/lib/ai/hooks.js.map +0 -1
  100. package/lib/ai/init.d.ts +0 -30
  101. package/lib/ai/init.d.ts.map +0 -1
  102. package/lib/ai/init.js +0 -686
  103. package/lib/ai/init.js.map +0 -1
  104. package/lib/ai/output.d.ts +0 -93
  105. package/lib/ai/output.d.ts.map +0 -1
  106. package/lib/ai/output.js +0 -176
  107. package/lib/ai/output.js.map +0 -1
  108. package/lib/ai/rate-limiter.d.ts +0 -38
  109. package/lib/ai/rate-limiter.d.ts.map +0 -1
  110. package/lib/ai/rate-limiter.js +0 -86
  111. package/lib/ai/rate-limiter.js.map +0 -1
  112. package/lib/ai/service.d.ts +0 -88
  113. package/lib/ai/service.d.ts.map +0 -1
  114. package/lib/ai/service.js +0 -285
  115. package/lib/ai/service.js.map +0 -1
  116. package/lib/ai/session.d.ts +0 -186
  117. package/lib/ai/session.d.ts.map +0 -1
  118. package/lib/ai/session.js +0 -443
  119. package/lib/ai/session.js.map +0 -1
  120. package/lib/ai/subagent.d.ts +0 -50
  121. package/lib/ai/subagent.d.ts.map +0 -1
  122. package/lib/ai/subagent.js +0 -144
  123. package/lib/ai/subagent.js.map +0 -1
  124. package/lib/ai/tone-detector.d.ts +0 -19
  125. package/lib/ai/tone-detector.d.ts.map +0 -1
  126. package/lib/ai/tone-detector.js +0 -72
  127. package/lib/ai/tone-detector.js.map +0 -1
  128. package/lib/ai/tools.d.ts +0 -45
  129. package/lib/ai/tools.d.ts.map +0 -1
  130. package/lib/ai/tools.js +0 -206
  131. package/lib/ai/tools.js.map +0 -1
  132. package/lib/ai/user-profile.d.ts +0 -56
  133. package/lib/ai/user-profile.d.ts.map +0 -1
  134. package/lib/ai/user-profile.js +0 -130
  135. package/lib/ai/user-profile.js.map +0 -1
  136. package/lib/ai/zhin-agent/builtin-tools.d.ts +0 -17
  137. package/lib/ai/zhin-agent/builtin-tools.d.ts.map +0 -1
  138. package/lib/ai/zhin-agent/builtin-tools.js +0 -220
  139. package/lib/ai/zhin-agent/builtin-tools.js.map +0 -1
  140. package/lib/ai/zhin-agent/config.d.ts +0 -54
  141. package/lib/ai/zhin-agent/config.d.ts.map +0 -1
  142. package/lib/ai/zhin-agent/config.js +0 -76
  143. package/lib/ai/zhin-agent/config.js.map +0 -1
  144. package/lib/ai/zhin-agent/exec-policy.d.ts +0 -20
  145. package/lib/ai/zhin-agent/exec-policy.d.ts.map +0 -1
  146. package/lib/ai/zhin-agent/exec-policy.js +0 -71
  147. package/lib/ai/zhin-agent/exec-policy.js.map +0 -1
  148. package/lib/ai/zhin-agent/index.d.ts +0 -70
  149. package/lib/ai/zhin-agent/index.d.ts.map +0 -1
  150. package/lib/ai/zhin-agent/index.js +0 -404
  151. package/lib/ai/zhin-agent/index.js.map +0 -1
  152. package/lib/ai/zhin-agent/prompt.d.ts +0 -21
  153. package/lib/ai/zhin-agent/prompt.d.ts.map +0 -1
  154. package/lib/ai/zhin-agent/prompt.js +0 -111
  155. package/lib/ai/zhin-agent/prompt.js.map +0 -1
  156. package/lib/ai/zhin-agent/tool-collector.d.ts +0 -22
  157. package/lib/ai/zhin-agent/tool-collector.d.ts.map +0 -1
  158. package/lib/ai/zhin-agent/tool-collector.js +0 -218
  159. package/lib/ai/zhin-agent/tool-collector.js.map +0 -1
  160. package/src/ai/agent.ts +0 -812
  161. package/src/ai/bootstrap.ts +0 -309
  162. package/src/ai/builtin-tools.ts +0 -849
  163. package/src/ai/compaction.ts +0 -529
  164. package/src/ai/context-manager.ts +0 -440
  165. package/src/ai/conversation-memory.ts +0 -774
  166. package/src/ai/cron-engine.ts +0 -337
  167. package/src/ai/follow-up.ts +0 -357
  168. package/src/ai/hooks.ts +0 -223
  169. package/src/ai/init.ts +0 -762
  170. package/src/ai/output.ts +0 -261
  171. package/src/ai/rate-limiter.ts +0 -129
  172. package/src/ai/service.ts +0 -331
  173. package/src/ai/session.ts +0 -544
  174. package/src/ai/subagent.ts +0 -209
  175. package/src/ai/tone-detector.ts +0 -89
  176. package/src/ai/tools.ts +0 -218
  177. package/src/ai/user-profile.ts +0 -181
  178. package/src/ai/zhin-agent/builtin-tools.ts +0 -247
  179. package/src/ai/zhin-agent/config.ts +0 -113
  180. package/src/ai/zhin-agent/exec-policy.ts +0 -78
  181. package/src/ai/zhin-agent/index.ts +0 -512
  182. package/src/ai/zhin-agent/prompt.ts +0 -131
  183. package/src/ai/zhin-agent/tool-collector.ts +0 -243
  184. package/tests/ai/agent.test.ts +0 -614
  185. package/tests/ai/context-manager.test.ts +0 -413
  186. package/tests/ai/conversation-memory.test.ts +0 -128
  187. package/tests/ai/follow-up.test.ts +0 -175
  188. package/tests/ai/integration.test.ts +0 -584
  189. package/tests/ai/output.test.ts +0 -128
  190. package/tests/ai/rate-limiter.test.ts +0 -108
  191. package/tests/ai/session.test.ts +0 -375
  192. package/tests/ai/subagent.test.ts +0 -270
  193. package/tests/ai/tone-detector.test.ts +0 -80
  194. package/tests/ai/tools-builtin.test.ts +0 -346
  195. package/tests/ai/user-profile.test.ts +0 -73
  196. 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
- }