@jasonwch/nodebb-plugin-meilisearch-r 1.1.2 → 1.2.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/README.md CHANGED
@@ -1,28 +1,29 @@
1
- # Meilisearch Plugin for NodeBB (Refactored)
2
- A plugin for integrating MeiliSearch with NodeBB (This plugin is inspired and refactored from the original [nodebb-plugin-meilisearch](https://github.com/oplik0/nodebb-plugin-meilisearch))
3
-
4
- ***__Make sure to disable nodebb-plugin-dbsearch when using this plugin.__***
5
-
6
- **Required existing Meilisearch instance. Set your URL and API Key in the ACP**
7
-
8
- - Refactored for NodeBB 4.14 up.
9
- - Updated to Meilisearch JS API 0.59.
10
- - ACP panel comply to NodeBB standard.
11
- - Reindex / Force reindex all working as expected
12
- - Composer suggestion optimization.
13
- - Bug fixes from existing plugin.
14
- - Chat search support
15
- - Global chat search support (will auto detect and override ```nodebb-plugin-chat-search```)
16
-
17
- \
18
- <ins>**Composer suggestions**</ins>
19
- \
20
- ![composer window](demo/composer.png)
21
-
22
- <ins>**Global search**</ins>
23
- \
24
- ![global search](demo/globalsearch.png)
25
-
26
- <ins>**ACP**</ins>
27
- \
28
- ![ACP page](demo/acp.png)
1
+ # Meilisearch Plugin for NodeBB (Refactored)
2
+ A plugin for integrating MeiliSearch with NodeBB (This plugin is inspired and refactored from the original [nodebb-plugin-meilisearch](https://github.com/oplik0/nodebb-plugin-meilisearch))
3
+
4
+ ***__Make sure to disable nodebb-plugin-dbsearch when using this plugin.__***
5
+
6
+ **Required existing Meilisearch instance. Set your URL and API Key in the ACP**
7
+
8
+ - Refactored for NodeBB 4.14 up.
9
+ - Updated to Meilisearch JS API 0.59.
10
+ - ACP panel comply to NodeBB standard.
11
+ - Reindex / Force reindex all working as expected
12
+ - Composer suggestion optimization.
13
+ - Bug fixes from existing plugin.
14
+ - Chat search support
15
+ - Global chat search support (will auto detect and override ```nodebb-plugin-chat-search```)
16
+ - Semantic / hybrid (AI) search via Meilisearch embedders, configurable from the ACP (OpenAI, Hugging Face, Ollama, or a custom REST embedding endpoint). Requires a Meilisearch instance with embedder support (v1.13+).
17
+
18
+ \
19
+ <ins>**Composer suggestions**</ins>
20
+ \
21
+ ![composer window](demo/composer.png)
22
+
23
+ <ins>**Global search**</ins>
24
+ \
25
+ ![global search](demo/globalsearch.png)
26
+
27
+ <ins>**ACP**</ins>
28
+ \
29
+ ![ACP page](demo/acp.png)
package/lib/alerts.js ADDED
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+
3
+ const pubsub = nodebb.require('./src/pubsub');
4
+
5
+ // Throttle window per alert key, so a hot failure path (e.g. every search hitting a
6
+ // broken embedder) doesn't flood every open ACP tab with a repeat toast on every request.
7
+ const THROTTLE_MS = 60 * 1000;
8
+ const lastSentAt = new Map();
9
+
10
+ // Real-time admin notifications, separate from winston logging: pushed over pubsub so
11
+ // every cluster instance's connected ACP sockets show the toast (lib/state.js relays
12
+ // 'meilisearch:alert' to the 'admin/plugins/meilisearch' room), not just the instance
13
+ // that hit the error.
14
+ module.exports = function attachAlerts(plugin) {
15
+ // `key` identifies the failure (e.g. "embedder:post") so repeats of the SAME issue are
16
+ // throttled independently from other issues, which should still surface immediately.
17
+ plugin.notifyAdmins = function notifyAdmins(key, alert) {
18
+ const now = Date.now();
19
+ const last = lastSentAt.get(key) || 0;
20
+ if (now - last < THROTTLE_MS) return;
21
+ lastSentAt.set(key, now);
22
+ pubsub.publish('meilisearch:alert', alert);
23
+ };
24
+ };
@@ -0,0 +1,221 @@
1
+ 'use strict';
2
+
3
+ const winston = nodebb.require('winston');
4
+ const settings = nodebb.require('./src/meta/settings');
5
+ const Messaging = nodebb.require('./src/messaging');
6
+ const user = nodebb.require('./src/user');
7
+ const translator = nodebb.require('./src/translator');
8
+ const db = nodebb.require('./src/database');
9
+ const socketPlugins = nodebb.require('./src/socket.io/plugins');
10
+ const { resolveMinTermLength } = require('./settings-helpers');
11
+ const { EMBEDDER_NAME, isSemanticSearchEnabled, getSemanticRatio, getSemanticScoreThreshold } = require('./embedder');
12
+ const { safeSearch } = require('./search');
13
+ const {
14
+ GLOBAL_CHAT_SEARCH_LIMIT, GLOBAL_CHAT_SEARCH_FETCH, MEMBERSHIP_CHUNK_SIZE, DECORATION_BATCH_SIZE,
15
+ } = require('./constants');
16
+
17
+ async function translateMeili(language, key, ...args) {
18
+ return await translator.translate(
19
+ translator.compile(`meilisearch:${key}`, ...args),
20
+ language,
21
+ );
22
+ }
23
+
24
+ // Attaches room name, participants, and sender metadata to each message for the client UI.
25
+ async function decorateRoomMatches({ matches, roomId, targetUid, userLang }) {
26
+ const uids = await Messaging.getUidsInRoom(roomId, 0, -1);
27
+ const usersData = await user.getUsersFields(uids, ['uid', 'username', 'picture', 'icon:text', 'icon:bgColor']);
28
+ const otherUsers = usersData.filter(u => parseInt(u.uid, 10) !== parseInt(targetUid, 10));
29
+
30
+ let displayName = '';
31
+ if (otherUsers.length === 0) {
32
+ displayName = await translateMeili(userLang, 'chatSearch.room.self-chat');
33
+ } else if (otherUsers.length <= 2) {
34
+ displayName = otherUsers.map(u => u.username).join(', ');
35
+ } else {
36
+ const firstTwo = otherUsers.slice(0, 2).map(u => u.username).join(', ');
37
+ const remaining = otherUsers.length - 2;
38
+ displayName = await translateMeili(userLang, 'chatSearch.room.and-more-users', firstTwo, remaining);
39
+ }
40
+
41
+ const roomData = await Messaging.getRoomData(roomId);
42
+ const roomName = (roomData && roomData.roomName) || displayName;
43
+
44
+ // Batch-fetch sorted-set ranks for all matched mids.
45
+ // Core's /message/:mid redirect drops the index segment when rank is 0 (falsy),
46
+ // so we attach the rank here and construct the chat URL client-side with rank+1
47
+ // to always include a truthy 1-based index segment.
48
+ const midsForRank = matches.map(m => m.mid || m.messageId);
49
+ const ranks = await db.sortedSetRanks(`chat:room:${roomId}:mids`, midsForRank);
50
+
51
+ matches.forEach((m, i) => {
52
+ if (!m.mid && m.messageId) m.mid = m.messageId;
53
+ if (!m.roomId) m.roomId = roomId;
54
+ if (!m.user || !m.user.username) {
55
+ m.user = m.fromUser || { username: 'Unknown', 'icon:text': '?', 'icon:bgColor': 'var(--bs-tertiary-bg)' };
56
+ }
57
+ m.roomName = roomName;
58
+ m.participants = otherUsers.length ? [otherUsers[0]] : [];
59
+ m.rank = ranks[i];
60
+ });
61
+
62
+ return matches;
63
+ }
64
+
65
+ // Global chat search (across all of the user's rooms) plus the config flag that
66
+ // enables/disables it client-side.
67
+ module.exports = function attachChatSearchGlobal(plugin) {
68
+ // Global chat search: queries the chat_message Meilisearch index across ALL rooms
69
+ // the user belongs to (unlike the per-room searchMessages which is scoped to one roomId).
70
+ // Uses 1.5× over-fetch (300) to compensate for join-timestamp/deleted-message filtering,
71
+ // then renders only surviving hits through NodeBB's getMessagesData pipeline.
72
+ // Results are sorted by message timestamp (newest first).
73
+ plugin.chatSearchGlobal = async function (socket, data) {
74
+ data = data || {};
75
+ if (!socket.uid) throw new Error('Not logged in');
76
+ const chatSearchEnabled = await settings.getOne(plugin.id, 'globalChatSearchEnabled');
77
+ if (chatSearchEnabled === false || chatSearchEnabled === 'off' || chatSearchEnabled === 'false') {
78
+ return [];
79
+ }
80
+ if (!plugin.healthy && !(await plugin.checkHealth())) {
81
+ throw new Error('MeiliSearch is not healthy');
82
+ }
83
+ const targetUid = socket.uid;
84
+ const userSettings = await user.getSettings(socket.uid);
85
+ const userLang = userSettings.userLang || 'en-GB';
86
+
87
+ const query = String(data.query || '').trim();
88
+ if (!query) {
89
+ return [];
90
+ }
91
+
92
+ let roomIds = await db.getSortedSetRevRange('uid:' + targetUid + ':chat:rooms', 0, -1);
93
+ // Chunked membership check with event loop yields to prevent blocking on large room lists.
94
+ // Each chunk fires hooks synchronously inside Promise.all (~0.6ms per 100 rooms);
95
+ // setImmediate between chunks lets other users' I/O callbacks run.
96
+ let inRoom = [];
97
+ for (let i = 0; i < roomIds.length; i += MEMBERSHIP_CHUNK_SIZE) {
98
+ const chunk = roomIds.slice(i, i + MEMBERSHIP_CHUNK_SIZE);
99
+ inRoom = inRoom.concat(await Messaging.isUserInRoom(targetUid, chunk));
100
+ await new Promise(resolve => setImmediate(resolve));
101
+ }
102
+ roomIds = roomIds.filter((roomId, idx) => inRoom[idx]);
103
+ if (!roomIds.length) {
104
+ return [];
105
+ }
106
+
107
+ const minTermLength = await resolveMinTermLength(plugin);
108
+ if (!query.split(' ').some(word => word.length >= minTermLength)) {
109
+ return [];
110
+ }
111
+
112
+ const filter = [roomIds.map(rid => `roomId = ${parseInt(rid, 10)}`)];
113
+ winston.debug(`[plugin/meilisearch] Global chat search for "${query}" in ${roomIds.length} rooms`);
114
+ const semanticExtras = (await isSemanticSearchEnabled(plugin))
115
+ ? {
116
+ hybrid: { embedder: EMBEDDER_NAME, semanticRatio: await getSemanticRatio(plugin) },
117
+ rankingScoreThreshold: await getSemanticScoreThreshold(plugin),
118
+ }
119
+ : {};
120
+ const result = await safeSearch(plugin, 'chat_message', query, {
121
+ attributesToRetrieve: ['mid', 'roomId', 'uid', 'timestamp'],
122
+ limit: GLOBAL_CHAT_SEARCH_FETCH,
123
+ filter: filter,
124
+ matchingStrategy: 'last',
125
+ ...semanticExtras,
126
+ });
127
+
128
+ if (!result || !result.hits || !result.hits.length) {
129
+ return [];
130
+ }
131
+
132
+ // Batch-fetch room public flags (1 query) + join timestamps for private rooms (N queries).
133
+ const hitRoomIds = [...new Set(result.hits.map(h => parseInt(h.roomId, 10)))];
134
+ const roomData = await db.getObjectsFields(
135
+ hitRoomIds.map(rid => `chat:room:${rid}`),
136
+ ['public'],
137
+ );
138
+ const roomIsPublic = {};
139
+ hitRoomIds.forEach((rid, i) => {
140
+ roomIsPublic[rid] = parseInt(roomData[i] && roomData[i].public, 10) === 1;
141
+ });
142
+
143
+ const privateRoomIds = hitRoomIds.filter(rid => !roomIsPublic[rid]);
144
+ const joinTimestamps = {};
145
+ if (privateRoomIds.length) {
146
+ const scores = await Promise.all(
147
+ privateRoomIds.map(rid => db.sortedSetScore(`chat:room:${rid}:uids`, targetUid)),
148
+ );
149
+ privateRoomIds.forEach((rid, i) => {
150
+ joinTimestamps[rid] = scores[i] || 0;
151
+ });
152
+ }
153
+
154
+ // Filter + sort by timestamp (newest first) + slice to limit.
155
+ const visibleHits = result.hits
156
+ .filter((hit) => {
157
+ const rid = parseInt(hit.roomId, 10);
158
+ if (roomIsPublic[rid]) return true;
159
+ const ts = parseInt(hit.timestamp, 10);
160
+ return ts >= (joinTimestamps[rid] || 0);
161
+ })
162
+ .sort((a, b) => parseInt(b.timestamp, 10) - parseInt(a.timestamp, 10))
163
+ .slice(0, GLOBAL_CHAT_SEARCH_LIMIT);
164
+
165
+ if (!visibleHits.length) {
166
+ return [];
167
+ }
168
+
169
+ // Group by room for decoration, then reassemble in timestamp order via mid→message map.
170
+ const hitsByRoom = {};
171
+ visibleHits.forEach((hit) => {
172
+ const rid = parseInt(hit.roomId, 10);
173
+ if (!hitsByRoom[rid]) hitsByRoom[rid] = [];
174
+ hitsByRoom[rid].push(hit.mid);
175
+ });
176
+
177
+ // Process rooms in bounded-concurrency batches (10 at a time) to avoid
178
+ // saturating the MongoDB connection pool (maxPoolSize: 20).
179
+ const roomIdsWithHits = Object.keys(hitsByRoom);
180
+ const decoratedMap = {};
181
+ for (let i = 0; i < roomIdsWithHits.length; i += DECORATION_BATCH_SIZE) {
182
+ const batch = roomIdsWithHits.slice(i, i + DECORATION_BATCH_SIZE);
183
+ const batchResults = await Promise.all(batch.map(async (ridStr) => {
184
+ const rid = parseInt(ridStr, 10);
185
+ const mids = hitsByRoom[rid];
186
+ const messages = await Messaging.getMessagesData(mids, targetUid, rid, false);
187
+ if (!messages || !messages.length) return [];
188
+ return decorateRoomMatches({ matches: messages, roomId: rid, targetUid, userLang });
189
+ }));
190
+ batchResults.forEach((decorated) => {
191
+ decorated.forEach((msg) => {
192
+ const mid = msg.mid || msg.messageId;
193
+ if (mid) decoratedMap[mid] = msg;
194
+ });
195
+ });
196
+ }
197
+
198
+ // Reassemble in pre-sorted timestamp order.
199
+ const allResults = [];
200
+ for (const hit of visibleHits) {
201
+ const msg = decoratedMap[hit.mid];
202
+ if (msg) allResults.push(msg);
203
+ }
204
+
205
+ return allResults;
206
+ };
207
+
208
+ // Register socket handler at module level per NodeBB 4.14.x convention
209
+ // (src/socket.io/plugins.js docs: require and add listeners at load time).
210
+ // The health check inside chatSearchGlobal guards against calls before init completes.
211
+ socketPlugins.meilisearch = socketPlugins.meilisearch || {};
212
+ socketPlugins.meilisearch.chatSearchGlobal = plugin.chatSearchGlobal;
213
+
214
+ // Expose globalChatSearchEnabled to client config (standard filter:config.get pattern,
215
+ // same as nodebb-plugin-emoji's emojiCustomFirst).
216
+ plugin.addConfig = async function (config) {
217
+ const enabled = await settings.getOne(plugin.id, 'globalChatSearchEnabled');
218
+ config.globalChatSearchEnabled = enabled !== false && enabled !== 'off' && enabled !== 'false';
219
+ return config;
220
+ };
221
+ };