nodebb-plugin-facebook-post 1.0.4 → 1.0.5

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,32 +1,55 @@
1
- # nodebb-plugin-facebook-post (worker) + Instagram
1
+ # nodebb-plugin-facebook-post
2
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.
3
+ Plugin NodeBB 4.x : publie automatiquement les **nouveaux topics** sur une **Page Facebook fixe** (association),
4
+ avec images (uploads du forum) et tag de lieu (Facebook Place ID).
6
5
 
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
6
+ ## Fonctionnement
13
7
 
14
- ## Variables d’environnement
8
+ - L’utilisateur (si autorisé) voit dans le composer :
9
+ - une checkbox : “Publier ce nouveau topic sur Facebook”
10
+ - un champ optionnel : “Lieu (Facebook Place ID)”
11
+ - Le plugin ne publie **que le 1er post d’un topic** (pas les réponses).
12
+ - Les images sont extraites du contenu (Markdown/HTML) et **filtrées sur le domaine du forum** (uploads NodeBB).
13
+ - Publication Facebook :
14
+ - upload des photos en `published=false`
15
+ - création du post `/feed` avec `attached_media[]`
16
+ - ajout du paramètre `place` si un Place ID est fourni
15
17
 
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`
18
+ ## Variables d’environnement (OBLIGATOIRES)
22
19
 
23
- ### Instagram
24
- - `NODEBB_IG_USER_ID` (obligatoire si Instagram activé)
20
+ - `NODEBB_FB_PAGE_ID` : l’ID de la Page Facebook (celle de l’association)
21
+ - `NODEBB_FB_PAGE_ACCESS_TOKEN` : token d’accès de la Page
22
+ - `NODEBB_FB_GRAPH_VERSION` : optionnel (défaut `v25.0`)
23
+ - `NODEBB_FB_ALLOWED_GROUPS` : liste de groupes NodeBB autorisés (séparés par virgules)
24
+ - ex: `Staff,Communication`
25
25
 
26
- ### Tuning (optionnel)
27
- - `NODEBB_IG_POLL_INTERVAL_MS` (défaut 2000)
28
- - `NODEBB_IG_POLL_MAX_MS` (défaut 120000)
26
+ Exemple :
29
27
 
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
28
+ ```bash
29
+ export NODEBB_FB_PAGE_ID="1234567890"
30
+ export NODEBB_FB_PAGE_ACCESS_TOKEN="EAAB..."
31
+ export NODEBB_FB_GRAPH_VERSION="v25.0"
32
+ export NODEBB_FB_ALLOWED_GROUPS="Staff,Communication"
33
+ ```
34
+
35
+ > ⚠️ Après modification des variables d’environnement, redémarre NodeBB.
36
+
37
+ ## Installation
38
+
39
+ 1. Copier le dossier du plugin dans `node_modules/nodebb-plugin-facebook-post`
40
+ (ou publier via git/npm, au choix).
41
+ 2. Rebuild & restart :
42
+
43
+ ```bash
44
+ ./nodebb build
45
+ ./nodebb restart
46
+ ```
47
+
48
+ 3. ACP → Plugins → activer “Facebook Post”.
49
+ 4. ACP → Plugins → Facebook Post : activer + régler options.
50
+
51
+ ## Notes
52
+
53
+ - Le plugin enregistre `fbPostedId` et `fbPostedAt` dans le post pour éviter les doubles publications.
54
+ - Si une image n’est pas accessible publiquement (permissions/catégorie privée), Facebook ne pourra pas la récupérer via `url=`.
32
55
 
package/library.js CHANGED
@@ -1,62 +1,42 @@
1
1
  'use strict';
2
2
 
3
- const Redis = require('ioredis');
4
- const { v4: uuidv4 } = require('uuid');
3
+ const axios = require('axios');
5
4
 
6
5
  let meta = {};
7
6
  let settings = {};
8
7
  let appRef;
9
8
 
10
9
  const SETTINGS_KEY = 'facebook-post';
11
- const QUEUE_KEY = 'fbpost:queue';
12
10
 
11
+ // Non-sensitive settings kept in ACP DB
13
12
  const DEFAULT_SETTINGS = {
14
13
  enabled: false,
15
14
 
15
+ // Behaviour
16
16
  includeExcerpt: true,
17
17
  excerptMaxLen: 220,
18
18
 
19
- categoriesWhitelist: '',
19
+ // Filters
20
+ categoriesWhitelist: '', // comma-separated cids, empty = all
20
21
  minimumReputation: 0,
21
22
 
23
+ // Image handling
22
24
  maxImages: 4,
23
25
 
26
+ // Location
24
27
  enablePlaceTagging: true,
25
-
26
- allowedGroups: '',
27
-
28
- // Also publish to Instagram (worker only does it if at least 1 image)
29
- publishInstagram: false,
30
28
  };
31
29
 
32
-
33
- function getAuthMiddleware(mw) {
34
- // NodeBB versions/plugins may expose different auth middleware names.
35
- // Ensure we always pass a real callback to express.
36
- const candidates = [
37
- mw && mw.authenticate,
38
- mw && mw.ensureLoggedIn,
39
- mw && mw.requireUser,
40
- mw && mw.checkAuth,
41
- ].filter(fn => typeof fn === 'function');
42
-
43
- return candidates[0] || function (req, res, next) { next(); };
30
+ function bool(v) {
31
+ return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
44
32
  }
45
-
46
- function getCoreMiddleware(params) {
47
- if (params && middleware) return middleware;
48
- try {
49
- return require.main.require('./src/middleware');
50
- } catch {
51
- return {};
52
- }
33
+ function int(v, def) {
34
+ const n = parseInt(v, 10);
35
+ return Number.isFinite(n) ? n : def;
36
+ }
37
+ function trimStr(v) {
38
+ return (v || '').toString().trim();
53
39
  }
54
-
55
- let redisClient = null;
56
-
57
- function bool(v) { return v === true || v === 'true' || v === 1 || v === '1' || v === 'on'; }
58
- function int(v, def) { const n = parseInt(v, 10); return Number.isFinite(n) ? n : def; }
59
- function trimStr(v) { return (v || '').toString().trim(); }
60
40
  function parseCsvInts(csv) {
61
41
  const s = trimStr(csv);
62
42
  if (!s) return [];
@@ -65,15 +45,15 @@ function parseCsvInts(csv) {
65
45
 
66
46
  function readEnv() {
67
47
  const env = process.env || {};
48
+ // Fixed Facebook page target (association page)
68
49
  return {
69
50
  fbGraphVersion: trimStr(env.NODEBB_FB_GRAPH_VERSION) || 'v25.0',
70
51
  fbPageId: trimStr(env.NODEBB_FB_PAGE_ID),
71
52
  fbPageAccessToken: trimStr(env.NODEBB_FB_PAGE_ACCESS_TOKEN),
72
53
 
73
- igUserId: trimStr(env.NODEBB_IG_USER_ID) || '',
74
-
75
- redisUrl: trimStr(env.NODEBB_FB_QUEUE_REDIS_URL) || 'redis://127.0.0.1:6379',
76
- workerSecret: trimStr(env.NODEBB_FB_WORKER_SECRET) || '',
54
+ // Restrict availability to NodeBB groups (comma-separated)
55
+ // If empty => nobody can use Facebook posting
56
+ allowedGroups: trimStr(env.NODEBB_FB_ALLOWED_GROUPS || ''),
77
57
  };
78
58
  }
79
59
 
@@ -82,6 +62,7 @@ async function loadSettings() {
82
62
  settings = Object.assign({}, DEFAULT_SETTINGS, raw || {});
83
63
 
84
64
  settings.enabled = bool(settings.enabled);
65
+
85
66
  settings.includeExcerpt = bool(settings.includeExcerpt);
86
67
  settings.excerptMaxLen = int(settings.excerptMaxLen, DEFAULT_SETTINGS.excerptMaxLen);
87
68
 
@@ -90,18 +71,13 @@ async function loadSettings() {
90
71
  settings.enablePlaceTagging = bool(settings.enablePlaceTagging);
91
72
 
92
73
  settings.categoriesWhitelist = trimStr(settings.categoriesWhitelist);
93
- settings.allowedGroups = trimStr(settings.allowedGroups);
94
-
95
- settings.publishInstagram = bool(settings.publishInstagram);
96
74
 
75
+ // ENV overrides / secrets
97
76
  const env = readEnv();
98
77
  settings.fbGraphVersion = env.fbGraphVersion;
99
78
  settings.fbPageId = env.fbPageId;
100
79
  settings.fbPageAccessToken = env.fbPageAccessToken;
101
- settings.igUserId = env.igUserId;
102
-
103
- settings.redisUrl = env.redisUrl;
104
- settings.workerSecret = env.workerSecret;
80
+ settings.allowedGroups = env.allowedGroups;
105
81
  }
106
82
 
107
83
  function getForumBaseUrl() {
@@ -120,28 +96,40 @@ function absolutizeUrl(url) {
120
96
  function isForumHosted(urlAbs) {
121
97
  const base = getForumBaseUrl();
122
98
  if (!base) return false;
123
- try { return new URL(urlAbs).host === new URL(base).host; } catch { return false; }
99
+ try {
100
+ return new URL(urlAbs).host === new URL(base).host;
101
+ } catch {
102
+ return false;
103
+ }
124
104
  }
125
105
 
126
106
  function extractImageUrlsFromContent(rawContent) {
127
107
  const urls = new Set();
128
108
  if (!rawContent) return [];
129
109
 
110
+ // Markdown images
130
111
  const mdImg = /!\[[^\]]*]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
131
112
  let m;
132
113
  while ((m = mdImg.exec(rawContent)) !== null) urls.add(m[1]);
133
114
 
115
+ // HTML images
134
116
  const htmlImg = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
135
117
  while ((m = htmlImg.exec(rawContent)) !== null) urls.add(m[1]);
136
118
 
119
+ // Fallback: bare image URLs
137
120
  const bare = /(https?:\/\/[^\s<>"']+\.(?:png|jpe?g|gif|webp))(?:\?[^\s<>"']*)?/gi;
138
121
  while ((m = bare.exec(rawContent)) !== null) urls.add(m[1]);
139
122
 
140
- return Array.from(urls).map(u => trimStr(u)).filter(Boolean).map(absolutizeUrl);
123
+ return Array.from(urls)
124
+ .map(u => trimStr(u))
125
+ .filter(Boolean)
126
+ .map(absolutizeUrl);
141
127
  }
142
128
 
143
129
  function sanitizeExcerpt(text, maxLen) {
144
- const s = trimStr(text).replace(/\s+/g, ' ').replace(/<[^>]*>/g, '');
130
+ const s = trimStr(text)
131
+ .replace(/\s+/g, ' ')
132
+ .replace(/<[^>]*>/g, '');
145
133
  if (!s) return '';
146
134
  if (s.length <= maxLen) return s;
147
135
  return s.slice(0, Math.max(0, maxLen - 1)).trimEnd() + '…';
@@ -152,17 +140,23 @@ function parseAllowedGroupsList() {
152
140
  if (!s) return [];
153
141
  return s.split(',').map(x => x.trim()).filter(Boolean);
154
142
  }
143
+
155
144
  async function userIsAllowed(uid) {
156
145
  const allowed = parseAllowedGroupsList();
157
146
  if (!allowed.length) return false;
158
147
 
159
148
  const Groups = require.main.require('./src/groups');
160
149
  for (const groupName of allowed) {
150
+ // Group names are case-sensitive in NodeBB
151
+ // If any match => allowed
152
+ // groups.isMember(uid, groupName) returns boolean
161
153
  try {
162
154
  // eslint-disable-next-line no-await-in-loop
163
155
  const ok = await Groups.isMember(uid, groupName);
164
156
  if (ok) return true;
165
- } catch {}
157
+ } catch {
158
+ // ignore
159
+ }
166
160
  }
167
161
  return false;
168
162
  }
@@ -188,18 +182,26 @@ async function getPostContext(postData) {
188
182
  return { post, topic, user, topicUrlAbs };
189
183
  }
190
184
 
191
- function shouldEnqueue(ctx) {
185
+ function shouldProcessPost(ctx) {
192
186
  if (!ctx || !ctx.post || !ctx.topic || !ctx.user) return false;
187
+
188
+ // Plugin enabled
193
189
  if (!settings.enabled) return false;
190
+
191
+ // Secrets set
194
192
  if (!settings.fbPageId || !settings.fbPageAccessToken) return false;
195
193
 
194
+ // Only first post of topic
196
195
  const isFirstPost = (ctx.post.isMainPost === true) || (ctx.post.index === 0);
197
196
  if (!isFirstPost) return false;
198
197
 
198
+ // User opted-in on composer checkbox
199
199
  if (!bool(ctx.post.fbPostEnabled)) return false;
200
200
 
201
+ // reputation filter
201
202
  if ((ctx.user.reputation || 0) < settings.minimumReputation) return false;
202
203
 
204
+ // category whitelist
203
205
  const whitelist = parseCsvInts(settings.categoriesWhitelist);
204
206
  if (whitelist.length > 0) {
205
207
  const cid = parseInt(ctx.topic.cid, 10);
@@ -209,22 +211,46 @@ function shouldEnqueue(ctx) {
209
211
  return true;
210
212
  }
211
213
 
212
- async function getRedis() {
213
- if (redisClient) return redisClient;
214
- const winston = require.main.require('winston');
215
- redisClient = new Redis(settings.redisUrl, {
216
- maxRetriesPerRequest: 2,
217
- enableReadyCheck: true,
218
- lazyConnect: true,
214
+ async function uploadPhotoToFacebook(urlAbs) {
215
+ const endpoint = `https://graph.facebook.com/${settings.fbGraphVersion}/${settings.fbPageId}/photos`;
216
+
217
+ const resp = await axios.post(endpoint, null, {
218
+ params: {
219
+ url: urlAbs,
220
+ published: false,
221
+ access_token: settings.fbPageAccessToken,
222
+ },
223
+ timeout: 20000,
219
224
  });
220
- redisClient.on('error', (err) => {
221
- winston.error(`[facebook-post] Redis error: ${err && err.message ? err.message : err}`);
225
+
226
+ return resp.data && resp.data.id;
227
+ }
228
+
229
+ async function publishFeedPost({ message, link, photoIds, placeId }) {
230
+ const endpoint = `https://graph.facebook.com/${settings.fbGraphVersion}/${settings.fbPageId}/feed`;
231
+
232
+ const form = new URLSearchParams();
233
+ form.append('message', String(message || ''));
234
+ form.append('access_token', String(settings.fbPageAccessToken));
235
+
236
+ if (link) form.append('link', String(link));
237
+ if (placeId) form.append('place', String(placeId));
238
+
239
+ if (Array.isArray(photoIds) && photoIds.length) {
240
+ photoIds.forEach((id, idx) => {
241
+ form.append(`attached_media[${idx}]`, JSON.stringify({ media_fbid: id }));
242
+ });
243
+ }
244
+
245
+ const resp = await axios.post(endpoint, form, {
246
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
247
+ timeout: 20000,
222
248
  });
223
- await redisClient.connect();
224
- return redisClient;
249
+
250
+ return resp.data && resp.data.id;
225
251
  }
226
252
 
227
- function buildJob(ctx) {
253
+ async function postToFacebook(ctx) {
228
254
  const rawContent = ctx.post.content || '';
229
255
  const topicTitle = ctx.topic.title || 'Nouveau sujet';
230
256
  const author = ctx.user.username || 'Quelqu’un';
@@ -232,12 +258,14 @@ function buildJob(ctx) {
232
258
 
233
259
  const excerpt = settings.includeExcerpt ? sanitizeExcerpt(rawContent, settings.excerptMaxLen) : '';
234
260
 
261
+ // Images: user said uploads NodeBB => enforce forum-hosted only
235
262
  let imageUrls = extractImageUrlsFromContent(rawContent)
236
263
  .map(absolutizeUrl)
237
264
  .filter(isForumHosted);
238
265
 
239
266
  imageUrls = imageUrls.slice(0, Math.max(0, settings.maxImages));
240
267
 
268
+ // Place tagging
241
269
  let placeId = null;
242
270
  if (settings.enablePlaceTagging) {
243
271
  const pid = trimStr(ctx.post.fbPlaceId);
@@ -251,53 +279,18 @@ function buildJob(ctx) {
251
279
  lines.push(`\n— ${author}`);
252
280
  const message = lines.join('\n');
253
281
 
254
- return {
255
- id: uuidv4(),
256
- createdAt: Date.now(),
257
- attempts: 0,
258
-
259
- pid: ctx.post.pid,
260
- tid: ctx.post.tid,
261
- uid: ctx.post.uid,
262
-
263
- message,
264
- link,
265
- imageUrls,
266
- placeId,
267
-
268
- publishInstagram: !!settings.publishInstagram,
269
-
270
- nodebbBaseUrl: getForumBaseUrl(),
271
- };
272
- }
273
-
274
- async function enqueueJob(job) {
275
- const redis = await getRedis();
276
- await redis.lpush(QUEUE_KEY, JSON.stringify(job));
277
- }
278
-
279
- async function ensureAdmin(req) {
280
- const User = require.main.require('./src/user');
281
- const uid = req.uid || (req.user && (req.user.uid || req.user.uid === 0 ? req.user.uid : null)) || (req.session && req.session.uid);
282
- if (uid === undefined || uid === null) return false;
282
+ if (!imageUrls.length) {
283
+ return await publishFeedPost({ message, link, photoIds: [], placeId });
284
+ }
283
285
 
284
- try { if (await User.isAdministrator(uid)) return true; } catch {}
285
- try { if (await User.isAdminOrGlobalMod(uid)) return true; } catch {}
286
- if (req.user && req.user.isAdmin) return true;
287
- return false;
288
- }
286
+ const photoIds = [];
287
+ for (const urlAbs of imageUrls) {
288
+ // eslint-disable-next-line no-await-in-loop
289
+ const id = await uploadPhotoToFacebook(urlAbs);
290
+ if (id) photoIds.push(id);
291
+ }
289
292
 
290
- async function listAllGroups() {
291
- const Groups = require.main.require('./src/groups');
292
- try {
293
- const groups = await Groups.getGroupsFromSet('groups:createtime', 0, -1);
294
- if (Array.isArray(groups)) return groups;
295
- } catch {}
296
- try {
297
- const groups2 = await Groups.getGroups('groups:createtime', 0, 9999);
298
- if (Array.isArray(groups2)) return groups2;
299
- } catch {}
300
- return [];
293
+ return await publishFeedPost({ message, link, photoIds, placeId });
301
294
  }
302
295
 
303
296
  const Plugin = {};
@@ -308,42 +301,36 @@ Plugin.init = async function (params) {
308
301
 
309
302
  meta = (params && params.meta) ? params.meta : require.main.require('./src/meta');
310
303
 
311
- await loadSettings();
304
+ // Used to render group list in ACP
305
+ const groups = require.main.require('./src/groups');
306
+ const db = require.main.require('./src/database');
312
307
 
313
- const routeHelpers = require.main.require('./src/routes/helpers');
308
+ await loadSettings();
314
309
 
315
- // ACP page (clean signature: router, route, middlewares, controller)
316
- routeHelpers.setupAdminPageRoute(router, '/admin/plugins/facebook-post', [], (req, res) => {
317
- res.render('admin/facebook-post', {});
318
- });
310
+ async function getAllGroups() {
311
+ let names = await db.getSortedSetRange('groups:createtime', 0, -1);
312
+ if (!names?.length) {
313
+ names = await db.getSortedSetRange('groups:visible:createtime', 0, -1);
314
+ }
319
315
 
320
- // Admin API: list groups (used by ACP UI)
321
- routeHelpers.setupApiRoute(router, 'get', '/api/admin/plugins/facebook-post/groups', [
322
- middleware.ensureLoggedIn,
323
- ], async (req, res) => {
324
- try {
325
- await loadSettings();
316
+ const data = await groups.getGroupsData(
317
+ (names || []).filter(name => !groups.isPrivilegeGroup(name))
318
+ );
326
319
 
327
- const ok = await ensureAdmin(req);
328
- if (!ok) return res.status(403).json({ error: 'forbidden' });
320
+ return (data || [])
321
+ .filter(g => g?.name && g.name.toLowerCase() !== 'guests')
322
+ .sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
323
+ }
329
324
 
330
- const groups = await listAllGroups();
331
- const names = (groups || [])
332
- .map(g => g && (g.name || g.displayName || g.slug))
333
- .filter(Boolean)
334
- .filter(n => n.toLowerCase() !== 'guests');
325
+ async function render(req, res) {
326
+ const allGroups = await getAllGroups();
327
+ res.render('admin/facebook-post', { allGroups });
328
+ }
335
329
 
336
- const uniq = Array.from(new Set(names)).sort((a, b) => a.localeCompare(b));
337
- return res.json({ groups: uniq });
338
- } catch {
339
- return res.status(500).json({ error: 'failed' });
340
- }
341
- });
330
+ router.get('/admin/plugins/facebook-post', middleware.admin.buildHeader, render);
331
+ router.get('/api/admin/plugins/facebook-post', render);
342
332
 
343
- // User API: check if current user can see the composer checkbox
344
- routeHelpers.setupApiRoute(router, 'get', '/api/facebook-post/can-post', [
345
- middleware.ensureLoggedIn,
346
- ], async (req, res) => {
333
+ router.get('/api/facebook-post/can-post', middleware.ensureLoggedIn, async (req, res) => {
347
334
  try {
348
335
  await loadSettings();
349
336
  if (!settings.enabled) return res.json({ allowed: false, reason: 'disabled' });
@@ -355,10 +342,7 @@ Plugin.init = async function (params) {
355
342
  }
356
343
  });
357
344
 
358
- // Admin API: worker can mark posts as posted (optional)
359
- routeHelpers.setupApiRoute(router, 'post', '/api/admin/plugins/facebook-post/mark-posted', [
360
- middleware.ensureLoggedIn,
361
- ], async (req, res) => {
345
+ router.post('/api/admin/plugins/facebook-post/mark-posted', middleware.ensureLoggedIn, async (req, res) => {
362
346
  try {
363
347
  await loadSettings();
364
348
 
@@ -385,29 +369,15 @@ Plugin.init = async function (params) {
385
369
  return res.status(500).json({ error: 'failed' });
386
370
  }
387
371
  });
388
-
389
- router.on && router.on('nodebb:settings:reload', async (payload) => {
390
- if (payload && payload.plugin === SETTINGS_KEY) {
391
- await loadSettings();
392
- }
393
- });
394
-
395
- // Also support the legacy app event emitter (older NodeBB versions)
396
- try {
397
- const app = params.app;
398
- if (app && typeof app.on === 'function') {
399
- app.on('nodebb:settings:reload', async (payload) => {
400
- if (payload && payload.plugin === SETTINGS_KEY) {
401
- await loadSettings();
402
- }
403
- });
404
- }
405
- } catch {}
406
372
  };
407
373
 
408
- Plugin.addAdminNavigation = async function (header) {
374
+ Plugin.addAdminNavigation = async function (header) { = async function (header) {
409
375
  header.plugins = header.plugins || [];
410
- header.plugins.push({ route: '/plugins/facebook-post', icon: 'fa-facebook', name: 'Facebook/Instagram Post' });
376
+ header.plugins.push({
377
+ route: '/plugins/facebook-post',
378
+ icon: 'fa-facebook',
379
+ name: 'Facebook Post',
380
+ });
411
381
  return header;
412
382
  };
413
383
 
@@ -421,13 +391,18 @@ Plugin.addAdminScripts = async function (scripts) {
421
391
  return scripts;
422
392
  };
423
393
 
394
+ // Save custom composer data into post fields
424
395
  Plugin.onPostCreate = async function (hookData) {
425
396
  try {
426
397
  if (!hookData?.post || !hookData?.data) return hookData;
398
+
427
399
  hookData.post.fbPostEnabled = bool(hookData.data.fbPostEnabled);
400
+
428
401
  const fbPlaceId = trimStr(hookData.data.fbPlaceId);
429
402
  if (fbPlaceId) hookData.post.fbPlaceId = fbPlaceId;
430
- } catch {}
403
+ } catch (e) {
404
+ // swallow
405
+ }
431
406
  return hookData;
432
407
  };
433
408
 
@@ -440,24 +415,26 @@ Plugin.onPostSave = async function (hookData) {
440
415
  const ctx = await getPostContext(hookData && hookData.post ? hookData.post : hookData);
441
416
  if (!ctx) return;
442
417
 
418
+ // Allowed group check (server-side enforcement)
443
419
  const allowed = await userIsAllowed(ctx.post.uid);
444
420
  if (!allowed) return;
445
421
 
446
- if (!shouldEnqueue(ctx)) return;
422
+ if (!shouldProcessPost(ctx)) return;
447
423
 
448
424
  const Posts = require.main.require('./src/posts');
449
- const alreadyPosted = await Posts.getPostField(ctx.post.pid, 'fbPostedId');
450
- const alreadyQueued = await Posts.getPostField(ctx.post.pid, 'fbQueueJobId');
451
- if (alreadyPosted || alreadyQueued) return;
452
425
 
453
- const job = buildJob(ctx);
454
- await enqueueJob(job);
426
+ // Avoid double-post
427
+ const already = await Posts.getPostField(ctx.post.pid, 'fbPostedId');
428
+ if (already) return;
455
429
 
456
- await Posts.setPostField(ctx.post.pid, 'fbQueueJobId', job.id);
457
- await Posts.setPostField(ctx.post.pid, 'fbQueuedAt', Date.now());
430
+ const fbId = await postToFacebook(ctx);
431
+ if (fbId) {
432
+ await Posts.setPostField(ctx.post.pid, 'fbPostedId', fbId);
433
+ await Posts.setPostField(ctx.post.pid, 'fbPostedAt', Date.now());
434
+ }
458
435
  } catch (e) {
459
436
  const detail = e && (e.response && JSON.stringify(e.response.data) || e.message || e);
460
- winston.error(`[facebook-post] Enqueue failed: ${detail}`);
437
+ winston.error(`[facebook-post] Failed: ${detail}`);
461
438
  }
462
439
  };
463
440
 
package/package.json CHANGED
@@ -1,12 +1,10 @@
1
1
  {
2
2
  "name": "nodebb-plugin-facebook-post",
3
- "version": "1.0.4",
4
- "description": "Queue-based worker to auto-post new NodeBB topics to a fixed Facebook Page and optionally Instagram (images only).",
3
+ "version": "1.0.5",
4
+ "description": "Auto-post new NodeBB topics to a fixed Facebook Page (text + NodeBB uploads + place id).",
5
5
  "main": "library.js",
6
6
  "dependencies": {
7
- "axios": "^1.6.0",
8
- "ioredis": "^5.4.1",
9
- "uuid": "^9.0.1"
7
+ "axios": "^1.6.0"
10
8
  },
11
9
  "engines": {
12
10
  "node": ">=18"
package/plugin.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
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).",
3
+ "name": "Facebook Post",
4
+ "description": "Publie automatiquement les nouveaux topics NodeBB sur une Page Facebook fixe (images uploads + place).",
5
5
  "url": "https://example.invalid/nodebb-plugin-facebook-post",
6
+ "library": "./library.js",
6
7
  "hooks": [
7
8
  {
8
9
  "hook": "static:app.load",
@@ -1,74 +1,27 @@
1
- /* global $, app, socket */
1
+ /* globals ajaxify */
2
2
  'use strict';
3
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);
4
+ (function () {
5
+ function init() {
6
+ const $form = $('.facebook-post-settings');
7
+ if (!$form.length) return;
8
+
9
+ require(['settings', 'alerts'], function (Settings, alerts) {
10
+ Settings.load('facebook-post', $form);
11
+
12
+ $('#save').off('click.fbpost').on('click.fbpost', function (e) {
13
+ e.preventDefault();
14
+ Settings.save('facebook-post', $form, function () {
15
+ if (alerts && typeof alerts.success === 'function') {
16
+ alerts.success('[[admin/settings:saved]]');
17
+ } else if (window.app && typeof window.app.alertSuccess === 'function') {
18
+ window.app.alertSuccess('[[admin/settings:saved]]');
19
+ }
20
+ });
50
21
  });
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
22
  });
72
- });
73
- });
23
+ }
74
24
 
25
+ $(document).ready(init);
26
+ $(window).on('action:ajaxify.end', init);
27
+ })();
@@ -19,7 +19,7 @@
19
19
  <div class="mb-2" data-fbpost-wrap>
20
20
  <div class="form-check mb-2">
21
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>
22
+ <label class="form-check-label" for="fbPostEnabled">Publier ce nouveau topic sur Facebook</label>
23
23
  </div>
24
24
 
25
25
  <div class="mb-2" data-fbpost-place-wrap style="display:none;">
@@ -28,7 +28,7 @@
28
28
  </label>
29
29
  <input type="text" class="form-control" data-fbpost-place-id placeholder="ex: 123456789012345">
30
30
  <div class="form-text" style="font-size: 11px;">
31
- Utilisé comme <code>place</code> sur Facebook et <code>location_id</code> sur Instagram.
31
+ Place ID Facebook (sert à tagger le lieu sur la Page).
32
32
  </div>
33
33
  </div>
34
34
  </div>
@@ -45,6 +45,7 @@
45
45
  $placeWrap.toggle($enabled.is(':checked'));
46
46
  });
47
47
 
48
+ // Inject custom data at submit time
48
49
  $(window).off('filter:composer.submit.fbpost')
49
50
  .on('filter:composer.submit.fbpost', function (ev2, submitData) {
50
51
  const enabled = $enabled.is(':checked');
@@ -68,7 +69,7 @@
68
69
  if (!perm.allowed) return;
69
70
 
70
71
  injectUI($composer);
71
- } catch {
72
+ } catch (e) {
72
73
  // ignore
73
74
  }
74
75
  });
@@ -17,7 +17,7 @@
17
17
  </ul>
18
18
  </div>
19
19
 
20
- <form role="form" class="facebook-post-settings">
20
+ <form class="facebook-post-settings" role="form">
21
21
  <div class="form-check mb-3">
22
22
  <input type="checkbox" class="form-check-input" id="enabled" name="enabled">
23
23
  <label class="form-check-label" for="enabled">Activer</label>
@@ -34,12 +34,15 @@
34
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
35
 
36
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>
37
+ <label class="form-label" for="allowedGroups">Groupes autorisés</label>
38
+ <select id="allowedGroups" name="allowedGroups" class="form-select" multiple size="12">
39
+ <!-- BEGIN allGroups -->
40
+ <option value="{allGroups.name}">{allGroups.name}</option>
41
+ <!-- END allGroups -->
42
+ </select>
43
+ <p class="form-text">Sans fallback : si aucun groupe n’est sélectionné, personne ne verra la case.</p>
39
44
  </div>
40
45
 
41
- <input type="hidden" id="allowedGroups" name="allowedGroups" value="">
42
-
43
46
  <hr/>
44
47
 
45
48
  <h4>Message</h4>
@@ -87,7 +90,6 @@
87
90
  <label class="form-check-label" for="enablePlaceTagging">Activer le tag de lieu (Place ID / location_id)</label>
88
91
  </div>
89
92
 
90
- <button type="button" class="btn btn-primary" id="save">Enregistrer</button>
93
+ <button id="save" class="btn btn-primary">Enregistrer</button>
91
94
  </form>
92
95
  </div>
93
-
package/worker.js DELETED
@@ -1,260 +0,0 @@
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
- });