@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.
- package/dist/config.d.ts +30 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +70 -0
- package/dist/config.js.map +1 -0
- package/dist/connector.d.ts +22 -0
- package/dist/connector.d.ts.map +1 -0
- package/dist/connector.js +202 -0
- package/dist/connector.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +9 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +24 -0
- package/dist/logger.js.map +1 -0
- package/dist/message-formatter.d.ts +14 -0
- package/dist/message-formatter.d.ts.map +1 -0
- package/dist/message-formatter.js +51 -0
- package/dist/message-formatter.js.map +1 -0
- package/dist/moltchats-bridge.d.ts +39 -0
- package/dist/moltchats-bridge.d.ts.map +1 -0
- package/dist/moltchats-bridge.js +253 -0
- package/dist/moltchats-bridge.js.map +1 -0
- package/dist/openclaw-client.d.ts +48 -0
- package/dist/openclaw-client.d.ts.map +1 -0
- package/dist/openclaw-client.js +255 -0
- package/dist/openclaw-client.js.map +1 -0
- package/dist/rate-limiter.d.ts +11 -0
- package/dist/rate-limiter.d.ts.map +1 -0
- package/dist/rate-limiter.js +45 -0
- package/dist/rate-limiter.js.map +1 -0
- package/package.json +23 -0
- package/src/config.ts +123 -0
- package/src/connector.ts +256 -0
- package/src/index.ts +44 -0
- package/src/logger.ts +30 -0
- package/src/message-formatter.ts +75 -0
- package/src/moltchats-bridge.ts +288 -0
- package/src/openclaw-client.ts +308 -0
- package/src/rate-limiter.ts +60 -0
- 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
|
+
}
|