thinkncollab-shell 0.0.4 → 0.0.6

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.
@@ -1,15 +1,14 @@
1
1
  /**
2
- * WebSocket Manager for ThinkNCollab Shell
3
- * Uses thinknsh: prefixed events for terminal-specific communication
2
+ * src/core/websocket.js — WebSocket Manager for ThinkNCollab Shell
4
3
  */
5
4
 
6
- const { io } = require('socket.io-client');
5
+ const { io } = require('socket.io-client');
7
6
  const EventEmitter = require('events');
8
7
 
9
8
  class WebSocketManager extends EventEmitter {
9
+
10
10
  constructor(config = {}) {
11
11
  super();
12
-
13
12
  this.config = {
14
13
  serverUrl: config.serverUrl || 'https://thinkncollab.com',
15
14
  reconnectAttempts: config.reconnectAttempts || 5,
@@ -19,10 +18,13 @@ class WebSocketManager extends EventEmitter {
19
18
 
20
19
  this.socket = null;
21
20
  this.connected = false;
22
- this.currentUser = null; // { userId, name, userType }
21
+ this.currentUser = null; // always { userId, name, userType }
23
22
  this.roomId = null;
24
23
  this.reconnectCount = 0;
25
24
  this.messageQueue = [];
25
+ this._authToken = null;
26
+ this._handlersSetup = false;
27
+ this._serverReady = false;
26
28
  }
27
29
 
28
30
  // ─── Connect ──────────────────────────────────────────────────────────────
@@ -30,31 +32,28 @@ class WebSocketManager extends EventEmitter {
30
32
  connect(authToken = null) {
31
33
  return new Promise((resolve, reject) => {
32
34
  try {
33
- // Connect to /thinknsh namespace — isolated from web
35
+ if (authToken) this._authToken = authToken;
36
+
34
37
  this.socket = io(this.config.serverUrl + '/thinknsh', {
35
38
  transports: ['websocket', 'polling'],
36
39
  reconnection: true,
37
40
  reconnectionAttempts: this.config.reconnectAttempts,
38
41
  reconnectionDelay: this.config.reconnectDelay,
39
- auth: authToken ? { token: authToken } : {},
42
+ auth: this._authToken ? { token: this._authToken } : {},
40
43
  });
41
44
 
42
- const timeout = setTimeout(() => reject(new Error('Connection timeout')), 10000);
45
+ const timeout = setTimeout(() => {
46
+ reject(new Error('Connection timeout'));
47
+ }, 10000);
43
48
 
44
49
  this.socket.on('connect', () => {
45
50
  clearTimeout(timeout);
46
51
  this.connected = true;
47
52
  this.reconnectCount = 0;
48
- this._authToken = authToken;
49
53
 
50
- // Announce terminal to backend — join personal notification room
54
+ // Announce user immediately on connect if we have one
51
55
  if (this.currentUser) {
52
- console.log('🖥️ Emitting thinknsh:connected for', this.currentUser.userId);
53
- this.socket.emit('thinknsh:connected', {
54
- userId: this.currentUser.userId,
55
- userName: this.currentUser.name,
56
- shellToken: authToken,
57
- });
56
+ this._announceUser();
58
57
  }
59
58
 
60
59
  this.processPendingMessages();
@@ -76,16 +75,18 @@ class WebSocketManager extends EventEmitter {
76
75
  this.emit('disconnected', { reason });
77
76
  });
78
77
 
79
- this.socket.on('reconnect', (attemptNumber) => {
78
+ this.socket.on('reconnect', () => {
80
79
  this.connected = true;
81
- this.emit('reconnected', { attemptNumber });
82
- // Rejoin room after reconnect
83
- if (this.roomId && this.currentUser) {
84
- this._emitJoin(this.roomId);
85
- }
80
+ this.emit('reconnected', {});
81
+
82
+ if (this.currentUser) this._announceUser();
83
+ if (this.roomId && this.currentUser) this._emitJoin(this.roomId);
86
84
  });
87
85
 
88
- this.setupEventHandlers(authToken);
86
+ if (!this._handlersSetup) {
87
+ this.setupEventHandlers();
88
+ this._handlersSetup = true;
89
+ }
89
90
 
90
91
  } catch (error) {
91
92
  reject(error);
@@ -93,33 +94,68 @@ class WebSocketManager extends EventEmitter {
93
94
  });
94
95
  }
95
96
 
96
- // ─── thinknsh: event handlers ─────────────────────────────────────────────
97
+ // ─── Announce user to server (thinknsh:connected) ─────────────────────────
98
+
99
+ _announceUser() {
100
+ if (!this.socket || !this.currentUser) return;
101
+ this.socket.emit('thinknsh:connected', {
102
+ userId: this.currentUser.userId,
103
+ userName: this.currentUser.name,
104
+ shellToken: this._authToken,
105
+ });
106
+ }
107
+
108
+ // ─── Set user — called right after login ──────────────────────────────────
109
+
110
+ /**
111
+ * Accepts any of these shapes and normalises to { userId, name, userType }:
112
+ * { _id, userId, name, email, userType } ← from api-client.getUser()
113
+ * { userId, name, userType } ← legacy
114
+ */
115
+ setUser(user, token = null) {
116
+ if (!user) return;
117
+
118
+ if (token) this._authToken = token;
119
+
120
+ // Normalise — support both _id and userId
121
+ const id = (user.userId || user._id || '').toString();
122
+ this.currentUser = {
123
+ userId: id,
124
+ name: user.name || user.email || 'Unknown',
125
+ userType: user.userType || user.model || user.role || 'User',
126
+ };
127
+
128
+ // If already connected, announce immediately
129
+ if (this.connected && this.socket) {
130
+ this._announceUser();
131
+ }
132
+ }
133
+
134
+ // ─── Event Handlers ───────────────────────────────────────────────────────
97
135
 
98
- setupEventHandlers(authToken) {
136
+ setupEventHandlers() {
99
137
 
100
- // Terminal ready confirmed by server
101
138
  this.socket.on('thinknsh:ready', (data) => {
139
+ this._serverReady = true;
102
140
  this.emit('ready', data);
103
141
  });
104
142
 
105
- // Room join confirmed
106
143
  this.socket.on('thinknsh:joined', (data) => {
107
144
  this.roomId = data.roomId;
145
+ if (data.history?.length) this.emit('messageHistory', data.history);
108
146
  this.emit('roomJoined', data);
109
147
  });
110
148
 
111
- // Incoming message from terminal or web
112
149
  this.socket.on('thinknsh:message', (data) => {
113
150
  this.emit('message', {
114
151
  username: data.name,
115
152
  userId: data.userId,
116
153
  message: data.message,
117
- source: data.source || 'web',
154
+ source: 'shell',
118
155
  timestamp: data.timestamp,
119
156
  });
120
157
  });
121
158
 
122
- // Also listen to standard chat-message (from web users)
123
159
  this.socket.on('chat-message', (data) => {
124
160
  this.emit('message', {
125
161
  username: data.name,
@@ -130,60 +166,55 @@ class WebSocketManager extends EventEmitter {
130
166
  });
131
167
  });
132
168
 
133
- // User joined from terminal
134
169
  this.socket.on('thinknsh:userJoined', (data) => {
135
170
  this.emit('userJoined', { username: data.name, source: 'shell' });
136
171
  });
137
172
 
138
- // User joined from web
173
+ this.socket.on('thinknsh:userLeft', (data) => {
174
+ this.emit('userLeft', { username: data.name, source: 'shell' });
175
+ });
176
+
139
177
  this.socket.on('user-joined', (name) => {
140
178
  this.emit('userJoined', { username: name, source: 'web' });
141
179
  });
142
180
 
143
- // User left
144
181
  this.socket.on('user-left', (name) => {
145
- this.emit('userLeft', { username: name });
182
+ this.emit('userLeft', { username: name, source: 'web' });
146
183
  });
147
184
 
148
- // User list updated
149
185
  this.socket.on('user-list', (users) => {
150
186
  this.emit('userList', { users });
151
187
  });
152
188
 
153
- // Notifications from NotificationEngine
154
- this.socket.on('new-notification', (notification) => {
155
- this.emit('notification', {
156
- type: notification.type || 'info',
157
- title: notification.type || 'Notification',
158
- message: notification.message || '',
159
- meta: notification.meta || {},
160
- });
161
- });
162
-
163
- // Terminal status (online/offline) — website sends this
164
- this.socket.on('thinknsh:status', (data) => {
165
- this.emit('shellStatus', data);
189
+ this.socket.on('thinknsh:history', (data) => {
190
+ this.emit('messageHistory', data.messages?.length ? data.messages : []);
166
191
  });
167
192
 
168
- // ── new-notification from NotificationEngine → forward to TCP window ──
169
193
  this.socket.on('new-notification', (data) => {
170
- // This goes to shell.js pushNotification → TCP → notification window
171
194
  this.emit('notification', {
172
- type: data.type || 'notification',
173
- level: data.type || 'info',
174
- title: data.type || 'Notification',
175
- message: data.message || '',
195
+ type: data.type || 'info',
196
+ level: data.type || 'info',
197
+ title: data.title || 'Notification',
198
+ message: data.message || '',
199
+ meta: data.meta || {},
176
200
  taskTitle: data.taskTitle || data.meta?.taskTitle || '',
177
201
  assignedBy: data.assignedBy || data.meta?.assignedBy || '',
178
202
  });
179
203
  });
180
204
 
181
- // Notification list response
205
+ this.socket.on('room:activity', (data) => {
206
+ this.emit('roomActivity', data);
207
+ });
208
+
209
+ this.socket.on('thinknsh:status', (data) => {
210
+ this.emit('shellStatus', data);
211
+ });
212
+
182
213
  this.socket.on('thinknsh:notifications', (data) => {
183
214
  this.emit('notificationList', data.notifications);
184
215
  });
185
216
 
186
- // Error from server
217
+ // Server errors — emit as 'serverError' so shell.js can handle separately
187
218
  this.socket.on('thinknsh:error', (data) => {
188
219
  this.emit('serverError', data);
189
220
  });
@@ -193,44 +224,64 @@ class WebSocketManager extends EventEmitter {
193
224
  });
194
225
  }
195
226
 
196
- // ─── Set user (call after login) ──────────────────────────────────────────
197
-
198
- setUser(user, token = null) {
199
- this.currentUser = user;
200
- // If already connected, announce now
201
- if (this.connected && this.socket) {
202
- this.socket.emit('thinknsh:connected', {
203
- userId: user.userId,
204
- shellToken: token,
205
- });
206
- }
207
- }
208
-
209
227
  // ─── Join room ────────────────────────────────────────────────────────────
210
228
 
211
229
  joinRoom(roomId) {
212
230
  return new Promise((resolve, reject) => {
213
- if (!this.connected || !this.socket) return reject(new Error('Not connected'));
214
- if (!this.currentUser) return reject(new Error('Not logged in'));
215
-
216
- const timeout = setTimeout(() => reject(new Error('Join timeout')), 5000);
217
-
218
- this.socket.once('thinknsh:joined', (data) => {
231
+ if (!this.connected || !this.socket)
232
+ return reject(new Error('Not connected to server'));
233
+ if (!this.currentUser)
234
+ return reject(new Error('Not logged in — run: login'));
235
+
236
+ const timeout = setTimeout(() => {
237
+ this.socket.off('thinknsh:joined', onJoined);
238
+ this.socket.off('thinknsh:error', onError);
239
+ this.socket.off('thinknsh:ready', onReady);
240
+ reject(new Error('Join timeout'));
241
+ }, 8000);
242
+
243
+ const onJoined = (data) => {
219
244
  clearTimeout(timeout);
245
+ this.socket.off('thinknsh:error', onError);
220
246
  this.roomId = data.roomId;
221
247
  resolve(data);
222
- });
248
+ };
249
+
250
+ // onReady declared at top so onError can reference it regardless of branch
251
+ let onReady = null;
223
252
 
224
- this._emitJoin(roomId);
253
+ const onError = (data) => {
254
+ clearTimeout(timeout);
255
+ this.socket.off('thinknsh:joined', onJoined);
256
+ if (onReady) this.socket.off('thinknsh:ready', onReady);
257
+ reject(new Error(data.message || 'Join error'));
258
+ };
259
+
260
+ const doJoin = () => {
261
+ this.socket.once('thinknsh:joined', onJoined);
262
+ this.socket.once('thinknsh:error', onError);
263
+ this._emitJoin(roomId);
264
+ };
265
+
266
+ if (this._serverReady) {
267
+ doJoin();
268
+ } else {
269
+ onReady = () => {
270
+ this._serverReady = true;
271
+ doJoin();
272
+ };
273
+ this.socket.once('thinknsh:ready', onReady);
274
+ }
225
275
  });
226
276
  }
227
277
 
228
278
  _emitJoin(roomId) {
279
+ if (!this.socket || !this.currentUser) return;
229
280
  this.socket.emit('thinknsh:join', {
230
281
  roomId,
231
282
  userId: this.currentUser.userId,
232
283
  name: this.currentUser.name,
233
- userType: this.currentUser.userType || 'User',
284
+ userType: this.currentUser.userType,
234
285
  });
235
286
  }
236
287
 
@@ -258,20 +309,43 @@ class WebSocketManager extends EventEmitter {
258
309
  roomId: this.roomId,
259
310
  userId: this.currentUser.userId,
260
311
  name: this.currentUser.name,
261
- userType: this.currentUser.userType || 'User',
312
+ userType: this.currentUser.userType,
262
313
  message,
263
314
  });
264
315
 
265
316
  return Promise.resolve({ success: true });
266
317
  }
267
318
 
268
- // ─── Get unread notifications ─────────────────────────────────────────────
319
+ // ─── History ──────────────────────────────────────────────────────────────
320
+
321
+ getHistory(limit = 30, skip = 0) {
322
+ if (!this.connected || !this.socket || !this.roomId) return;
323
+ this.socket.emit('thinknsh:getHistory', { roomId: this.roomId, limit, skip });
324
+ }
325
+
326
+ // ─── Notifications ────────────────────────────────────────────────────────
269
327
 
270
328
  getNotifications() {
271
329
  if (!this.connected || !this.currentUser) return;
272
330
  this.socket.emit('thinknsh:getNotifications', { userId: this.currentUser.userId });
273
331
  }
274
332
 
333
+ sendNotification(notification, target = 'room') {
334
+ if (!this.connected || !this.socket)
335
+ return Promise.reject(new Error('Not connected'));
336
+
337
+ this.socket.emit('notification:send', {
338
+ type: notification.type || 'info',
339
+ title: notification.title || 'Notification',
340
+ message: notification.message,
341
+ data: notification.data || {},
342
+ target: target === 'room' ? this.roomId : target,
343
+ timestamp: new Date().toISOString()
344
+ });
345
+
346
+ return Promise.resolve({ success: true });
347
+ }
348
+
275
349
  markNotificationsRead(notificationIds = []) {
276
350
  if (!this.connected || !this.currentUser) return;
277
351
  this.socket.emit('thinknsh:markRead', {
@@ -282,23 +356,8 @@ class WebSocketManager extends EventEmitter {
282
356
 
283
357
  // ─── Helpers ──────────────────────────────────────────────────────────────
284
358
 
285
- joinUserRoom(userId) {
286
- if (!this.socket || !userId) return;
287
- this.socket.emit('thinknsh:connected', {
288
- userId,
289
- userName: this.currentUser?.name || '',
290
- shellToken: this._token || null,
291
- });
292
- }
293
-
294
- sendTyping() {} // add later if needed
295
-
296
- ping() {
297
- if (this.connected && this.socket) this.socket.emit('ping');
298
- }
299
-
300
359
  processPendingMessages() {
301
- if (this.messageQueue.length > 0 && this.roomId && this.currentUser) {
360
+ if (this.messageQueue.length && this.roomId && this.currentUser) {
302
361
  this.messageQueue.forEach(msg => this.sendMessage(msg));
303
362
  this.messageQueue = [];
304
363
  }
@@ -306,18 +365,24 @@ class WebSocketManager extends EventEmitter {
306
365
 
307
366
  disconnect() {
308
367
  if (this.socket) {
368
+ this.socket.removeAllListeners();
309
369
  this.socket.disconnect();
310
- this.socket = null;
311
- this.connected = false;
312
- this.currentUser = null;
313
- this.roomId = null;
370
+ this.socket = null;
371
+ this.connected = false;
372
+ this.currentUser = null;
373
+ this.roomId = null;
374
+ this._handlersSetup = false;
375
+ this._serverReady = false;
376
+ this._serverReady = false;
314
377
  }
315
378
  }
316
379
 
317
380
  isConnected() { return this.connected && !!this.socket?.connected; }
318
381
  getCurrentRoom() { return this.roomId; }
319
382
  getSocketId() { return this.socket?.id || null; }
320
- getParticipants() { return Promise.resolve([]); }
383
+ getParticipants(){ return Promise.resolve([]); }
384
+ sendTyping() {}
385
+ ping() { if (this.connected && this.socket) this.socket.emit('ping'); }
321
386
  }
322
387
 
323
388
  module.exports = WebSocketManager;