nodebb-plugin-facebook-post 1.0.34 → 1.0.36
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 +2 -1
- package/library.js +140 -9
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -20,8 +20,9 @@ There is no build step for this plugin. NodeBB handles asset bundling.
|
|
|
20
20
|
|
|
21
21
|
**Environment variables (required at NodeBB startup):**
|
|
22
22
|
- `NODEBB_FB_PAGE_ID` – Facebook Page ID
|
|
23
|
-
- `NODEBB_FB_PAGE_ACCESS_TOKEN` – Page access token
|
|
23
|
+
- `NODEBB_FB_PAGE_ACCESS_TOKEN` – Page access token (also used for Instagram Graph API)
|
|
24
24
|
- `NODEBB_FB_GRAPH_VERSION` – optional, defaults to `v25.0`
|
|
25
|
+
- `NODEBB_IG_USER_ID` – optional; Instagram Business Account ID. If omitted, fetched automatically from the linked Page via `/{pageId}?fields=instagram_business_account`
|
|
25
26
|
|
|
26
27
|
## Architecture
|
|
27
28
|
|
package/library.js
CHANGED
|
@@ -38,6 +38,7 @@ function readEnv() {
|
|
|
38
38
|
fbGraphVersion: trimStr(env.NODEBB_FB_GRAPH_VERSION) || 'v25.0',
|
|
39
39
|
fbPageId: trimStr(env.NODEBB_FB_PAGE_ID),
|
|
40
40
|
fbPageAccessToken: trimStr(env.NODEBB_FB_PAGE_ACCESS_TOKEN),
|
|
41
|
+
igUserId: trimStr(env.NODEBB_IG_USER_ID),
|
|
41
42
|
};
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -56,6 +57,7 @@ async function loadSettings() {
|
|
|
56
57
|
settings.fbGraphVersion = env.fbGraphVersion;
|
|
57
58
|
settings.fbPageId = env.fbPageId;
|
|
58
59
|
settings.fbPageAccessToken = env.fbPageAccessToken;
|
|
60
|
+
settings.igUserId = env.igUserId;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
function getForumBaseUrl() {
|
|
@@ -227,6 +229,115 @@ async function publishFeedPost({ message, link, photoIds }) {
|
|
|
227
229
|
return resp.data && resp.data.id;
|
|
228
230
|
}
|
|
229
231
|
|
|
232
|
+
let cachedIgUserId = null;
|
|
233
|
+
|
|
234
|
+
async function getInstagramUserId() {
|
|
235
|
+
if (settings.igUserId) return settings.igUserId;
|
|
236
|
+
if (cachedIgUserId) return cachedIgUserId;
|
|
237
|
+
const resp = await axios.get(
|
|
238
|
+
`https://graph.facebook.com/${settings.fbGraphVersion}/${settings.fbPageId}`,
|
|
239
|
+
{
|
|
240
|
+
params: { fields: 'instagram_business_account', access_token: settings.fbPageAccessToken },
|
|
241
|
+
timeout: 10000,
|
|
242
|
+
}
|
|
243
|
+
);
|
|
244
|
+
const igId = resp.data && resp.data.instagram_business_account && resp.data.instagram_business_account.id;
|
|
245
|
+
if (!igId) throw new Error('No Instagram Business Account linked to this Facebook Page');
|
|
246
|
+
cachedIgUserId = igId;
|
|
247
|
+
return igId;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function postToInstagram(ctx) {
|
|
251
|
+
const rawContent = ctx.post.content || '';
|
|
252
|
+
const topicTitle = decodeHtmlEntities(ctx.topic.title || 'Nouveau sujet');
|
|
253
|
+
const author = ctx.user.username || 'Quelqu\u2019un';
|
|
254
|
+
const link = ctx.topicUrlAbs;
|
|
255
|
+
|
|
256
|
+
const imageUrls = extractImageUrlsFromContent(rawContent)
|
|
257
|
+
.filter(isForumHosted)
|
|
258
|
+
.slice(0, Math.max(0, settings.maxImages));
|
|
259
|
+
|
|
260
|
+
// Instagram feed posts require at least one image
|
|
261
|
+
if (!imageUrls.length) return null;
|
|
262
|
+
|
|
263
|
+
const excerpt = settings.includeExcerpt ? sanitizeExcerpt(rawContent, settings.excerptMaxLen) : '';
|
|
264
|
+
const lines = [`\uD83D\uDCDD ${topicTitle}`];
|
|
265
|
+
if (excerpt) lines.push(excerpt);
|
|
266
|
+
lines.push(`\uD83D\uDD17 ${link}`);
|
|
267
|
+
lines.push(`\u2014 ${author}`);
|
|
268
|
+
const caption = lines.join('\n\n');
|
|
269
|
+
|
|
270
|
+
const igUserId = await getInstagramUserId();
|
|
271
|
+
const baseUrl = `https://graph.facebook.com/${settings.fbGraphVersion}/${igUserId}`;
|
|
272
|
+
|
|
273
|
+
if (imageUrls.length === 1) {
|
|
274
|
+
const containerResp = await axios.post(`${baseUrl}/media`, null, {
|
|
275
|
+
params: { image_url: imageUrls[0], caption, access_token: settings.fbPageAccessToken },
|
|
276
|
+
timeout: 20000,
|
|
277
|
+
});
|
|
278
|
+
const containerId = containerResp.data && containerResp.data.id;
|
|
279
|
+
if (!containerId) return null;
|
|
280
|
+
|
|
281
|
+
const publishResp = await axios.post(`${baseUrl}/media_publish`, null, {
|
|
282
|
+
params: { creation_id: containerId, access_token: settings.fbPageAccessToken },
|
|
283
|
+
timeout: 20000,
|
|
284
|
+
});
|
|
285
|
+
return publishResp.data && publishResp.data.id;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Carousel
|
|
289
|
+
const childIds = [];
|
|
290
|
+
for (const url of imageUrls) {
|
|
291
|
+
// eslint-disable-next-line no-await-in-loop
|
|
292
|
+
const childResp = await axios.post(`${baseUrl}/media`, null, {
|
|
293
|
+
params: {
|
|
294
|
+
image_url: url,
|
|
295
|
+
media_type: 'IMAGE',
|
|
296
|
+
is_carousel_item: true,
|
|
297
|
+
access_token: settings.fbPageAccessToken,
|
|
298
|
+
},
|
|
299
|
+
timeout: 20000,
|
|
300
|
+
});
|
|
301
|
+
const childId = childResp.data && childResp.data.id;
|
|
302
|
+
if (childId) childIds.push(childId);
|
|
303
|
+
}
|
|
304
|
+
if (!childIds.length) return null;
|
|
305
|
+
|
|
306
|
+
// Instagram requires at least 2 children for a carousel; fall back to single image
|
|
307
|
+
if (childIds.length < 2) {
|
|
308
|
+
const singleResp = await axios.post(`${baseUrl}/media`, null, {
|
|
309
|
+
params: { image_url: imageUrls[0], caption, access_token: settings.fbPageAccessToken },
|
|
310
|
+
timeout: 20000,
|
|
311
|
+
});
|
|
312
|
+
const singleId = singleResp.data && singleResp.data.id;
|
|
313
|
+
if (!singleId) return null;
|
|
314
|
+
const pubResp = await axios.post(`${baseUrl}/media_publish`, null, {
|
|
315
|
+
params: { creation_id: singleId, access_token: settings.fbPageAccessToken },
|
|
316
|
+
timeout: 20000,
|
|
317
|
+
});
|
|
318
|
+
return pubResp.data && pubResp.data.id;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const form = new URLSearchParams();
|
|
322
|
+
form.append('media_type', 'CAROUSEL');
|
|
323
|
+
form.append('caption', caption);
|
|
324
|
+
childIds.forEach(id => form.append('children', id));
|
|
325
|
+
form.append('access_token', settings.fbPageAccessToken);
|
|
326
|
+
|
|
327
|
+
const carouselResp = await axios.post(`${baseUrl}/media`, form, {
|
|
328
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
329
|
+
timeout: 20000,
|
|
330
|
+
});
|
|
331
|
+
const carouselId = carouselResp.data && carouselResp.data.id;
|
|
332
|
+
if (!carouselId) return null;
|
|
333
|
+
|
|
334
|
+
const publishResp = await axios.post(`${baseUrl}/media_publish`, null, {
|
|
335
|
+
params: { creation_id: carouselId, access_token: settings.fbPageAccessToken },
|
|
336
|
+
timeout: 20000,
|
|
337
|
+
});
|
|
338
|
+
return publishResp.data && publishResp.data.id;
|
|
339
|
+
}
|
|
340
|
+
|
|
230
341
|
async function postToFacebook(ctx) {
|
|
231
342
|
const rawContent = ctx.post.content || '';
|
|
232
343
|
const topicTitle = decodeHtmlEntities(ctx.topic.title || 'Nouveau sujet');
|
|
@@ -341,16 +452,36 @@ Plugin.onPostSave = async function (hookData) {
|
|
|
341
452
|
|
|
342
453
|
const Posts = require.main.require('./src/posts');
|
|
343
454
|
|
|
344
|
-
const
|
|
345
|
-
if (
|
|
455
|
+
const fbAlready = await Posts.getPostField(ctx.post.pid, 'fbPostedId');
|
|
456
|
+
if (!fbAlready) {
|
|
457
|
+
try {
|
|
458
|
+
const fbId = await postToFacebook(ctx);
|
|
459
|
+
if (fbId) {
|
|
460
|
+
await Posts.setPostField(ctx.post.pid, 'fbPostedId', fbId);
|
|
461
|
+
await Posts.setPostField(ctx.post.pid, 'fbPostedAt', Date.now());
|
|
462
|
+
winston.info(`[facebook-post] facebook published pid=${ctx.post.pid} fbId=${fbId}`);
|
|
463
|
+
} else {
|
|
464
|
+
winston.warn(`[facebook-post] postToFacebook returned no id for pid=${ctx.post.pid}`);
|
|
465
|
+
}
|
|
466
|
+
} catch (fbErr) {
|
|
467
|
+
const detail = fbErr?.response ? JSON.stringify(fbErr.response.data) : (fbErr?.message || fbErr);
|
|
468
|
+
winston.error(`[facebook-post] facebook error pid=${ctx.post.pid}: ${detail}`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
346
471
|
|
|
347
|
-
const
|
|
348
|
-
if (
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
472
|
+
const igAlready = await Posts.getPostField(ctx.post.pid, 'igPostedId');
|
|
473
|
+
if (!igAlready) {
|
|
474
|
+
try {
|
|
475
|
+
const igId = await postToInstagram(ctx);
|
|
476
|
+
if (igId) {
|
|
477
|
+
await Posts.setPostField(ctx.post.pid, 'igPostedId', igId);
|
|
478
|
+
await Posts.setPostField(ctx.post.pid, 'igPostedAt', Date.now());
|
|
479
|
+
winston.info(`[facebook-post] instagram published pid=${ctx.post.pid} igId=${igId}`);
|
|
480
|
+
}
|
|
481
|
+
} catch (igErr) {
|
|
482
|
+
const detail = igErr?.response ? JSON.stringify(igErr.response.data) : (igErr?.message || igErr);
|
|
483
|
+
winston.error(`[facebook-post] instagram error pid=${ctx.post.pid}: ${detail}`);
|
|
484
|
+
}
|
|
354
485
|
}
|
|
355
486
|
} catch (e) {
|
|
356
487
|
const detail = e?.response ? JSON.stringify(e.response.data) : (e?.message || e);
|
package/package.json
CHANGED