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
package/src/routes/api.js
CHANGED
|
@@ -1,97 +1,255 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
error:
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
|
|
5
|
+
const Config = require("../config");
|
|
6
|
+
const Search = require("../ai/search");
|
|
7
|
+
const Categories = require("../nodebb/categories");
|
|
8
|
+
const Meta = require("../nodebb/meta");
|
|
9
|
+
const Posts = require("../nodebb/posts");
|
|
10
|
+
const Topics = require("../nodebb/topics");
|
|
11
|
+
const Users = require("../nodebb/users");
|
|
12
|
+
const Privileges = require("../nodebb/privileges");
|
|
13
|
+
|
|
14
|
+
function validId(value) {
|
|
15
|
+
const id = Number(value);
|
|
16
|
+
return Number.isSafeInteger(id) && id > 0 ? id : 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function internalTokenMatches(req, expected) {
|
|
20
|
+
const authorization = req.headers.authorization || "";
|
|
21
|
+
const match = authorization.match(/^Bearer\s+([^\s]+)$/i);
|
|
22
|
+
|
|
23
|
+
if (!match || typeof expected !== "string" || !expected) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const actualBuffer = Buffer.from(match[1]);
|
|
28
|
+
const expectedBuffer = Buffer.from(expected);
|
|
29
|
+
|
|
30
|
+
return actualBuffer.length === expectedBuffer.length &&
|
|
31
|
+
crypto.timingSafeEqual(actualBuffer, expectedBuffer);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
exports.internalAuth = async function (req, res, next) {
|
|
35
|
+
try {
|
|
36
|
+
const settings = await Config.get();
|
|
37
|
+
|
|
38
|
+
if (!settings.mcpEnabled) {
|
|
39
|
+
return res.status(403).json({ error: "MCP is disabled" });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!internalTokenMatches(req, settings.internalApiToken)) {
|
|
43
|
+
return res.status(401).json({ error: "Unauthorized" });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
req.integrationSettings = settings;
|
|
47
|
+
return next();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.error("[Integration] Internal authentication failed:", error.message);
|
|
50
|
+
return res.status(500).json({ error: "Internal API error" });
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
exports.apiSearch = async function (req, res) {
|
|
55
|
+
try {
|
|
56
|
+
const query = typeof req.body?.query === "string"
|
|
57
|
+
? req.body.query.trim()
|
|
58
|
+
: "";
|
|
59
|
+
|
|
60
|
+
if (!query || query.length > 2000) {
|
|
61
|
+
return res.status(400).json({ error: "Invalid search query" });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const results = await Search.searchText(query, req.uid);
|
|
65
|
+
|
|
66
|
+
return res.json({ results });
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error.code === "AI_DISABLED") {
|
|
69
|
+
return res.status(503).json({ error: error.message });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.error("[NodeBB Integration] Search failed:", error);
|
|
73
|
+
return res.status(500).json({ error: "Search failed" });
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
exports.internalInfo = async function (req, res) {
|
|
78
|
+
try {
|
|
79
|
+
return res.json(await Meta.getInfo());
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error("[Integration] Forum info failed:", error.message);
|
|
82
|
+
return res.status(500).json({ error: "Unable to get forum information" });
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
exports.internalPost = async function (req, res) {
|
|
87
|
+
try {
|
|
88
|
+
const pid = validId(req.body?.pid);
|
|
89
|
+
const uid = req.integrationSettings.mcpServiceUid;
|
|
90
|
+
|
|
91
|
+
if (!pid) {
|
|
92
|
+
return res.status(400).json({ error: "Invalid post ID" });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const post = await Posts.getPostMeta(pid);
|
|
96
|
+
|
|
97
|
+
if (!post || post.deleted) {
|
|
98
|
+
return res.status(404).json({ error: "Post not found" });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const tid = validId(post.tid);
|
|
102
|
+
|
|
103
|
+
if (!tid) {
|
|
104
|
+
return res.status(404).json({ error: "Post not found" });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const [postAllowed, topicAllowed] = await Promise.all([
|
|
108
|
+
Privileges.canReadPost(pid, uid),
|
|
109
|
+
Privileges.canReadTopic(tid, uid)
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
if (!postAllowed || !topicAllowed) {
|
|
113
|
+
return res.status(404).json({ error: "Post not found" });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const content = await Posts.getPostContent(pid);
|
|
117
|
+
|
|
118
|
+
if (!content) {
|
|
119
|
+
return res.status(404).json({ error: "Post not found" });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return res.json({
|
|
123
|
+
pid: Number(post.pid),
|
|
124
|
+
tid,
|
|
125
|
+
uid: Number(post.uid),
|
|
126
|
+
cid: Number(post.cid),
|
|
127
|
+
content: content.content,
|
|
128
|
+
timestamp: post.timestamp
|
|
129
|
+
});
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error("[Integration] MCP post retrieval failed:", error.message);
|
|
132
|
+
return res.status(500).json({ error: "Unable to get post" });
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
exports.internalTopic = async function (req, res) {
|
|
137
|
+
try {
|
|
138
|
+
const tid = validId(req.body?.tid);
|
|
139
|
+
const uid = req.integrationSettings.mcpServiceUid;
|
|
140
|
+
|
|
141
|
+
if (!tid) {
|
|
142
|
+
return res.status(400).json({ error: "Invalid topic ID" });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!(await Privileges.canReadTopic(tid, uid))) {
|
|
146
|
+
return res.status(404).json({ error: "Topic not found" });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const topic = await Topics.getTopic(tid);
|
|
150
|
+
|
|
151
|
+
if (!topic || topic.deleted) {
|
|
152
|
+
return res.status(404).json({ error: "Topic not found" });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return res.json({
|
|
156
|
+
tid: Number(topic.tid),
|
|
157
|
+
uid: Number(topic.uid),
|
|
158
|
+
title: topic.title,
|
|
159
|
+
cid: Number(topic.cid),
|
|
160
|
+
timestamp: topic.timestamp,
|
|
161
|
+
mainPid: Number(topic.mainPid)
|
|
162
|
+
});
|
|
163
|
+
} catch (error) {
|
|
164
|
+
console.error("[Integration] MCP topic retrieval failed:", error.message);
|
|
165
|
+
return res.status(500).json({ error: "Unable to get topic" });
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
exports.internalUser = async function (req, res) {
|
|
170
|
+
try {
|
|
171
|
+
const uid = validId(req.body?.uid);
|
|
172
|
+
|
|
173
|
+
if (!uid) {
|
|
174
|
+
return res.status(400).json({ error: "Invalid user ID" });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const user = await Users.getUser(uid);
|
|
178
|
+
|
|
179
|
+
if (!user || !Number(user.uid)) {
|
|
180
|
+
return res.status(404).json({ error: "User not found" });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return res.json({
|
|
184
|
+
uid: Number(user.uid),
|
|
185
|
+
username: user.username,
|
|
186
|
+
userslug: user.userslug,
|
|
187
|
+
picture: user.picture
|
|
188
|
+
});
|
|
189
|
+
} catch (error) {
|
|
190
|
+
console.error("[Integration] MCP user retrieval failed:", error.message);
|
|
191
|
+
return res.status(500).json({ error: "Unable to get user" });
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
exports.internalSearch = async function (req, res) {
|
|
196
|
+
try {
|
|
197
|
+
const query = typeof req.body?.query === "string"
|
|
198
|
+
? req.body.query.trim()
|
|
199
|
+
: "";
|
|
200
|
+
|
|
201
|
+
const requestedLimit = Number(req.body?.limit ?? 5);
|
|
202
|
+
|
|
203
|
+
if (!query || query.length > 2000) {
|
|
204
|
+
return res.status(400).json({ error: "Invalid search query" });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!Number.isSafeInteger(requestedLimit) || requestedLimit < 1) {
|
|
208
|
+
return res.status(400).json({ error: "Invalid search limit" });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const limit = Math.min(requestedLimit, 20);
|
|
212
|
+
const uid = req.integrationSettings.mcpServiceUid;
|
|
213
|
+
|
|
214
|
+
const results = await Search.searchText(query, uid, limit);
|
|
215
|
+
|
|
216
|
+
return res.json({ results });
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (error.code === "AI_DISABLED") {
|
|
219
|
+
return res.status(503).json({ error: error.message });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
console.error("[Integration] MCP search failed:", error.message);
|
|
223
|
+
return res.status(500).json({ error: "Search failed" });
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
exports.renderAdmin = async function (req, res) {
|
|
228
|
+
try {
|
|
229
|
+
const [settings, allCategories] = await Promise.all([
|
|
230
|
+
Config.info(),
|
|
231
|
+
Categories.getAll()
|
|
232
|
+
]);
|
|
233
|
+
|
|
234
|
+
const excluded = new Set(settings.excludedCategories.map(Number));
|
|
235
|
+
|
|
236
|
+
const categories = allCategories
|
|
237
|
+
.filter(category => category.cid > 0)
|
|
238
|
+
.map(category => ({
|
|
239
|
+
...category,
|
|
240
|
+
selected: excluded.has(Number(category.cid))
|
|
241
|
+
}));
|
|
242
|
+
|
|
243
|
+
return res.render("admin/plugins/integration", {
|
|
244
|
+
title: "NodeBB Integration",
|
|
245
|
+
settings,
|
|
246
|
+
categories
|
|
247
|
+
});
|
|
248
|
+
} catch (error) {
|
|
249
|
+
console.error("[NodeBB Integration] ACP render failed:", error);
|
|
250
|
+
|
|
251
|
+
return res.status(500).render("500", {
|
|
252
|
+
title: "NodeBB Integration error"
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
};
|
package/src/routes/index.js
CHANGED
|
@@ -1,49 +1,64 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
router.
|
|
22
|
-
"/api/v3/integration/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const API = require("./api");
|
|
4
|
+
|
|
5
|
+
exports.init = function (router, middleware) {
|
|
6
|
+
if (!router || !middleware) {
|
|
7
|
+
throw new Error("[Integration] Router middleware unavailable");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (typeof middleware.pluginHooks !== "function") {
|
|
11
|
+
throw new Error("[Integration] pluginHooks middleware unavailable");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const helpers = nodebb.require("./src/routes/helpers");
|
|
15
|
+
|
|
16
|
+
if (typeof helpers.setupAdminPageRoute !== "function") {
|
|
17
|
+
throw new Error("[Integration] setupAdminPageRoute unavailable");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Existing user-facing semantic search.
|
|
21
|
+
router.post(
|
|
22
|
+
"/api/v3/integration/search",
|
|
23
|
+
middleware.ensureLoggedIn,
|
|
24
|
+
API.apiSearch
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
router.get(
|
|
28
|
+
"/api/v3/integration/internal/info",
|
|
29
|
+
API.internalAuth,
|
|
30
|
+
API.internalInfo
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
router.post(
|
|
34
|
+
"/api/v3/integration/internal/post",
|
|
35
|
+
API.internalAuth,
|
|
36
|
+
API.internalPost
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
router.post(
|
|
40
|
+
"/api/v3/integration/internal/topic",
|
|
41
|
+
API.internalAuth,
|
|
42
|
+
API.internalTopic
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
router.post(
|
|
46
|
+
"/api/v3/integration/internal/user",
|
|
47
|
+
API.internalAuth,
|
|
48
|
+
API.internalUser
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
router.post(
|
|
52
|
+
"/api/v3/integration/internal/search",
|
|
53
|
+
API.internalAuth,
|
|
54
|
+
API.internalSearch
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Existing ACP page.
|
|
58
|
+
helpers.setupAdminPageRoute(
|
|
59
|
+
router,
|
|
60
|
+
"/admin/plugins/integration",
|
|
61
|
+
[middleware.pluginHooks],
|
|
62
|
+
API.renderAdmin
|
|
63
|
+
);
|
|
64
|
+
};
|