nodebb-plugin-integration 0.4.1 → 0.4.4
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 +199 -320
- package/README.ru.md +199 -347
- package/languages/en-GB/integration.json +15 -0
- package/languages/ru/integration.json +15 -0
- package/library.js +93 -115
- package/package.json +60 -53
- package/plugin.json +50 -33
- package/public/js/admin.js +58 -0
- package/public/templates/admin/plugins/integration.tpl +213 -0
- package/src/ai/embeddings.js +46 -253
- package/src/ai/indexer.js +156 -343
- package/src/ai/openai.js +74 -330
- package/src/ai/qdrant.js +114 -517
- package/src/ai/search.js +138 -251
- package/src/config/index.js +176 -133
- package/src/config/validation.js +250 -0
- package/src/events/index.js +174 -324
- package/src/mcp/manager.js +396 -0
- package/src/mcp/server.js +338 -172
- package/src/mcp/tools.js +158 -617
- package/src/nodebb/categories.js +39 -0
- package/src/nodebb/index.js +72 -305
- package/src/nodebb/meta.js +24 -273
- package/src/nodebb/posts.js +93 -348
- package/src/nodebb/privileges.js +68 -0
- package/src/nodebb/topics.js +91 -355
- package/src/nodebb/users.js +62 -345
- package/src/routes/api.js +255 -97
- package/src/routes/index.js +64 -49
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const { fork } = require("node:child_process");
|
|
5
|
+
const Config = require("../config");
|
|
6
|
+
|
|
7
|
+
const SERVER_PATH = path.join(__dirname, "server.js");
|
|
8
|
+
const CHECK_INTERVAL = 3000;
|
|
9
|
+
const SHUTDOWN_TIMEOUT = 3000;
|
|
10
|
+
const STOP_TIMEOUT = 5000;
|
|
11
|
+
|
|
12
|
+
let child = null;
|
|
13
|
+
let timer = null;
|
|
14
|
+
let lastSignature = null;
|
|
15
|
+
let initialized = false;
|
|
16
|
+
let refreshRequested = false;
|
|
17
|
+
let refreshPromise = null;
|
|
18
|
+
let stopPromise = null;
|
|
19
|
+
let shuttingDown = false;
|
|
20
|
+
|
|
21
|
+
function isPrimaryWorker() {
|
|
22
|
+
try {
|
|
23
|
+
const nconf = nodebb.require("nconf");
|
|
24
|
+
const value = nconf.get("isPrimary");
|
|
25
|
+
|
|
26
|
+
if (value !== undefined && value !== null) {
|
|
27
|
+
return value === true || value === "true";
|
|
28
|
+
}
|
|
29
|
+
} catch (error) {
|
|
30
|
+
// Fall back to the worker environment.
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const value = process.env.isPrimary;
|
|
34
|
+
|
|
35
|
+
return value === undefined || value === "true";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isExited(processRef) {
|
|
39
|
+
return processRef.exitCode !== null || processRef.signalCode !== null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function waitForExit(processRef, timeout = STOP_TIMEOUT) {
|
|
43
|
+
if (isExited(processRef)) {
|
|
44
|
+
return Promise.resolve();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
let settled = false;
|
|
49
|
+
|
|
50
|
+
const finish = () => {
|
|
51
|
+
if (settled) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
settled = true;
|
|
56
|
+
clearTimeout(timerRef);
|
|
57
|
+
processRef.removeListener("exit", finish);
|
|
58
|
+
processRef.removeListener("close", finish);
|
|
59
|
+
resolve();
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const timerRef = setTimeout(() => {
|
|
63
|
+
if (!isExited(processRef)) {
|
|
64
|
+
console.warn("[NodeBB Integration] MCP process did not exit; sending SIGKILL");
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
processRef.kill("SIGKILL");
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.error(
|
|
70
|
+
"[NodeBB Integration] Failed to kill MCP process:",
|
|
71
|
+
error.message
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (isExited(processRef)) {
|
|
77
|
+
finish();
|
|
78
|
+
}
|
|
79
|
+
}, timeout);
|
|
80
|
+
|
|
81
|
+
timerRef.unref();
|
|
82
|
+
processRef.once("exit", finish);
|
|
83
|
+
processRef.once("close", finish);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function requestShutdown(processRef) {
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
let settled = false;
|
|
89
|
+
|
|
90
|
+
const finish = () => {
|
|
91
|
+
if (settled) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
settled = true;
|
|
96
|
+
clearTimeout(timerRef);
|
|
97
|
+
processRef.removeListener("message", onMessage);
|
|
98
|
+
processRef.removeListener("exit", finish);
|
|
99
|
+
resolve();
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const onMessage = (message) => {
|
|
103
|
+
if (message?.type === "shutdown-complete") {
|
|
104
|
+
finish();
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const timerRef = setTimeout(() => {
|
|
109
|
+
console.warn("[NodeBB Integration] MCP shutdown handshake timed out");
|
|
110
|
+
|
|
111
|
+
if (!isExited(processRef)) {
|
|
112
|
+
try {
|
|
113
|
+
processRef.kill("SIGTERM");
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error(
|
|
116
|
+
"[NodeBB Integration] Failed to terminate MCP process:",
|
|
117
|
+
error.message
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
finish();
|
|
123
|
+
}, SHUTDOWN_TIMEOUT);
|
|
124
|
+
|
|
125
|
+
timerRef.unref();
|
|
126
|
+
|
|
127
|
+
processRef.once("message", onMessage);
|
|
128
|
+
processRef.once("exit", finish);
|
|
129
|
+
|
|
130
|
+
if (!processRef.connected || isExited(processRef)) {
|
|
131
|
+
finish();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
processRef.send({ type: "shutdown" }, (error) => {
|
|
137
|
+
if (error) {
|
|
138
|
+
console.error(
|
|
139
|
+
"[NodeBB Integration] Failed to send MCP shutdown:",
|
|
140
|
+
error.message
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
if (!isExited(processRef)) {
|
|
144
|
+
processRef.kill("SIGTERM");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
finish();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
} catch (error) {
|
|
151
|
+
console.error(
|
|
152
|
+
"[NodeBB Integration] MCP shutdown IPC error:",
|
|
153
|
+
error.message
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
if (!isExited(processRef)) {
|
|
157
|
+
processRef.kill("SIGTERM");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
finish();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function stopChild() {
|
|
166
|
+
if (stopPromise) {
|
|
167
|
+
return stopPromise;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!child) {
|
|
171
|
+
lastSignature = null;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const previous = child;
|
|
176
|
+
|
|
177
|
+
child = null;
|
|
178
|
+
lastSignature = null;
|
|
179
|
+
|
|
180
|
+
stopPromise = (async () => {
|
|
181
|
+
if (!isExited(previous)) {
|
|
182
|
+
await requestShutdown(previous);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!isExited(previous)) {
|
|
186
|
+
try {
|
|
187
|
+
previous.kill("SIGTERM");
|
|
188
|
+
} catch (error) {
|
|
189
|
+
console.error(
|
|
190
|
+
"[NodeBB Integration] Failed to stop MCP process:",
|
|
191
|
+
error.message
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
await waitForExit(previous);
|
|
197
|
+
})();
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
await stopPromise;
|
|
201
|
+
} finally {
|
|
202
|
+
stopPromise = null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function startChild(config, signature) {
|
|
207
|
+
const nextChild = fork(SERVER_PATH, [], {
|
|
208
|
+
stdio: ["ignore", "inherit", "inherit", "ipc"]
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
child = nextChild;
|
|
212
|
+
lastSignature = signature;
|
|
213
|
+
|
|
214
|
+
nextChild.on("message", (message) => {
|
|
215
|
+
if (child !== nextChild || !message) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (message.type === "configured") {
|
|
220
|
+
console.log("[NodeBB Integration] MCP process configured");
|
|
221
|
+
} else if (message.type === "configuration-error") {
|
|
222
|
+
console.error(
|
|
223
|
+
"[NodeBB Integration] MCP configuration failed:",
|
|
224
|
+
message.message
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
lastSignature = null;
|
|
228
|
+
nextChild.kill("SIGTERM");
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
nextChild.on("error", (error) => {
|
|
233
|
+
console.error(
|
|
234
|
+
"[NodeBB Integration] MCP process error:",
|
|
235
|
+
error.message
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
nextChild.on("exit", (code, signal) => {
|
|
240
|
+
if (child === nextChild) {
|
|
241
|
+
child = null;
|
|
242
|
+
lastSignature = null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
console.log(
|
|
246
|
+
`[NodeBB Integration] MCP process exited (code=${code}, signal=${signal})`
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
nextChild.send({
|
|
252
|
+
type: "configure",
|
|
253
|
+
config: {
|
|
254
|
+
nodebbUrl: config.mcpNodebbUrl,
|
|
255
|
+
internalApiToken: config.internalApiToken,
|
|
256
|
+
bearerToken: config.mcpBearerToken,
|
|
257
|
+
host: config.mcpHost,
|
|
258
|
+
port: config.mcpPort
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
} catch (error) {
|
|
262
|
+
console.error(
|
|
263
|
+
"[NodeBB Integration] Failed to configure MCP process:",
|
|
264
|
+
error.message
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
lastSignature = null;
|
|
268
|
+
nextChild.kill("SIGTERM");
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function syncConfiguration() {
|
|
273
|
+
const config = await Config.get();
|
|
274
|
+
|
|
275
|
+
if (!config.mcpEnabled) {
|
|
276
|
+
if (child) {
|
|
277
|
+
console.log("[NodeBB Integration] MCP disabled");
|
|
278
|
+
await stopChild();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!config.mcpBearerToken || !config.internalApiToken) {
|
|
285
|
+
console.error(
|
|
286
|
+
"[NodeBB Integration] MCP is enabled but required secrets are missing"
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
await stopChild();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const signature = JSON.stringify({
|
|
294
|
+
nodebbUrl: config.mcpNodebbUrl,
|
|
295
|
+
internalApiToken: config.internalApiToken,
|
|
296
|
+
bearerToken: config.mcpBearerToken,
|
|
297
|
+
host: config.mcpHost,
|
|
298
|
+
port: config.mcpPort
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
if (child && child.connected && signature === lastSignature) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (child) {
|
|
306
|
+
console.log("[NodeBB Integration] MCP configuration changed");
|
|
307
|
+
await stopChild();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
console.log("[NodeBB Integration] Starting MCP process");
|
|
311
|
+
startChild(config, signature);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function refresh() {
|
|
315
|
+
if (shuttingDown || !isPrimaryWorker()) {
|
|
316
|
+
return Promise.resolve();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
refreshRequested = true;
|
|
320
|
+
|
|
321
|
+
if (refreshPromise) {
|
|
322
|
+
return refreshPromise;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
refreshPromise = (async () => {
|
|
326
|
+
try {
|
|
327
|
+
while (refreshRequested && !shuttingDown) {
|
|
328
|
+
refreshRequested = false;
|
|
329
|
+
await syncConfiguration().catch((error) => {
|
|
330
|
+
console.error(
|
|
331
|
+
"[NodeBB Integration] MCP manager error:",
|
|
332
|
+
error.message
|
|
333
|
+
);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
} finally {
|
|
337
|
+
refreshPromise = null;
|
|
338
|
+
|
|
339
|
+
if (refreshRequested && !shuttingDown) {
|
|
340
|
+
refresh().catch((error) => {
|
|
341
|
+
console.error(
|
|
342
|
+
"[NodeBB Integration] MCP refresh failed:",
|
|
343
|
+
error.message
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
})();
|
|
349
|
+
|
|
350
|
+
return refreshPromise;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function init() {
|
|
354
|
+
if (initialized || !isPrimaryWorker()) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
initialized = true;
|
|
359
|
+
shuttingDown = false;
|
|
360
|
+
|
|
361
|
+
refresh().catch((error) => {
|
|
362
|
+
console.error(
|
|
363
|
+
"[NodeBB Integration] MCP startup failed:",
|
|
364
|
+
error.message
|
|
365
|
+
);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
timer = setInterval(refresh, CHECK_INTERVAL);
|
|
369
|
+
timer.unref();
|
|
370
|
+
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function shutdown() {
|
|
374
|
+
shuttingDown = true;
|
|
375
|
+
refreshRequested = false;
|
|
376
|
+
|
|
377
|
+
if (timer) {
|
|
378
|
+
clearInterval(timer);
|
|
379
|
+
timer = null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (refreshPromise) {
|
|
383
|
+
await refreshPromise;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
await stopChild();
|
|
387
|
+
|
|
388
|
+
initialized = false;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
module.exports = {
|
|
392
|
+
init,
|
|
393
|
+
refresh,
|
|
394
|
+
shutdown,
|
|
395
|
+
isPrimaryWorker
|
|
396
|
+
};
|