nodebb-plugin-discord-onekite 1.1.22 → 1.1.25

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,7 +1,17 @@
1
- # nodebb-plugin-discord-onekite v1.1.4.13
1
+ # nodebb-plugin-discord-onekite v1.1.4.15
2
2
 
3
- Fixes ACP not applying JS / not persisting:
4
- - Uses `acpScripts` + modules key `admin/plugins/discord-onekite.js` to ensure admin script loads.
5
- - Removes bottom-right save button partial; uses only the top "Enregistrer les paramètres" button.
6
- - Persists categories via hidden CSV field `data-field="cids"`.
7
- - Toast uses title "Succès" and message "Paramètres enregistrés".
3
+ ACP is implemented like the working `nodebb-plugin-calendar-onekite` you provided:
4
+ - Admin template uses `admin/partials/settings/header.tpl` + `footer.tpl` (top save bar)
5
+ - Admin JS uses NodeBB v3 admin API endpoints:
6
+ - GET /api/v3/admin/plugins/discord-onekite/settings
7
+ - PUT /api/v3/admin/plugins/discord-onekite/settings
8
+ - Save buttons from header are intercepted and call the PUT endpoint.
9
+
10
+ Toast:
11
+ - Shows "Succès" (title) and "Paramètres enregistrés" (message) via `app.alert` when available.
12
+
13
+ Discord:
14
+ - Embed only: clickable title (embed.url) + message excerpt in embed.description.
15
+
16
+
17
+ Toast updated to match calendar-onekite: alerts.success/error with dedup.
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
+ };
package/library.js CHANGED
@@ -4,13 +4,14 @@ const { request } = require('undici');
4
4
 
5
5
  const routeHelpers = require.main.require('./src/routes/helpers');
6
6
  const meta = require.main.require('./src/meta');
7
- const middleware = require.main.require('./src/middleware');
8
7
 
9
8
  const topics = require.main.require('./src/topics');
10
9
  const posts = require.main.require('./src/posts');
11
- const user = require.main.require('./src/user');
12
10
 
13
11
  const controllers = require('./lib/controllers');
12
+ const adminApi = require('./lib/admin');
13
+
14
+ const Plugin = {};
14
15
 
15
16
  const SETTINGS_KEY = 'discord-onekite';
16
17
 
@@ -38,15 +39,6 @@ function ensureAbsoluteUrl(baseUrl, maybeUrl) {
38
39
  return s;
39
40
  }
40
41
 
41
- function isValidHttpUrl(s) {
42
- try {
43
- const u = new URL(s);
44
- return u.protocol === 'http:' || u.protocol === 'https:';
45
- } catch {
46
- return false;
47
- }
48
- }
49
-
50
42
  function stripHtml(html) {
51
43
  if (!html) return '';
52
44
  return String(html)
@@ -99,19 +91,15 @@ async function postToDiscord(webhookUrl, payload) {
99
91
  }
100
92
  }
101
93
 
102
- async function sendDiscord(webhookUrl, payload, fallbackContent) {
94
+ async function sendDiscord(webhookUrl, payload) {
103
95
  if (!webhookUrl) return;
104
-
105
96
  try {
106
97
  await postToDiscord(webhookUrl, payload);
107
98
  } catch (e) {
108
- if (e && e.statusCode === 400 && fallbackContent) {
109
- try {
110
- await postToDiscord(webhookUrl, { content: fallbackContent });
111
- return;
112
- } catch (e2) {
113
- console.error(e2);
114
- }
99
+ // fallback to plain content if embeds rejected
100
+ if (e && e.statusCode === 400 && payload && payload.embeds) {
101
+ const fallback = payload.embeds[0] && payload.embeds[0].description ? payload.embeds[0].description : 'Notification';
102
+ try { await postToDiscord(webhookUrl, { content: fallback }); return; } catch (e2) { console.error(e2); }
115
103
  }
116
104
  console.error(e);
117
105
  }
@@ -128,43 +116,20 @@ async function getPostExcerpt(pid) {
128
116
  }
129
117
  }
130
118
 
131
- async function buildPayload({ tid, pid, isReply }) {
119
+ async function buildEmbed({ tid, pid, isReply }) {
132
120
  let baseUrl = normalizeBaseUrl(meta.config.url || meta.config['url']);
133
- if (!baseUrl) { baseUrl = 'https://www.onekite.com'; }
121
+ if (!baseUrl) baseUrl = 'https://www.onekite.com';
134
122
 
135
- const topicData = await topics.getTopicFields(tid, ['tid', 'uid', 'cid', 'title', 'slug', 'mainPid']);
123
+ const topicData = await topics.getTopicFields(tid, ['tid', 'cid', 'title', 'slug', 'mainPid']);
136
124
  if (!topicData) return null;
137
125
 
138
- // Always build the forum link like the example: https://www.onekite.com/topic/<tid or slug>
139
126
  const topicUrl = ensureAbsoluteUrl(baseUrl, `/topic/${topicData.slug || topicData.tid}`);
127
+ const targetUrl = (isReply && pid) ? `${topicUrl}/${pid}` : topicUrl;
140
128
 
141
- // For replies, link directly to the post if possible
142
- const postUrl = (isReply && pid) ? `${topicUrl}/${pid}` : topicUrl;
143
-
144
- const u = await user.getUserFields(topicData.uid, ['username']);
145
129
  const title = (topicData.title || (isReply ? 'Nouvelle réponse' : 'Nouveau sujet')).toString().slice(0, 256);
146
- const authorName = (u && u.username ? String(u.username) : 'Utilisateur').slice(0, 256);
147
-
148
130
  const excerpt = await getPostExcerpt(isReply ? pid : topicData.mainPid);
149
- const description = truncate(excerpt || (isReply ? `Réponse de ${authorName}` : `Sujet créé par ${authorName}`), 500);
150
131
 
151
- // Minimal embed but with URL so the title is clickable
152
- const embed = {
153
- title,
154
- description,
155
- };
156
- if (isValidHttpUrl(postUrl)) embed.url = postUrl;
157
-
158
- // Message format:
159
- // 1) A single clickable link line: "🆕 Nouveau sujet : [Titre](URL)" (or reply variant)
160
- // 2) Excerpt below
161
- const linkLine = isReply
162
- ? `🗨️ Nouvelle réponse : [${title}](${postUrl})`
163
- : `🆕 Nouveau sujet : [${title}](${topicUrl})`;
164
-
165
- const content = [linkLine, description].filter(Boolean).join('\n');
166
-
167
- return { topicData, embed, content };
132
+ return { topicData, embed: { title, url: targetUrl, description: excerpt || '' } };
168
133
  }
169
134
 
170
135
  function extractTidPid(data) {
@@ -173,15 +138,20 @@ function extractTidPid(data) {
173
138
  return { tid, pid };
174
139
  }
175
140
 
176
- const Plugin = {};
141
+ Plugin.init = async function (params) {
142
+ const { router, middleware } = params;
143
+
144
+ // ACP page
145
+ routeHelpers.setupAdminPageRoute(router, '/admin/plugins/discord-onekite', [], controllers.renderAdminPage);
177
146
 
178
- Plugin.init = async ({ router }) => {
179
- routeHelpers.setupAdminPageRoute(
180
- router,
181
- '/admin/plugins/discord-onekite',
182
- [],
183
- controllers.renderAdminPage
184
- );
147
+ // Admin API v3 routes (same pattern as calendar-onekite)
148
+ const adminBases = ['/api/v3/admin/plugins/discord-onekite'];
149
+ const adminMws = [middleware.admin.checkPrivileges];
150
+
151
+ adminBases.forEach((base) => {
152
+ router.get(`${base}/settings`, ...adminMws, adminApi.getSettings);
153
+ router.put(`${base}/settings`, ...adminMws, adminApi.saveSettings);
154
+ });
185
155
  };
186
156
 
187
157
  Plugin.addAdminNavigation = (header) => {
@@ -200,13 +170,11 @@ Plugin.onTopicPost = async (data) => {
200
170
  const { tid } = extractTidPid(data);
201
171
  if (!tid) return;
202
172
 
203
- const built = await buildPayload({ tid, isReply: false });
173
+ const built = await buildEmbed({ tid, isReply: false });
204
174
  if (!built) return;
205
-
206
175
  if (!cidAllowed(built.topicData.cid, settings.cids)) return;
207
176
 
208
- // Send content + embed (content ensures clickable link always)
209
- await sendDiscord(settings.webhookUrl, { content: built.content, embeds: [built.embed] }, built.content);
177
+ await sendDiscord(settings.webhookUrl, { embeds: [built.embed] });
210
178
  };
211
179
 
212
180
  Plugin.onTopicReply = async (data) => {
@@ -217,14 +185,11 @@ Plugin.onTopicReply = async (data) => {
217
185
  const { tid, pid } = extractTidPid(data);
218
186
  if (!tid || !pid) return;
219
187
 
220
- const built = await buildPayload({ tid, pid, isReply: true });
188
+ const built = await buildEmbed({ tid, pid, isReply: true });
221
189
  if (!built) return;
222
-
223
- if (built.topicData?.mainPid && String(built.topicData.mainPid) === String(pid)) return;
224
-
225
190
  if (!cidAllowed(built.topicData.cid, settings.cids)) return;
226
191
 
227
- await sendDiscord(settings.webhookUrl, { content: built.content, embeds: [built.embed] }, built.content);
192
+ await sendDiscord(settings.webhookUrl, { embeds: [built.embed] });
228
193
  };
229
194
 
230
195
  module.exports = Plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-discord-onekite",
3
- "version": "1.1.22",
3
+ "version": "1.1.25",
4
4
  "description": "Discord webhook notifier for Onekite (NodeBB v4.x only)",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "nodebb-plugin-discord-onekite",
3
3
  "name": "Discord Onekite Notifier",
4
- "description": "Notifie Discord via webhook pour nouveaux sujets et/ou r\u00e9ponses, filtrable par cat\u00e9gories (NodeBB v4.x uniquement).",
4
+ "description": "Notifie Discord via webhook pour nouveaux sujets et/ou r\u00e9ponses (NodeBB v4.x).",
5
5
  "library": "./library.js",
6
6
  "hooks": [
7
7
  {
@@ -21,11 +21,16 @@
21
21
  "method": "onTopicReply"
22
22
  }
23
23
  ],
24
- "templates": "templates",
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
+ },
25
32
  "acpScripts": [
26
- "static/lib/admin.js"
33
+ "public/admin.js"
27
34
  ],
28
- "modules": {
29
- "admin/plugins/discord-onekite.js": "static/lib/admin.js"
30
- }
35
+ "version": "1.1.4.16"
31
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';
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
+ });
@@ -1,39 +1,40 @@
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 id="onekite-save" type="button" class="btn btn-primary">Enregistrer les paramètres</button>
8
- </div>
9
-
10
- <form role="form" class="discord-onekite-settings">
11
- <div class="mb-3">
12
- <label class="form-label" for="webhookUrl">Discord Webhook URL</label>
13
- <input type="text" class="form-control" id="webhookUrl" data-field="webhookUrl" value="{settings.webhookUrl}" placeholder="https://discord.com/api/webhooks/..." />
14
- </div>
1
+ <!-- IMPORT admin/partials/settings/header.tpl -->
15
2
 
16
- <div class="form-check mb-3">
17
- <input class="form-check-input" type="checkbox" id="notifyReplies" data-field="notifyReplies" <!-- IF settings.notifyReplies -->checked<!-- ENDIF settings.notifyReplies -->>
18
- <label class="form-check-label" for="notifyReplies">
19
- Notifier aussi les réponses (si décoché : uniquement les nouveaux sujets)
20
- </label>
21
- </div>
22
-
23
- <div class="mb-3">
24
- <label class="form-label" for="cidsMulti">Catégories à notifier</label>
3
+ <div class="row">
4
+ <div class="col-lg-8">
5
+ <div class="card mb-3">
6
+ <div class="card-header">
7
+ <strong>Discord Onekite</strong>
8
+ </div>
9
+ <div class="card-body">
10
+ <p class="text-muted">Notifications Discord via webhook.</p>
25
11
 
26
- <select class="form-select" id="cidsMulti" multiple size="12">
27
- <!-- BEGIN categories -->
28
- <option value="{categories.cid}" <!-- IF categories.selected -->selected<!-- ENDIF categories.selected -->>{categories.name}</option>
29
- <!-- END categories -->
30
- </select>
12
+ <form class="discord-onekite-settings" role="form">
13
+ <div class="mb-3">
14
+ <label class="form-label" for="webhookUrl">Discord Webhook URL</label>
15
+ <input id="webhookUrl" type="text" class="form-control" placeholder="https://discord.com/api/webhooks/..." value="{settings.webhookUrl}" />
16
+ </div>
31
17
 
32
- <input type="hidden" id="cids" data-field="cids" value="{settings.cids}" />
18
+ <div class="form-check mb-3">
19
+ <input class="form-check-input" type="checkbox" id="notifyReplies" <!-- IF settings.notifyReplies -->checked<!-- ENDIF settings.notifyReplies -->>
20
+ <label class="form-check-label" for="notifyReplies">Notifier aussi les réponses</label>
21
+ </div>
33
22
 
34
- <p class="form-text text-muted">
35
- Si aucune catégorie n’est sélectionnée : <strong>toutes les catégories</strong> seront notifiées.
36
- </p>
23
+ <div class="mb-3">
24
+ <label class="form-label" for="cidsMulti">Catégories à notifier</label>
25
+ <select class="form-select" id="cidsMulti" multiple size="12">
26
+ <!-- BEGIN categories -->
27
+ <option value="{categories.cid}" <!-- IF categories.selected -->selected<!-- ENDIF categories.selected -->>{categories.name}</option>
28
+ <!-- END categories -->
29
+ </select>
30
+ <p class="form-text text-muted">
31
+ Si aucune catégorie n’est sélectionnée : <strong>toutes les catégories</strong> seront notifiées.
32
+ </p>
33
+ </div>
34
+ </form>
35
+ </div>
37
36
  </div>
38
- </form>
37
+ </div>
39
38
  </div>
39
+
40
+ <!-- IMPORT admin/partials/settings/footer.tpl -->
@@ -1,50 +0,0 @@
1
- 'use strict';
2
- /* global $, app */
3
-
4
- define('admin/plugins/discord-onekite', ['settings'], function (settings) {
5
- const ACP = {};
6
-
7
- function parseCsv(v) {
8
- if (!v) return [];
9
- return String(v).split(',').map(s => s.trim()).filter(Boolean);
10
- }
11
- function toCsv(arr) {
12
- return (arr || []).map(String).filter(Boolean).join(',');
13
- }
14
- function setSelectedCids(cids) {
15
- const set = new Set((cids || []).map(String));
16
- $('#cidsMulti option').each(function () {
17
- $(this).prop('selected', set.has(String($(this).val())));
18
- });
19
- }
20
- function getSelectedCids() {
21
- return $('#cidsMulti option:selected').map(function () { return String($(this).val()); }).get();
22
- }
23
-
24
- function toastSuccess() {
25
- if (window.app && typeof app.alert === 'function') {
26
- app.alert({ type: 'success', title: 'Succès', message: 'Paramètres enregistrés' });
27
- } else if (window.app && typeof app.alertSuccess === 'function') {
28
- app.alertSuccess('Succès: Paramètres enregistrés');
29
- }
30
- }
31
-
32
- ACP.init = function () {
33
- const $form = $('.discord-onekite-settings');
34
- if (!$form.length) return;
35
-
36
- settings.sync('discord-onekite', $form, function () {
37
- setSelectedCids(parseCsv($('#cids').val()));
38
- });
39
-
40
- $('#onekite-save').off('click.discordOnekite').on('click.discordOnekite', function (e) {
41
- e.preventDefault();
42
- $('#cids').val(toCsv(getSelectedCids()));
43
- settings.persist('discord-onekite', $form, function () {
44
- toastSuccess();
45
- });
46
- });
47
- };
48
-
49
- return ACP;
50
- });