nodebb-plugin-onekite-discord 1.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.
package/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # nodebb-plugin-discord-onekite v1.1.4.19
2
+
3
+ Fix: category names (and topic titles) are HTML-entity decoded before being used in Discord embeds.
4
+ Example: `l'asso` -> `l'asso`.
5
+ Supports French accents and special characters via numeric entity decoding.
package/lib/admin.js ADDED
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ const meta = require.main.require('./src/meta');
4
+
5
+ const SETTINGS_KEY = 'discord-onekite';
6
+
7
+ function normalizeCids(v) {
8
+ if (!v) return [];
9
+ if (Array.isArray(v)) return v.map(String).filter(Boolean);
10
+ if (typeof v === 'string') return v.split(',').map(s => s.trim()).filter(Boolean);
11
+ return [];
12
+ }
13
+
14
+ async function getSettings() {
15
+ const s = await meta.settings.get(SETTINGS_KEY);
16
+ return {
17
+ webhookUrl: (s && s.webhookUrl) ? String(s.webhookUrl).trim() : '',
18
+ notifyReplies: !!(s && (s.notifyReplies === true || s.notifyReplies === 'on' || s.notifyReplies === 'true')),
19
+ cids: normalizeCids(s && s.cids),
20
+ };
21
+ }
22
+
23
+ async function saveSettings(body) {
24
+ const payload = {
25
+ webhookUrl: body.webhookUrl || '',
26
+ notifyReplies: body.notifyReplies ? 'on' : '',
27
+ // Store as CSV string for meta.settings
28
+ cids: Array.isArray(body.cids) ? body.cids.map(String).filter(Boolean).join(',') : (body.cids || ''),
29
+ };
30
+ await meta.settings.set(SETTINGS_KEY, payload);
31
+ }
32
+
33
+ module.exports = {
34
+ async getSettings(req, res) {
35
+ res.json({ settings: await getSettings() });
36
+ },
37
+ async saveSettings(req, res) {
38
+ await saveSettings(req.body || {});
39
+ res.json({ ok: true });
40
+ },
41
+ };
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+
3
+ const meta = require.main.require('./src/meta');
4
+ const categories = require.main.require('./src/categories');
5
+
6
+ const SETTINGS_KEY = 'discord-onekite';
7
+
8
+ function normalizeCids(v) {
9
+ if (!v) return [];
10
+ if (Array.isArray(v)) return v.map(String).filter(Boolean);
11
+ if (typeof v === 'string') return v.split(',').map(s => s.trim()).filter(Boolean);
12
+ return [];
13
+ }
14
+
15
+ async function getReadableCategories(uid) {
16
+ return await new Promise((resolve) => {
17
+ categories.getCategoriesByPrivilege('categories:cid', uid || 0, 'read', (err, categoriesData) => {
18
+ if (err || !Array.isArray(categoriesData)) return resolve([]);
19
+ resolve(categoriesData.filter(Boolean));
20
+ });
21
+ });
22
+ }
23
+
24
+ const controllers = {};
25
+
26
+ controllers.renderAdminPage = async function (req, res) {
27
+ const settings = await meta.settings.get(SETTINGS_KEY);
28
+ const savedCids = normalizeCids(settings && settings.cids);
29
+
30
+ const cats = await getReadableCategories(req.uid);
31
+ const categoriesForTpl = (cats || [])
32
+ .filter(c => c && typeof c.cid !== 'undefined' && c.name)
33
+ .map(c => ({
34
+ cid: String(c.cid),
35
+ name: c.name,
36
+ selected: savedCids.includes(String(c.cid)),
37
+ }))
38
+ .sort((a, b) => a.name.localeCompare(b.name));
39
+
40
+ res.render('admin/plugins/discord-onekite', {
41
+ settings: settings || {},
42
+ categories: categoriesForTpl,
43
+ });
44
+ };
45
+
46
+ module.exports = controllers;
package/library.js ADDED
@@ -0,0 +1,236 @@
1
+ 'use strict';
2
+
3
+ const { request } = require('undici');
4
+
5
+ const routeHelpers = require.main.require('./src/routes/helpers');
6
+ const meta = require.main.require('./src/meta');
7
+
8
+ const topics = require.main.require('./src/topics');
9
+ const categories = require.main.require('./src/categories');
10
+ const posts = require.main.require('./src/posts');
11
+
12
+ const controllers = require('./lib/controllers');
13
+ const adminApi = require('./lib/admin');
14
+
15
+ const Plugin = {};
16
+
17
+ const SETTINGS_KEY = 'discord-onekite';
18
+
19
+ function normalizeCids(value) {
20
+ if (!value) return [];
21
+ if (Array.isArray(value)) return value.map(v => String(v)).filter(Boolean);
22
+ if (typeof value === 'string') return value.split(',').map(s => s.trim()).filter(Boolean);
23
+ return [];
24
+ }
25
+
26
+ function normalizeBaseUrl(url) {
27
+ if (!url) return '';
28
+ let s = String(url).trim();
29
+ if (!/^https?:\/\//i.test(s)) s = `https://${s}`;
30
+ s = s.replace(/\/+$/, '');
31
+ return s;
32
+ }
33
+
34
+ function ensureAbsoluteUrl(baseUrl, maybeUrl) {
35
+ if (!maybeUrl) return '';
36
+ const s = String(maybeUrl);
37
+ if (/^https?:\/\//i.test(s)) return s;
38
+ if (s.startsWith('//')) return `https:${s}`;
39
+ if (s.startsWith('/')) return `${baseUrl}${s}`;
40
+ return s;
41
+ }
42
+
43
+ function stripHtml(html) {
44
+ if (!html) return '';
45
+ return String(html)
46
+ .replace(/<br\s*\/?>/gi, '\n')
47
+ .replace(/<\/p>/gi, '\n')
48
+ .replace(/<[^>]*>/g, '')
49
+ .replace(/&nbsp;/g, ' ')
50
+ .replace(/&amp;/g, '&')
51
+ .replace(/&lt;/g, '<')
52
+ .replace(/&gt;/g, '>')
53
+ .replace(/&#39;/g, "'")
54
+ .replace(/&quot;/g, '"')
55
+ .trim();
56
+ }
57
+
58
+
59
+ function decodeHtmlEntities(input) {
60
+ if (!input) return '';
61
+ let s = String(input);
62
+
63
+ // Named entities commonly encountered
64
+ s = s
65
+ .replace(/&nbsp;/g, ' ')
66
+ .replace(/&amp;/g, '&')
67
+ .replace(/&lt;/g, '<')
68
+ .replace(/&gt;/g, '>')
69
+ .replace(/&quot;/g, '"')
70
+ .replace(/&apos;/g, "'");
71
+
72
+ // Decimal numeric entities
73
+ s = s.replace(/&#(\d+);/g, (m, dec) => {
74
+ const code = parseInt(dec, 10);
75
+ if (!Number.isFinite(code)) return m;
76
+ return String.fromCodePoint(code);
77
+ });
78
+
79
+ // Hex numeric entities
80
+ s = s.replace(/&#x([0-9a-fA-F]+);/g, (m, hex) => {
81
+ const code = parseInt(hex, 16);
82
+ if (!Number.isFinite(code)) return m;
83
+ return String.fromCodePoint(code);
84
+ });
85
+
86
+ return s;
87
+ }
88
+
89
+ function truncate(s, n) {
90
+ const str = String(s || '');
91
+ if (str.length <= n) return str;
92
+ return str.slice(0, n - 1) + '…';
93
+ }
94
+
95
+ async function getSettings() {
96
+ const s = await meta.settings.get(SETTINGS_KEY);
97
+ return {
98
+ webhookUrl: (s && s.webhookUrl) ? String(s.webhookUrl).trim() : '',
99
+ notifyReplies: !!(s && (s.notifyReplies === true || s.notifyReplies === 'on' || s.notifyReplies === 'true')),
100
+ cids: normalizeCids(s && s.cids),
101
+ };
102
+ }
103
+
104
+ function cidAllowed(topicCid, allowedCids) {
105
+ if (!allowedCids || allowedCids.length === 0) return true;
106
+ return allowedCids.includes(String(topicCid));
107
+ }
108
+
109
+ async function postToDiscord(webhookUrl, payload) {
110
+ const res = await request(webhookUrl, {
111
+ method: 'POST',
112
+ headers: { 'Content-Type': 'application/json' },
113
+ body: JSON.stringify(payload),
114
+ });
115
+
116
+ const bodyText = await res.body.text().catch(() => '');
117
+
118
+ if (res.statusCode < 200 || res.statusCode >= 300) {
119
+ const err = new Error(`[discord-onekite] Discord webhook HTTP ${res.statusCode}: ${bodyText}`);
120
+ err.statusCode = res.statusCode;
121
+ err.responseBody = bodyText;
122
+ throw err;
123
+ }
124
+ }
125
+
126
+ async function sendDiscord(webhookUrl, payload) {
127
+ if (!webhookUrl) return;
128
+ try {
129
+ await postToDiscord(webhookUrl, payload);
130
+ } catch (e) {
131
+ // fallback to plain content if embeds rejected
132
+ if (e && e.statusCode === 400 && payload && payload.embeds) {
133
+ const fallback = payload.embeds[0] && payload.embeds[0].description ? payload.embeds[0].description : 'Notification';
134
+ try { await postToDiscord(webhookUrl, { content: fallback }); return; } catch (e2) { console.error(e2); }
135
+ }
136
+ console.error(e);
137
+ }
138
+ }
139
+
140
+ async function getPostExcerpt(pid) {
141
+ if (!pid) return '';
142
+ try {
143
+ const p = await posts.getPostFields(pid, ['content']);
144
+ const text = stripHtml(p && p.content);
145
+ return truncate(text, 500);
146
+ } catch {
147
+ return '';
148
+ }
149
+ }
150
+
151
+ async function buildEmbed({ tid, pid, isReply }) {
152
+ let baseUrl = normalizeBaseUrl(meta.config.url || meta.config['url']);
153
+ if (!baseUrl) baseUrl = 'https://www.onekite.com';
154
+
155
+ const topicData = await topics.getTopicFields(tid, ['tid', 'cid', 'title', 'slug', 'mainPid']);
156
+ if (!topicData) return null;
157
+
158
+ let categoryName = '';
159
+ try {
160
+ const cat = await categories.getCategoryData(topicData.cid);
161
+ if (cat && cat.name) categoryName = decodeHtmlEntities(cat.name);
162
+ } catch (e) {}
163
+
164
+ const topicUrl = ensureAbsoluteUrl(baseUrl, `/topic/${topicData.slug || topicData.tid}`);
165
+ const targetUrl = (isReply && pid) ? `${topicUrl}/${pid}` : topicUrl;
166
+
167
+ const baseTitle = decodeHtmlEntities((topicData.title || (isReply ? 'Nouvelle réponse' : 'Nouveau sujet')).toString());
168
+ const title = categoryName ? `[${categoryName}] ${baseTitle}` : baseTitle;
169
+ const safeTitle = title.slice(0, 256);
170
+
171
+ const excerpt = await getPostExcerpt(isReply ? pid : topicData.mainPid);
172
+
173
+ return { topicData, embed: { title: safeTitle, url: targetUrl, description: excerpt || '' } };
174
+ }
175
+
176
+ function extractTidPid(data) {
177
+ const tid = data?.tid || data?.topic?.tid || data?.post?.tid;
178
+ const pid = data?.pid || data?.post?.pid;
179
+ return { tid, pid };
180
+ }
181
+
182
+ Plugin.init = async function (params) {
183
+ const { router, middleware } = params;
184
+
185
+ // ACP page
186
+ routeHelpers.setupAdminPageRoute(router, '/admin/plugins/discord-onekite', [], controllers.renderAdminPage);
187
+
188
+ // Admin API v3 routes (same pattern as calendar-onekite)
189
+ const adminBases = ['/api/v3/admin/plugins/discord-onekite'];
190
+ const adminMws = [middleware.admin.checkPrivileges];
191
+
192
+ adminBases.forEach((base) => {
193
+ router.get(`${base}/settings`, ...adminMws, adminApi.getSettings);
194
+ router.put(`${base}/settings`, ...adminMws, adminApi.saveSettings);
195
+ });
196
+ };
197
+
198
+ Plugin.addAdminNavigation = (header) => {
199
+ header.plugins.push({
200
+ route: '/plugins/discord-onekite',
201
+ icon: 'fa-bell',
202
+ name: 'Discord Onekite',
203
+ });
204
+ return header;
205
+ };
206
+
207
+ Plugin.onTopicPost = async (data) => {
208
+ const settings = await getSettings();
209
+ if (!settings.webhookUrl) return;
210
+
211
+ const { tid } = extractTidPid(data);
212
+ if (!tid) return;
213
+
214
+ const built = await buildEmbed({ tid, isReply: false });
215
+ if (!built) return;
216
+ if (!cidAllowed(built.topicData.cid, settings.cids)) return;
217
+
218
+ await sendDiscord(settings.webhookUrl, { embeds: [built.embed] });
219
+ };
220
+
221
+ Plugin.onTopicReply = async (data) => {
222
+ const settings = await getSettings();
223
+ if (!settings.notifyReplies) return;
224
+ if (!settings.webhookUrl) return;
225
+
226
+ const { tid, pid } = extractTidPid(data);
227
+ if (!tid || !pid) return;
228
+
229
+ const built = await buildEmbed({ tid, pid, isReply: true });
230
+ if (!built) return;
231
+ if (!cidAllowed(built.topicData.cid, settings.cids)) return;
232
+
233
+ await sendDiscord(settings.webhookUrl, { embeds: [built.embed] });
234
+ };
235
+
236
+ module.exports = Plugin;
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "nodebb-plugin-onekite-discord",
3
+ "version": "1.0.0",
4
+ "description": "Discord webhook notifier for Onekite (NodeBB v4.x only)",
5
+ "main": "library.js",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "nodebb",
9
+ "plugin",
10
+ "discord",
11
+ "webhook",
12
+ "notifications"
13
+ ],
14
+ "dependencies": {
15
+ "undici": "^6.0.0"
16
+ },
17
+ "nbbpm": {
18
+ "compatibility": "^4.0.0"
19
+ }
20
+ }
package/plugin.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "id": "nodebb-plugin-onekite-discord",
3
+ "name": "Discord Onekite Notifier",
4
+ "description": "Notifie Discord via webhook pour nouveaux sujets et/ou r\u00e9ponses (NodeBB v4.x).",
5
+ "library": "./library.js",
6
+ "hooks": [
7
+ {
8
+ "hook": "static:app.load",
9
+ "method": "init"
10
+ },
11
+ {
12
+ "hook": "filter:admin.header.build",
13
+ "method": "addAdminNavigation"
14
+ },
15
+ {
16
+ "hook": "action:topic.post",
17
+ "method": "onTopicPost"
18
+ },
19
+ {
20
+ "hook": "action:topic.reply",
21
+ "method": "onTopicReply"
22
+ }
23
+ ],
24
+ "staticDirs": {
25
+ "public": "./public"
26
+ },
27
+ "templates": "./templates",
28
+ "modules": {
29
+ "../admin/plugins/discord-onekite.js": "./public/admin.js",
30
+ "admin/plugins/discord-onekite": "./public/admin.js"
31
+ },
32
+ "acpScripts": [
33
+ "public/admin.js"
34
+ ],
35
+ "version": "1.1.4.19"
36
+ }
@@ -0,0 +1,118 @@
1
+ define('admin/plugins/discord-onekite', ['alerts', 'bootbox'], function (alerts) {
2
+ 'use strict';
3
+
4
+ function showAlert(type, msg) {
5
+ // Deduplicate identical alerts that can be triggered multiple times
6
+ // by NodeBB ACP save buttons/hooks across ajaxify navigations.
7
+ try {
8
+ const now = Date.now();
9
+ const last = window.oneKiteDiscordLastAlert;
10
+ if (last && last.type === type && last.msg === msg && (now - last.ts) < 1200) {
11
+ return;
12
+ }
13
+ window.oneKiteDiscordLastAlert = { type, msg, ts: now };
14
+ } catch (e) {}
15
+ try {
16
+ if (alerts && typeof alerts[type] === 'function') {
17
+ alerts[type](msg);
18
+ return;
19
+ }
20
+ } catch (e) {}
21
+ alert(msg);
22
+ }
23
+
24
+ async function fetchJson(url, opts) {
25
+ const res = await fetch(url, Object.assign({
26
+ headers: Object.assign({
27
+ 'Content-Type': 'application/json',
28
+ 'x-csrf-token': (document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
29
+ (document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
30
+ (typeof app !== 'undefined' && app && app.csrfToken) ||
31
+ '',
32
+ }, (opts && opts.headers) || {}),
33
+ credentials: 'same-origin',
34
+ }, opts || {}));
35
+
36
+ const text = await res.text();
37
+ let data = null;
38
+ try { data = text ? JSON.parse(text) : null; } catch (e) {}
39
+
40
+ if (!res.ok) {
41
+ const err = new Error((data && data.status && data.status.message) || text || 'Request failed');
42
+ err.status = res.status;
43
+ err.data = data;
44
+ throw err;
45
+ }
46
+ return data;
47
+ }
48
+
49
+ async function loadSettings() {
50
+ const data = await fetchJson('/api/v3/admin/plugins/discord-onekite/settings');
51
+ return (data && data.settings) ? data.settings : {};
52
+ }
53
+
54
+ async function saveSettings(payload) {
55
+ return await fetchJson('/api/v3/admin/plugins/discord-onekite/settings', {
56
+ method: 'PUT',
57
+ body: JSON.stringify(payload),
58
+ });
59
+ }
60
+
61
+ function getSelectedCids() {
62
+ return Array.from(document.querySelectorAll('#cidsMulti option:checked')).map(o => String(o.value));
63
+ }
64
+
65
+ function setSelectedCids(cids) {
66
+ const set = new Set((cids || []).map(String));
67
+ document.querySelectorAll('#cidsMulti option').forEach(opt => {
68
+ opt.selected = set.has(String(opt.value));
69
+ });
70
+ }
71
+
72
+ function getPayload() {
73
+ return {
74
+ webhookUrl: String(document.getElementById('webhookUrl')?.value || '').trim(),
75
+ notifyReplies: !!document.getElementById('notifyReplies')?.checked,
76
+ cids: getSelectedCids(),
77
+ };
78
+ }
79
+
80
+ async function doSave() {
81
+ try {
82
+ await saveSettings(getPayload());
83
+ showAlert('success', 'Paramètres enregistrés');
84
+ } catch (e) {
85
+ // eslint-disable-next-line no-console
86
+ console.error(e);
87
+ showAlert('error', e && e.message ? e.message : 'Erreur lors de l’enregistrement');
88
+ }
89
+ }
90
+
91
+ function bindSaveButtons() {
92
+ const SAVE_SELECTOR = '#save, .save, [data-action="save"], .settings-save, .floating-save, .btn[data-action="save"], #onekite-save, #save-top, #onekite-discord-save';
93
+ if (window.oneKiteDiscordAdminBound) return;
94
+ window.oneKiteDiscordAdminBound = true;
95
+
96
+ document.addEventListener('click', function (ev) {
97
+ const target = ev.target && (ev.target.closest ? ev.target.closest(SAVE_SELECTOR) : null);
98
+ if (!target) return;
99
+ ev.preventDefault();
100
+ doSave();
101
+ }, true);
102
+ }
103
+
104
+ async function init() {
105
+ bindSaveButtons();
106
+ try {
107
+ const s = await loadSettings();
108
+ if (typeof s.webhookUrl !== 'undefined') document.getElementById('webhookUrl').value = s.webhookUrl || '';
109
+ if (typeof s.notifyReplies !== 'undefined') document.getElementById('notifyReplies').checked = !!s.notifyReplies;
110
+ if (Array.isArray(s.cids)) setSelectedCids(s.cids);
111
+ } catch (e) {
112
+ // eslint-disable-next-line no-console
113
+ console.error(e);
114
+ }
115
+ }
116
+
117
+ return { init: init };
118
+ });
@@ -0,0 +1,41 @@
1
+ <div class="acp-page-container">
2
+ <div class="d-flex justify-content-between align-items-start mb-3">
3
+ <div>
4
+ <h4 class="mb-1">Discord Onekite</h4>
5
+ <p class="text-muted mb-0">Notifications Discord via webhook.</p>
6
+ </div>
7
+ <button class="btn btn-primary" id="onekite-discord-save" type="button">Enregistrer</button>
8
+ </div>
9
+
10
+ <div class="row">
11
+ <div class="col-lg-8">
12
+ <div class="card mb-3">
13
+ <div class="card-body">
14
+ <form class="discord-onekite-settings" role="form">
15
+ <div class="mb-3">
16
+ <label class="form-label" for="webhookUrl">Discord Webhook URL</label>
17
+ <input id="webhookUrl" type="text" class="form-control" placeholder="https://discord.com/api/webhooks/..." value="{settings.webhookUrl}" />
18
+ </div>
19
+
20
+ <div class="form-check mb-3">
21
+ <input class="form-check-input" type="checkbox" id="notifyReplies" <!-- IF settings.notifyReplies -->checked<!-- ENDIF settings.notifyReplies -->>
22
+ <label class="form-check-label" for="notifyReplies">Notifier aussi les réponses</label>
23
+ </div>
24
+
25
+ <div class="mb-3">
26
+ <label class="form-label" for="cidsMulti">Catégories à notifier</label>
27
+ <select class="form-select" id="cidsMulti" multiple size="12">
28
+ <!-- BEGIN categories -->
29
+ <option value="{categories.cid}" <!-- IF categories.selected -->selected<!-- ENDIF categories.selected -->>{categories.name}</option>
30
+ <!-- END categories -->
31
+ </select>
32
+ <p class="form-text text-muted">
33
+ Si aucune catégorie n’est sélectionnée : <strong>toutes les catégories</strong> seront notifiées.
34
+ </p>
35
+ </div>
36
+ </form>
37
+ </div>
38
+ </div>
39
+ </div>
40
+ </div>
41
+ </div>