nodebb-plugin-facebook-post 1.0.16 → 1.0.18

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.
Files changed (3) hide show
  1. package/CLAUDE.md +68 -0
  2. package/library.js +23 -138
  3. package/package.json +1 -1
package/CLAUDE.md ADDED
@@ -0,0 +1,68 @@
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
+
26
+ ## Architecture
27
+
28
+ ### File Map
29
+
30
+ | File | Role |
31
+ |------|------|
32
+ | `library.js` | Server-side plugin entry point (Node.js) |
33
+ | `plugin.json` | NodeBB manifest: hooks, static dirs, client scripts |
34
+ | `static/lib/composer.js` | Client JS injected into the NodeBB post composer |
35
+ | `static/lib/admin.js` | Client JS for the ACP settings page |
36
+ | `static/templates/admin/facebook-post.tpl` | NodeBB Benchpress template for ACP page |
37
+
38
+ ### Hook Flow
39
+
40
+ 1. **`static:app.load` → `Plugin.init`**
41
+ Loads settings, registers Express routes on the NodeBB router:
42
+ - `GET /admin/plugins/facebook-post` – ACP page (HTML)
43
+ - `GET /api/admin/plugins/facebook-post` – ACP page (JSON, for ajaxify)
44
+ - `GET /api/facebook-post/can-post` – called by composer.js to check if the logged-in user may see the Facebook UI
45
+
46
+ 2. **`filter:post.create` → `Plugin.onPostCreate`**
47
+ Copies `fbPostEnabled` and `fbPlaceId` from the composer submit payload into the post object so they survive into the next hook.
48
+
49
+ 3. **`action:post.save` → `Plugin.onPostSave`**
50
+ Main publication logic: validates guards, calls Facebook Graph API via `postToFacebook()`. Stores `fbPostedId` and `fbPostedAt` on the post to prevent double-posting.
51
+
52
+ ### Settings Split
53
+
54
+ - **Sensitive** (env vars, never stored in DB): `fbPageId`, `fbPageAccessToken`, `fbGraphVersion`
55
+ - **Non-sensitive** (stored in NodeBB DB via `meta.settings` with key `facebook-post`): `enabled`, `includeExcerpt`, `excerptMaxLen`, `categoriesWhitelist`, `minimumReputation`, `maxImages`, `enablePlaceTagging`, `allowedGroups`
56
+
57
+ Both are merged in `loadSettings()`, which is called on each relevant request to pick up live DB changes without restart.
58
+
59
+ ### Facebook Publication Flow (`postToFacebook`)
60
+
61
+ 1. Extract image URLs from post content (Markdown `![]()`, HTML `<img>`, bare URLs) — only forum-hosted images pass the `isForumHosted()` filter.
62
+ 2. Upload each image to `/{pageId}/photos` with `published=false` to get photo IDs.
63
+ 3. POST to `/{pageId}/feed` with `attached_media[]`, optional `place`, and `link`.
64
+
65
+ ### Client-Side (composer.js)
66
+
67
+ 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.
68
+
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,16 +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
- allowedGroups: trimStr(env.NODEBB_FB_ALLOWED_GROUPS || ''),
57
42
  };
58
43
  }
59
44
 
@@ -62,28 +47,24 @@ async function loadSettings() {
62
47
  settings = Object.assign({}, DEFAULT_SETTINGS, raw || {});
63
48
 
64
49
  settings.enabled = bool(settings.enabled);
65
-
66
50
  settings.includeExcerpt = bool(settings.includeExcerpt);
67
51
  settings.excerptMaxLen = int(settings.excerptMaxLen, DEFAULT_SETTINGS.excerptMaxLen);
68
-
69
52
  settings.minimumReputation = int(settings.minimumReputation, 0);
70
53
  settings.maxImages = int(settings.maxImages, DEFAULT_SETTINGS.maxImages);
71
54
  settings.enablePlaceTagging = bool(settings.enablePlaceTagging);
72
-
73
55
  settings.categoriesWhitelist = trimStr(settings.categoriesWhitelist);
74
56
 
75
- // ENV overrides / secrets
76
57
  const env = readEnv();
77
58
  settings.fbGraphVersion = env.fbGraphVersion;
78
59
  settings.fbPageId = env.fbPageId;
79
60
  settings.fbPageAccessToken = env.fbPageAccessToken;
80
- settings.allowedGroups = env.allowedGroups;
81
61
  }
82
62
 
83
63
  function getForumBaseUrl() {
84
64
  const nconf = require.main.require('nconf');
85
65
  return trimStr(nconf.get('url')) || '';
86
66
  }
67
+
87
68
  function absolutizeUrl(url) {
88
69
  const base = getForumBaseUrl();
89
70
  if (!url) return '';
@@ -91,14 +72,15 @@ function absolutizeUrl(url) {
91
72
  if (url.startsWith('//')) return `https:${url}`;
92
73
  if (!base) return url;
93
74
  if (url.startsWith('/')) return base + url;
94
- return base + '/' + url;
75
+ return `${base}/${url}`;
95
76
  }
77
+
96
78
  function isForumHosted(urlAbs) {
97
79
  const base = getForumBaseUrl();
98
80
  if (!base) return false;
99
81
  try {
100
82
  return new URL(urlAbs).host === new URL(base).host;
101
- } catch (err) {
83
+ } catch {
102
84
  return false;
103
85
  }
104
86
  }
@@ -107,12 +89,10 @@ function extractImageUrlsFromContent(rawContent) {
107
89
  const urls = new Set();
108
90
  if (!rawContent) return [];
109
91
 
110
- // Markdown images
111
92
  const mdImg = /!\[[^\]]*]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
112
93
  let m;
113
94
  while ((m = mdImg.exec(rawContent)) !== null) urls.add(m[1]);
114
95
 
115
- // HTML images
116
96
  const htmlImg = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
117
97
  while ((m = htmlImg.exec(rawContent)) !== null) urls.add(m[1]);
118
98
 
@@ -132,25 +112,7 @@ function sanitizeExcerpt(text, maxLen) {
132
112
  .replace(/<[^>]*>/g, '');
133
113
  if (!s) return '';
134
114
  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;
115
+ return s.slice(0, maxLen - 1).trimEnd() + '\u2026';
154
116
  }
155
117
 
156
118
  function parseAllowedGroupsList() {
@@ -159,7 +121,6 @@ function parseAllowedGroupsList() {
159
121
  return s.split(',').map(v => v.trim()).filter(Boolean);
160
122
  }
161
123
 
162
-
163
124
  function getUidFromReq(req) {
164
125
  return req && (req.uid || (req.user && req.user.uid) || (req.session && req.session.uid));
165
126
  }
@@ -170,14 +131,11 @@ async function userIsAllowed(uid) {
170
131
 
171
132
  const Groups = require.main.require('./src/groups');
172
133
  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
134
  try {
177
135
  // eslint-disable-next-line no-await-in-loop
178
136
  const ok = await Groups.isMember(uid, groupName);
179
137
  if (ok) return true;
180
- } catch (err) {
138
+ } catch {
181
139
  // ignore
182
140
  }
183
141
  }
@@ -207,36 +165,23 @@ async function getPostContext(postData) {
207
165
 
208
166
  function shouldProcessPost(ctx) {
209
167
  if (!ctx || !ctx.post || !ctx.topic || !ctx.user) return false;
210
-
211
- // Plugin enabled
212
168
  if (!settings.enabled) return false;
213
-
214
- // Secrets set
215
169
  if (!settings.fbPageId || !settings.fbPageAccessToken) return false;
216
170
 
217
- // Only first post of topic
218
171
  const isFirstPost = (ctx.post.isMainPost === true) || (ctx.post.index === 0);
219
172
  if (!isFirstPost) return false;
220
173
 
221
- // User opted-in on composer checkbox
222
174
  if (!bool(ctx.post.fbPostEnabled)) return false;
223
-
224
- // reputation filter
225
175
  if ((ctx.user.reputation || 0) < settings.minimumReputation) return false;
226
176
 
227
- // category whitelist
228
177
  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
- }
178
+ if (whitelist.length > 0 && !whitelist.includes(parseInt(ctx.topic.cid, 10))) return false;
233
179
 
234
180
  return true;
235
181
  }
236
182
 
237
183
  async function uploadPhotoToFacebook(urlAbs) {
238
184
  const endpoint = `https://graph.facebook.com/${settings.fbGraphVersion}/${settings.fbPageId}/photos`;
239
-
240
185
  const resp = await axios.post(endpoint, null, {
241
186
  params: {
242
187
  url: urlAbs,
@@ -245,7 +190,6 @@ async function uploadPhotoToFacebook(urlAbs) {
245
190
  },
246
191
  timeout: 20000,
247
192
  });
248
-
249
193
  return resp.data && resp.data.id;
250
194
  }
251
195
 
@@ -255,10 +199,8 @@ async function publishFeedPost({ message, link, photoIds, placeId }) {
255
199
  const form = new URLSearchParams();
256
200
  form.append('message', String(message || ''));
257
201
  form.append('access_token', String(settings.fbPageAccessToken));
258
-
259
202
  if (link) form.append('link', String(link));
260
203
  if (placeId) form.append('place', String(placeId));
261
-
262
204
  if (Array.isArray(photoIds) && photoIds.length) {
263
205
  photoIds.forEach((id, idx) => {
264
206
  form.append(`attached_media[${idx}]`, JSON.stringify({ media_fbid: id }));
@@ -269,42 +211,28 @@ async function publishFeedPost({ message, link, photoIds, placeId }) {
269
211
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
270
212
  timeout: 20000,
271
213
  });
272
-
273
214
  return resp.data && resp.data.id;
274
215
  }
275
216
 
276
217
  async function postToFacebook(ctx) {
277
218
  const rawContent = ctx.post.content || '';
278
219
  const topicTitle = ctx.topic.title || 'Nouveau sujet';
279
- const author = ctx.user.username || 'Quelqu’un';
220
+ const author = ctx.user.username || 'Quelqu\u2019un';
280
221
  const link = ctx.topicUrlAbs;
281
222
 
282
223
  const excerpt = settings.includeExcerpt ? sanitizeExcerpt(rawContent, settings.excerptMaxLen) : '';
283
224
 
284
- // Images: user said uploads NodeBB => enforce forum-hosted only
285
- let imageUrls = extractImageUrlsFromContent(rawContent)
286
- .map(absolutizeUrl)
287
- .filter(isForumHosted);
225
+ const imageUrls = extractImageUrlsFromContent(rawContent)
226
+ .filter(isForumHosted)
227
+ .slice(0, Math.max(0, settings.maxImages));
288
228
 
289
- imageUrls = imageUrls.slice(0, Math.max(0, settings.maxImages));
229
+ const placeId = (settings.enablePlaceTagging && trimStr(ctx.post.fbPlaceId)) || null;
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');
304
-
305
- if (!imageUrls.length) {
306
- return await publishFeedPost({ message, link, photoIds: [], placeId });
307
- }
231
+ const lines = [`\uD83D\uDCDD ${topicTitle}`];
232
+ if (excerpt) lines.push(excerpt);
233
+ lines.push(`\uD83D\uDD17 ${link}`);
234
+ lines.push(`\u2014 ${author}`);
235
+ const message = lines.join('\n\n');
308
236
 
309
237
  const photoIds = [];
310
238
  for (const urlAbs of imageUrls) {
@@ -313,18 +241,16 @@ async function postToFacebook(ctx) {
313
241
  if (id) photoIds.push(id);
314
242
  }
315
243
 
316
- return await publishFeedPost({ message, link, photoIds, placeId });
244
+ return publishFeedPost({ message, link, photoIds, placeId });
317
245
  }
318
246
 
319
247
  const Plugin = {};
320
248
 
321
249
  Plugin.init = async function (params) {
322
- // NodeBB v4.9.x: params provides { router, middleware, meta, ... }
323
250
  const { router, middleware } = params;
324
251
 
325
252
  meta = (params && params.meta) ? params.meta : require.main.require('./src/meta');
326
253
 
327
- // Used to render group list in ACP
328
254
  const groups = require.main.require('./src/groups');
329
255
  const db = require.main.require('./src/database');
330
256
 
@@ -335,11 +261,9 @@ Plugin.init = async function (params) {
335
261
  if (!names?.length) {
336
262
  names = await db.getSortedSetRange('groups:visible:createtime', 0, -1);
337
263
  }
338
-
339
264
  const data = await groups.getGroupsData(
340
265
  (names || []).filter(name => !groups.isPrivilegeGroup(name))
341
266
  );
342
-
343
267
  return (data || [])
344
268
  .filter(g => g?.name && g.name.toLowerCase() !== 'guests')
345
269
  .sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
@@ -366,42 +290,10 @@ Plugin.init = async function (params) {
366
290
  if (!allowedGroups.length) return res.json({ allowed: false, reason: 'no_groups_configured' });
367
291
  const ok = await userIsAllowed(uid);
368
292
  return res.json({ allowed: ok, reason: ok ? 'ok' : 'not_in_group' });
369
- } catch (err) {
293
+ } catch {
370
294
  return res.json({ allowed: false, reason: 'error' });
371
295
  }
372
296
  });
373
- } catch (err) {
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 (err) {
402
- return res.status(500).json({ error: 'failed' });
403
- }
404
- });
405
297
  };
406
298
 
407
299
  Plugin.addAdminNavigation = async function (header) {
@@ -414,17 +306,13 @@ Plugin.addAdminNavigation = async function (header) {
414
306
  return header;
415
307
  };
416
308
 
417
-
418
- // Save custom composer data into post fields
419
309
  Plugin.onPostCreate = async function (hookData) {
420
310
  try {
421
311
  if (!hookData?.post || !hookData?.data) return hookData;
422
-
423
312
  hookData.post.fbPostEnabled = bool(hookData.data.fbPostEnabled);
424
-
425
313
  const fbPlaceId = trimStr(hookData.data.fbPlaceId);
426
314
  if (fbPlaceId) hookData.post.fbPlaceId = fbPlaceId;
427
- } catch (e) {
315
+ } catch {
428
316
  // swallow
429
317
  }
430
318
  return hookData;
@@ -439,7 +327,6 @@ Plugin.onPostSave = async function (hookData) {
439
327
  const ctx = await getPostContext(hookData && hookData.post ? hookData.post : hookData);
440
328
  if (!ctx) return;
441
329
 
442
- // Allowed group check (server-side enforcement)
443
330
  const allowed = await userIsAllowed(ctx.post.uid);
444
331
  if (!allowed) return;
445
332
 
@@ -447,7 +334,6 @@ Plugin.onPostSave = async function (hookData) {
447
334
 
448
335
  const Posts = require.main.require('./src/posts');
449
336
 
450
- // Avoid double-post
451
337
  const already = await Posts.getPostField(ctx.post.pid, 'fbPostedId');
452
338
  if (already) return;
453
339
 
@@ -457,10 +343,9 @@ Plugin.onPostSave = async function (hookData) {
457
343
  await Posts.setPostField(ctx.post.pid, 'fbPostedAt', Date.now());
458
344
  }
459
345
  } catch (e) {
460
- const detail = e && (e.response && JSON.stringify(e.response.data) || e.message || e);
346
+ const detail = e?.response ? JSON.stringify(e.response.data) : (e?.message || e);
461
347
  winston.error(`[facebook-post] Failed: ${detail}`);
462
348
  }
463
349
  };
464
350
 
465
351
  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.16",
3
+ "version": "1.0.18",
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": {