@jasonwch/nodebb-plugin-meilisearch-r 1.1.2 → 1.2.1

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/lib/reindex.js ADDED
@@ -0,0 +1,311 @@
1
+ 'use strict';
2
+
3
+ const _ = nodebb.require('lodash');
4
+ const winston = nodebb.require('winston');
5
+ const db = nodebb.require('./src/database');
6
+ const batch = nodebb.require('./src/batch');
7
+ const Posts = nodebb.require('./src/posts');
8
+ const Topics = nodebb.require('./src/topics');
9
+ const Messaging = nodebb.require('./src/messaging');
10
+ const pubsub = nodebb.require('./src/pubsub');
11
+ const settings = nodebb.require('./src/meta/settings');
12
+ const {
13
+ REINDEX_BATCH_SIZE, REINDEX_LOCK_KEY, REINDEX_LOCK_TTL, LOCK_REFRESH_INTERVAL, REPLAY_OPS,
14
+ } = require('./constants');
15
+ const { isSemanticSearchEnabled } = require('./embedder');
16
+ const { waitForSucceededTask } = require('./client');
17
+
18
+ // Full reindex of posts, topics, and chat messages, guarded by a cross-instance lock.
19
+ module.exports = function attachReindex(plugin) {
20
+ const emitProgress = _.throttle(() => {
21
+ pubsub.publish('meilisearch:reindex', plugin.indexing);
22
+ }, 500, { leading: true, trailing: true });
23
+
24
+ async function acquireReindexLock() {
25
+ const ttl = await db.ttl(REINDEX_LOCK_KEY);
26
+ if (ttl > 0) {
27
+ winston.warn('[plugin/meilisearch] Reindex lock held by another instance');
28
+ return false;
29
+ }
30
+ await db.set(REINDEX_LOCK_KEY, String(Date.now()));
31
+ await db.expire(REINDEX_LOCK_KEY, REINDEX_LOCK_TTL);
32
+ plugin.reindexLockRefresh = setInterval(() => {
33
+ db.expire(REINDEX_LOCK_KEY, REINDEX_LOCK_TTL).catch(err =>
34
+ winston.error(`[plugin/meilisearch] lock refresh failed: ${err.message}`));
35
+ }, LOCK_REFRESH_INTERVAL);
36
+ return true;
37
+ }
38
+
39
+ async function releaseReindexLock() {
40
+ if (plugin.reindexLockRefresh) {
41
+ clearInterval(plugin.reindexLockRefresh);
42
+ plugin.reindexLockRefresh = null;
43
+ }
44
+ try {
45
+ await db.delete(REINDEX_LOCK_KEY);
46
+ } catch (err) {
47
+ winston.warn(`[plugin/meilisearch] Could not release reindex lock: ${err.message}`);
48
+ }
49
+ }
50
+
51
+ async function runReindex(force) {
52
+ let succeeded = false;
53
+ let errorMsg = null;
54
+ let skippedDeletedTopics = 0;
55
+ let skippedDeletedPosts = 0;
56
+ let skippedDeletedMessages = 0;
57
+ let skippedSystemMessages = 0;
58
+ let skippedOrphanMessages = 0;
59
+ const locked = await acquireReindexLock();
60
+ if (!locked) {
61
+ plugin.indexing.running = false;
62
+ return;
63
+ }
64
+ pubsub.publish('meilisearch:init', true);
65
+ plugin.initializingOnAnotherInstance = true;
66
+ try {
67
+ if (force) {
68
+ plugin.reindexingForced = true;
69
+ if (await isSemanticSearchEnabled(plugin)) {
70
+ // Every document added back after this will need a fresh embedding call
71
+ // against the configured (often paid) embedding API - there is no way
72
+ // around that once the documents themselves are gone, so surface it here.
73
+ winston.warn('[plugin/meilisearch] Force reindex with semantic search enabled: all vector embeddings will be regenerated from scratch via the embedding API.');
74
+ }
75
+ const postTask = await plugin.client.index('post').deleteAllDocuments();
76
+ const topicTask = await plugin.client.index('topic').deleteAllDocuments();
77
+ const messageTask = await plugin.client.index('chat_message').deleteAllDocuments();
78
+ await waitForSucceededTask(plugin, postTask.taskUid, { timeout: 600000, interval: 200 });
79
+ await waitForSucceededTask(plugin, topicTask.taskUid, { timeout: 600000, interval: 200 });
80
+ await waitForSucceededTask(plugin, messageTask.taskUid, { timeout: 600000, interval: 200 });
81
+ }
82
+ const [postSettingsTask, topicSettingsTask, messageSettingsTask] = await plugin.updateIndexSettings();
83
+ await waitForSucceededTask(plugin, postSettingsTask.taskUid, { timeout: 600000, interval: 200 });
84
+ await waitForSucceededTask(plugin, topicSettingsTask.taskUid, { timeout: 600000, interval: 200 });
85
+ await waitForSucceededTask(plugin, messageSettingsTask.taskUid, { timeout: 600000, interval: 200 });
86
+ // Explicit, deliberate part of a reindex run - ensures the embedder config
87
+ // matches current ACP settings before documents start flowing back in. Gated
88
+ // internally (plugin.updateEmbedders) so it's a no-op unless something about
89
+ // the semantic search config actually changed since it was last applied.
90
+ await plugin.updateEmbedders();
91
+ plugin.indexing.topic_progress.total = await db.sortedSetCard('topics:tid') || 0;
92
+ plugin.indexing.post_progress.total = await db.sortedSetCard('posts:pid') || 0;
93
+ plugin.indexing.message_progress.total = await db.sortedSetCard('messages:mid') || 0;
94
+ emitProgress();
95
+ await Promise.all([
96
+ batch.processSortedSet(
97
+ 'topics:tid',
98
+ async (tids) => {
99
+ plugin.indexing.topic_progress.current += tids.length;
100
+ emitProgress();
101
+ const topics = await Topics.getTopicsFields(tids, ['tid', 'cid', 'uid', 'mainPid', 'title', 'timestamp', 'deleted']);
102
+ const activeTopics = [];
103
+ const deletedTids = [];
104
+ topics.forEach((topic) => {
105
+ if (parseInt(topic.deleted, 10) === 1) {
106
+ deletedTids.push(topic.tid);
107
+ } else {
108
+ activeTopics.push(topic);
109
+ }
110
+ });
111
+ skippedDeletedTopics += deletedTids.length;
112
+ if (deletedTids.length) {
113
+ await plugin.client.index('topic').deleteDocuments(deletedTids);
114
+ }
115
+ if (!activeTopics.length) return;
116
+ await plugin.client.index('topic').updateDocuments(
117
+ activeTopics.map(topic => ({
118
+ tid: topic.tid,
119
+ cid: topic.cid,
120
+ uid: topic.uid,
121
+ mainPid: topic.mainPid,
122
+ title: topic.title,
123
+ timestamp: topic.timestamp,
124
+ })),
125
+ { primaryKey: 'tid' },
126
+ );
127
+ },
128
+ {
129
+ batch: REINDEX_BATCH_SIZE,
130
+ progress: plugin.indexing.topic_progress,
131
+ },
132
+ ),
133
+ batch.processSortedSet(
134
+ 'posts:pid',
135
+ async (pids) => {
136
+ plugin.indexing.post_progress.current += pids.length;
137
+ emitProgress();
138
+ const posts = await Posts.getPostsFields(pids, ['pid', 'tid', 'uid', 'content', 'timestamp', 'deleted']);
139
+ // Fetch topic deleted status for posts in this batch
140
+ const tids = [...new Set(posts.map(p => p && p.tid).filter(Boolean))];
141
+ const topicsData = tids.length ? await Topics.getTopicsFields(tids, ['tid', 'deleted']) : [];
142
+ const tidToDeleted = {};
143
+ topicsData.forEach((t) => { tidToDeleted[t.tid] = parseInt(t.deleted, 10) === 1; });
144
+
145
+ const activePosts = [];
146
+ const deletedPids = [];
147
+ posts.forEach((post) => {
148
+ if (parseInt(post.deleted, 10) === 1 || tidToDeleted[post.tid]) {
149
+ deletedPids.push(post.pid);
150
+ } else {
151
+ activePosts.push(post);
152
+ }
153
+ });
154
+ skippedDeletedPosts += deletedPids.length;
155
+ if (deletedPids.length) {
156
+ await plugin.client.index('post').deleteDocuments(deletedPids);
157
+ }
158
+ const activePids = activePosts.map(p => p.pid);
159
+ const cids = activePids.length ? await Posts.getCidsByPids(activePids) : [];
160
+ await plugin.client.index('post').updateDocuments(
161
+ activePosts.map((post, index) => ({
162
+ pid: post.pid,
163
+ tid: post.tid,
164
+ cid: cids[index],
165
+ uid: post.uid,
166
+ content: post.content,
167
+ timestamp: post.timestamp,
168
+ })),
169
+ { primaryKey: 'pid' },
170
+ );
171
+ },
172
+ {
173
+ batch: REINDEX_BATCH_SIZE,
174
+ progress: plugin.indexing.post_progress,
175
+ },
176
+ ),
177
+ ]);
178
+ // Reindex chat messages SEQUENTIALLY after topics+posts (keeps Meilisearch enqueue rate bounded).
179
+ // Option B: cross-check each batch's roomIds against existing rooms and deindex orphan messages
180
+ // whose room was deleted via admin (messaging.deleteRooms fires no message hook and leaves
181
+ // message:${mid} objects + messages:mid entries, so they would otherwise be re-indexed as orphans).
182
+ await batch.processSortedSet(
183
+ 'messages:mid',
184
+ async (mids) => {
185
+ plugin.indexing.message_progress.current += mids.length;
186
+ emitProgress();
187
+ const messages = await Messaging.getMessagesFields(mids, ['mid', 'content', 'roomId', 'fromuid', 'timestamp', 'deleted', 'system']);
188
+ // Resolve deleted/system messages and orphan rooms in one pass.
189
+ const seenRoomIds = [...new Set(messages.map(m => m && m.roomId).filter(rid => rid != null && rid !== ''))];
190
+ const roomsData = seenRoomIds.length ? await Messaging.getRoomsData(seenRoomIds) : [];
191
+ const roomIdToExists = {};
192
+ roomsData.forEach((room) => {
193
+ if (room && room.roomId != null) roomIdToExists[room.roomId] = true;
194
+ });
195
+ const activeMessages = [];
196
+ const deletedMids = [];
197
+ const systemMids = [];
198
+ const orphanMids = [];
199
+ messages.forEach((msg) => {
200
+ if (!msg || !msg.mid) return;
201
+ if (parseInt(msg.deleted, 10) === 1) {
202
+ deletedMids.push(msg.mid);
203
+ } else if (parseInt(msg.system, 10) === 1) {
204
+ systemMids.push(msg.mid);
205
+ } else if (!roomIdToExists[msg.roomId]) {
206
+ orphanMids.push(msg.mid);
207
+ } else {
208
+ activeMessages.push(msg);
209
+ }
210
+ });
211
+ skippedDeletedMessages += deletedMids.length;
212
+ skippedSystemMessages += systemMids.length;
213
+ skippedOrphanMessages += orphanMids.length;
214
+ const allDeindexMids = [...deletedMids, ...systemMids, ...orphanMids];
215
+ if (allDeindexMids.length) {
216
+ await plugin.client.index('chat_message').deleteDocuments(allDeindexMids);
217
+ }
218
+ if (!activeMessages.length) return;
219
+ await plugin.client.index('chat_message').updateDocuments(
220
+ activeMessages.map(msg => ({
221
+ mid: msg.mid,
222
+ roomId: msg.roomId,
223
+ uid: msg.fromuid,
224
+ content: msg.content,
225
+ timestamp: msg.timestamp,
226
+ })),
227
+ { primaryKey: 'mid' },
228
+ );
229
+ },
230
+ { batch: REINDEX_BATCH_SIZE, progress: plugin.indexing.message_progress },
231
+ );
232
+ succeeded = true;
233
+ winston.info('[plugin/meilisearch] Indexing of posts, topics and chat messages complete');
234
+ } catch (err) {
235
+ winston.error(`[plugin/meilisearch] Indexing failed: ${err.message}`);
236
+ errorMsg = err.message;
237
+ } finally {
238
+ plugin.indexing.running = false;
239
+ plugin.reindexingForced = false;
240
+ plugin.initializingOnAnotherInstance = false;
241
+ pubsub.publish('meilisearch:init', false);
242
+ await releaseReindexLock();
243
+ emitProgress.flush();
244
+ const pending = plugin.pendingDuringReindex.splice(0);
245
+ for (const { op, payload } of pending) {
246
+ if (!REPLAY_OPS.includes(op)) continue;
247
+ try {
248
+ await plugin[op](payload);
249
+ } catch (err) {
250
+ winston.error(`[plugin/meilisearch] replay ${op} failed: ${err.message}`);
251
+ }
252
+ }
253
+ const lastIndexResult = {
254
+ success: succeeded,
255
+ finishedAt: Date.now(),
256
+ topic_progress: {
257
+ current: plugin.indexing.topic_progress.current,
258
+ total: plugin.indexing.topic_progress.total,
259
+ },
260
+ post_progress: {
261
+ current: plugin.indexing.post_progress.current,
262
+ total: plugin.indexing.post_progress.total,
263
+ },
264
+ message_progress: {
265
+ current: plugin.indexing.message_progress.current,
266
+ total: plugin.indexing.message_progress.total,
267
+ },
268
+ skippedDeletedTopics,
269
+ skippedDeletedPosts,
270
+ skippedDeletedMessages,
271
+ skippedSystemMessages,
272
+ skippedOrphanMessages,
273
+ error: errorMsg,
274
+ };
275
+ try {
276
+ await settings.set(plugin.id, { lastReindexResult: lastIndexResult }, true);
277
+ if (succeeded) {
278
+ await settings.set(plugin.id, { indexed: true }, true);
279
+ }
280
+ } catch (err) {
281
+ winston.error(`[plugin/meilisearch] Failed to persist reindex result: ${err.message}`);
282
+ }
283
+ }
284
+ }
285
+
286
+ plugin.reindex = async function (force = false) {
287
+ if (plugin.indexing.running) {
288
+ winston.warn('[plugin/meilisearch] Already indexing');
289
+ return;
290
+ }
291
+ plugin.indexing = {
292
+ running: true,
293
+ topic_progress: {
294
+ current: 0,
295
+ total: 0,
296
+ },
297
+ post_progress: {
298
+ current: 0,
299
+ total: 0,
300
+ },
301
+ message_progress: {
302
+ current: 0,
303
+ total: 0,
304
+ },
305
+ };
306
+ emitProgress();
307
+ winston.info(`[plugin/meilisearch] Indexing posts, topics and chat messages${force ? ' (forced)' : ''}`);
308
+ plugin._reindexPromise = runReindex(force);
309
+ return plugin._reindexPromise;
310
+ };
311
+ };
package/lib/routes.js ADDED
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+
3
+ const winston = nodebb.require('winston');
4
+ const routeHelpers = nodebb.require('./src/routes/helpers');
5
+ const settings = nodebb.require('./src/meta/settings');
6
+
7
+ // Plugin lifecycle + HTTP surface: static:app.load init, the reindex REST API,
8
+ // the ACP nav entry, and the admin-notices health line.
9
+ module.exports = function attachRoutes(plugin) {
10
+ plugin.init = async function (params) {
11
+ const { router } = params;
12
+ winston.debug('[plugin/meilisearch] Initializing MeiliSearch plugin');
13
+ routeHelpers.setupAdminPageRoute(router, '/admin/plugins/meilisearch', [], async (req, res) => {
14
+ const topicTotal = Number(plugin.indexing.topic_progress.total) || 0;
15
+ const topicCurrent = Number(plugin.indexing.topic_progress.current) || 0;
16
+ const postTotal = Number(plugin.indexing.post_progress.total) || 0;
17
+ const postCurrent = Number(plugin.indexing.post_progress.current) || 0;
18
+ const messageTotal = Number(plugin.indexing.message_progress.total) || 0;
19
+ const messageCurrent = Number(plugin.indexing.message_progress.current) || 0;
20
+ const topicPercent = topicTotal > 0 ? Math.min(100, Math.max(0, Math.round(100 * topicCurrent / topicTotal))) : 0;
21
+ const postPercent = postTotal > 0 ? Math.min(100, Math.max(0, Math.round(100 * postCurrent / postTotal))) : 0;
22
+ const messagePercent = messageTotal > 0 ? Math.min(100, Math.max(0, Math.round(100 * messageCurrent / messageTotal))) : 0;
23
+ const lastReindexResult = await settings.getOne(plugin.id, 'lastReindexResult') || {};
24
+ res.render('admin/plugins/meilisearch', {
25
+ title: '[[meilisearch:admin.settings]]',
26
+ indexing: plugin.indexing,
27
+ topicPercent,
28
+ postPercent,
29
+ messagePercent,
30
+ lastReindexResult,
31
+ });
32
+ });
33
+ await settings.setOnEmpty(plugin.id, plugin.defaults);
34
+ await plugin.prepareSearch();
35
+ await plugin.ensureChatMessageIndex();
36
+ plugin.patchTopicsSearch();
37
+ plugin.initialized = true;
38
+ };
39
+
40
+ plugin.addRoutes = async function ({ router, middleware, helpers }) {
41
+ const middlewares = [
42
+ middleware.ensureLoggedIn,
43
+ middleware.admin.checkPrivileges,
44
+ ];
45
+ routeHelpers.setupApiRoute(router, 'get', '/meilisearch/reindex', middlewares, async (req, res) => {
46
+ helpers.formatApiResponse(200, res, {
47
+ indexing: plugin.indexing,
48
+ healthy: plugin.healthy,
49
+ lastReindexResult: await settings.getOne(plugin.id, 'lastReindexResult') || {},
50
+ });
51
+ });
52
+
53
+ routeHelpers.setupApiRoute(router, 'post', '/meilisearch/reindex', middlewares, async (req, res) => {
54
+ await settings.set(plugin.id, { indexed: false }, true);
55
+ plugin.reindex(false);
56
+ helpers.formatApiResponse(202, res, plugin.indexing);
57
+ });
58
+ routeHelpers.setupApiRoute(router, 'delete', '/meilisearch/reindex', middlewares, async (req, res) => {
59
+ await settings.set(plugin.id, { indexed: false }, true);
60
+ plugin.reindex(true);
61
+ helpers.formatApiResponse(202, res, plugin.indexing);
62
+ });
63
+
64
+ // Pure A: Ollama reachability probe. NodeBB container makes a 5s HTTP GET to
65
+ // <url>/api/version (or http://localhost:11434/api/version if URL is blank — the
66
+ // lib/embedder.js default). Used by static/lib/admin.js saveSettings to show a
67
+ // modal when Ollama is unreachable from NodeBB. Test runs from NodeBB's container,
68
+ // not Meilisearch's — admin is informed of this in the modal text so they can
69
+ // override with "Save anyway" if they're confident Meilisearch can reach Ollama
70
+ // on a different network path (e.g. Meilisearch runs with --network=host).
71
+ routeHelpers.setupApiRoute(router, 'post', '/meilisearch/test-ollama', middlewares, async (req, res) => {
72
+ const rawUrl = (req.body && typeof req.body.url === 'string') ? req.body.url : '';
73
+ // Default URL mirrors lib/embedder.js ollama fallback
74
+ const ollamaUrl = rawUrl || 'http://localhost:11434/api/embeddings';
75
+ // Strip /api/embeddings suffix if admin included it, then append /api/version probe path
76
+ const ollamaBase = ollamaUrl.replace(/\/api\/embeddings\/?$/, '').replace(/\/$/, '');
77
+ const probeUrl = `${ollamaBase}/api/version`;
78
+ // Declare controller/timeout above the try block so the finally clause can always
79
+ // clear the timer — even if the synchronous URL parsing or fetch setup throws
80
+ // before the await resolves (e.g. malformed URL → `new URL()` throws inside fetch).
81
+ const controller = new AbortController();
82
+ const timeout = setTimeout(() => controller.abort(), 5000);
83
+ try {
84
+ const response = await fetch(probeUrl, { signal: controller.signal });
85
+ if (!response.ok) {
86
+ return helpers.formatApiResponse(200, res, {
87
+ reachable: false,
88
+ error: `Ollama responded HTTP ${response.status} ${response.statusText}`,
89
+ probeUrl,
90
+ });
91
+ }
92
+ const data = await response.json().catch(() => ({}));
93
+ return helpers.formatApiResponse(200, res, {
94
+ reachable: true,
95
+ version: data.version,
96
+ probeUrl,
97
+ });
98
+ } catch (err) {
99
+ return helpers.formatApiResponse(200, res, {
100
+ reachable: false,
101
+ error: err.name === 'AbortError' ? 'Timeout after 5s' : err.message,
102
+ probeUrl,
103
+ });
104
+ } finally {
105
+ clearTimeout(timeout);
106
+ }
107
+ });
108
+ };
109
+
110
+ plugin.addAdminNavigation = function (header) {
111
+ header.plugins.push({
112
+ route: '/plugins/meilisearch',
113
+ icon: 'fa-tint',
114
+ name: 'Meilisearch',
115
+ });
116
+
117
+ return header;
118
+ };
119
+
120
+ plugin.getNotices = async function (notices) {
121
+ const checkHealth = await plugin.checkHealth();
122
+ notices.push({
123
+ done: checkHealth,
124
+ doneText: 'MeiliSearch connection OK',
125
+ notDoneText: 'Could not connect to MeiliSearch',
126
+ });
127
+ return notices;
128
+ };
129
+ };
package/lib/search.js ADDED
@@ -0,0 +1,249 @@
1
+ 'use strict';
2
+
3
+ const winston = nodebb.require('winston');
4
+ const settings = nodebb.require('./src/meta/settings');
5
+ const plugins = nodebb.require('./src/plugins');
6
+ const { resolveMinTermLength } = require('./settings-helpers');
7
+ const { EMBEDDER_NAME, isSemanticSearchEnabled, getSemanticRatio, getSemanticScoreThreshold } = require('./embedder');
8
+ const { redactSecrets } = require('./redact');
9
+
10
+ // rankingScoreThreshold is a top-level search parameter (a sibling of "hybrid", not nested
11
+ // inside it) - Meilisearch defaults it to 0 (no filtering) when omitted, which is why
12
+ // unfiltered vector search returns its full `limit` regardless of how unrelated the tail is.
13
+ async function buildSemanticSearchExtras(plugin) {
14
+ if (!(await isSemanticSearchEnabled(plugin))) return {};
15
+ return {
16
+ hybrid: { embedder: EMBEDDER_NAME, semanticRatio: await getSemanticRatio(plugin) },
17
+ rankingScoreThreshold: await getSemanticScoreThreshold(plugin),
18
+ };
19
+ }
20
+
21
+ // Runs a Meilisearch query and never lets it throw past this point: a broken/misconfigured
22
+ // embedder (e.g. "Cannot find embedder with name `default`") must not take down normal
23
+ // keyword search or callers like Topics.getSuggestedTopics, which propagate rejections
24
+ // straight up through NodeBB's hook chain. If hybrid search fails, retries once as a plain
25
+ // keyword search so users still get results instead of nothing; if that also fails (e.g.
26
+ // Meilisearch itself is down), logs + alerts once (throttled) and returns null so the
27
+ // caller can fall back to its original, unmodified data.
28
+ async function safeSearch(plugin, indexName, term, opts) {
29
+ try {
30
+ return await plugin.client.index(indexName).search(term, opts);
31
+ } catch (err) {
32
+ const reason = err?.message || String(err);
33
+ // B1 fix: redact any apiKey Meilisearch may echo in search error responses.
34
+ // Search errors typically return code+message rather than echoing credentials,
35
+ // but uniform posture with the embedder-task paths is cheap defense-in-depth.
36
+ // A3-style inner try/catch preserves safeSearch's "never throw" contract: if
37
+ // redaction itself fails, fall back to the raw reason rather than rejecting.
38
+ let safeReason;
39
+ try {
40
+ safeReason = await redactSecrets(reason, plugin);
41
+ } catch {
42
+ safeReason = reason;
43
+ }
44
+ if (opts.hybrid) {
45
+ winston.error(`[plugin/meilisearch] Hybrid search on "${indexName}" failed (${safeReason}); falling back to keyword-only search`);
46
+ plugin.notifyAdmins(`search:${indexName}`, {
47
+ type: 'danger',
48
+ titleKey: '[[meilisearch:admin.semanticSearchDegraded]]',
49
+ message: `${indexName}: ${safeReason}`,
50
+ });
51
+ // rankingScoreThreshold is tuned for hybrid/vector scores - drop it too, or a
52
+ // keyword-only fallback could wrongly filter out valid BM25-style matches.
53
+ const { hybrid, rankingScoreThreshold, ...keywordOnly } = opts;
54
+ try {
55
+ return await plugin.client.index(indexName).search(term, keywordOnly);
56
+ } catch (fallbackErr) {
57
+ const fallbackReason = fallbackErr?.message || String(fallbackErr);
58
+ let safeFallbackReason;
59
+ try {
60
+ safeFallbackReason = await redactSecrets(fallbackReason, plugin);
61
+ } catch {
62
+ safeFallbackReason = fallbackReason;
63
+ }
64
+ winston.error(`[plugin/meilisearch] Keyword-only fallback search on "${indexName}" also failed: ${safeFallbackReason}`);
65
+ return null;
66
+ }
67
+ }
68
+ winston.error(`[plugin/meilisearch] Search on "${indexName}" failed: ${safeReason}`);
69
+ plugin.notifyAdmins(`search:${indexName}`, {
70
+ type: 'danger',
71
+ titleKey: '[[meilisearch:admin.semanticSearchDegraded]]',
72
+ message: `${indexName}: ${safeReason}`,
73
+ });
74
+ return null;
75
+ }
76
+ }
77
+
78
+ // Core search integration: filter:search.query / filter:topic.search / filter:messaging.searchMessages.
79
+ module.exports = function attachSearch(plugin) {
80
+ plugin.checkConflict = function () {
81
+ const hooksToCheck = [
82
+ 'filter:search.query',
83
+ 'filter:topic.search',
84
+ 'filter:messaging.searchMessages',
85
+ ];
86
+ // blacklist, in case someone makes a plugin using these hooks that doesn't conflict.
87
+ // also, outside of dbsearch the user is expected to realize they installed two search plugins.
88
+ const conflictingPlugins = [
89
+ 'nodebb-plugin-dbsearch',
90
+ 'nodebb-plugin-solr',
91
+ 'nodebb-plugin-elasticsearch',
92
+ 'nodebb-plugin-search-elasticsearch',
93
+ ];
94
+ for (const hook of hooksToCheck) {
95
+ if ((plugins.loadedHooks[hook] || []).filter(hookData => conflictingPlugins.includes(hookData.id)).length >= 1) {
96
+ return true;
97
+ }
98
+ }
99
+ return false;
100
+ };
101
+
102
+ plugin.searchMessages = async function (data) {
103
+ if (!data || !data.content) {
104
+ return data;
105
+ }
106
+ if (plugin.checkConflict()) {
107
+ winston.warn('[plugin/meilisearch] Another search plugin (most likely dbsearch) is enabled, so chat search via Meilisearch was aborted.');
108
+ return data;
109
+ }
110
+ if (!plugin.healthy && !(await plugin.checkHealth())) {
111
+ winston.warn('[plugin/meilisearch] Meilisearch instance did not return a healthy response, so chat search via Meilisearch was aborted.');
112
+ return data;
113
+ }
114
+ const rawQuery = String(data.content).trim();
115
+ const minTermLength = await resolveMinTermLength(plugin);
116
+ if (!rawQuery || !rawQuery.split(' ').some(word => word.length >= minTermLength)) {
117
+ winston.debug(`[plugin/meilisearch] Skipping chat search: no query word >= ${minTermLength} char(s)`);
118
+ return data;
119
+ }
120
+ const num = (v) => {
121
+ const n = parseInt(v, 10);
122
+ return Number.isFinite(n) ? n : null;
123
+ };
124
+ const filter = [];
125
+ // Core passes roomId/uid as arrays (filter:messaging.searchMessages payload in src/api/search.js).
126
+ const roomIds = Array.isArray(data.roomId) ? data.roomId : [data.roomId];
127
+ const roomNums = roomIds.map(num).filter(r => r !== null);
128
+ // Fail closed: if no valid roomId, return no results rather than searching globally across all rooms.
129
+ if (!roomNums.length) {
130
+ winston.warn('[plugin/meilisearch] searchMessages called with no valid roomId; returning no results');
131
+ return data;
132
+ }
133
+ filter.push(roomNums.map(rid => `roomId = ${rid}`));
134
+ // uid is an optional sender filter (message author, not the searcher); never populated by core callers.
135
+ const uids = Array.isArray(data.uid) ? data.uid : [data.uid];
136
+ const uidNums = uids.map(num).filter(u => u !== null);
137
+ if (uidNums.length) {
138
+ filter.push(uidNums.map(u => `uid = ${u}`));
139
+ }
140
+ const limit = parseInt(await settings.getOne(plugin.id, 'maxDocuments') || 100, 10);
141
+ winston.debug(`[plugin/meilisearch] Searching chat messages for "${rawQuery}" in room ${roomNums.join(',')}`);
142
+ const result = await safeSearch(plugin, 'chat_message', rawQuery, {
143
+ attributesToRetrieve: ['mid'],
144
+ limit: Math.min(limit, 100),
145
+ filter: filter.length ? filter : undefined,
146
+ matchingStrategy: data.matchWords === 'all' ? 'all' : 'last',
147
+ ...(await buildSemanticSearchExtras(plugin)),
148
+ });
149
+ if (!result) return data;
150
+ data.ids = data.ids.concat(result.hits.map(hit => hit.mid));
151
+ return data;
152
+ };
153
+
154
+ plugin.search = async function (data) {
155
+ if (plugin.checkConflict()) {
156
+ // The dbsearch plugin was detected, abort search!
157
+ winston.warn(
158
+ '[plugin/meilisearch] Another search plugin (most likely dbsearch) is enabled, so search via Meilisearch was aborted.',
159
+ );
160
+ return data;
161
+ }
162
+ if (!plugin.healthy && !(await plugin.checkHealth())) {
163
+ winston.warn(
164
+ '[plugin/meilisearch] Meilisearch instance did not return a healthy response, so search via Meilisearch was aborted.',
165
+ );
166
+ return data;
167
+ }
168
+ const rawQuery = (data.term || data.content || '').trim();
169
+ const minTermLength = await resolveMinTermLength(plugin);
170
+ if (!rawQuery || !rawQuery.split(' ').some(word => word.length >= minTermLength)) {
171
+ winston.debug(`[plugin/meilisearch] Skipping search: no query word >= ${minTermLength} char(s)`);
172
+ return data;
173
+ }
174
+ // #6: Use local variables instead of mutating the shared payload.
175
+ const content = data.term || data.content;
176
+ const searchData = data.term ? { tid: data.tid } : data?.searchData;
177
+ const rawIndex = Array.isArray(data?.index) ? data.index[0] : data.index;
178
+ const ALLOWED_INDEXES = { post: 'pid', topic: 'tid' };
179
+ const index = ALLOWED_INDEXES[rawIndex] ? rawIndex : 'post';
180
+ const id = ALLOWED_INDEXES[index];
181
+ winston.debug(`[plugin/meilisearch] Searching for ${content} in ${index}`);
182
+ const result = await safeSearch(plugin, index, content, {
183
+ attributesToRetrieve: [id],
184
+ limit: parseInt(await settings.getOne(plugin.id, 'maxDocuments') || 500, 10),
185
+ filter: plugin.buildFilter(
186
+ data.cid,
187
+ data.uid,
188
+ searchData?.timeFilter,
189
+ searchData?.timeRange,
190
+ searchData?.tid,
191
+ ),
192
+ sort: plugin.buildSort(searchData?.sortBy, searchData?.sortDirection),
193
+ matchingStrategy: data.matchWords === 'all' ? 'all' : 'last',
194
+ ...(await buildSemanticSearchExtras(plugin)),
195
+ });
196
+ if (!result) return data;
197
+ data.ids = result.hits.map(hit => hit[id]);
198
+ return data;
199
+ };
200
+
201
+ plugin.buildFilter = function (categories, postedBy, timeFilter, timeRange, tid) {
202
+ const num = (v) => {
203
+ const n = parseInt(v, 10);
204
+ return Number.isFinite(n) ? n : null;
205
+ };
206
+ const filter = [];
207
+ if (categories?.length) {
208
+ const cids = categories.map(num).filter(c => c !== null);
209
+ if (cids.length) filter.push(cids.map(cid => `cid = ${cid}`));
210
+ }
211
+ if (postedBy?.length) {
212
+ const uids = postedBy.map(num).filter(u => u !== null);
213
+ if (uids.length) filter.push(uids.map(uid => `uid = ${uid}`));
214
+ }
215
+ if (timeFilter && timeRange) {
216
+ const range = num(timeRange);
217
+ if (range !== null) {
218
+ filter.push(`timestamp ${timeFilter === 'newer' ? '>' : '<'} ${Date.now() - (range * 1000)}`);
219
+ }
220
+ }
221
+ const numericTid = num(tid);
222
+ if (numericTid !== null) {
223
+ filter.push(`tid = ${numericTid}`);
224
+ }
225
+ return filter.length ? filter : undefined;
226
+ };
227
+
228
+ plugin.buildSort = function (sortBy, sortDirection) {
229
+ let field = '';
230
+ switch (sortBy) {
231
+ case 'timestamp':
232
+ field = 'timestamp';
233
+ break;
234
+ case 'topic.title':
235
+ field = 'title';
236
+ break;
237
+ case 'category':
238
+ field = 'cid';
239
+ break;
240
+ default:
241
+ return undefined;
242
+ }
243
+ return [`${field}:${sortDirection === 'ascending' ? 'asc' : 'desc'}`];
244
+ };
245
+ };
246
+
247
+ // Exposed so other direct-search call sites (lib/chat-search-global.js) get the same
248
+ // crash-proofing/degrade-and-alert behavior instead of duplicating or, worse, skipping it.
249
+ module.exports.safeSearch = safeSearch;