nodebb-plugin-facebook-post 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,32 @@
1
+ # nodebb-plugin-facebook-post (worker) + Instagram
2
+
3
+ Cette version publie :
4
+ - **Facebook Page** : toujours (si checkbox cochée dans le composer)
5
+ - **Instagram** : option ACP “Publier aussi sur Instagram” ET seulement si une image est présente.
6
+
7
+ ## Instagram: carrousel (jusqu’à 10 médias)
8
+ Si le message contient plusieurs images, le worker publie un **carrousel Instagram** :
9
+ 1. crée un container enfant par image (`is_carousel_item=true`)
10
+ 2. crée un container parent (`media_type=CAROUSEL`, `children=<ids>`)
11
+ 3. publie via `/{ig-user-id}/media_publish`
12
+ Le worker attend que les containers passent à `FINISHED` via `status_code`. citeturn0search1turn0search0
13
+
14
+ ## Variables d’environnement
15
+
16
+ ### Communes (NodeBB + worker)
17
+ - `NODEBB_FB_PAGE_ID`
18
+ - `NODEBB_FB_PAGE_ACCESS_TOKEN`
19
+ - `NODEBB_FB_GRAPH_VERSION` (optionnel, défaut `v25.0`)
20
+ - `NODEBB_FB_QUEUE_REDIS_URL`
21
+ - `NODEBB_FB_WORKER_SECRET`
22
+
23
+ ### Instagram
24
+ - `NODEBB_IG_USER_ID` (obligatoire si Instagram activé)
25
+
26
+ ### Tuning (optionnel)
27
+ - `NODEBB_IG_POLL_INTERVAL_MS` (défaut 2000)
28
+ - `NODEBB_IG_POLL_MAX_MS` (défaut 120000)
29
+
30
+ ## Note sur la géolocalisation
31
+ Le plugin réutilise ton `placeId` comme `place` (Facebook) et `location_id` (Instagram) lors de la création du container. citeturn0search0turn0search1
32
+
package/library.js ADDED
@@ -0,0 +1,414 @@
1
+ 'use strict';
2
+
3
+ const Redis = require('ioredis');
4
+ const { v4: uuidv4 } = require('uuid');
5
+
6
+ let meta = {};
7
+ let settings = {};
8
+ let appRef;
9
+
10
+ const SETTINGS_KEY = 'facebook-post';
11
+ const QUEUE_KEY = 'fbpost:queue';
12
+
13
+ const DEFAULT_SETTINGS = {
14
+ enabled: false,
15
+
16
+ includeExcerpt: true,
17
+ excerptMaxLen: 220,
18
+
19
+ categoriesWhitelist: '',
20
+ minimumReputation: 0,
21
+
22
+ maxImages: 4,
23
+
24
+ enablePlaceTagging: true,
25
+
26
+ allowedGroups: '',
27
+
28
+ // Also publish to Instagram (worker only does it if at least 1 image)
29
+ publishInstagram: false,
30
+ };
31
+
32
+ let redisClient = null;
33
+
34
+ function bool(v) { return v === true || v === 'true' || v === 1 || v === '1' || v === 'on'; }
35
+ function int(v, def) { const n = parseInt(v, 10); return Number.isFinite(n) ? n : def; }
36
+ function trimStr(v) { return (v || '').toString().trim(); }
37
+ function parseCsvInts(csv) {
38
+ const s = trimStr(csv);
39
+ if (!s) return [];
40
+ return s.split(',').map(x => parseInt(x.trim(), 10)).filter(n => Number.isFinite(n));
41
+ }
42
+
43
+ function readEnv() {
44
+ const env = process.env || {};
45
+ return {
46
+ fbGraphVersion: trimStr(env.NODEBB_FB_GRAPH_VERSION) || 'v25.0',
47
+ fbPageId: trimStr(env.NODEBB_FB_PAGE_ID),
48
+ fbPageAccessToken: trimStr(env.NODEBB_FB_PAGE_ACCESS_TOKEN),
49
+
50
+ igUserId: trimStr(env.NODEBB_IG_USER_ID) || '',
51
+
52
+ redisUrl: trimStr(env.NODEBB_FB_QUEUE_REDIS_URL) || 'redis://127.0.0.1:6379',
53
+ workerSecret: trimStr(env.NODEBB_FB_WORKER_SECRET) || '',
54
+ };
55
+ }
56
+
57
+ async function loadSettings() {
58
+ const raw = await meta.settings.get(SETTINGS_KEY);
59
+ settings = Object.assign({}, DEFAULT_SETTINGS, raw || {});
60
+
61
+ settings.enabled = bool(settings.enabled);
62
+ settings.includeExcerpt = bool(settings.includeExcerpt);
63
+ settings.excerptMaxLen = int(settings.excerptMaxLen, DEFAULT_SETTINGS.excerptMaxLen);
64
+
65
+ settings.minimumReputation = int(settings.minimumReputation, 0);
66
+ settings.maxImages = int(settings.maxImages, DEFAULT_SETTINGS.maxImages);
67
+ settings.enablePlaceTagging = bool(settings.enablePlaceTagging);
68
+
69
+ settings.categoriesWhitelist = trimStr(settings.categoriesWhitelist);
70
+ settings.allowedGroups = trimStr(settings.allowedGroups);
71
+
72
+ settings.publishInstagram = bool(settings.publishInstagram);
73
+
74
+ const env = readEnv();
75
+ settings.fbGraphVersion = env.fbGraphVersion;
76
+ settings.fbPageId = env.fbPageId;
77
+ settings.fbPageAccessToken = env.fbPageAccessToken;
78
+ settings.igUserId = env.igUserId;
79
+
80
+ settings.redisUrl = env.redisUrl;
81
+ settings.workerSecret = env.workerSecret;
82
+ }
83
+
84
+ function getForumBaseUrl() {
85
+ const nconf = require.main.require('nconf');
86
+ return trimStr(nconf.get('url')) || '';
87
+ }
88
+ function absolutizeUrl(url) {
89
+ const base = getForumBaseUrl();
90
+ if (!url) return '';
91
+ if (url.startsWith('http://') || url.startsWith('https://')) return url;
92
+ if (url.startsWith('//')) return `https:${url}`;
93
+ if (!base) return url;
94
+ if (url.startsWith('/')) return base + url;
95
+ return base + '/' + url;
96
+ }
97
+ function isForumHosted(urlAbs) {
98
+ const base = getForumBaseUrl();
99
+ if (!base) return false;
100
+ try { return new URL(urlAbs).host === new URL(base).host; } catch { return false; }
101
+ }
102
+
103
+ function extractImageUrlsFromContent(rawContent) {
104
+ const urls = new Set();
105
+ if (!rawContent) return [];
106
+
107
+ const mdImg = /!\[[^\]]*]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
108
+ let m;
109
+ while ((m = mdImg.exec(rawContent)) !== null) urls.add(m[1]);
110
+
111
+ const htmlImg = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
112
+ while ((m = htmlImg.exec(rawContent)) !== null) urls.add(m[1]);
113
+
114
+ const bare = /(https?:\/\/[^\s<>"']+\.(?:png|jpe?g|gif|webp))(?:\?[^\s<>"']*)?/gi;
115
+ while ((m = bare.exec(rawContent)) !== null) urls.add(m[1]);
116
+
117
+ return Array.from(urls).map(u => trimStr(u)).filter(Boolean).map(absolutizeUrl);
118
+ }
119
+
120
+ function sanitizeExcerpt(text, maxLen) {
121
+ const s = trimStr(text).replace(/\s+/g, ' ').replace(/<[^>]*>/g, '');
122
+ if (!s) return '';
123
+ if (s.length <= maxLen) return s;
124
+ return s.slice(0, Math.max(0, maxLen - 1)).trimEnd() + '…';
125
+ }
126
+
127
+ function parseAllowedGroupsList() {
128
+ const s = trimStr(settings.allowedGroups);
129
+ if (!s) return [];
130
+ return s.split(',').map(x => x.trim()).filter(Boolean);
131
+ }
132
+ async function userIsAllowed(uid) {
133
+ const allowed = parseAllowedGroupsList();
134
+ if (!allowed.length) return false;
135
+
136
+ const Groups = require.main.require('./src/groups');
137
+ for (const groupName of allowed) {
138
+ try {
139
+ // eslint-disable-next-line no-await-in-loop
140
+ const ok = await Groups.isMember(uid, groupName);
141
+ if (ok) return true;
142
+ } catch {}
143
+ }
144
+ return false;
145
+ }
146
+
147
+ async function getPostContext(postData) {
148
+ const Posts = require.main.require('./src/posts');
149
+ const Topics = require.main.require('./src/topics');
150
+ const User = require.main.require('./src/user');
151
+
152
+ const pid = postData && (postData.pid || (postData.post && postData.post.pid));
153
+ if (!pid) return null;
154
+
155
+ const post = await Posts.getPostData(pid);
156
+ if (!post) return null;
157
+
158
+ const topic = await Topics.getTopicData(post.tid);
159
+ if (!topic) return null;
160
+
161
+ const user = await User.getUserFields(post.uid, ['uid', 'username', 'userslug', 'reputation']);
162
+ const url = await Topics.getTopicUrl(post.tid);
163
+ const topicUrlAbs = absolutizeUrl(url);
164
+
165
+ return { post, topic, user, topicUrlAbs };
166
+ }
167
+
168
+ function shouldEnqueue(ctx) {
169
+ if (!ctx || !ctx.post || !ctx.topic || !ctx.user) return false;
170
+ if (!settings.enabled) return false;
171
+ if (!settings.fbPageId || !settings.fbPageAccessToken) return false;
172
+
173
+ const isFirstPost = (ctx.post.isMainPost === true) || (ctx.post.index === 0);
174
+ if (!isFirstPost) return false;
175
+
176
+ if (!bool(ctx.post.fbPostEnabled)) return false;
177
+
178
+ if ((ctx.user.reputation || 0) < settings.minimumReputation) return false;
179
+
180
+ const whitelist = parseCsvInts(settings.categoriesWhitelist);
181
+ if (whitelist.length > 0) {
182
+ const cid = parseInt(ctx.topic.cid, 10);
183
+ if (!whitelist.includes(cid)) return false;
184
+ }
185
+
186
+ return true;
187
+ }
188
+
189
+ async function getRedis() {
190
+ if (redisClient) return redisClient;
191
+ const winston = require.main.require('winston');
192
+ redisClient = new Redis(settings.redisUrl, {
193
+ maxRetriesPerRequest: 2,
194
+ enableReadyCheck: true,
195
+ lazyConnect: true,
196
+ });
197
+ redisClient.on('error', (err) => {
198
+ winston.error(`[facebook-post] Redis error: ${err && err.message ? err.message : err}`);
199
+ });
200
+ await redisClient.connect();
201
+ return redisClient;
202
+ }
203
+
204
+ function buildJob(ctx) {
205
+ const rawContent = ctx.post.content || '';
206
+ const topicTitle = ctx.topic.title || 'Nouveau sujet';
207
+ const author = ctx.user.username || 'Quelqu’un';
208
+ const link = ctx.topicUrlAbs;
209
+
210
+ const excerpt = settings.includeExcerpt ? sanitizeExcerpt(rawContent, settings.excerptMaxLen) : '';
211
+
212
+ let imageUrls = extractImageUrlsFromContent(rawContent)
213
+ .map(absolutizeUrl)
214
+ .filter(isForumHosted);
215
+
216
+ imageUrls = imageUrls.slice(0, Math.max(0, settings.maxImages));
217
+
218
+ let placeId = null;
219
+ if (settings.enablePlaceTagging) {
220
+ const pid = trimStr(ctx.post.fbPlaceId);
221
+ if (pid) placeId = pid;
222
+ }
223
+
224
+ const lines = [];
225
+ lines.push(`📝 ${topicTitle}`);
226
+ if (excerpt) lines.push(`\n${excerpt}`);
227
+ lines.push(`\n🔗 ${link}`);
228
+ lines.push(`\n— ${author}`);
229
+ const message = lines.join('\n');
230
+
231
+ return {
232
+ id: uuidv4(),
233
+ createdAt: Date.now(),
234
+ attempts: 0,
235
+
236
+ pid: ctx.post.pid,
237
+ tid: ctx.post.tid,
238
+ uid: ctx.post.uid,
239
+
240
+ message,
241
+ link,
242
+ imageUrls,
243
+ placeId,
244
+
245
+ publishInstagram: !!settings.publishInstagram,
246
+
247
+ nodebbBaseUrl: getForumBaseUrl(),
248
+ };
249
+ }
250
+
251
+ async function enqueueJob(job) {
252
+ const redis = await getRedis();
253
+ await redis.lpush(QUEUE_KEY, JSON.stringify(job));
254
+ }
255
+
256
+ async function ensureAdmin(req) {
257
+ const User = require.main.require('./src/user');
258
+ const uid = req.uid;
259
+ if (!uid) return false;
260
+
261
+ try { if (await User.isAdministrator(uid)) return true; } catch {}
262
+ try { if (await User.isAdminOrGlobalMod(uid)) return true; } catch {}
263
+ if (req.user && req.user.isAdmin) return true;
264
+ return false;
265
+ }
266
+
267
+ async function listAllGroups() {
268
+ const Groups = require.main.require('./src/groups');
269
+ try {
270
+ const groups = await Groups.getGroupsFromSet('groups:createtime', 0, -1);
271
+ if (Array.isArray(groups)) return groups;
272
+ } catch {}
273
+ try {
274
+ const groups2 = await Groups.getGroups('groups:createtime', 0, 9999);
275
+ if (Array.isArray(groups2)) return groups2;
276
+ } catch {}
277
+ return [];
278
+ }
279
+
280
+ const Plugin = {};
281
+
282
+ Plugin.init = async function (params) {
283
+ appRef = params.app;
284
+ meta = (params && params.meta) ? params.meta : require.main.require('./src/meta');
285
+
286
+ await loadSettings();
287
+
288
+ const routeHelpers = require.main.require('./src/routes/helpers');
289
+ routeHelpers.setupAdminPageRoute(appRef, '/admin/plugins/facebook-post', params.middleware, [], (req, res) => {
290
+ res.render('admin/facebook-post', {});
291
+ });
292
+
293
+ appRef.get('/api/admin/plugins/facebook-post/groups', params.middleware.authenticate, async (req, res) => {
294
+ try {
295
+ await loadSettings();
296
+ const ok = await ensureAdmin(req);
297
+ if (!ok) return res.status(403).json({ error: 'forbidden' });
298
+
299
+ const groups = await listAllGroups();
300
+ const names = (groups || [])
301
+ .map(g => g && (g.name || g.displayName || g.slug))
302
+ .filter(Boolean)
303
+ .filter(n => n.toLowerCase() !== 'guests');
304
+
305
+ const uniq = Array.from(new Set(names)).sort((a, b) => a.localeCompare(b));
306
+ return res.json({ groups: uniq });
307
+ } catch {
308
+ return res.status(500).json({ error: 'failed' });
309
+ }
310
+ });
311
+
312
+ appRef.get('/api/facebook-post/can-post', params.middleware.authenticate, async (req, res) => {
313
+ try {
314
+ await loadSettings();
315
+ if (!settings.enabled) return res.json({ allowed: false, reason: 'disabled' });
316
+
317
+ const ok = await userIsAllowed(req.uid);
318
+ return res.json({ allowed: ok });
319
+ } catch {
320
+ return res.json({ allowed: false });
321
+ }
322
+ });
323
+
324
+ appRef.post('/api/admin/plugins/facebook-post/mark-posted', params.middleware.authenticate, async (req, res) => {
325
+ try {
326
+ await loadSettings();
327
+ const secret = trimStr(req.body && req.body.secret);
328
+ if (!secret || secret !== settings.workerSecret) {
329
+ return res.status(403).json({ error: 'bad_secret' });
330
+ }
331
+ const ok = await ensureAdmin(req);
332
+ if (!ok) return res.status(403).json({ error: 'forbidden' });
333
+
334
+ const pid = parseInt(req.body && req.body.pid, 10);
335
+ const fbPostedId = trimStr(req.body && req.body.fbPostedId);
336
+ const fbPostedAt = parseInt(req.body && req.body.fbPostedAt, 10) || Date.now();
337
+ if (!Number.isFinite(pid) || !fbPostedId) return res.status(400).json({ error: 'bad_payload' });
338
+
339
+ const Posts = require.main.require('./src/posts');
340
+ await Posts.setPostField(pid, 'fbPostedId', fbPostedId);
341
+ await Posts.setPostField(pid, 'fbPostedAt', fbPostedAt);
342
+ await Posts.deletePostField(pid, 'fbQueueJobId');
343
+ await Posts.deletePostField(pid, 'fbQueuedAt');
344
+ return res.json({ ok: true });
345
+ } catch {
346
+ return res.status(500).json({ error: 'failed' });
347
+ }
348
+ });
349
+
350
+ appRef.on('nodebb:settings:reload', async (payload) => {
351
+ if (payload && payload.plugin === SETTINGS_KEY) {
352
+ await loadSettings();
353
+ }
354
+ });
355
+ };
356
+
357
+ Plugin.addAdminNavigation = async function (header) {
358
+ header.plugins = header.plugins || [];
359
+ header.plugins.push({ route: '/plugins/facebook-post', icon: 'fa-facebook', name: 'Facebook/Instagram Post' });
360
+ return header;
361
+ };
362
+
363
+ Plugin.addClientScripts = async function (scripts) {
364
+ scripts.push('plugins/nodebb-plugin-facebook-post/lib/composer.js');
365
+ return scripts;
366
+ };
367
+
368
+ Plugin.addAdminScripts = async function (scripts) {
369
+ scripts.push('plugins/nodebb-plugin-facebook-post/lib/admin.js');
370
+ return scripts;
371
+ };
372
+
373
+ Plugin.onPostCreate = async function (hookData) {
374
+ try {
375
+ if (!hookData?.post || !hookData?.data) return hookData;
376
+ hookData.post.fbPostEnabled = bool(hookData.data.fbPostEnabled);
377
+ const fbPlaceId = trimStr(hookData.data.fbPlaceId);
378
+ if (fbPlaceId) hookData.post.fbPlaceId = fbPlaceId;
379
+ } catch {}
380
+ return hookData;
381
+ };
382
+
383
+ Plugin.onPostSave = async function (hookData) {
384
+ const winston = require.main.require('winston');
385
+ try {
386
+ await loadSettings();
387
+ if (!settings.enabled) return;
388
+
389
+ const ctx = await getPostContext(hookData && hookData.post ? hookData.post : hookData);
390
+ if (!ctx) return;
391
+
392
+ const allowed = await userIsAllowed(ctx.post.uid);
393
+ if (!allowed) return;
394
+
395
+ if (!shouldEnqueue(ctx)) return;
396
+
397
+ const Posts = require.main.require('./src/posts');
398
+ const alreadyPosted = await Posts.getPostField(ctx.post.pid, 'fbPostedId');
399
+ const alreadyQueued = await Posts.getPostField(ctx.post.pid, 'fbQueueJobId');
400
+ if (alreadyPosted || alreadyQueued) return;
401
+
402
+ const job = buildJob(ctx);
403
+ await enqueueJob(job);
404
+
405
+ await Posts.setPostField(ctx.post.pid, 'fbQueueJobId', job.id);
406
+ await Posts.setPostField(ctx.post.pid, 'fbQueuedAt', Date.now());
407
+ } catch (e) {
408
+ const detail = e && (e.response && JSON.stringify(e.response.data) || e.message || e);
409
+ winston.error(`[facebook-post] Enqueue failed: ${detail}`);
410
+ }
411
+ };
412
+
413
+ module.exports = Plugin;
414
+
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "nodebb-plugin-facebook-post",
3
+ "version": "1.0.0",
4
+ "description": "Queue-based worker to auto-post new NodeBB topics to a fixed Facebook Page and optionally Instagram (images only).",
5
+ "main": "library.js",
6
+ "dependencies": {
7
+ "axios": "^1.6.0",
8
+ "ioredis": "^5.4.1",
9
+ "uuid": "^9.0.1"
10
+ },
11
+ "engines": {
12
+ "node": ">=18"
13
+ },
14
+ "license": "MIT"
15
+ }
package/plugin.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "id": "nodebb-plugin-facebook-post",
3
+ "name": "Facebook/Instagram Post (Worker)",
4
+ "description": "Enqueue new NodeBB topics to a worker that posts to a fixed Facebook Page (+ optional Instagram).",
5
+ "url": "https://example.invalid/nodebb-plugin-facebook-post",
6
+ "library": "./library.js",
7
+ "hooks": [
8
+ {
9
+ "hook": "static:app.load",
10
+ "method": "init"
11
+ },
12
+ {
13
+ "hook": "filter:admin.header.build",
14
+ "method": "addAdminNavigation"
15
+ },
16
+ {
17
+ "hook": "filter:scripts.get",
18
+ "method": "addClientScripts"
19
+ },
20
+ {
21
+ "hook": "filter:admin.scripts.get",
22
+ "method": "addAdminScripts"
23
+ },
24
+ {
25
+ "hook": "filter:post.create",
26
+ "method": "onPostCreate"
27
+ },
28
+ {
29
+ "hook": "action:post.save",
30
+ "method": "onPostSave"
31
+ }
32
+ ],
33
+ "staticDirs": {
34
+ "static": "static"
35
+ },
36
+ "templates": "static/templates"
37
+ }
@@ -0,0 +1,74 @@
1
+ /* global $, app, socket */
2
+ 'use strict';
3
+
4
+ function parseCsv(s) { return (s || '').split(',').map(x => x.trim()).filter(Boolean); }
5
+ function toCsv(arr) { return (arr || []).map(x => String(x).trim()).filter(Boolean).join(','); }
6
+
7
+ async function fetchGroupsList() {
8
+ const res = await fetch('/api/admin/plugins/facebook-post/groups', { credentials: 'same-origin' });
9
+ if (!res.ok) throw new Error('groups api not ok');
10
+ const data = await res.json();
11
+ return (data && data.groups) ? data.groups : [];
12
+ }
13
+
14
+ $(document).ready(function () {
15
+ socket.emit('admin.settings.get', { hash: 'facebook-post' }, async function (err, data) {
16
+ if (err) return app.alertError(err.message || err);
17
+
18
+ data = data || {};
19
+ const savedAllowed = parseCsv(data.allowedGroups);
20
+
21
+ for (const [k, v] of Object.entries(data)) {
22
+ if (k === 'allowedGroups') continue;
23
+ const $el = $('[name="' + k + '"]');
24
+ if (!$el.length) continue;
25
+
26
+ if ($el.attr('type') === 'checkbox') {
27
+ $el.prop('checked', v === true || v === 'true' || v === 1 || v === '1' || v === 'on');
28
+ } else {
29
+ $el.val(v);
30
+ }
31
+ }
32
+
33
+ const $select = $('#allowedGroupsSelect');
34
+ const $hidden = $('input[type="hidden"][name="allowedGroups"]');
35
+
36
+ function syncHidden() {
37
+ const selected = $select.val() || [];
38
+ $hidden.val(toCsv(selected));
39
+ }
40
+
41
+ try {
42
+ const groups = await fetchGroupsList();
43
+ $select.empty();
44
+ groups.forEach(name => {
45
+ const opt = document.createElement('option');
46
+ opt.value = name;
47
+ opt.textContent = name;
48
+ if (savedAllowed.includes(name)) opt.selected = true;
49
+ $select.append(opt);
50
+ });
51
+ $select.on('change', syncHidden);
52
+ syncHidden();
53
+ } catch (e) {
54
+ app.alertError('Impossible de charger la liste des groupes. Vérifie les droits admin et les logs serveur.');
55
+ }
56
+ });
57
+
58
+ $('#save').on('click', function () {
59
+ const payload = {};
60
+ $('.facebook-post-settings [name]').each(function () {
61
+ const $el = $(this);
62
+ const name = $el.attr('name');
63
+ if ($el.attr('type') === 'checkbox') payload[name] = $el.is(':checked');
64
+ else payload[name] = $el.val();
65
+ });
66
+
67
+ socket.emit('admin.settings.set', { hash: 'facebook-post', values: payload }, function (err) {
68
+ if (err) return app.alertError(err.message || err);
69
+ app.alertSuccess('Enregistré.');
70
+ socket.emit('admin.settings.sync', { hash: 'facebook-post' });
71
+ });
72
+ });
73
+ });
74
+
@@ -0,0 +1,76 @@
1
+ /* global $, app */
2
+ 'use strict';
3
+
4
+ (function () {
5
+ async function canPost() {
6
+ try {
7
+ const res = await fetch('/api/facebook-post/can-post', { credentials: 'same-origin' });
8
+ if (!res.ok) return { allowed: false };
9
+ return await res.json();
10
+ } catch {
11
+ return { allowed: false };
12
+ }
13
+ }
14
+
15
+ function injectUI($composer) {
16
+ if ($composer.find('[data-fbpost-wrap]').length) return;
17
+
18
+ const html = `
19
+ <div class="mb-2" data-fbpost-wrap>
20
+ <div class="form-check mb-2">
21
+ <input type="checkbox" class="form-check-input" id="fbPostEnabled" data-fbpost-enabled>
22
+ <label class="form-check-label" for="fbPostEnabled">Publier ce nouveau topic sur Facebook (et Insta si activé)</label>
23
+ </div>
24
+
25
+ <div class="mb-2" data-fbpost-place-wrap style="display:none;">
26
+ <label class="form-label" style="font-size: 12px; opacity: .8;">
27
+ Lieu (Facebook Place ID) – optionnel
28
+ </label>
29
+ <input type="text" class="form-control" data-fbpost-place-id placeholder="ex: 123456789012345">
30
+ <div class="form-text" style="font-size: 11px;">
31
+ Utilisé comme <code>place</code> sur Facebook et <code>location_id</code> sur Instagram.
32
+ </div>
33
+ </div>
34
+ </div>
35
+ `;
36
+
37
+ const $target = $composer.find('.write, .composer-body, .composer-content').first();
38
+ if ($target.length) $target.prepend(html);
39
+ else $composer.prepend(html);
40
+
41
+ const $enabled = $composer.find('[data-fbpost-enabled]');
42
+ const $placeWrap = $composer.find('[data-fbpost-place-wrap]');
43
+
44
+ $enabled.on('change', function () {
45
+ $placeWrap.toggle($enabled.is(':checked'));
46
+ });
47
+
48
+ $(window).off('filter:composer.submit.fbpost')
49
+ .on('filter:composer.submit.fbpost', function (ev2, submitData) {
50
+ const enabled = $enabled.is(':checked');
51
+ submitData.data = submitData.data || {};
52
+ submitData.data.fbPostEnabled = enabled;
53
+
54
+ if (enabled) {
55
+ const placeId = $composer.find('[data-fbpost-place-id]').val();
56
+ if (placeId) submitData.data.fbPlaceId = placeId;
57
+ }
58
+ return submitData;
59
+ });
60
+ }
61
+
62
+ $(window).on('action:composer.loaded', async function (ev, data) {
63
+ try {
64
+ const $composer = data && data.composer ? data.composer : $('.composer');
65
+ if (!$composer || !$composer.length) return;
66
+
67
+ const perm = await canPost();
68
+ if (!perm.allowed) return;
69
+
70
+ injectUI($composer);
71
+ } catch {
72
+ // ignore
73
+ }
74
+ });
75
+ })();
76
+
@@ -0,0 +1,93 @@
1
+ <div class="acp-page-container">
2
+ <h2>Facebook/Instagram Post (Worker)</h2>
3
+ <p class="text-muted">
4
+ Enfile les publications dans une <strong>queue Redis</strong>. Un worker séparé consomme la queue et poste sur Facebook,
5
+ et sur Instagram (si activé et si une image est présente).
6
+ </p>
7
+
8
+ <div class="alert alert-info">
9
+ Variables d’environnement :
10
+ <ul class="mb-0">
11
+ <li><code>NODEBB_FB_PAGE_ID</code></li>
12
+ <li><code>NODEBB_FB_PAGE_ACCESS_TOKEN</code></li>
13
+ <li><code>NODEBB_FB_GRAPH_VERSION</code> (optionnel)</li>
14
+ <li><code>NODEBB_FB_QUEUE_REDIS_URL</code></li>
15
+ <li><code>NODEBB_FB_WORKER_SECRET</code></li>
16
+ <li><code>NODEBB_IG_USER_ID</code> (si Instagram activé)</li>
17
+ </ul>
18
+ </div>
19
+
20
+ <form role="form" class="facebook-post-settings">
21
+ <div class="form-check mb-3">
22
+ <input type="checkbox" class="form-check-input" id="enabled" name="enabled">
23
+ <label class="form-check-label" for="enabled">Activer</label>
24
+ </div>
25
+
26
+ <div class="form-check mb-3">
27
+ <input type="checkbox" class="form-check-input" id="publishInstagram" name="publishInstagram">
28
+ <label class="form-check-label" for="publishInstagram">Publier aussi sur Instagram (uniquement si une image est présente)</label>
29
+ </div>
30
+
31
+ <hr/>
32
+
33
+ <h4>Accès</h4>
34
+ <p class="text-muted">Groupes NodeBB autorisés à voir la case “Publier sur Facebook” dans le composer (et donc déclencher Facebook + Insta).</p>
35
+
36
+ <div class="mb-3">
37
+ <label class="form-label" for="allowedGroupsSelect">Groupes autorisés</label>
38
+ <select class="form-select" id="allowedGroupsSelect" multiple size="10"></select>
39
+ </div>
40
+
41
+ <input type="hidden" id="allowedGroups" name="allowedGroups" value="">
42
+
43
+ <hr/>
44
+
45
+ <h4>Message</h4>
46
+
47
+ <div class="form-check mb-2">
48
+ <input type="checkbox" class="form-check-input" id="includeExcerpt" name="includeExcerpt">
49
+ <label class="form-check-label" for="includeExcerpt">Inclure un extrait du message</label>
50
+ </div>
51
+
52
+ <div class="mb-3">
53
+ <label class="form-label" for="excerptMaxLen">Longueur max de l’extrait</label>
54
+ <input type="number" class="form-control" id="excerptMaxLen" name="excerptMaxLen" min="50" max="5000">
55
+ </div>
56
+
57
+ <hr/>
58
+
59
+ <h4>Filtres</h4>
60
+
61
+ <div class="mb-3">
62
+ <label class="form-label" for="categoriesWhitelist">Whitelist catégories (cids séparés par virgules)</label>
63
+ <input type="text" class="form-control" id="categoriesWhitelist" name="categoriesWhitelist" placeholder="1,2,5">
64
+ <div class="form-text">Vide = toutes les catégories.</div>
65
+ </div>
66
+
67
+ <div class="mb-3">
68
+ <label class="form-label" for="minimumReputation">Réputation minimale</label>
69
+ <input type="number" class="form-control" id="minimumReputation" name="minimumReputation" min="0">
70
+ </div>
71
+
72
+ <hr/>
73
+
74
+ <h4>Images</h4>
75
+
76
+ <div class="mb-3">
77
+ <label class="form-label" for="maxImages">Nombre max d’images (uploads du forum)</label>
78
+ <input type="number" class="form-control" id="maxImages" name="maxImages" min="0" max="20">
79
+ </div>
80
+
81
+ <hr/>
82
+
83
+ <h4>Lieu / Géolocalisation</h4>
84
+
85
+ <div class="form-check mb-3">
86
+ <input type="checkbox" class="form-check-input" id="enablePlaceTagging" name="enablePlaceTagging">
87
+ <label class="form-check-label" for="enablePlaceTagging">Activer le tag de lieu (Place ID / location_id)</label>
88
+ </div>
89
+
90
+ <button type="button" class="btn btn-primary" id="save">Enregistrer</button>
91
+ </form>
92
+ </div>
93
+
package/worker.js ADDED
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const Redis = require('ioredis');
5
+ const axios = require('axios');
6
+
7
+ const QUEUE_KEY = 'fbpost:queue';
8
+
9
+ function trimStr(v) { return (v || '').toString().trim(); }
10
+ function int(v, def) { const n = parseInt(v, 10); return Number.isFinite(n) ? n : def; }
11
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
12
+
13
+ const env = process.env || {};
14
+ const redisUrl = trimStr(env.NODEBB_FB_QUEUE_REDIS_URL) || 'redis://127.0.0.1:6379';
15
+
16
+ const graphVersion = trimStr(env.NODEBB_FB_GRAPH_VERSION) || 'v25.0';
17
+ const pageId = trimStr(env.NODEBB_FB_PAGE_ID);
18
+ const pageToken = trimStr(env.NODEBB_FB_PAGE_ACCESS_TOKEN);
19
+
20
+ const igUserId = trimStr(env.NODEBB_IG_USER_ID);
21
+
22
+ const workerSecret = trimStr(env.NODEBB_FB_WORKER_SECRET);
23
+
24
+ const maxAttempts = int(env.NODEBB_FB_WORKER_MAX_ATTEMPTS, 5);
25
+ const retryDelayMs = int(env.NODEBB_FB_WORKER_RETRY_DELAY_MS, 30000);
26
+
27
+ // Instagram polling (containers processing)
28
+ const igPollIntervalMs = int(env.NODEBB_IG_POLL_INTERVAL_MS, 2000);
29
+ const igPollMaxMs = int(env.NODEBB_IG_POLL_MAX_MS, 120000); // 2 minutes
30
+
31
+ if (!pageId || !pageToken) {
32
+ console.error('[fb-worker] Missing NODEBB_FB_PAGE_ID or NODEBB_FB_PAGE_ACCESS_TOKEN');
33
+ process.exit(2);
34
+ }
35
+ if (!workerSecret) {
36
+ console.error('[fb-worker] Missing NODEBB_FB_WORKER_SECRET (required to mark posts as posted)');
37
+ process.exit(2);
38
+ }
39
+
40
+ const redis = new Redis(redisUrl, { enableReadyCheck: true });
41
+
42
+ /* ---------- Facebook ---------- */
43
+ async function fbUploadPhoto(urlAbs) {
44
+ const endpoint = `https://graph.facebook.com/${graphVersion}/${pageId}/photos`;
45
+ const resp = await axios.post(endpoint, null, {
46
+ params: { url: urlAbs, published: false, access_token: pageToken },
47
+ timeout: 20000,
48
+ });
49
+ return resp.data && resp.data.id;
50
+ }
51
+
52
+ async function fbPublishFeed({ message, link, photoIds, placeId }) {
53
+ const endpoint = `https://graph.facebook.com/${graphVersion}/${pageId}/feed`;
54
+ const form = new URLSearchParams();
55
+ form.append('message', String(message || ''));
56
+ form.append('access_token', String(pageToken));
57
+ if (link) form.append('link', String(link));
58
+ if (placeId) form.append('place', String(placeId));
59
+ if (Array.isArray(photoIds) && photoIds.length) {
60
+ photoIds.forEach((id, idx) => {
61
+ form.append(`attached_media[${idx}]`, JSON.stringify({ media_fbid: id }));
62
+ });
63
+ }
64
+ const resp = await axios.post(endpoint, form, {
65
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
66
+ timeout: 20000,
67
+ });
68
+ return resp.data && resp.data.id;
69
+ }
70
+
71
+ /* ---------- Instagram ---------- */
72
+ /**
73
+ * Instagram posting uses containers:
74
+ * - Single image: POST /{ig-user-id}/media (image_url + caption) -> creation_id; then POST /media_publish
75
+ * - Carousel: create N child containers with is_carousel_item=true, then create parent container media_type=CAROUSEL with children,
76
+ * then publish parent. Poll child/parent status_code until FINISHED.
77
+ */
78
+ async function igCreateImageContainer({ imageUrl, caption, locationId, isCarouselItem }) {
79
+ const endpoint = `https://graph.facebook.com/${graphVersion}/${igUserId}/media`;
80
+ const form = new URLSearchParams();
81
+ form.append('image_url', String(imageUrl));
82
+ if (caption) form.append('caption', String(caption));
83
+ form.append('access_token', String(pageToken));
84
+ if (locationId) form.append('location_id', String(locationId));
85
+ if (isCarouselItem) form.append('is_carousel_item', 'true');
86
+
87
+ const resp = await axios.post(endpoint, form, {
88
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
89
+ timeout: 20000,
90
+ });
91
+ return resp.data && resp.data.id; // creation_id
92
+ }
93
+
94
+ async function igCreateCarouselContainer({ childrenIds, caption, locationId }) {
95
+ const endpoint = `https://graph.facebook.com/${graphVersion}/${igUserId}/media`;
96
+ const form = new URLSearchParams();
97
+ form.append('media_type', 'CAROUSEL');
98
+ // children is a comma-separated string of container IDs
99
+ form.append('children', childrenIds.join(','));
100
+ if (caption) form.append('caption', String(caption));
101
+ form.append('access_token', String(pageToken));
102
+ if (locationId) form.append('location_id', String(locationId));
103
+
104
+ const resp = await axios.post(endpoint, form, {
105
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
106
+ timeout: 20000,
107
+ });
108
+ return resp.data && resp.data.id; // creation_id
109
+ }
110
+
111
+ async function igGetContainerStatus(containerId) {
112
+ const endpoint = `https://graph.facebook.com/${graphVersion}/${containerId}`;
113
+ const resp = await axios.get(endpoint, {
114
+ params: { fields: 'status_code', access_token: pageToken },
115
+ timeout: 20000,
116
+ });
117
+ return resp.data && resp.data.status_code;
118
+ }
119
+
120
+ async function igWaitFinished(containerId) {
121
+ const deadline = Date.now() + igPollMaxMs;
122
+ while (Date.now() < deadline) {
123
+ // eslint-disable-next-line no-await-in-loop
124
+ const status = await igGetContainerStatus(containerId);
125
+ if (status === 'FINISHED') return;
126
+ if (status === 'ERROR') throw new Error(`Instagram container ${containerId} status ERROR`);
127
+ // IN_PROGRESS or unknown
128
+ // eslint-disable-next-line no-await-in-loop
129
+ await sleep(igPollIntervalMs);
130
+ }
131
+ throw new Error(`Instagram container ${containerId} timeout after ${igPollMaxMs}ms`);
132
+ }
133
+
134
+ async function igPublishContainer(creationId) {
135
+ const endpoint = `https://graph.facebook.com/${graphVersion}/${igUserId}/media_publish`;
136
+ const form = new URLSearchParams();
137
+ form.append('creation_id', String(creationId));
138
+ form.append('access_token', String(pageToken));
139
+ const resp = await axios.post(endpoint, form, {
140
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
141
+ timeout: 20000,
142
+ });
143
+ return resp.data && resp.data.id;
144
+ }
145
+
146
+ /* ---------- NodeBB callback (optional) ---------- */
147
+ async function markPosted(job, fbPostedId) {
148
+ const bearer = trimStr(env.NODEBB_FB_NODEBB_BEARER_TOKEN);
149
+ if (!bearer) return;
150
+
151
+ const url = `${trimStr(job.nodebbBaseUrl).replace(/\/$/, '')}/api/admin/plugins/facebook-post/mark-posted`;
152
+ const payload = { pid: job.pid, fbPostedId, fbPostedAt: Date.now(), secret: workerSecret };
153
+
154
+ await axios.post(url, payload, {
155
+ headers: { Authorization: `Bearer ${bearer}` },
156
+ timeout: 15000,
157
+ });
158
+ }
159
+
160
+ async function handleJob(job) {
161
+ const imgs = Array.isArray(job.imageUrls) ? job.imageUrls : [];
162
+ const placeId = job.placeId ? String(job.placeId) : null;
163
+
164
+ // Facebook
165
+ let fbId;
166
+ if (!imgs.length) {
167
+ fbId = await fbPublishFeed({ message: job.message, link: job.link, photoIds: [], placeId });
168
+ } else {
169
+ const photoIds = [];
170
+ for (const urlAbs of imgs) {
171
+ // eslint-disable-next-line no-await-in-loop
172
+ const id = await fbUploadPhoto(urlAbs);
173
+ if (id) photoIds.push(id);
174
+ }
175
+ fbId = await fbPublishFeed({ message: job.message, link: job.link, photoIds, placeId });
176
+ }
177
+
178
+ // Instagram (only if enabled AND at least one image)
179
+ if (job.publishInstagram && imgs.length) {
180
+ if (!igUserId) {
181
+ console.warn('[fb-worker] publishInstagram enabled but NODEBB_IG_USER_ID missing; skipping Instagram.');
182
+ } else {
183
+ const caption = job.message;
184
+
185
+ // Instagram carousel limit: up to 10 items.
186
+ const igImgs = imgs.slice(0, 10);
187
+
188
+ if (igImgs.length === 1) {
189
+ const creationId = await igCreateImageContainer({ imageUrl: igImgs[0], caption, locationId: placeId, isCarouselItem: false });
190
+ await igWaitFinished(creationId);
191
+ const igPostId = await igPublishContainer(creationId);
192
+ console.log(`[fb-worker] Instagram published (single): ${igPostId}`);
193
+ } else {
194
+ const childIds = [];
195
+ for (const imageUrl of igImgs) {
196
+ // eslint-disable-next-line no-await-in-loop
197
+ const childId = await igCreateImageContainer({ imageUrl, caption: null, locationId: null, isCarouselItem: true });
198
+ // eslint-disable-next-line no-await-in-loop
199
+ await igWaitFinished(childId);
200
+ childIds.push(childId);
201
+ }
202
+
203
+ const parentId = await igCreateCarouselContainer({ childrenIds: childIds, caption, locationId: placeId });
204
+ await igWaitFinished(parentId);
205
+ const igPostId = await igPublishContainer(parentId);
206
+ console.log(`[fb-worker] Instagram published (carousel ${igImgs.length}): ${igPostId}`);
207
+ }
208
+ }
209
+ }
210
+
211
+ return fbId;
212
+ }
213
+
214
+ async function requeue(job) {
215
+ job.attempts = int(job.attempts, 0) + 1;
216
+ if (job.attempts >= maxAttempts) {
217
+ console.error(`[fb-worker] Job ${job.id} reached max attempts (${maxAttempts}). Dropping.`);
218
+ return;
219
+ }
220
+ console.warn(`[fb-worker] Requeue job ${job.id} in ${retryDelayMs}ms (attempt ${job.attempts}/${maxAttempts})`);
221
+ setTimeout(async () => {
222
+ try { await redis.lpush(QUEUE_KEY, JSON.stringify(job)); }
223
+ catch (e) { console.error('[fb-worker] Failed to requeue:', e && e.message ? e.message : e); }
224
+ }, retryDelayMs);
225
+ }
226
+
227
+ async function loop() {
228
+ console.log('[fb-worker] Started. Waiting for jobs…');
229
+ while (true) {
230
+ try {
231
+ const res = await redis.brpop(QUEUE_KEY, 0);
232
+ const payload = res && res[1];
233
+ if (!payload) continue;
234
+
235
+ let job;
236
+ try { job = JSON.parse(payload); }
237
+ catch { console.error('[fb-worker] Bad job payload, skipping.'); continue; }
238
+
239
+ console.log(`[fb-worker] Processing job ${job.id} pid=${job.pid} tid=${job.tid}`);
240
+ try {
241
+ const fbId = await handleJob(job);
242
+ console.log(`[fb-worker] Facebook posted: ${fbId}`);
243
+ try { await markPosted(job, fbId); } catch (e) {
244
+ console.warn('[fb-worker] mark-posted failed:', e && e.message ? e.message : e);
245
+ }
246
+ } catch (e) {
247
+ console.error('[fb-worker] Job failed:', e && (e.response && JSON.stringify(e.response.data) || e.message || e));
248
+ await requeue(job);
249
+ }
250
+ } catch (e) {
251
+ console.error('[fb-worker] Loop error:', e && e.message ? e.message : e);
252
+ await sleep(2000);
253
+ }
254
+ }
255
+ }
256
+
257
+ loop().catch((e) => {
258
+ console.error('[fb-worker] Fatal:', e && e.message ? e.message : e);
259
+ process.exit(1);
260
+ });