@moltchats/connector 0.3.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 (42) hide show
  1. package/dist/config.d.ts +30 -0
  2. package/dist/config.d.ts.map +1 -0
  3. package/dist/config.js +70 -0
  4. package/dist/config.js.map +1 -0
  5. package/dist/connector.d.ts +22 -0
  6. package/dist/connector.d.ts.map +1 -0
  7. package/dist/connector.js +202 -0
  8. package/dist/connector.js.map +1 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +35 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/logger.d.ts +9 -0
  14. package/dist/logger.d.ts.map +1 -0
  15. package/dist/logger.js +24 -0
  16. package/dist/logger.js.map +1 -0
  17. package/dist/message-formatter.d.ts +14 -0
  18. package/dist/message-formatter.d.ts.map +1 -0
  19. package/dist/message-formatter.js +51 -0
  20. package/dist/message-formatter.js.map +1 -0
  21. package/dist/moltchats-bridge.d.ts +39 -0
  22. package/dist/moltchats-bridge.d.ts.map +1 -0
  23. package/dist/moltchats-bridge.js +253 -0
  24. package/dist/moltchats-bridge.js.map +1 -0
  25. package/dist/openclaw-client.d.ts +48 -0
  26. package/dist/openclaw-client.d.ts.map +1 -0
  27. package/dist/openclaw-client.js +255 -0
  28. package/dist/openclaw-client.js.map +1 -0
  29. package/dist/rate-limiter.d.ts +11 -0
  30. package/dist/rate-limiter.d.ts.map +1 -0
  31. package/dist/rate-limiter.js +45 -0
  32. package/dist/rate-limiter.js.map +1 -0
  33. package/package.json +23 -0
  34. package/src/config.ts +123 -0
  35. package/src/connector.ts +256 -0
  36. package/src/index.ts +44 -0
  37. package/src/logger.ts +30 -0
  38. package/src/message-formatter.ts +75 -0
  39. package/src/moltchats-bridge.ts +288 -0
  40. package/src/openclaw-client.ts +308 -0
  41. package/src/rate-limiter.ts +60 -0
  42. package/tsconfig.json +8 -0
@@ -0,0 +1,288 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { MoltChatsClient, MoltChatsWs } from '@moltchats/sdk';
3
+ import type { WsServerOp } from '@moltchats/shared';
4
+ import type { ConnectorConfig, StoredCredentials } from './config.js';
5
+ import type { Logger } from './logger.js';
6
+ import type { ChannelMeta } from './message-formatter.js';
7
+
8
+ type MessageHandler = (data: WsServerOp) => void;
9
+
10
+ const MAX_RECONNECT_ATTEMPTS = 20;
11
+ const RECONNECT_BASE_MS = 1000;
12
+ const RECONNECT_CAP_MS = 30000;
13
+ const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // refresh 5 min before expiry
14
+
15
+ export class MoltChatsBridge {
16
+ private client: MoltChatsClient;
17
+ private ws: MoltChatsWs | null = null;
18
+ private config: ConnectorConfig;
19
+ private credentials: StoredCredentials;
20
+ private logger: Logger;
21
+ private handlers = new Map<string, Set<MessageHandler>>();
22
+ private subscribedChannels = new Set<string>();
23
+ private channelMeta = new Map<string, ChannelMeta>();
24
+ private refreshTimer: ReturnType<typeof setTimeout> | null = null;
25
+ private reconnectAttempts = 0;
26
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
27
+ private closed = false;
28
+
29
+ constructor(config: ConnectorConfig, credentials: StoredCredentials, logger: Logger) {
30
+ this.config = config;
31
+ this.credentials = credentials;
32
+ this.logger = logger;
33
+ this.client = new MoltChatsClient({ baseUrl: config.moltchats.apiBase });
34
+ }
35
+
36
+ get agentId(): string {
37
+ return this.credentials.agentId;
38
+ }
39
+
40
+ get username(): string {
41
+ return this.credentials.username;
42
+ }
43
+
44
+ get restClient(): MoltChatsClient {
45
+ return this.client;
46
+ }
47
+
48
+ getChannelMeta(channelId: string): ChannelMeta | undefined {
49
+ return this.channelMeta.get(channelId);
50
+ }
51
+
52
+ async authenticate(): Promise<void> {
53
+ // Try existing token first
54
+ if (this.credentials.token) {
55
+ try {
56
+ this.client.setToken(this.credentials.token);
57
+ await this.client.getProfile();
58
+ this.logger.debug('Existing token is valid');
59
+ this.scheduleTokenRefresh(this.credentials.token);
60
+ return;
61
+ } catch {
62
+ this.logger.debug('Existing token expired, trying refresh...');
63
+ }
64
+ }
65
+
66
+ // Try refresh token
67
+ try {
68
+ const auth = await this.client.refreshToken(this.credentials.refreshToken);
69
+ this.updateCredentials(auth.token, auth.refreshToken);
70
+ this.logger.info('Authenticated via refresh token');
71
+ this.scheduleTokenRefresh(auth.token);
72
+ return;
73
+ } catch {
74
+ this.logger.debug('Refresh token failed, falling back to reauth...');
75
+ }
76
+
77
+ // Fall back to challenge-response
78
+ const auth = await this.client.reauth(this.credentials.agentId, this.credentials.privateKey);
79
+ this.updateCredentials(auth.token, auth.refreshToken);
80
+ this.logger.info('Authenticated via challenge-response');
81
+ this.scheduleTokenRefresh(auth.token);
82
+ }
83
+
84
+ async connectWs(): Promise<void> {
85
+ this.closed = false;
86
+ const token = this.credentials.token!;
87
+ this.ws = new MoltChatsWs({
88
+ url: this.config.moltchats.wsBase,
89
+ token,
90
+ autoReconnect: false, // we handle reconnection ourselves for token refresh
91
+ });
92
+
93
+ // Register event handlers before connecting
94
+ this.ws.on('*', (data: WsServerOp) => {
95
+ this.emit(data.op, data);
96
+ });
97
+
98
+ this.ws.on('error', (data: WsServerOp) => {
99
+ if ('code' in data && data.code === 'MAX_RECONNECT') {
100
+ this.logger.warn('MoltChats WS max reconnect reached, handling manually');
101
+ this.handleDisconnect();
102
+ }
103
+ });
104
+
105
+ await this.ws.connect();
106
+ this.reconnectAttempts = 0;
107
+ this.logger.info('Connected to MoltChats WebSocket');
108
+ }
109
+
110
+ async resolveAndSubscribe(): Promise<void> {
111
+ const channels: string[] = [...this.config.channels.serverChannels];
112
+
113
+ // Auto-subscribe to DM channels
114
+ if (this.config.channels.autoSubscribeDMs) {
115
+ try {
116
+ const friends = await this.client.getFriends();
117
+ for (const friend of friends) {
118
+ if (friend.dmChannelId) {
119
+ channels.push(friend.dmChannelId);
120
+ this.channelMeta.set(friend.dmChannelId, {
121
+ channelId: friend.dmChannelId,
122
+ type: 'dm',
123
+ friendUsername: friend.username,
124
+ });
125
+ }
126
+ }
127
+ this.logger.info(`Found ${friends.length} friends with DM channels`);
128
+ } catch (err) {
129
+ this.logger.error('Failed to fetch friends list:', (err as Error).message);
130
+ }
131
+ }
132
+
133
+ // Subscribe to server channels
134
+ for (const serverId of this.config.channels.serverIds) {
135
+ try {
136
+ const serverChannels = await this.client.getServerChannels(serverId);
137
+ const server = await this.client.getServer(serverId);
138
+ for (const ch of serverChannels) {
139
+ channels.push(ch.id);
140
+ this.channelMeta.set(ch.id, {
141
+ channelId: ch.id,
142
+ type: ch.type ?? 'text',
143
+ serverName: server.name,
144
+ channelName: ch.name,
145
+ });
146
+ }
147
+ this.logger.info(`Subscribed to ${serverChannels.length} channels in server "${server.name}"`);
148
+ } catch (err) {
149
+ this.logger.error(`Failed to fetch channels for server ${serverId}:`, (err as Error).message);
150
+ }
151
+ }
152
+
153
+ if (channels.length > 0) {
154
+ this.ws!.subscribe(channels);
155
+ for (const ch of channels) this.subscribedChannels.add(ch);
156
+ this.logger.info(`Subscribed to ${channels.length} channels total`);
157
+ } else {
158
+ this.logger.warn('No channels to subscribe to');
159
+ }
160
+ }
161
+
162
+ subscribeChannel(channelId: string, meta?: ChannelMeta): void {
163
+ if (this.subscribedChannels.has(channelId)) return;
164
+ this.subscribedChannels.add(channelId);
165
+ if (meta) this.channelMeta.set(channelId, meta);
166
+ this.ws?.subscribe([channelId]);
167
+ this.logger.debug(`Subscribed to channel ${channelId}`);
168
+ }
169
+
170
+ sendMessage(channelId: string, content: string): void {
171
+ this.ws?.sendMessage(channelId, content);
172
+ }
173
+
174
+ sendTyping(channelId: string): void {
175
+ this.ws?.sendTyping(channelId);
176
+ }
177
+
178
+ on(event: string, handler: MessageHandler): () => void {
179
+ if (!this.handlers.has(event)) {
180
+ this.handlers.set(event, new Set());
181
+ }
182
+ this.handlers.get(event)!.add(handler);
183
+ return () => this.handlers.get(event)?.delete(handler);
184
+ }
185
+
186
+ disconnect(): void {
187
+ this.closed = true;
188
+ if (this.refreshTimer) {
189
+ clearTimeout(this.refreshTimer);
190
+ this.refreshTimer = null;
191
+ }
192
+ if (this.reconnectTimer) {
193
+ clearTimeout(this.reconnectTimer);
194
+ this.reconnectTimer = null;
195
+ }
196
+ this.ws?.disconnect();
197
+ this.ws = null;
198
+ }
199
+
200
+ private emit(event: string, data: WsServerOp): void {
201
+ this.handlers.get(event)?.forEach(h => h(data));
202
+ this.handlers.get('*')?.forEach(h => h(data));
203
+ }
204
+
205
+ private async handleDisconnect(): Promise<void> {
206
+ if (this.closed) return;
207
+
208
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
209
+ this.logger.error('Max MoltChats reconnect attempts reached');
210
+ return;
211
+ }
212
+
213
+ const delay = Math.min(
214
+ RECONNECT_BASE_MS * Math.pow(2, this.reconnectAttempts),
215
+ RECONNECT_CAP_MS,
216
+ );
217
+ this.reconnectAttempts++;
218
+ this.logger.info(`Reconnecting to MoltChats in ${delay}ms (attempt ${this.reconnectAttempts})`);
219
+
220
+ this.reconnectTimer = setTimeout(async () => {
221
+ try {
222
+ // Refresh auth before reconnecting
223
+ await this.authenticate();
224
+ await this.connectWs();
225
+ // Re-subscribe to all channels
226
+ if (this.subscribedChannels.size > 0) {
227
+ this.ws!.subscribe([...this.subscribedChannels]);
228
+ }
229
+ } catch (err) {
230
+ this.logger.error('MoltChats reconnect failed:', (err as Error).message);
231
+ this.handleDisconnect();
232
+ }
233
+ }, delay);
234
+ }
235
+
236
+ private scheduleTokenRefresh(token: string): void {
237
+ if (this.refreshTimer) clearTimeout(this.refreshTimer);
238
+
239
+ try {
240
+ // Parse JWT exp claim
241
+ const payload = JSON.parse(
242
+ Buffer.from(token.split('.')[1], 'base64').toString(),
243
+ );
244
+ const expiresAt = payload.exp * 1000;
245
+ const refreshAt = expiresAt - TOKEN_REFRESH_BUFFER_MS;
246
+ const delay = Math.max(refreshAt - Date.now(), 0);
247
+
248
+ this.logger.debug(`Token refresh scheduled in ${Math.round(delay / 1000)}s`);
249
+
250
+ this.refreshTimer = setTimeout(async () => {
251
+ try {
252
+ const auth = await this.client.refreshToken(this.credentials.refreshToken);
253
+ this.updateCredentials(auth.token, auth.refreshToken);
254
+ this.logger.info('Token refreshed proactively');
255
+ this.scheduleTokenRefresh(auth.token);
256
+
257
+ // Reconnect WS with new token
258
+ this.ws?.disconnect();
259
+ await this.connectWs();
260
+ if (this.subscribedChannels.size > 0) {
261
+ this.ws!.subscribe([...this.subscribedChannels]);
262
+ }
263
+ } catch (err) {
264
+ this.logger.error('Proactive token refresh failed:', (err as Error).message);
265
+ // Will be caught on next WS reconnect
266
+ }
267
+ }, delay);
268
+ } catch {
269
+ this.logger.warn('Could not parse JWT for refresh scheduling');
270
+ }
271
+ }
272
+
273
+ private updateCredentials(token: string, refreshToken: string): void {
274
+ this.credentials.token = token;
275
+ this.credentials.refreshToken = refreshToken;
276
+
277
+ // Persist updated credentials
278
+ try {
279
+ writeFileSync(
280
+ this.config.moltchats.credentialsPath,
281
+ JSON.stringify(this.credentials, null, 2),
282
+ { mode: 0o600 },
283
+ );
284
+ } catch (err) {
285
+ this.logger.warn('Failed to persist credentials:', (err as Error).message);
286
+ }
287
+ }
288
+ }
@@ -0,0 +1,308 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import WebSocket from 'ws';
3
+ import type { Logger } from './logger.js';
4
+
5
+ export interface OpenClawConfig {
6
+ gatewayUrl: string;
7
+ authToken: string;
8
+ sessionKey: string;
9
+ }
10
+
11
+ export type ChatEventState = 'delta' | 'final' | 'aborted' | 'error';
12
+
13
+ export interface ChatEvent {
14
+ runId: string;
15
+ sessionKey: string;
16
+ state: ChatEventState;
17
+ message?: unknown;
18
+ errorMessage?: string;
19
+ }
20
+
21
+ interface PendingRequest {
22
+ resolve: (payload: unknown) => void;
23
+ reject: (err: Error) => void;
24
+ timer: ReturnType<typeof setTimeout>;
25
+ }
26
+
27
+ type ChatEventHandler = (event: ChatEvent) => void;
28
+
29
+ const MAX_RECONNECT_ATTEMPTS = 20;
30
+ const RECONNECT_BASE_MS = 2000;
31
+ const RECONNECT_CAP_MS = 30000;
32
+ const REQUEST_TIMEOUT_MS = 30000;
33
+ const RUN_TIMEOUT_MS = 300000; // 5 minutes
34
+
35
+ export class OpenClawClient {
36
+ private ws: WebSocket | null = null;
37
+ private config: OpenClawConfig;
38
+ private logger: Logger;
39
+ private pending = new Map<string, PendingRequest>();
40
+ private chatHandlers = new Set<ChatEventHandler>();
41
+ private reconnectAttempts = 0;
42
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
43
+ private closed = false;
44
+ private _connected = false;
45
+
46
+ constructor(config: OpenClawConfig, logger: Logger) {
47
+ this.config = config;
48
+ this.logger = logger;
49
+ }
50
+
51
+ get connected(): boolean {
52
+ return this._connected;
53
+ }
54
+
55
+ onChat(handler: ChatEventHandler): () => void {
56
+ this.chatHandlers.add(handler);
57
+ return () => this.chatHandlers.delete(handler);
58
+ }
59
+
60
+ async connect(): Promise<void> {
61
+ this.closed = false;
62
+ return new Promise((resolve, reject) => {
63
+ this.ws = new WebSocket(this.config.gatewayUrl);
64
+
65
+ let handshakeComplete = false;
66
+
67
+ this.ws.on('open', () => {
68
+ this.logger.debug('OpenClaw Gateway WS connected, waiting for challenge...');
69
+ });
70
+
71
+ this.ws.on('message', (raw: WebSocket.RawData) => {
72
+ let frame: Record<string, unknown>;
73
+ try {
74
+ frame = JSON.parse(raw.toString());
75
+ } catch {
76
+ return;
77
+ }
78
+
79
+ // Handle connect.challenge during handshake
80
+ if (!handshakeComplete && frame.type === 'event' && frame.event === 'connect.challenge') {
81
+ const payload = frame.payload as Record<string, unknown>;
82
+ this.sendRaw({
83
+ type: 'req',
84
+ id: randomUUID(),
85
+ method: 'connect',
86
+ params: {
87
+ minProtocol: 3,
88
+ maxProtocol: 3,
89
+ client: {
90
+ id: 'moltchats-connector',
91
+ version: '0.3.0',
92
+ platform: 'node',
93
+ mode: 'api',
94
+ },
95
+ auth: { token: this.config.authToken },
96
+ role: 'operator',
97
+ scopes: ['operator.read', 'operator.write'],
98
+ nonce: payload.nonce,
99
+ },
100
+ });
101
+ return;
102
+ }
103
+
104
+ // Handle connect response
105
+ if (!handshakeComplete && frame.type === 'res') {
106
+ const resFrame = frame as { ok?: boolean; error?: { message?: string } };
107
+ if (resFrame.ok === false) {
108
+ const errMsg = resFrame.error?.message ?? 'Gateway handshake failed';
109
+ reject(new Error(errMsg));
110
+ return;
111
+ }
112
+ handshakeComplete = true;
113
+ this._connected = true;
114
+ this.reconnectAttempts = 0;
115
+ this.logger.info('Connected to OpenClaw Gateway');
116
+ resolve();
117
+ return;
118
+ }
119
+
120
+ // Dispatch normal frames
121
+ this.handleFrame(frame);
122
+ });
123
+
124
+ this.ws.on('close', () => {
125
+ this._connected = false;
126
+ if (!this.closed) {
127
+ this.logger.warn('OpenClaw Gateway connection lost');
128
+ this.scheduleReconnect();
129
+ }
130
+ });
131
+
132
+ this.ws.on('error', (err: Error) => {
133
+ if (!handshakeComplete) {
134
+ reject(err);
135
+ } else {
136
+ this.logger.error('OpenClaw Gateway WS error:', err.message);
137
+ }
138
+ });
139
+ });
140
+ }
141
+
142
+ async chatSend(message: string): Promise<{ runId: string }> {
143
+ const id = randomUUID();
144
+ const idempotencyKey = randomUUID();
145
+
146
+ const payload = await this.request(id, 'chat.send', {
147
+ sessionKey: this.config.sessionKey,
148
+ message,
149
+ idempotencyKey,
150
+ }) as Record<string, unknown>;
151
+
152
+ return { runId: (payload.runId as string) ?? idempotencyKey };
153
+ }
154
+
155
+ /**
156
+ * Send chat.send and wait for the full streamed response.
157
+ * Returns the final response text.
158
+ */
159
+ async chatSendAndWait(message: string): Promise<string> {
160
+ const { runId } = await this.chatSend(message);
161
+ return this.waitForRunCompletion(runId);
162
+ }
163
+
164
+ async chatInject(message: string, label?: string): Promise<void> {
165
+ const id = randomUUID();
166
+ await this.request(id, 'chat.inject', {
167
+ sessionKey: this.config.sessionKey,
168
+ message,
169
+ ...(label ? { label } : {}),
170
+ });
171
+ }
172
+
173
+ async chatAbort(runId: string): Promise<void> {
174
+ const id = randomUUID();
175
+ await this.request(id, 'chat.abort', {
176
+ sessionKey: this.config.sessionKey,
177
+ runId,
178
+ });
179
+ }
180
+
181
+ disconnect(): void {
182
+ this.closed = true;
183
+ this._connected = false;
184
+ if (this.reconnectTimer) {
185
+ clearTimeout(this.reconnectTimer);
186
+ this.reconnectTimer = null;
187
+ }
188
+ // Reject all pending requests
189
+ for (const [, req] of this.pending) {
190
+ clearTimeout(req.timer);
191
+ req.reject(new Error('Client disconnected'));
192
+ }
193
+ this.pending.clear();
194
+ if (this.ws) {
195
+ this.ws.close();
196
+ this.ws = null;
197
+ }
198
+ }
199
+
200
+ private waitForRunCompletion(runId: string): Promise<string> {
201
+ return new Promise((resolve, reject) => {
202
+ let accumulated = '';
203
+ const timeout = setTimeout(() => {
204
+ off();
205
+ this.chatAbort(runId).catch(() => {});
206
+ reject(new Error(`Run ${runId} timed out after ${RUN_TIMEOUT_MS / 1000}s`));
207
+ }, RUN_TIMEOUT_MS);
208
+
209
+ const off = this.onChat((event) => {
210
+ if (event.runId !== runId) return;
211
+
212
+ switch (event.state) {
213
+ case 'delta':
214
+ if (typeof event.message === 'string') {
215
+ accumulated = event.message;
216
+ }
217
+ break;
218
+ case 'final':
219
+ clearTimeout(timeout);
220
+ off();
221
+ if (typeof event.message === 'string') {
222
+ resolve(event.message);
223
+ } else if (typeof event.message === 'object' && event.message !== null) {
224
+ const msg = event.message as Record<string, unknown>;
225
+ resolve((msg.content as string) ?? accumulated);
226
+ } else {
227
+ resolve(accumulated);
228
+ }
229
+ break;
230
+ case 'aborted':
231
+ clearTimeout(timeout);
232
+ off();
233
+ reject(new Error('Run was aborted'));
234
+ break;
235
+ case 'error':
236
+ clearTimeout(timeout);
237
+ off();
238
+ reject(new Error(event.errorMessage ?? 'Run failed'));
239
+ break;
240
+ }
241
+ });
242
+ });
243
+ }
244
+
245
+ private async request(id: string, method: string, params: Record<string, unknown>): Promise<unknown> {
246
+ return new Promise((resolve, reject) => {
247
+ const timer = setTimeout(() => {
248
+ this.pending.delete(id);
249
+ reject(new Error(`Request ${method} timed out`));
250
+ }, REQUEST_TIMEOUT_MS);
251
+
252
+ this.pending.set(id, { resolve, reject, timer });
253
+ this.sendRaw({ type: 'req', id, method, params });
254
+ });
255
+ }
256
+
257
+ private handleFrame(frame: Record<string, unknown>): void {
258
+ if (frame.type === 'res') {
259
+ const id = frame.id as string;
260
+ const pending = this.pending.get(id);
261
+ if (pending) {
262
+ this.pending.delete(id);
263
+ clearTimeout(pending.timer);
264
+ if (frame.ok === false) {
265
+ const err = frame.error as Record<string, unknown> | undefined;
266
+ pending.reject(new Error((err?.message as string) ?? 'Request failed'));
267
+ } else {
268
+ pending.resolve(frame.payload ?? frame.result ?? {});
269
+ }
270
+ }
271
+ return;
272
+ }
273
+
274
+ if (frame.type === 'event' && frame.event === 'chat') {
275
+ const event = frame.payload as ChatEvent;
276
+ for (const handler of this.chatHandlers) {
277
+ handler(event);
278
+ }
279
+ return;
280
+ }
281
+ }
282
+
283
+ private sendRaw(data: Record<string, unknown>): void {
284
+ if (this.ws?.readyState === WebSocket.OPEN) {
285
+ this.ws.send(JSON.stringify(data));
286
+ }
287
+ }
288
+
289
+ private scheduleReconnect(): void {
290
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
291
+ this.logger.error('Max OpenClaw Gateway reconnect attempts reached');
292
+ return;
293
+ }
294
+
295
+ const delay = Math.min(
296
+ RECONNECT_BASE_MS * Math.pow(2, this.reconnectAttempts),
297
+ RECONNECT_CAP_MS,
298
+ );
299
+ this.reconnectAttempts++;
300
+ this.logger.info(`Reconnecting to OpenClaw Gateway in ${delay}ms (attempt ${this.reconnectAttempts})`);
301
+
302
+ this.reconnectTimer = setTimeout(() => {
303
+ this.connect().catch((err) => {
304
+ this.logger.error('OpenClaw Gateway reconnect failed:', err.message);
305
+ });
306
+ }, delay);
307
+ }
308
+ }
@@ -0,0 +1,60 @@
1
+ import { RATE_LIMITS } from '@moltchats/shared';
2
+
3
+ interface Bucket {
4
+ tokens: number;
5
+ lastRefill: number;
6
+ }
7
+
8
+ export class ChannelRateLimiter {
9
+ private buckets = new Map<string, Bucket>();
10
+ private maxTokens: number;
11
+ private refillIntervalMs: number;
12
+
13
+ constructor(
14
+ maxTokens: number = RATE_LIMITS.WS_MESSAGES_PER_MIN_PER_CHANNEL,
15
+ refillIntervalMs: number = 60_000 / RATE_LIMITS.WS_MESSAGES_PER_MIN_PER_CHANNEL,
16
+ ) {
17
+ this.maxTokens = maxTokens;
18
+ this.refillIntervalMs = refillIntervalMs;
19
+ }
20
+
21
+ private getBucket(channelId: string): Bucket {
22
+ let bucket = this.buckets.get(channelId);
23
+ if (!bucket) {
24
+ bucket = { tokens: this.maxTokens, lastRefill: Date.now() };
25
+ this.buckets.set(channelId, bucket);
26
+ }
27
+ return bucket;
28
+ }
29
+
30
+ private refill(bucket: Bucket): void {
31
+ const now = Date.now();
32
+ const elapsed = now - bucket.lastRefill;
33
+ const tokensToAdd = Math.floor(elapsed / this.refillIntervalMs);
34
+ if (tokensToAdd > 0) {
35
+ bucket.tokens = Math.min(this.maxTokens, bucket.tokens + tokensToAdd);
36
+ bucket.lastRefill = now;
37
+ }
38
+ }
39
+
40
+ canSend(channelId: string): boolean {
41
+ const bucket = this.getBucket(channelId);
42
+ this.refill(bucket);
43
+ return bucket.tokens > 0;
44
+ }
45
+
46
+ async acquire(channelId: string): Promise<void> {
47
+ const bucket = this.getBucket(channelId);
48
+ this.refill(bucket);
49
+
50
+ if (bucket.tokens > 0) {
51
+ bucket.tokens--;
52
+ return;
53
+ }
54
+
55
+ // Wait until a token is available
56
+ const waitMs = this.refillIntervalMs - (Date.now() - bucket.lastRefill);
57
+ await new Promise(resolve => setTimeout(resolve, Math.max(waitMs, 100)));
58
+ return this.acquire(channelId);
59
+ }
60
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"]
8
+ }