nodebb-plugin-facebook-post 1.0.34 → 1.0.35
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 +125 -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,100 @@ 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
|
+
const form = new URLSearchParams();
|
|
307
|
+
form.append('media_type', 'CAROUSEL');
|
|
308
|
+
form.append('caption', caption);
|
|
309
|
+
childIds.forEach(id => form.append('children', id));
|
|
310
|
+
form.append('access_token', settings.fbPageAccessToken);
|
|
311
|
+
|
|
312
|
+
const carouselResp = await axios.post(`${baseUrl}/media`, form, {
|
|
313
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
314
|
+
timeout: 20000,
|
|
315
|
+
});
|
|
316
|
+
const carouselId = carouselResp.data && carouselResp.data.id;
|
|
317
|
+
if (!carouselId) return null;
|
|
318
|
+
|
|
319
|
+
const publishResp = await axios.post(`${baseUrl}/media_publish`, null, {
|
|
320
|
+
params: { creation_id: carouselId, access_token: settings.fbPageAccessToken },
|
|
321
|
+
timeout: 20000,
|
|
322
|
+
});
|
|
323
|
+
return publishResp.data && publishResp.data.id;
|
|
324
|
+
}
|
|
325
|
+
|
|
230
326
|
async function postToFacebook(ctx) {
|
|
231
327
|
const rawContent = ctx.post.content || '';
|
|
232
328
|
const topicTitle = decodeHtmlEntities(ctx.topic.title || 'Nouveau sujet');
|
|
@@ -341,16 +437,36 @@ Plugin.onPostSave = async function (hookData) {
|
|
|
341
437
|
|
|
342
438
|
const Posts = require.main.require('./src/posts');
|
|
343
439
|
|
|
344
|
-
const
|
|
345
|
-
if (
|
|
440
|
+
const fbAlready = await Posts.getPostField(ctx.post.pid, 'fbPostedId');
|
|
441
|
+
if (!fbAlready) {
|
|
442
|
+
try {
|
|
443
|
+
const fbId = await postToFacebook(ctx);
|
|
444
|
+
if (fbId) {
|
|
445
|
+
await Posts.setPostField(ctx.post.pid, 'fbPostedId', fbId);
|
|
446
|
+
await Posts.setPostField(ctx.post.pid, 'fbPostedAt', Date.now());
|
|
447
|
+
winston.info(`[facebook-post] facebook published pid=${ctx.post.pid} fbId=${fbId}`);
|
|
448
|
+
} else {
|
|
449
|
+
winston.warn(`[facebook-post] postToFacebook returned no id for pid=${ctx.post.pid}`);
|
|
450
|
+
}
|
|
451
|
+
} catch (fbErr) {
|
|
452
|
+
const detail = fbErr?.response ? JSON.stringify(fbErr.response.data) : (fbErr?.message || fbErr);
|
|
453
|
+
winston.error(`[facebook-post] facebook error pid=${ctx.post.pid}: ${detail}`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
346
456
|
|
|
347
|
-
const
|
|
348
|
-
if (
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
457
|
+
const igAlready = await Posts.getPostField(ctx.post.pid, 'igPostedId');
|
|
458
|
+
if (!igAlready) {
|
|
459
|
+
try {
|
|
460
|
+
const igId = await postToInstagram(ctx);
|
|
461
|
+
if (igId) {
|
|
462
|
+
await Posts.setPostField(ctx.post.pid, 'igPostedId', igId);
|
|
463
|
+
await Posts.setPostField(ctx.post.pid, 'igPostedAt', Date.now());
|
|
464
|
+
winston.info(`[facebook-post] instagram published pid=${ctx.post.pid} igId=${igId}`);
|
|
465
|
+
}
|
|
466
|
+
} catch (igErr) {
|
|
467
|
+
const detail = igErr?.response ? JSON.stringify(igErr.response.data) : (igErr?.message || igErr);
|
|
468
|
+
winston.error(`[facebook-post] instagram error pid=${ctx.post.pid}: ${detail}`);
|
|
469
|
+
}
|
|
354
470
|
}
|
|
355
471
|
} catch (e) {
|
|
356
472
|
const detail = e?.response ? JSON.stringify(e.response.data) : (e?.message || e);
|
package/package.json
CHANGED