nodebb-plugin-admin-chats 1.0.2 → 2.0.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.
@@ -0,0 +1,3 @@
1
+ {
2
+ "kiroAgent.configureMCP": "Disabled"
3
+ }
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # Super Admin Chat Control for NodeBB
2
+
3
+ A professional administration plugin for NodeBB that allows administrators to monitor, manage, and interact with any chat room on the forum, regardless of membership.
4
+
5
+ ## Main Features
6
+
7
+ * **Global Chat Access**: Administrators can view any private or group conversation.
8
+ * **Management Privileges**: Grants admins the ability to edit or delete any message in any room.
9
+ * **Admin Room Locking**: Administrators can lock any chat room so only admins can continue replying.
10
+ * **Profile Integration**: Adds a "View Chats" button to the user profile menu for administrators to quickly audit a user's interactions.
11
+ * **Non-Intrusive Monitoring**: Admins can load and view room history without being added as permanent members, maintaining a clean member list.
12
+ * **UI Enhancements**: Replaces generic "no chats" messages with helpful instructions for admins and supports both English and Hebrew interfaces.
13
+
14
+ ## Technical Overview
15
+
16
+ ### Plugin Information
17
+ * **Name**: Super Admin Chat Control
18
+ * **ID**: `nodebb-plugin-admin-chats`
19
+ * **Compatibility**: NodeBB versions `^3.0.0` or `^4.0.0`
20
+
21
+ ### Implemented Hooks
22
+ The plugin utilizes several filters to elevate administrator permissions:
23
+
24
+ | Hook | Method | Functionality |
25
+ | :--- | :--- | :--- |
26
+ | `filter:messaging.isRoomOwner` | `isRoomOwner` | Treats admins as owners for permission checks. |
27
+ | `filter:messaging.canReply` | `canReply` | Allows admins to send messages in any room and blocks regular users in admin-locked rooms. |
28
+ | `filter:messaging.canGetMessages` | `canGetMessages` | Allows admins to fetch chat history. |
29
+ | `filter:messaging.loadRoom` | `onLoadRoom` | Manages room loading logic for non-member admins and injects lock state. |
30
+ | `filter:user.accountMenu` | `addProfileLink` | Injects the admin link into the profile menu. |
31
+
32
+ ### Function Overrides
33
+ The plugin overrides core messaging functions to ensure full administrative control:
34
+ * **Edit/Delete**: `Messaging.canEdit` and `Messaging.canDelete` are bypassed for administrators.
35
+ * **Visibility**: `Messaging.canViewMessage` is modified to always return `true` for admins.
36
+ * **Room Locking**: `Messaging.canReply` is wrapped so non-admins cannot post in rooms locked by an admin.
37
+
38
+ ## Installation
39
+
40
+ 1. Install the plugin via terminal:
41
+ ```bash
42
+ npm install nodebb-plugin-admin-chats
43
+ ```
44
+ 2. Activate the plugin in the NodeBB Admin Control Panel (ACP).
45
+ 3. Restart NodeBB.
46
+
47
+ ## Client-Side Support
48
+ The plugin includes a `client.js` script that:
49
+ * Detects the system language (English or Hebrew).
50
+ * Dynamically injects action buttons into the account sub-links menu.
51
+ * Adds an admin lock/unlock control to the chat UI.
52
+ * Disables the composer for non-admin users when a room is locked.
53
+ * Cleans up the chat UI empty states for a better admin experience.
54
+
55
+ ---
56
+ *Developed by [palmoni5](https://github.com/palmoni5/nodebb-plugin-admin-chats).*
package/client.js CHANGED
@@ -1,62 +1,387 @@
1
- $(document).ready(function() {
2
-
3
- function isEnglishSystem() {
4
- return $('html').attr('lang') && $('html').attr('lang').startsWith('en');
5
- }
6
-
7
- function replaceAdminEmptyStateText() {
8
- if (!app.user.isAdmin) return;
9
-
10
- $('span.text-muted.text-sm').each(function() {
11
- const currentText = $(this).text().trim();
12
-
13
- if (currentText.includes("אין לכם צ'אטים פעילים") || currentText === "אין לכם צ'אטים פעילים.") {
14
- $(this).text("אנא בחר צ'אט מסרגל הצד.");
15
- $(this).removeClass('text-muted');
16
- }
17
-
18
- if (currentText.includes("You have no active chats") || currentText === "You have no active chats.") {
19
- $(this).text("Please select a chat from the sidebar.");
20
- $(this).removeClass('text-muted');
21
- }
22
- });
23
- }
24
-
25
- $(window).on('action:ajaxify.end', function(ev, data) {
26
-
27
- if (app.user.isAdmin && ajaxify.data.template.name.startsWith('account/')) {
28
- const userSlug = ajaxify.data.userslug || (ajaxify.data.user && ajaxify.data.user.userslug);
29
-
30
- if (userSlug) {
31
- const buttonText = isEnglishSystem() ? "View Chats" : "צפיה בצ'אטים";
32
-
33
- const relativePath = config.relative_path || '';
34
-
35
- const btnHtml = `
36
- <li role="presentation">
37
- <a class="dropdown-item rounded-1 d-flex align-items-center gap-2" href="${relativePath}/user/${userSlug}/chats" role="menuitem">
38
- <i class="far fa-fw fa-comments"></i>
39
- <span>${buttonText}</span>
40
- </a>
41
- </li>
42
- <li role="presentation" class="dropdown-divider"></li>
43
- `;
44
- const menu = $('.account-sub-links');
45
- if (menu.length) {
46
- menu.find(`a[href*="/user/${userSlug}/chats"]`).parent().remove();
47
- menu.prepend(btnHtml);
48
- }
49
- }
50
- }
51
-
52
- if (data.url.match(/^user\/.+\/chats/) || data.url === 'chats') {
53
- replaceAdminEmptyStateText();
54
- setTimeout(replaceAdminEmptyStateText, 500);
55
- }
56
- });
57
-
58
- $(window).on('action:chat.loaded', replaceAdminEmptyStateText);
59
- $(window).on('action:chat.closed', function() {
60
- setTimeout(replaceAdminEmptyStateText, 200);
61
- });
62
- });
1
+ $(document).ready(function() {
2
+ const LOCK_PREFIX = '[admin-chat-lock]';
3
+ const roomStateCache = new Map();
4
+ const TEXT = {
5
+ he: {
6
+ lockBanner: '🔒 חדר זה ננעל ע"י המנהלים.',
7
+ menuLock: 'נעל חדר',
8
+ menuRelease: 'שחרר נעילה',
9
+ updateError: 'לא ניתן לעדכן את נעילת החדר.',
10
+ viewChats: "צפיה בצ'אטים",
11
+ emptyState: "אנא בחר צ'אט מסרגל הצד.",
12
+ lockedAction: 'לא ניתן לבצע פעולה זו בחדר נעול',
13
+ },
14
+ en: {
15
+ lockBanner: '🔒 This room was locked by the administrators.',
16
+ menuLock: 'Lock Room',
17
+ menuRelease: 'Release Lock',
18
+ updateError: 'Unable to update room lock.',
19
+ viewChats: 'View Chats',
20
+ emptyState: 'Please select a chat from the sidebar.',
21
+ lockedAction: 'This action cannot be performed in a locked room.',
22
+ },
23
+ };
24
+
25
+ function isEnglishSystem() {
26
+ return $('html').attr('lang') && $('html').attr('lang').startsWith('en');
27
+ }
28
+
29
+ function t(key) {
30
+ const dict = isEnglishSystem() ? TEXT.en : TEXT.he;
31
+ return dict[key];
32
+ }
33
+
34
+ function replaceAdminEmptyStateText() {
35
+ if (!app.user.isAdmin) {
36
+ return;
37
+ }
38
+
39
+ $('span.text-muted.text-sm').each(function() {
40
+ const currentText = $(this).text().trim();
41
+ if (
42
+ currentText.includes("אין לכם צ'אטים פעילים") ||
43
+ currentText === "אין לכם צ'אטים פעילים." ||
44
+ currentText.includes('You have no active chats') ||
45
+ currentText === 'You have no active chats.'
46
+ ) {
47
+ $(this).text(t('emptyState'));
48
+ $(this).removeClass('text-muted');
49
+ }
50
+ });
51
+ }
52
+
53
+ function getChatWindows() {
54
+ return $('[component="chat/message/window"]');
55
+ }
56
+
57
+ function getWindowRoomId($window) {
58
+ const roomId = $window.find('[component="chat/messages"]').first().attr('data-roomid') ||
59
+ $window.closest('.chat-modal').attr('data-roomid') ||
60
+ (ajaxify && ajaxify.data && ajaxify.data.roomId);
61
+ return parseInt(roomId, 10) || 0;
62
+ }
63
+
64
+ function getCachedRoomData(roomId) {
65
+ const cached = roomStateCache.get(String(roomId));
66
+ if (!cached) {
67
+ return null;
68
+ }
69
+ if (Date.now() - cached.timestamp > 10000) {
70
+ roomStateCache.delete(String(roomId));
71
+ return null;
72
+ }
73
+ return cached.data;
74
+ }
75
+
76
+ function setCachedRoomData(roomId, data) {
77
+ roomStateCache.set(String(roomId), {
78
+ data,
79
+ timestamp: Date.now(),
80
+ });
81
+ }
82
+
83
+ async function fetchRoomData(roomId) {
84
+ if (!roomId) {
85
+ return null;
86
+ }
87
+
88
+ const currentRoom = ajaxify && ajaxify.data && (ajaxify.data.room || ajaxify.data);
89
+ if (currentRoom && parseInt(currentRoom.roomId, 10) === roomId && currentRoom.adminChatLock) {
90
+ setCachedRoomData(roomId, currentRoom);
91
+ return currentRoom;
92
+ }
93
+
94
+ const cached = getCachedRoomData(roomId);
95
+ if (cached) {
96
+ return cached;
97
+ }
98
+
99
+ const response = await fetch(`${config.relative_path || ''}/api/chats/${roomId}`, {
100
+ headers: {
101
+ 'x-csrf-token': config.csrf_token,
102
+ },
103
+ credentials: 'same-origin',
104
+ });
105
+ if (!response.ok) {
106
+ return null;
107
+ }
108
+
109
+ const payload = await response.json();
110
+ const roomData = payload && payload.roomId ? payload : (payload && payload.room ? payload.room : null);
111
+ if (roomData) {
112
+ setCachedRoomData(roomId, roomData);
113
+ }
114
+ return roomData;
115
+ }
116
+
117
+ function isLockedForUser(roomData) {
118
+ return !!(roomData && roomData.adminChatLock && roomData.adminChatLock.isLocked && !(app.user && app.user.isAdmin));
119
+ }
120
+
121
+ function setComposerHidden($window, hidden) {
122
+ $window.find('[component="chat/composer"]').each(function() {
123
+ const $composer = $(this);
124
+ if (hidden) {
125
+ $composer.addClass('hidden').hide();
126
+ } else {
127
+ $composer.removeClass('hidden').show();
128
+ }
129
+ });
130
+
131
+ $window.find('[component="chat/input"], [component="chat/send"], button[data-action="send"], textarea.chat-input')
132
+ .prop('disabled', hidden)
133
+ .attr('disabled', hidden ? 'disabled' : null);
134
+ }
135
+
136
+ function renderLockBanner($window, hidden) {
137
+ $window.find('.admin-chat-lock-banner').remove();
138
+ if (!hidden) {
139
+ return;
140
+ }
141
+
142
+ const target = $window.find('[component="chat/messages"]').first();
143
+ if (!target.length) {
144
+ return;
145
+ }
146
+
147
+ const positionStyle = isEnglishSystem() ? 'float:right; clear:both;' : 'float:left; clear:both;';
148
+ target.prepend(`<div class="admin-chat-lock-banner alert alert-warning mb-2 text-start" style="${positionStyle} max-width: fit-content;">${t('lockBanner')}</div>`);
149
+ }
150
+
151
+ function updateLockedActionVisibility($window, hidden) {
152
+ [
153
+ '[data-action="reply"]',
154
+ '[data-action="edit"]',
155
+ '[data-action="delete"]',
156
+ '[data-action="restore"]',
157
+ '[data-action="kick"]',
158
+ '[data-action="toggleOwner"]'
159
+ ].forEach(function(selector) {
160
+ $window.find(selector).toggleClass('hidden', hidden).toggle(!hidden);
161
+ });
162
+
163
+ $window.find('[component="chat/controlsToggle"]').closest('.dropdown').toggleClass('hidden', hidden).toggle(!hidden);
164
+ $window.find('[component="chat/manage/user/add/search"], [component="chat/manage/user/list/search"], [component="chat/manage/save"]')
165
+ .toggleClass('hidden', hidden)
166
+ .toggle(!hidden)
167
+ .prop('disabled', hidden)
168
+ .attr('disabled', hidden ? 'disabled' : null);
169
+ $window.find('[component="chat/manage-modal"] .form-text, [component="chat/manage-modal"] .text-danger')
170
+ .toggleClass('hidden', hidden);
171
+ }
172
+
173
+ function normalizeLockMessages($window) {
174
+ $window.find('[component="chat/system-message"] > div').each(function() {
175
+ const $el = $(this);
176
+ const text = $el.text().trim();
177
+ if (!text.includes(LOCK_PREFIX.slice(1, -1)) && !text.includes('admin-chat-lock')) {
178
+ return;
179
+ }
180
+ $el.text(t('lockBanner'));
181
+ });
182
+ }
183
+
184
+ function getRoomMenuTargets($window) {
185
+ const scoped = $window.find('[component="chat/controls"]');
186
+ if (scoped.length) {
187
+ return scoped;
188
+ }
189
+ return $window.find('[component="chat/header"], .chat-header, .modal-header, [component="chat/nav"]').find('.dropdown-menu');
190
+ }
191
+
192
+ function renderAdminLockControl($window, roomData) {
193
+ $window.find('.admin-chat-lock-toggle-item, .admin-chat-lock-divider, .admin-chat-lock-item-wrap').remove();
194
+
195
+ if (!app.user.isAdmin || !roomData || !roomData.roomId) {
196
+ return;
197
+ }
198
+
199
+ const menus = getRoomMenuTargets($window);
200
+ if (!menus.length) {
201
+ return;
202
+ }
203
+
204
+ const lockData = roomData.adminChatLock || {};
205
+ const isLocked = !!lockData.isLocked;
206
+ const itemText = isLocked ? t('menuRelease') : t('menuLock');
207
+ const iconClass = isLocked ? 'fa-lock-open' : 'fa-lock';
208
+ const menuItemHtml = `
209
+ <li role="presentation" class="admin-chat-lock-item-wrap">
210
+ <a href="#" role="menuitem" class="dropdown-item rounded-1 d-flex align-items-center gap-2 admin-chat-lock-toggle-item" data-room-id="${roomData.roomId}" data-locked="${isLocked}">
211
+ <i class="fa fa-fw ${iconClass}"></i>
212
+ <span>${itemText}</span>
213
+ </a>
214
+ </li>
215
+ <li role="presentation" class="dropdown-divider admin-chat-lock-divider"></li>
216
+ `;
217
+
218
+ menus.each(function() {
219
+ const $menu = $(this);
220
+ if ($menu.find('.admin-chat-lock-toggle-item').length) {
221
+ return;
222
+ }
223
+ $menu.prepend(menuItemHtml);
224
+ });
225
+ }
226
+
227
+ async function applyUiToWindow($window) {
228
+ const roomId = getWindowRoomId($window);
229
+ if (!roomId) {
230
+ return;
231
+ }
232
+
233
+ const roomData = await fetchRoomData(roomId);
234
+ if (!roomData) {
235
+ return;
236
+ }
237
+
238
+ const hidden = isLockedForUser(roomData);
239
+ setComposerHidden($window, hidden);
240
+ updateLockedActionVisibility($window, hidden);
241
+ renderLockBanner($window, hidden);
242
+ renderAdminLockControl($window, roomData);
243
+ normalizeLockMessages($window);
244
+ }
245
+
246
+ async function refreshChatUi() {
247
+ replaceAdminEmptyStateText();
248
+ const windows = getChatWindows();
249
+ await Promise.all(windows.map(function() {
250
+ return applyUiToWindow($(this));
251
+ }).get());
252
+ }
253
+
254
+ async function toggleRoomLock(roomId, nextState) {
255
+ const response = await fetch(`${config.relative_path || ''}/api/admin-chats/${roomId}/lock`, {
256
+ method: 'POST',
257
+ headers: {
258
+ 'Content-Type': 'application/json',
259
+ 'x-csrf-token': config.csrf_token,
260
+ },
261
+ body: JSON.stringify({ locked: nextState }),
262
+ credentials: 'same-origin',
263
+ });
264
+
265
+ if (!response.ok) {
266
+ throw new Error('Unable to update room lock');
267
+ }
268
+
269
+ return await response.json();
270
+ }
271
+
272
+ $(window).on('action:ajaxify.end', function(ev, data) {
273
+ const templateName = ajaxify && ajaxify.data && ajaxify.data.template ? ajaxify.data.template.name : '';
274
+
275
+ if (app.user.isAdmin && templateName.startsWith('account/')) {
276
+ const userSlug = ajaxify.data.userslug || (ajaxify.data.user && ajaxify.data.user.userslug);
277
+
278
+ if (userSlug) {
279
+ const relativePath = config.relative_path || '';
280
+ const btnHtml = `
281
+ <li role="presentation">
282
+ <a class="dropdown-item rounded-1 d-flex align-items-center gap-2" href="${relativePath}/user/${userSlug}/chats" role="menuitem">
283
+ <i class="far fa-fw fa-comments"></i>
284
+ <span>${t('viewChats')}</span>
285
+ </a>
286
+ </li>
287
+ <li role="presentation" class="dropdown-divider"></li>
288
+ `;
289
+ const menu = $('.account-sub-links');
290
+ if (menu.length) {
291
+ menu.find(`a[href*="/user/${userSlug}/chats"]`).parent().remove();
292
+ menu.prepend(btnHtml);
293
+ }
294
+ }
295
+ }
296
+
297
+ const url = data && data.url ? data.url : '';
298
+ if (url.match(/^user\/.+\/chats/) || url === 'chats') {
299
+ refreshChatUi();
300
+ setTimeout(refreshChatUi, 500);
301
+ setTimeout(refreshChatUi, 1200);
302
+ }
303
+ });
304
+
305
+ $(window).on('action:chat.loaded', function() {
306
+ refreshChatUi();
307
+ setTimeout(refreshChatUi, 200);
308
+ setTimeout(refreshChatUi, 1000);
309
+ });
310
+
311
+ $(window).on('action:chat.closed', function() {
312
+ setTimeout(refreshChatUi, 200);
313
+ });
314
+
315
+ $(window).on('action:chat.onMessagesAddedToDom action:chat.edited action:chat.renamed', function() {
316
+ setTimeout(refreshChatUi, 0);
317
+ });
318
+
319
+ $(document).on('click', '.admin-chat-lock-toggle-item', async function(ev) {
320
+ ev.preventDefault();
321
+
322
+ const $button = $(this);
323
+ const roomId = parseInt($button.attr('data-room-id'), 10);
324
+ const isLocked = $button.attr('data-locked') === 'true';
325
+
326
+ if (!roomId) {
327
+ return;
328
+ }
329
+
330
+ $button.addClass('disabled').attr('aria-disabled', 'true');
331
+
332
+ try {
333
+ const result = await toggleRoomLock(roomId, !isLocked);
334
+ const currentRoom = ajaxify && ajaxify.data && (ajaxify.data.room || ajaxify.data);
335
+ if (currentRoom && parseInt(currentRoom.roomId, 10) === roomId) {
336
+ currentRoom.adminChatLock = result.lockData;
337
+ currentRoom.canReply = !result.lockData.isLocked || (app.user && app.user.isAdmin);
338
+ currentRoom.showUserInput = currentRoom.canReply;
339
+ if (ajaxify.data.room) {
340
+ ajaxify.data.room.adminChatLock = result.lockData;
341
+ ajaxify.data.room.canReply = currentRoom.canReply;
342
+ ajaxify.data.room.showUserInput = currentRoom.showUserInput;
343
+ }
344
+ }
345
+
346
+ const cached = getCachedRoomData(roomId) || { roomId };
347
+ cached.adminChatLock = result.lockData;
348
+ cached.canReply = !result.lockData.isLocked || (app.user && app.user.isAdmin);
349
+ cached.showUserInput = cached.canReply;
350
+ setCachedRoomData(roomId, cached);
351
+ await refreshChatUi();
352
+ } catch (err) {
353
+ app.alertError(t('updateError'));
354
+ $button.removeClass('disabled').removeAttr('aria-disabled');
355
+ }
356
+ });
357
+
358
+ // Intercept error alerts to translate locked action errors
359
+ if (app && app.alertError) {
360
+ const originalAlertError = app.alertError;
361
+ app.alertError = function(error) {
362
+ let message = error;
363
+ if (typeof error === 'object' && error.message) {
364
+ message = error.message;
365
+ }
366
+ if (typeof message === 'string' && message.includes('admin-chats:errors.lockedAction')) {
367
+ message = t('lockedAction');
368
+ }
369
+ return originalAlertError.call(this, message);
370
+ };
371
+ }
372
+
373
+ // Intercept and translate error messages from API
374
+ if (app.alertError && !app.alertError._adminChatsWrapped) {
375
+ const originalAlertError = app.alertError;
376
+ app.alertError = function(message) {
377
+ if (typeof message === 'string') {
378
+ // Check if message contains translation key
379
+ if (message.includes('[[admin-chats:errors.lockedAction]]') || message.includes('errors.lockedAction')) {
380
+ message = t('lockedAction');
381
+ }
382
+ }
383
+ return originalAlertError.call(this, message);
384
+ };
385
+ app.alertError._adminChatsWrapped = true;
386
+ }
387
+ });
package/library.js CHANGED
@@ -1,166 +1,450 @@
1
- 'use strict';
2
-
3
- const User = require.main.require('./src/user');
4
- const Messaging = require.main.require('./src/messaging');
5
- const db = require.main.require('./src/database');
6
- const _ = require('lodash');
7
-
8
- const plugin = {};
9
-
10
- plugin.init = async function (params) {
11
-
12
- overrideMessagingFunctions();
13
- };
14
-
15
-
16
- plugin.addProfileLink = async function (data) {
17
- try {
18
- const userSlug = data.user ? data.user.userslug : (data.userData ? data.userData.userslug : null);
19
-
20
- if (userSlug) {
21
- data.links.push({
22
- id: 'admin-view-chats',
23
- route: 'user/' + userSlug + '/chats',
24
- icon: 'fa-comments',
25
- name: 'צפה בצ\'אטים',
26
- visibility: {
27
- self: false,
28
- other: true,
29
- moderator: false,
30
- globalMod: false,
31
- admin: true,
32
- }
33
- });
34
- }
35
- } catch (e) {
36
- console.error('[Super Admin Chats] Error adding profile link:', e);
37
- }
38
- return data;
39
- };
40
-
41
- plugin.isRoomOwner = async function (payload) {
42
- const isAdmin = await User.isAdministrator(payload.uid);
43
- if (isAdmin) {
44
- payload.isOwner = true;
45
- }
46
- return payload;
47
- };
48
-
49
- plugin.isUserInRoom = async function (payload) {
50
- const isAdmin = await User.isAdministrator(payload.uid);
51
- if (isAdmin) {
52
- payload.inRoom = true;
53
- }
54
- return payload;
55
- };
56
-
57
- plugin.canReply = async function (payload) {
58
- const isAdmin = await User.isAdministrator(payload.uid);
59
- if (isAdmin) {
60
- payload.canReply = true;
61
- }
62
- return payload;
63
- };
64
-
65
- plugin.canGetMessages = async function (payload) {
66
- const isAdmin = await User.isAdministrator(payload.callerUid);
67
- if (isAdmin) {
68
- payload.canGet = true;
69
- }
70
- return payload;
71
- };
72
-
73
- plugin.canGetRecentChats = async function (payload) {
74
- const isAdmin = await User.isAdministrator(payload.callerUid);
75
- if (isAdmin) {
76
- payload.canGet = true;
77
- }
78
- return payload;
79
- };
80
-
81
- plugin.canGetPublicChats = async function (payload) {
82
- const isAdmin = await User.isAdministrator(payload.callerUid);
83
- if (isAdmin) {
84
- payload.canGet = true;
85
- }
86
- return payload;
87
- };
88
-
89
- plugin.onLoadRoom = async function (payload) {
90
- const { uid, room } = payload;
91
- const isAdmin = await User.isAdministrator(uid);
92
- if (!isAdmin || !room) return payload;
93
-
94
- const isOfficialMember = await db.isSortedSetMember(`chat:room:${room.roomId}:uids`, uid);
95
-
96
- if (!isOfficialMember) {
97
- await db.sortedSetRemove(`chat:room:${room.roomId}:uids:online`, uid);
98
-
99
- if (Array.isArray(room.users)) {
100
- room.users = room.users.filter(user => user && parseInt(user.uid, 10) !== parseInt(uid, 10));
101
- }
102
- if (room.userCount > 0) {
103
- room.userCount -= 1;
104
- }
105
- room.groupChat = room.userCount > 2;
106
-
107
- const allMids = await db.getSortedSetRevRange(`chat:room:${room.roomId}:mids`, 0, 49);
108
-
109
- if (allMids.length > 0) {
110
- const messages = await Messaging.getMessagesData(allMids, uid, room.roomId, false);
111
- const messageCount = await db.getObjectField(`chat:room:${room.roomId}`, 'messageCount');
112
- const count = parseInt(messageCount, 10) || 0;
113
-
114
- messages.forEach((msg, index) => {
115
- msg.index = count - index - 1;
116
- });
117
-
118
- room.messages = messages.reverse();
119
-
120
- room.messages.forEach((msg, index) => {
121
- if (index === 0) {
122
- msg.newSet = true;
123
- } else {
124
- const prevMsg = room.messages[index - 1];
125
- msg.newSet = parseInt(msg.fromuid, 10) !== parseInt(prevMsg.fromuid, 10);
126
- }
127
- });
128
- }
129
-
130
- room.isAdmin = true;
131
- room.isOwner = true;
132
- } else {
133
- room.isAdmin = true;
134
- }
135
- return payload;
136
- };
137
-
138
- function overrideMessagingFunctions() {
139
-
140
- const originalCanEdit = Messaging.canEdit;
141
- const originalCanDelete = Messaging.canDelete;
142
- const originalCanViewMessage = Messaging.canViewMessage;
143
-
144
- Messaging.canEdit = async function (messageId, uid) {
145
- if (await User.isAdministrator(uid)) {
146
- return true;
147
- }
148
- return await originalCanEdit(messageId, uid);
149
- };
150
-
151
- Messaging.canDelete = async function (messageId, uid) {
152
- if (await User.isAdministrator(uid)) {
153
- return true;
154
- }
155
- return await originalCanDelete(messageId, uid);
156
- };
157
-
158
- Messaging.canViewMessage = async function (mids, roomId, uid) {
159
- if (await User.isAdministrator(uid)) {
160
- return Array.isArray(mids) ? mids.map(() => true) : true;
161
- }
162
- return await originalCanViewMessage(mids, roomId, uid);
163
- };
164
- }
165
-
166
- module.exports = plugin;
1
+ 'use strict';
2
+
3
+ const User = require.main.require('./src/user');
4
+ const Messaging = require.main.require('./src/messaging');
5
+ const ChatsAPI = require.main.require('./src/api/chats');
6
+ const db = require.main.require('./src/database');
7
+
8
+ const plugin = {};
9
+ const LOCK_PREFIX = '[admin-chat-lock]';
10
+ const LOCKED_MESSAGE_TEXT = '🔒 חדר זה ננעל ע"י המנהלים.';
11
+
12
+ plugin.init = async function (params) {
13
+ registerRoutes(params.router, params.middleware);
14
+ overrideMessagingFunctions();
15
+ overrideChatsApi();
16
+ };
17
+
18
+ plugin.addProfileLink = async function (data) {
19
+ try {
20
+ const userSlug = data.user ? data.user.userslug : (data.userData ? data.userData.userslug : null);
21
+
22
+ if (userSlug) {
23
+ data.links.push({
24
+ id: 'admin-view-chats',
25
+ route: 'user/' + userSlug + '/chats',
26
+ icon: 'fa-comments',
27
+ name: 'צפה בצ\'אטים',
28
+ visibility: {
29
+ self: false,
30
+ other: true,
31
+ moderator: false,
32
+ globalMod: false,
33
+ admin: true,
34
+ }
35
+ });
36
+ }
37
+ } catch (e) {
38
+ console.error('[Super Admin Chats] Error adding profile link:', e);
39
+ }
40
+ return data;
41
+ };
42
+
43
+ plugin.isRoomOwner = async function (payload) {
44
+ const isAdmin = await User.isAdministrator(payload.uid);
45
+ if (isAdmin) {
46
+ payload.isOwner = true;
47
+ }
48
+ return payload;
49
+ };
50
+
51
+ plugin.isUserInRoom = async function (payload) {
52
+ const isAdmin = await User.isAdministrator(payload.uid);
53
+ if (isAdmin) {
54
+ payload.inRoom = true;
55
+ }
56
+ return payload;
57
+ };
58
+
59
+ plugin.canReply = async function (payload) {
60
+ const uid = getCallerUid(payload);
61
+ const roomId = getRoomId(payload);
62
+ const isAdmin = await User.isAdministrator(uid);
63
+
64
+ if (isAdmin) {
65
+ payload.canReply = true;
66
+ return payload;
67
+ }
68
+
69
+ if (roomId && await isRoomLocked(roomId)) {
70
+ payload.canReply = false;
71
+ }
72
+
73
+ return payload;
74
+ };
75
+
76
+ plugin.filterMessagingSend = async function (payload) {
77
+ const uid = getCallerUid(payload);
78
+ const roomId = getRoomId(payload);
79
+
80
+ if (!uid || !roomId) {
81
+ return payload;
82
+ }
83
+
84
+ if (await User.isAdministrator(uid)) {
85
+ return payload;
86
+ }
87
+
88
+ if (await isRoomLocked(roomId)) {
89
+ throw new Error('[[admin-chats:errors.lockedAction]]');
90
+ }
91
+
92
+ return payload;
93
+ };
94
+ plugin.filterAddUsersToRoom = async function (payload) {
95
+ if (await canNonAdminModifyLockedRoom(payload.uid, payload.roomId)) {
96
+ throw new Error('[[admin-chats:errors.lockedAction]]');
97
+ }
98
+ return payload;
99
+ };
100
+
101
+ plugin.filterRemoveUsersFromRoom = async function (payload) {
102
+ if (await canNonAdminModifyLockedRoom(payload.uid, payload.roomId)) {
103
+ throw new Error('[[admin-chats:errors.lockedAction]]');
104
+ }
105
+ return payload;
106
+ };
107
+
108
+ plugin.filterRenameRoom = async function (payload) {
109
+ if (await canNonAdminModifyLockedRoom(payload.uid, payload.roomId)) {
110
+ throw new Error('[[admin-chats:errors.lockedAction]]');
111
+ }
112
+ return payload;
113
+ };
114
+
115
+ plugin.canGetMessages = async function (payload) {
116
+ const isAdmin = await User.isAdministrator(payload.callerUid);
117
+ if (isAdmin) {
118
+ payload.canGet = true;
119
+ }
120
+ return payload;
121
+ };
122
+
123
+ plugin.canGetRecentChats = async function (payload) {
124
+ const isAdmin = await User.isAdministrator(payload.callerUid);
125
+ if (isAdmin) {
126
+ payload.canGet = true;
127
+ }
128
+ return payload;
129
+ };
130
+
131
+ plugin.canGetPublicChats = async function (payload) {
132
+ const isAdmin = await User.isAdministrator(payload.callerUid);
133
+ if (isAdmin) {
134
+ payload.canGet = true;
135
+ }
136
+ return payload;
137
+ };
138
+
139
+ plugin.onLoadRoom = async function (payload) {
140
+ const { uid, room } = payload;
141
+ if (!room) {
142
+ return payload;
143
+ }
144
+
145
+ const isAdmin = await User.isAdministrator(uid);
146
+ const lockData = await getRoomLockData(room.roomId);
147
+ const isLockedForUser = lockData.isLocked && !isAdmin;
148
+
149
+ room.adminChatLock = lockData;
150
+ room.messages = buildRoomMessagesWithLockNotice(room.messages, lockData);
151
+ room.canReply = room.canReply && !isLockedForUser;
152
+ room.showUserInput = room.showUserInput && !isLockedForUser;
153
+
154
+ if (!isAdmin) {
155
+ return payload;
156
+ }
157
+
158
+ const isOfficialMember = await db.isSortedSetMember(`chat:room:${room.roomId}:uids`, uid);
159
+
160
+ if (!isOfficialMember) {
161
+ await db.sortedSetRemove(`chat:room:${room.roomId}:uids:online`, uid);
162
+
163
+ if (Array.isArray(room.users)) {
164
+ room.users = room.users.filter(user => user && parseInt(user.uid, 10) !== parseInt(uid, 10));
165
+ }
166
+ if (room.userCount > 0) {
167
+ room.userCount -= 1;
168
+ }
169
+ room.groupChat = room.userCount > 2;
170
+
171
+ const allMids = await db.getSortedSetRevRange(`chat:room:${room.roomId}:mids`, 0, 49);
172
+
173
+ if (allMids.length > 0) {
174
+ const messages = await Messaging.getMessagesData(allMids, uid, room.roomId, false);
175
+ const messageCount = await db.getObjectField(`chat:room:${room.roomId}`, 'messageCount');
176
+ const count = parseInt(messageCount, 10) || 0;
177
+
178
+ messages.forEach((msg, index) => {
179
+ msg.index = count - index - 1;
180
+ });
181
+
182
+ room.messages = buildRoomMessagesWithLockNotice(messages.reverse(), lockData);
183
+
184
+ room.messages.forEach((msg, index) => {
185
+ if (index === 0) {
186
+ msg.newSet = true;
187
+ } else {
188
+ const prevMsg = room.messages[index - 1];
189
+ msg.newSet = parseInt(msg.fromuid, 10) !== parseInt(prevMsg.fromuid, 10);
190
+ }
191
+ });
192
+ }
193
+
194
+ room.isAdmin = true;
195
+ room.isOwner = true;
196
+ } else {
197
+ room.isAdmin = true;
198
+ }
199
+ return payload;
200
+ };
201
+
202
+ function overrideChatsApi() {
203
+ if (!ChatsAPI || typeof ChatsAPI.kick !== 'function' || ChatsAPI.kick._adminChatLockWrapped) {
204
+ return;
205
+ }
206
+
207
+ const originalKick = ChatsAPI.kick;
208
+ const wrappedKick = async function (caller, data) {
209
+ if (caller && !await User.isAdministrator(caller.uid) && data && data.roomId && Array.isArray(data.uids) && data.uids.length === 1 && parseInt(data.uids[0], 10) === parseInt(caller.uid, 10) && await isRoomLocked(data.roomId)) {
210
+ throw new Error('[[admin-chats:errors.lockedAction]]');
211
+ }
212
+
213
+ return await originalKick.call(this, caller, data);
214
+ };
215
+
216
+ wrappedKick._adminChatLockWrapped = true;
217
+ ChatsAPI.kick = wrappedKick;
218
+ }
219
+ function overrideMessagingFunctions() {
220
+ const originalCanEdit = Messaging.canEdit;
221
+ const originalCanDelete = Messaging.canDelete;
222
+ const originalCanViewMessage = Messaging.canViewMessage;
223
+ const originalCanReply = Messaging.canReply;
224
+
225
+ Messaging.canEdit = async function (messageId, uid) {
226
+ if (await User.isAdministrator(uid)) {
227
+ return true;
228
+ }
229
+
230
+ const roomId = await getMessageRoomId(messageId);
231
+ if (roomId && await isRoomLocked(roomId)) {
232
+ throw new Error('[[admin-chats:errors.lockedAction]]');
233
+ }
234
+
235
+ return await originalCanEdit(messageId, uid);
236
+ };
237
+
238
+ Messaging.canDelete = async function (messageId, uid) {
239
+ if (await User.isAdministrator(uid)) {
240
+ return true;
241
+ }
242
+
243
+ const roomId = await getMessageRoomId(messageId);
244
+ if (roomId && await isRoomLocked(roomId)) {
245
+ throw new Error('[[admin-chats:errors.lockedAction]]');
246
+ }
247
+
248
+ return await originalCanDelete(messageId, uid);
249
+ };
250
+
251
+ Messaging.canViewMessage = async function (mids, roomId, uid) {
252
+ if (await User.isAdministrator(uid)) {
253
+ return Array.isArray(mids) ? mids.map(() => true) : true;
254
+ }
255
+ return await originalCanViewMessage(mids, roomId, uid);
256
+ };
257
+
258
+ if (typeof originalCanReply === 'function') {
259
+ Messaging.canReply = async function (...args) {
260
+ const guessed = guessRoomIdUidFromArgs(args);
261
+ if (await User.isAdministrator(guessed.uid)) {
262
+ return true;
263
+ }
264
+ if (guessed.roomId && await isRoomLocked(guessed.roomId)) {
265
+ return false;
266
+ }
267
+ return await originalCanReply.apply(this, args);
268
+ };
269
+ }
270
+ }
271
+
272
+ function registerRoutes(router, middleware) {
273
+ if (!router) {
274
+ return;
275
+ }
276
+
277
+ const routeMiddleware = [];
278
+ if (middleware && typeof middleware.ensureLoggedIn === 'function') {
279
+ routeMiddleware.push(middleware.ensureLoggedIn);
280
+ }
281
+
282
+ router.post('/api/admin-chats/:roomId/lock', ...routeMiddleware, async (req, res) => {
283
+ try {
284
+ const actingUid = req.uid;
285
+ const roomId = parseInt(req.params.roomId, 10);
286
+
287
+ if (!actingUid || Number.isNaN(roomId)) {
288
+ return res.status(400).json({ status: { code: 'bad-request', message: 'Invalid room id' } });
289
+ }
290
+
291
+ if (!await User.isAdministrator(actingUid)) {
292
+ return res.status(403).json({ status: { code: 'forbidden', message: 'Admin only' } });
293
+ }
294
+
295
+ if (!await roomExists(roomId)) {
296
+ return res.status(404).json({ status: { code: 'not-found', message: 'Room not found' } });
297
+ }
298
+
299
+ const shouldLock = req.body && typeof req.body.locked === 'boolean' ? req.body.locked : true;
300
+ const lockData = shouldLock ?
301
+ await lockRoom(roomId, actingUid) :
302
+ await unlockRoom(roomId, actingUid);
303
+
304
+ return res.json({
305
+ status: { code: 'ok', message: 'Room updated' },
306
+ roomId,
307
+ lockData,
308
+ });
309
+ } catch (err) {
310
+ console.error('[Super Admin Chats] Error updating room lock:', err);
311
+ return res.status(500).json({ status: { code: 'error', message: err.message } });
312
+ }
313
+ });
314
+ }
315
+
316
+ async function canNonAdminModifyLockedRoom(uid, roomId) {
317
+ if (!uid || !roomId) {
318
+ return false;
319
+ }
320
+
321
+ if (await User.isAdministrator(uid)) {
322
+ return false;
323
+ }
324
+
325
+ return await isRoomLocked(roomId);
326
+ }
327
+ async function roomExists(roomId) {
328
+ const roomData = await db.getObject(`chat:room:${roomId}`);
329
+ return !!roomData;
330
+ }
331
+
332
+ async function lockRoom(roomId, actingUid) {
333
+ const now = Date.now();
334
+ await db.setObject(`chat:room:${roomId}:adminLock`, {
335
+ isLocked: 1,
336
+ lockedBy: actingUid,
337
+ lockedAt: now,
338
+ });
339
+
340
+ return await getRoomLockData(roomId);
341
+ }
342
+
343
+ async function unlockRoom(roomId, actingUid) {
344
+ await db.setObject(`chat:room:${roomId}:adminLock`, {
345
+ isLocked: 0,
346
+ lockedBy: actingUid,
347
+ lockedAt: 0,
348
+ });
349
+
350
+ return await getRoomLockData(roomId);
351
+ }
352
+
353
+ async function isRoomLocked(roomId) {
354
+ const field = await db.getObjectField(`chat:room:${roomId}:adminLock`, 'isLocked');
355
+ return field === '1' || field === 1 || field === true;
356
+ }
357
+
358
+ async function getMessageRoomId(messageId) {
359
+ const roomId = await db.getObjectField(`message:${messageId}`, 'roomId');
360
+ return parseInt(roomId, 10) || 0;
361
+ }
362
+
363
+ async function getRoomLockData(roomId) {
364
+ const data = await db.getObject(`chat:room:${roomId}:adminLock`) || {};
365
+ const lockedBy = parseInt(data.lockedBy, 10) || 0;
366
+ const lockedAt = parseInt(data.lockedAt, 10) || 0;
367
+ const isLockedValue = data.isLocked === '1' || data.isLocked === 1 || data.isLocked === true;
368
+
369
+ return {
370
+ isLocked: isLockedValue,
371
+ lockedBy,
372
+ lockedAt,
373
+ };
374
+ }
375
+
376
+ function buildRoomMessagesWithLockNotice(messages, lockData) {
377
+ const cleanedMessages = stripAdminLockMessages(messages);
378
+ if (!lockData || !lockData.isLocked || !lockData.lockedAt) {
379
+ return cleanedMessages;
380
+ }
381
+
382
+ cleanedMessages.push({
383
+ content: `${LOCK_PREFIX} ${LOCKED_MESSAGE_TEXT}`,
384
+ fromuid: lockData.lockedBy,
385
+ uid: lockData.lockedBy,
386
+ timestamp: lockData.lockedAt,
387
+ datetime: lockData.lockedAt,
388
+ system: true,
389
+ newSet: true,
390
+ });
391
+
392
+ cleanedMessages.sort((left, right) => {
393
+ const leftTime = parseInt(left && (left.timestamp || left.datetime), 10) || 0;
394
+ const rightTime = parseInt(right && (right.timestamp || right.datetime), 10) || 0;
395
+ return leftTime - rightTime;
396
+ });
397
+
398
+ return cleanedMessages;
399
+ }
400
+
401
+ function stripAdminLockMessages(messages) {
402
+ const list = Array.isArray(messages) ? messages.slice() : [];
403
+ return list.filter(msg => !isAdminLockMessage(msg));
404
+ }
405
+
406
+ function isAdminLockMessage(msg) {
407
+ const content = String(msg && msg.content || '');
408
+ return content.includes('admin-chat-lock') ||
409
+ content.includes(LOCKED_MESSAGE_TEXT) ||
410
+ content.includes('נפתח מחדש ע"י המנהלים') ||
411
+ content.includes('modules:chat.system.[admin-chat-lock]');
412
+ }
413
+
414
+ function guessRoomIdUidFromArgs(args) {
415
+ if (!Array.isArray(args) || !args.length) {
416
+ return { roomId: null, uid: null };
417
+ }
418
+
419
+ if (args.length === 1 && args[0] && typeof args[0] === 'object') {
420
+ return {
421
+ roomId: getRoomId(args[0]),
422
+ uid: getCallerUid(args[0]),
423
+ };
424
+ }
425
+
426
+ return {
427
+ roomId: args[0],
428
+ uid: args[1],
429
+ };
430
+ }
431
+
432
+ function getCallerUid(payload) {
433
+ return payload && (
434
+ payload.uid ||
435
+ payload.callerUid ||
436
+ (payload.data && payload.data.uid) ||
437
+ (payload.message && payload.message.uid)
438
+ );
439
+ }
440
+
441
+ function getRoomId(payload) {
442
+ return payload && (
443
+ payload.roomId ||
444
+ (payload.data && payload.data.roomId) ||
445
+ (payload.message && payload.message.roomId)
446
+ );
447
+ }
448
+
449
+ module.exports = plugin;
450
+
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
- {
2
- "name": "nodebb-plugin-admin-chats",
3
- "version": "1.0.2",
4
- "description": "A plugin that allows the administrator to view and manage user chats.",
5
- "main": "library.js",
6
- "dependencies": {
7
- "lodash": "^4.17.0"
8
- },
9
- "nbbpm": {
10
- "compatibility": "^3.0.0 || ^4.0.0"
11
- }
12
- }
1
+ {
2
+ "name": "nodebb-plugin-admin-chats",
3
+ "version": "2.0.0",
4
+ "description": "A plugin that allows the administrator to view and manage user chats.",
5
+ "main": "library.js",
6
+ "dependencies": {
7
+ "lodash": "^4.17.0"
8
+ },
9
+ "nbbpm": {
10
+ "compatibility": "^3.0.0 || ^4.0.0"
11
+ }
12
+ }
package/plugin.json CHANGED
@@ -1,19 +1,64 @@
1
- {
2
- "id": "nodebb-plugin-admin-chats",
3
- "name": "Super Admin Chat Control",
4
- "description": "A plugin that allows the administrator to view and manage user chats",
5
- "url": "https://github.com/palmoni5/nodebb-plugin-admin-chats",
6
- "library": "./library.js",
7
- "scripts": [ "client.js" ],
8
- "hooks": [
9
- { "hook": "filter:user.accountMenu", "method": "addProfileLink" },
10
- { "hook": "filter:messaging.isRoomOwner", "method": "isRoomOwner" },
11
- { "hook": "filter:messaging.isUserInRoom", "method": "isUserInRoom" },
12
- { "hook": "filter:messaging.canReply", "method": "canReply" },
13
- { "hook": "filter:messaging.canGetMessages", "method": "canGetMessages" },
14
- { "hook": "filter:messaging.canGetRecentChats", "method": "canGetRecentChats" },
15
- { "hook": "filter:messaging.canGetPublicChats", "method": "canGetPublicChats" },
16
- { "hook": "filter:messaging.loadRoom", "method": "onLoadRoom" },
17
- { "hook": "static:app.load", "method": "init" }
18
- ]
19
- }
1
+ {
2
+ "id": "nodebb-plugin-admin-chats",
3
+ "name": "Super Admin Chat Control",
4
+ "description": "A plugin that allows the administrator to view and manage user chats",
5
+ "url": "https://github.com/palmoni5/nodebb-plugin-admin-chats",
6
+ "library": "./library.js",
7
+ "scripts": [
8
+ "client.js"
9
+ ],
10
+ "hooks": [
11
+ {
12
+ "hook": "filter:user.accountMenu",
13
+ "method": "addProfileLink"
14
+ },
15
+ {
16
+ "hook": "filter:messaging.isRoomOwner",
17
+ "method": "isRoomOwner"
18
+ },
19
+ {
20
+ "hook": "filter:messaging.isUserInRoom",
21
+ "method": "isUserInRoom"
22
+ },
23
+ {
24
+ "hook": "filter:messaging.canReply",
25
+ "method": "canReply"
26
+ },
27
+ {
28
+ "hook": "filter:messaging.canGetMessages",
29
+ "method": "canGetMessages"
30
+ },
31
+ {
32
+ "hook": "filter:messaging.canGetRecentChats",
33
+ "method": "canGetRecentChats"
34
+ },
35
+ {
36
+ "hook": "filter:messaging.canGetPublicChats",
37
+ "method": "canGetPublicChats"
38
+ },
39
+ {
40
+ "hook": "filter:messaging.loadRoom",
41
+ "method": "onLoadRoom"
42
+ },
43
+ {
44
+ "hook": "filter:messaging.send",
45
+ "method": "filterMessagingSend"
46
+ },
47
+ {
48
+ "hook": "static:app.load",
49
+ "method": "init"
50
+ },
51
+ {
52
+ "hook": "filter:messaging.addUsersToRoom",
53
+ "method": "filterAddUsersToRoom"
54
+ },
55
+ {
56
+ "hook": "filter:messaging.removeUsersFromRoom",
57
+ "method": "filterRemoveUsersFromRoom"
58
+ },
59
+ {
60
+ "hook": "filter:chat.renameRoom",
61
+ "method": "filterRenameRoom"
62
+ }
63
+ ]
64
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "lock.banner": "🔒 This room was locked by the administrators.",
3
+ "menu.lock": "Lock Room",
4
+ "menu.release": "Release Lock",
5
+ "errors.update": "Unable to update room lock.",
6
+ "profile.viewChats": "View Chats",
7
+ "empty.selectChat": "Please select a chat from the sidebar.",
8
+ "errors.lockedAction": "This action cannot be performed in a locked room."
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "lock.banner": "🔒 חדר זה ננעל ע\"י המנהלים.",
3
+ "menu.lock": "נעילת חדר",
4
+ "menu.release": "שיחרור נעילה",
5
+ "errors.update": "לא ניתן לעדכן את נעילת החדר.",
6
+ "profile.viewChats": "צפיה בצ'אטים",
7
+ "empty.selectChat": "אנא בחר צ'אט מסרגל הצד.",
8
+ "errors.lockedAction": "לא ניתן לבצע פעולה זו בחדר נעול"
9
+ }