nodebb-plugin-discord-onekite 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,20 @@
1
+ # nodebb-plugin-discord-onekite (NodeBB v4.x)
2
+
3
+ Plugin NodeBB qui notifie un salon Discord via **webhook** lors :
4
+ - des **nouveaux sujets**
5
+ - et optionnellement des **réponses**
6
+
7
+ ## Installation (local)
8
+ Depuis le dossier NodeBB :
9
+
10
+ ```bash
11
+ npm install /chemin/vers/nodebb-plugin-discord-onekite
12
+ ./nodebb build
13
+ ./nodebb restart
14
+ ```
15
+
16
+ ## Configuration
17
+ ACP → Plugins → **Discord Onekite**
18
+ - Webhook URL
19
+ - (option) Notifier les réponses
20
+ - Catégories à notifier (si aucune sélection : toutes)
@@ -0,0 +1,9 @@
1
+ 'use strict';
2
+
3
+ const controllers = {};
4
+
5
+ controllers.renderAdminPage = async function (req, res) {
6
+ res.render('admin/plugins/discord-onekite', {});
7
+ };
8
+
9
+ module.exports = controllers;
package/library.js ADDED
@@ -0,0 +1,169 @@
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 user = require.main.require('./src/user');
10
+
11
+ const controllers = require('./lib/controllers');
12
+
13
+ // Settings are stored under this key in NodeBB's settings system
14
+ const SETTINGS_KEY = 'discord-onekite';
15
+
16
+ function normalizeCids(value) {
17
+ if (!value) return [];
18
+ if (Array.isArray(value)) return value.map(v => String(v)).filter(Boolean);
19
+
20
+ // Sometimes saved as "1,2,3"
21
+ if (typeof value === 'string') {
22
+ return value
23
+ .split(',')
24
+ .map(s => s.trim())
25
+ .filter(Boolean);
26
+ }
27
+ return [];
28
+ }
29
+
30
+ async function getSettings() {
31
+ const s = await meta.settings.get(SETTINGS_KEY);
32
+ return {
33
+ webhookUrl: (s && s.webhookUrl) ? String(s.webhookUrl).trim() : '',
34
+ notifyReplies: !!(s && (s.notifyReplies === true || s.notifyReplies === 'on' || s.notifyReplies === 'true')),
35
+ cids: normalizeCids(s && s.cids),
36
+ };
37
+ }
38
+
39
+ function cidAllowed(topicCid, allowedCids) {
40
+ // IMPORTANT (per request): if no categories are selected => notify ALL categories.
41
+ if (!allowedCids || allowedCids.length === 0) return true;
42
+ return allowedCids.includes(String(topicCid));
43
+ }
44
+
45
+ async function postToDiscord(webhookUrl, payload) {
46
+ if (!webhookUrl) return;
47
+
48
+ const res = await request(webhookUrl, {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify(payload),
52
+ });
53
+
54
+ // Discord webhook success is commonly 204 No Content
55
+ if (res.statusCode < 200 || res.statusCode >= 300) {
56
+ const text = await res.body.text().catch(() => '');
57
+ throw new Error(`[discord-onekite] Discord webhook HTTP ${res.statusCode}: ${text}`);
58
+ }
59
+ }
60
+
61
+ async function buildTopicEmbed({ tid, pid, type }) {
62
+ const baseUrl = meta.config.url;
63
+
64
+ // For v4.x, these fields are stable and enough for URL + title
65
+ const topicData = await topics.getTopicFields(tid, ['tid', 'uid', 'cid', 'title', 'slug', 'timestamp', 'mainPid']);
66
+ if (!topicData) return null;
67
+
68
+ const topicUrl = `${baseUrl}/topic/${topicData.slug || topicData.tid}`;
69
+
70
+ // Author (topic author). If you prefer reply author, adjust to read post author.
71
+ const u = await user.getUserFields(topicData.uid, ['username', 'picture']);
72
+
73
+ const isReply = type === 'reply';
74
+ const embed = {
75
+ title: topicData.title || (isReply ? 'Nouvelle réponse' : 'Nouveau sujet'),
76
+ url: isReply && pid ? `${topicUrl}/${pid}` : topicUrl,
77
+ timestamp: new Date(isReply ? Date.now() : (topicData.timestamp || Date.now())).toISOString(),
78
+ author: u?.username ? { name: u.username, icon_url: u.picture || undefined } : undefined,
79
+ footer: { text: 'NodeBB → Discord (Onekite)' },
80
+ };
81
+
82
+ if (isReply) {
83
+ embed.description = 'Nouvelle réponse dans un sujet.';
84
+ }
85
+
86
+ return { topicData, embed };
87
+ }
88
+
89
+ const Plugin = {};
90
+
91
+ // Mount ACP page route (NodeBB v4.x)
92
+ Plugin.init = async ({ router }) => {
93
+ routeHelpers.setupAdminPageRoute(
94
+ router,
95
+ '/admin/plugins/discord-onekite',
96
+ [],
97
+ controllers.renderAdminPage
98
+ );
99
+ };
100
+
101
+ // Add entry under ACP -> Plugins
102
+ Plugin.addAdminNavigation = (header) => {
103
+ header.plugins.push({
104
+ route: '/plugins/discord-onekite',
105
+ icon: 'fa-bell',
106
+ name: 'Discord Onekite',
107
+ });
108
+ return header;
109
+ };
110
+
111
+ // NEW TOPICS ONLY (unless notifyReplies enabled for posts)
112
+ Plugin.onTopicSave = async (data) => {
113
+ try {
114
+ const { webhookUrl, cids } = await getSettings();
115
+ if (!webhookUrl) return;
116
+
117
+ const tid = data?.tid || data?.topic?.tid;
118
+ if (!tid) return;
119
+
120
+ // In v4.x, new topics often expose isNew; as a fallback, we detect postcount=1 if present.
121
+ const isNew =
122
+ data?.isNew === true ||
123
+ data?.topic?.isNew === true ||
124
+ (typeof data?.topic?.postcount === 'number' && data.topic.postcount === 1);
125
+
126
+ if (!isNew) return;
127
+
128
+ const built = await buildTopicEmbed({ tid, type: 'topic' });
129
+ if (!built) return;
130
+
131
+ if (!cidAllowed(built.topicData.cid, cids)) return;
132
+
133
+ await postToDiscord(webhookUrl, { embeds: [built.embed] });
134
+ } catch (err) {
135
+ // eslint-disable-next-line no-console
136
+ console.error(err);
137
+ }
138
+ };
139
+
140
+ // REPLIES (optional)
141
+ Plugin.onPostSave = async (data) => {
142
+ try {
143
+ const { webhookUrl, notifyReplies, cids } = await getSettings();
144
+ if (!webhookUrl || !notifyReplies) return;
145
+
146
+ const pid = data?.pid || data?.post?.pid;
147
+ const tid = data?.tid || data?.post?.tid;
148
+ if (!pid || !tid) return;
149
+
150
+ // Only new posts (avoid edits). v4.x commonly provides isNew for creates.
151
+ const isNew = data?.isNew === true || data?.post?.isNew === true;
152
+ if (!isNew) return;
153
+
154
+ const built = await buildTopicEmbed({ tid, pid, type: 'reply' });
155
+ if (!built) return;
156
+
157
+ // Avoid notifying the main post (already covered by topic.save)
158
+ if (built.topicData?.mainPid && String(built.topicData.mainPid) === String(pid)) return;
159
+
160
+ if (!cidAllowed(built.topicData.cid, cids)) return;
161
+
162
+ await postToDiscord(webhookUrl, { embeds: [built.embed] });
163
+ } catch (err) {
164
+ // eslint-disable-next-line no-console
165
+ console.error(err);
166
+ }
167
+ };
168
+
169
+ module.exports = Plugin;
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "nodebb-plugin-discord-onekite",
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": ["nodebb", "plugin", "discord", "webhook", "notifications"],
8
+ "dependencies": {
9
+ "undici": "^6.0.0"
10
+ },
11
+ "nbbpm": {
12
+ "compatibility": "^4.0.0"
13
+ }
14
+ }
package/plugin.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "id": "nodebb-plugin-discord-onekite",
3
+ "name": "Discord Onekite Notifier",
4
+ "description": "Notifie Discord via webhook pour nouveaux sujets et/ou réponses, filtrable par catégories (NodeBB v4.x uniquement).",
5
+ "library": "./library.js",
6
+ "hooks": [
7
+ { "hook": "static:app.load", "method": "init" },
8
+ { "hook": "filter:admin.header.build", "method": "addAdminNavigation" },
9
+ { "hook": "action:topic.save", "method": "onTopicSave" },
10
+ { "hook": "action:post.save", "method": "onPostSave" }
11
+ ],
12
+ "templates": "static/templates",
13
+ "acpScripts": ["static/lib/admin.js"]
14
+ }
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ /* global $, app */
4
+
5
+ define('admin/plugins/discord-onekite', ['settings', 'api'], function (settings, api) {
6
+ const ACP = {};
7
+
8
+ ACP.init = async function () {
9
+ // Charge settings existants (webhookUrl, notifyReplies, cids)
10
+ settings.load('discord-onekite', $('.discord-onekite-settings'));
11
+
12
+ // Charge les catégories via l'API NodeBB (client api module ajoute /api)
13
+ try {
14
+ const res = await api.get('/categories');
15
+ const categories = (res && res.categories) ? res.categories : [];
16
+
17
+ const $select = $('#cids');
18
+ $select.empty();
19
+
20
+ categories
21
+ .filter(c => c && typeof c.cid !== 'undefined' && c.name)
22
+ .forEach(c => {
23
+ $('<option />')
24
+ .val(String(c.cid))
25
+ .text(c.name)
26
+ .appendTo($select);
27
+ });
28
+
29
+ // Recharge settings pour re-sélectionner les catégories déjà sauvegardées
30
+ settings.load('discord-onekite', $('.discord-onekite-settings'));
31
+ } catch (e) {
32
+ // eslint-disable-next-line no-console
33
+ console.error('[discord-onekite] Could not load categories', e);
34
+ app.alertError('Impossible de charger la liste des catégories.');
35
+ }
36
+
37
+ $('#save').on('click', function () {
38
+ settings.save('discord-onekite', $('.discord-onekite-settings'), function () {
39
+ app.alertSuccess('Paramètres enregistrés !');
40
+ });
41
+ });
42
+ };
43
+
44
+ return ACP;
45
+ });
@@ -0,0 +1,33 @@
1
+ <div class="acp-page-container">
2
+ <h4>Discord Onekite</h4>
3
+ <p class="text-muted">
4
+ Envoie une notification dans Discord via webhook, avec filtres par catégories.
5
+ </p>
6
+
7
+ <form role="form" class="discord-onekite-settings">
8
+ <div class="mb-3">
9
+ <label class="form-label" for="webhookUrl">Discord Webhook URL</label>
10
+ <input type="text" class="form-control" id="webhookUrl" name="webhookUrl" placeholder="https://discord.com/api/webhooks/..." />
11
+ <p class="form-text text-muted">
12
+ Discord → Salon #notifications → Intégrations → Webhooks → Copier l’URL.
13
+ </p>
14
+ </div>
15
+
16
+ <div class="form-check mb-3">
17
+ <input class="form-check-input" type="checkbox" id="notifyReplies" name="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="cids">Catégories à notifier</label>
25
+ <select class="form-select" id="cids" name="cids" multiple size="12"></select>
26
+ <p class="form-text text-muted">
27
+ Si aucune catégorie n’est sélectionnée : <strong>toutes les catégories</strong> seront notifiées.
28
+ </p>
29
+ </div>
30
+
31
+ <!-- IMPORT admin/partials/save_button.tpl -->
32
+ </form>
33
+ </div>