nodebb-plugin-facebook-post 1.0.15 → 1.0.17

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/CLAUDE.md ADDED
@@ -0,0 +1,69 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ NodeBB 4.x plugin that automatically publishes new forum topics to a fixed Facebook Page (association page). Only the first post of a topic is ever published, and only when the user opts in via a composer checkbox.
8
+
9
+ ## Installation & Deployment
10
+
11
+ There is no build step for this plugin. NodeBB handles asset bundling.
12
+
13
+ ```bash
14
+ # After modifying the plugin, copy it into the NodeBB instance and rebuild:
15
+ ./nodebb build
16
+ ./nodebb restart
17
+
18
+ # Activate via: ACP → Plugins → Facebook Post
19
+ ```
20
+
21
+ **Environment variables (required at NodeBB startup):**
22
+ - `NODEBB_FB_PAGE_ID` – Facebook Page ID
23
+ - `NODEBB_FB_PAGE_ACCESS_TOKEN` – Page access token
24
+ - `NODEBB_FB_GRAPH_VERSION` – optional, defaults to `v25.0`
25
+ - `NODEBB_FB_ALLOWED_GROUPS` – comma-separated NodeBB group names allowed to use the feature (e.g. `Staff,Communication`); empty = nobody
26
+
27
+ ## Architecture
28
+
29
+ ### File Map
30
+
31
+ | File | Role |
32
+ |------|------|
33
+ | `library.js` | Server-side plugin entry point (Node.js) |
34
+ | `plugin.json` | NodeBB manifest: hooks, static dirs, client scripts |
35
+ | `static/lib/composer.js` | Client JS injected into the NodeBB post composer |
36
+ | `static/lib/admin.js` | Client JS for the ACP settings page |
37
+ | `static/templates/admin/facebook-post.tpl` | NodeBB Benchpress template for ACP page |
38
+
39
+ ### Hook Flow
40
+
41
+ 1. **`static:app.load` → `Plugin.init`**
42
+ Loads settings, registers Express routes on the NodeBB router:
43
+ - `GET /admin/plugins/facebook-post` – ACP page (HTML)
44
+ - `GET /api/admin/plugins/facebook-post` – ACP page (JSON, for ajaxify)
45
+ - `GET /api/facebook-post/can-post` – called by composer.js to check if the logged-in user may see the Facebook UI
46
+
47
+ 2. **`filter:post.create` → `Plugin.onPostCreate`**
48
+ Copies `fbPostEnabled` and `fbPlaceId` from the composer submit payload into the post object so they survive into the next hook.
49
+
50
+ 3. **`action:post.save` → `Plugin.onPostSave`**
51
+ Main publication logic: validates guards, calls Facebook Graph API via `postToFacebook()`. Stores `fbPostedId` and `fbPostedAt` on the post to prevent double-posting.
52
+
53
+ ### Settings Split
54
+
55
+ - **Sensitive** (env vars, never stored in DB): `fbPageId`, `fbPageAccessToken`, `fbGraphVersion`, `allowedGroups`
56
+ - **Non-sensitive** (stored in NodeBB DB via `meta.settings` with key `facebook-post`): `enabled`, `includeExcerpt`, `excerptMaxLen`, `categoriesWhitelist`, `minimumReputation`, `maxImages`, `enablePlaceTagging`
57
+
58
+ Both are merged in `loadSettings()`, which is called on each relevant request to pick up live DB changes without restart.
59
+
60
+ ### Facebook Publication Flow (`postToFacebook`)
61
+
62
+ 1. Extract image URLs from post content (Markdown `![]()`, HTML `<img>`, bare URLs) — only forum-hosted images pass the `isForumHosted()` filter.
63
+ 2. Upload each image to `/{pageId}/photos` with `published=false` to get photo IDs.
64
+ 3. POST to `/{pageId}/feed` with `attached_media[]`, optional `place`, and `link`.
65
+
66
+ ### Client-Side (composer.js)
67
+
68
+ Listens to `action:composer.loaded`. Calls `/api/facebook-post/can-post`; if allowed, injects a checkbox and optional Place ID field into the composer. Hooks into `filter:composer.submit.fbpost` to append `fbPostEnabled` and `fbPlaceId` to the submit payload.
69
+
package/library.js CHANGED
@@ -4,26 +4,16 @@ const axios = require('axios');
4
4
 
5
5
  let meta = {};
6
6
  let settings = {};
7
- let appRef;
8
7
 
9
8
  const SETTINGS_KEY = 'facebook-post';
10
9
 
11
- // Non-sensitive settings kept in ACP DB
12
10
  const DEFAULT_SETTINGS = {
13
11
  enabled: false,
14
-
15
- // Behaviour
16
12
  includeExcerpt: true,
17
13
  excerptMaxLen: 220,
18
-
19
- // Filters
20
- categoriesWhitelist: '', // comma-separated cids, empty = all
14
+ categoriesWhitelist: '',
21
15
  minimumReputation: 0,
22
-
23
- // Image handling
24
16
  maxImages: 4,
25
-
26
- // Location
27
17
  enablePlaceTagging: true,
28
18
  };
29
19
 
@@ -44,15 +34,11 @@ function parseCsvInts(csv) {
44
34
  }
45
35
 
46
36
  function readEnv() {
47
- const env = process.env || {};
48
- // Fixed Facebook page target (association page)
37
+ const env = process.env;
49
38
  return {
50
39
  fbGraphVersion: trimStr(env.NODEBB_FB_GRAPH_VERSION) || 'v25.0',
51
40
  fbPageId: trimStr(env.NODEBB_FB_PAGE_ID),
52
41
  fbPageAccessToken: trimStr(env.NODEBB_FB_PAGE_ACCESS_TOKEN),
53
-
54
- // Restrict availability to NodeBB groups (comma-separated)
55
- // If empty => nobody can use Facebook posting
56
42
  allowedGroups: trimStr(env.NODEBB_FB_ALLOWED_GROUPS || ''),
57
43
  };
58
44
  }
@@ -62,17 +48,13 @@ async function loadSettings() {
62
48
  settings = Object.assign({}, DEFAULT_SETTINGS, raw || {});
63
49
 
64
50
  settings.enabled = bool(settings.enabled);
65
-
66
51
  settings.includeExcerpt = bool(settings.includeExcerpt);
67
52
  settings.excerptMaxLen = int(settings.excerptMaxLen, DEFAULT_SETTINGS.excerptMaxLen);
68
-
69
53
  settings.minimumReputation = int(settings.minimumReputation, 0);
70
54
  settings.maxImages = int(settings.maxImages, DEFAULT_SETTINGS.maxImages);
71
55
  settings.enablePlaceTagging = bool(settings.enablePlaceTagging);
72
-
73
56
  settings.categoriesWhitelist = trimStr(settings.categoriesWhitelist);
74
57
 
75
- // ENV overrides / secrets
76
58
  const env = readEnv();
77
59
  settings.fbGraphVersion = env.fbGraphVersion;
78
60
  settings.fbPageId = env.fbPageId;
@@ -84,6 +66,7 @@ function getForumBaseUrl() {
84
66
  const nconf = require.main.require('nconf');
85
67
  return trimStr(nconf.get('url')) || '';
86
68
  }
69
+
87
70
  function absolutizeUrl(url) {
88
71
  const base = getForumBaseUrl();
89
72
  if (!url) return '';
@@ -91,8 +74,9 @@ function absolutizeUrl(url) {
91
74
  if (url.startsWith('//')) return `https:${url}`;
92
75
  if (!base) return url;
93
76
  if (url.startsWith('/')) return base + url;
94
- return base + '/' + url;
77
+ return `${base}/${url}`;
95
78
  }
79
+
96
80
  function isForumHosted(urlAbs) {
97
81
  const base = getForumBaseUrl();
98
82
  if (!base) return false;
@@ -107,12 +91,10 @@ function extractImageUrlsFromContent(rawContent) {
107
91
  const urls = new Set();
108
92
  if (!rawContent) return [];
109
93
 
110
- // Markdown images
111
94
  const mdImg = /!\[[^\]]*]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
112
95
  let m;
113
96
  while ((m = mdImg.exec(rawContent)) !== null) urls.add(m[1]);
114
97
 
115
- // HTML images
116
98
  const htmlImg = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
117
99
  while ((m = htmlImg.exec(rawContent)) !== null) urls.add(m[1]);
118
100
 
@@ -132,25 +114,7 @@ function sanitizeExcerpt(text, maxLen) {
132
114
  .replace(/<[^>]*>/g, '');
133
115
  if (!s) return '';
134
116
  if (s.length <= maxLen) return s;
135
- return s.slice(0, Math.max(0, maxLen - 1)).trimEnd() + '';
136
- }
137
-
138
- function normalizeAllowedGroupsValue(value) {
139
- if (!value) return '';
140
- if (Array.isArray(value)) {
141
- return value.map(String).map(v => v.trim()).filter(Boolean).join(',');
142
- }
143
- const s = String(value).trim();
144
- if (!s) return '';
145
- if (s.startsWith('[')) {
146
- try {
147
- const parsed = JSON.parse(s);
148
- if (Array.isArray(parsed)) {
149
- return parsed.map(String).map(v => v.trim()).filter(Boolean).join(',');
150
- }
151
- } catch (_) {}
152
- }
153
- return s;
117
+ return s.slice(0, maxLen - 1).trimEnd() + '\u2026';
154
118
  }
155
119
 
156
120
  function parseAllowedGroupsList() {
@@ -159,7 +123,6 @@ function parseAllowedGroupsList() {
159
123
  return s.split(',').map(v => v.trim()).filter(Boolean);
160
124
  }
161
125
 
162
-
163
126
  function getUidFromReq(req) {
164
127
  return req && (req.uid || (req.user && req.user.uid) || (req.session && req.session.uid));
165
128
  }
@@ -170,9 +133,6 @@ async function userIsAllowed(uid) {
170
133
 
171
134
  const Groups = require.main.require('./src/groups');
172
135
  for (const groupName of allowed) {
173
- // Group names are case-sensitive in NodeBB
174
- // If any match => allowed
175
- // groups.isMember(uid, groupName) returns boolean
176
136
  try {
177
137
  // eslint-disable-next-line no-await-in-loop
178
138
  const ok = await Groups.isMember(uid, groupName);
@@ -207,36 +167,23 @@ async function getPostContext(postData) {
207
167
 
208
168
  function shouldProcessPost(ctx) {
209
169
  if (!ctx || !ctx.post || !ctx.topic || !ctx.user) return false;
210
-
211
- // Plugin enabled
212
170
  if (!settings.enabled) return false;
213
-
214
- // Secrets set
215
171
  if (!settings.fbPageId || !settings.fbPageAccessToken) return false;
216
172
 
217
- // Only first post of topic
218
173
  const isFirstPost = (ctx.post.isMainPost === true) || (ctx.post.index === 0);
219
174
  if (!isFirstPost) return false;
220
175
 
221
- // User opted-in on composer checkbox
222
176
  if (!bool(ctx.post.fbPostEnabled)) return false;
223
-
224
- // reputation filter
225
177
  if ((ctx.user.reputation || 0) < settings.minimumReputation) return false;
226
178
 
227
- // category whitelist
228
179
  const whitelist = parseCsvInts(settings.categoriesWhitelist);
229
- if (whitelist.length > 0) {
230
- const cid = parseInt(ctx.topic.cid, 10);
231
- if (!whitelist.includes(cid)) return false;
232
- }
180
+ if (whitelist.length > 0 && !whitelist.includes(parseInt(ctx.topic.cid, 10))) return false;
233
181
 
234
182
  return true;
235
183
  }
236
184
 
237
185
  async function uploadPhotoToFacebook(urlAbs) {
238
186
  const endpoint = `https://graph.facebook.com/${settings.fbGraphVersion}/${settings.fbPageId}/photos`;
239
-
240
187
  const resp = await axios.post(endpoint, null, {
241
188
  params: {
242
189
  url: urlAbs,
@@ -245,7 +192,6 @@ async function uploadPhotoToFacebook(urlAbs) {
245
192
  },
246
193
  timeout: 20000,
247
194
  });
248
-
249
195
  return resp.data && resp.data.id;
250
196
  }
251
197
 
@@ -255,10 +201,8 @@ async function publishFeedPost({ message, link, photoIds, placeId }) {
255
201
  const form = new URLSearchParams();
256
202
  form.append('message', String(message || ''));
257
203
  form.append('access_token', String(settings.fbPageAccessToken));
258
-
259
204
  if (link) form.append('link', String(link));
260
205
  if (placeId) form.append('place', String(placeId));
261
-
262
206
  if (Array.isArray(photoIds) && photoIds.length) {
263
207
  photoIds.forEach((id, idx) => {
264
208
  form.append(`attached_media[${idx}]`, JSON.stringify({ media_fbid: id }));
@@ -269,42 +213,28 @@ async function publishFeedPost({ message, link, photoIds, placeId }) {
269
213
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
270
214
  timeout: 20000,
271
215
  });
272
-
273
216
  return resp.data && resp.data.id;
274
217
  }
275
218
 
276
219
  async function postToFacebook(ctx) {
277
220
  const rawContent = ctx.post.content || '';
278
221
  const topicTitle = ctx.topic.title || 'Nouveau sujet';
279
- const author = ctx.user.username || 'Quelqu’un';
222
+ const author = ctx.user.username || 'Quelqu\u2019un';
280
223
  const link = ctx.topicUrlAbs;
281
224
 
282
225
  const excerpt = settings.includeExcerpt ? sanitizeExcerpt(rawContent, settings.excerptMaxLen) : '';
283
226
 
284
- // Images: user said uploads NodeBB => enforce forum-hosted only
285
- let imageUrls = extractImageUrlsFromContent(rawContent)
286
- .map(absolutizeUrl)
287
- .filter(isForumHosted);
288
-
289
- imageUrls = imageUrls.slice(0, Math.max(0, settings.maxImages));
227
+ const imageUrls = extractImageUrlsFromContent(rawContent)
228
+ .filter(isForumHosted)
229
+ .slice(0, Math.max(0, settings.maxImages));
290
230
 
291
- // Place tagging
292
- let placeId = null;
293
- if (settings.enablePlaceTagging) {
294
- const pid = trimStr(ctx.post.fbPlaceId);
295
- if (pid) placeId = pid;
296
- }
297
-
298
- const lines = [];
299
- lines.push(`📝 ${topicTitle}`);
300
- if (excerpt) lines.push(`\n${excerpt}`);
301
- lines.push(`\n🔗 ${link}`);
302
- lines.push(`\n— ${author}`);
303
- const message = lines.join('\n');
231
+ const placeId = (settings.enablePlaceTagging && trimStr(ctx.post.fbPlaceId)) || null;
304
232
 
305
- if (!imageUrls.length) {
306
- return await publishFeedPost({ message, link, photoIds: [], placeId });
307
- }
233
+ const lines = [`\uD83D\uDCDD ${topicTitle}`];
234
+ if (excerpt) lines.push(excerpt);
235
+ lines.push(`\uD83D\uDD17 ${link}`);
236
+ lines.push(`\u2014 ${author}`);
237
+ const message = lines.join('\n\n');
308
238
 
309
239
  const photoIds = [];
310
240
  for (const urlAbs of imageUrls) {
@@ -313,18 +243,16 @@ async function postToFacebook(ctx) {
313
243
  if (id) photoIds.push(id);
314
244
  }
315
245
 
316
- return await publishFeedPost({ message, link, photoIds, placeId });
246
+ return publishFeedPost({ message, link, photoIds, placeId });
317
247
  }
318
248
 
319
249
  const Plugin = {};
320
250
 
321
251
  Plugin.init = async function (params) {
322
- // NodeBB v4.9.x: params provides { router, middleware, meta, ... }
323
252
  const { router, middleware } = params;
324
253
 
325
254
  meta = (params && params.meta) ? params.meta : require.main.require('./src/meta');
326
255
 
327
- // Used to render group list in ACP
328
256
  const groups = require.main.require('./src/groups');
329
257
  const db = require.main.require('./src/database');
330
258
 
@@ -335,11 +263,9 @@ Plugin.init = async function (params) {
335
263
  if (!names?.length) {
336
264
  names = await db.getSortedSetRange('groups:visible:createtime', 0, -1);
337
265
  }
338
-
339
266
  const data = await groups.getGroupsData(
340
267
  (names || []).filter(name => !groups.isPrivilegeGroup(name))
341
268
  );
342
-
343
269
  return (data || [])
344
270
  .filter(g => g?.name && g.name.toLowerCase() !== 'guests')
345
271
  .sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
@@ -370,38 +296,6 @@ Plugin.init = async function (params) {
370
296
  return res.json({ allowed: false, reason: 'error' });
371
297
  }
372
298
  });
373
- } catch {
374
- return res.json({ allowed: false });
375
- }
376
- });
377
-
378
- router.post('/api/admin/plugins/facebook-post/mark-posted', middleware.ensureLoggedIn, async (req, res) => {
379
- try {
380
- await loadSettings();
381
-
382
- const secret = trimStr(req.body && req.body.secret);
383
- if (!secret || secret !== settings.workerSecret) {
384
- return res.status(403).json({ error: 'bad_secret' });
385
- }
386
-
387
- const ok = await ensureAdmin(req);
388
- if (!ok) return res.status(403).json({ error: 'forbidden' });
389
-
390
- const pid = parseInt(req.body && req.body.pid, 10);
391
- const fbPostedId = trimStr(req.body && req.body.fbPostedId);
392
- const fbPostedAt = parseInt(req.body && req.body.fbPostedAt, 10) || Date.now();
393
- if (!Number.isFinite(pid) || !fbPostedId) return res.status(400).json({ error: 'bad_payload' });
394
-
395
- const Posts = require.main.require('./src/posts');
396
- await Posts.setPostField(pid, 'fbPostedId', fbPostedId);
397
- await Posts.setPostField(pid, 'fbPostedAt', fbPostedAt);
398
- await Posts.deletePostField(pid, 'fbQueueJobId');
399
- await Posts.deletePostField(pid, 'fbQueuedAt');
400
- return res.json({ ok: true });
401
- } catch {
402
- return res.status(500).json({ error: 'failed' });
403
- }
404
- });
405
299
  };
406
300
 
407
301
  Plugin.addAdminNavigation = async function (header) {
@@ -414,17 +308,13 @@ Plugin.addAdminNavigation = async function (header) {
414
308
  return header;
415
309
  };
416
310
 
417
-
418
- // Save custom composer data into post fields
419
311
  Plugin.onPostCreate = async function (hookData) {
420
312
  try {
421
313
  if (!hookData?.post || !hookData?.data) return hookData;
422
-
423
314
  hookData.post.fbPostEnabled = bool(hookData.data.fbPostEnabled);
424
-
425
315
  const fbPlaceId = trimStr(hookData.data.fbPlaceId);
426
316
  if (fbPlaceId) hookData.post.fbPlaceId = fbPlaceId;
427
- } catch (e) {
317
+ } catch {
428
318
  // swallow
429
319
  }
430
320
  return hookData;
@@ -439,7 +329,6 @@ Plugin.onPostSave = async function (hookData) {
439
329
  const ctx = await getPostContext(hookData && hookData.post ? hookData.post : hookData);
440
330
  if (!ctx) return;
441
331
 
442
- // Allowed group check (server-side enforcement)
443
332
  const allowed = await userIsAllowed(ctx.post.uid);
444
333
  if (!allowed) return;
445
334
 
@@ -447,7 +336,6 @@ Plugin.onPostSave = async function (hookData) {
447
336
 
448
337
  const Posts = require.main.require('./src/posts');
449
338
 
450
- // Avoid double-post
451
339
  const already = await Posts.getPostField(ctx.post.pid, 'fbPostedId');
452
340
  if (already) return;
453
341
 
@@ -457,10 +345,9 @@ Plugin.onPostSave = async function (hookData) {
457
345
  await Posts.setPostField(ctx.post.pid, 'fbPostedAt', Date.now());
458
346
  }
459
347
  } catch (e) {
460
- const detail = e && (e.response && JSON.stringify(e.response.data) || e.message || e);
348
+ const detail = e?.response ? JSON.stringify(e.response.data) : (e?.message || e);
461
349
  winston.error(`[facebook-post] Failed: ${detail}`);
462
350
  }
463
351
  };
464
352
 
465
353
  module.exports = Plugin;
466
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-facebook-post",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
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": {
@@ -10,7 +10,7 @@
10
10
  });
11
11
  if (!res.ok) return { allowed: false };
12
12
  return await res.json();
13
- } catch {
13
+ } catch (err) {
14
14
  return { allowed: false };
15
15
  }
16
16
  }
@@ -71,7 +71,7 @@
71
71
  if (!perm.allowed) return;
72
72
 
73
73
  injectUI($composer);
74
- } catch {
74
+ } catch (err) {
75
75
  // ignore
76
76
  }
77
77
  });