nodebb-plugin-facebook-post 1.0.4 → 1.0.6
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 +47 -24
- package/library.js +147 -170
- package/package.json +3 -5
- package/plugin.json +3 -2
- package/static/lib/admin.js +22 -69
- package/static/lib/composer.js +4 -3
- package/static/templates/admin/facebook-post.tpl +9 -7
- package/worker.js +0 -260
package/README.md
CHANGED
|
@@ -1,32 +1,55 @@
|
|
|
1
|
-
# nodebb-plugin-facebook-post
|
|
1
|
+
# nodebb-plugin-facebook-post
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
##
|
|
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`. citeturn0search1turn0search0
|
|
6
|
+
## Fonctionnement
|
|
13
7
|
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
- `
|
|
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
|
-
|
|
27
|
-
- `NODEBB_IG_POLL_INTERVAL_MS` (défaut 2000)
|
|
28
|
-
- `NODEBB_IG_POLL_MAX_MS` (défaut 120000)
|
|
26
|
+
Exemple :
|
|
29
27
|
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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.
|
|
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 {
|
|
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)
|
|
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)
|
|
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
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
return
|
|
249
|
+
|
|
250
|
+
return resp.data && resp.data.id;
|
|
225
251
|
}
|
|
226
252
|
|
|
227
|
-
function
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
308
|
+
await loadSettings();
|
|
314
309
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
328
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
374
|
Plugin.addAdminNavigation = async function (header) {
|
|
409
375
|
header.plugins = header.plugins || [];
|
|
410
|
-
header.plugins.push({
|
|
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 (!
|
|
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
|
-
|
|
454
|
-
await
|
|
426
|
+
// Avoid double-post
|
|
427
|
+
const already = await Posts.getPostField(ctx.post.pid, 'fbPostedId');
|
|
428
|
+
if (already) return;
|
|
455
429
|
|
|
456
|
-
await
|
|
457
|
-
|
|
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]
|
|
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
|
-
"description": "
|
|
3
|
+
"version": "1.0.6",
|
|
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
|
|
4
|
-
"description": "
|
|
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",
|
package/static/lib/admin.js
CHANGED
|
@@ -1,74 +1,27 @@
|
|
|
1
|
-
/*
|
|
1
|
+
/* globals ajaxify */
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
function
|
|
5
|
-
function
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
+
})();
|
package/static/lib/composer.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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="
|
|
38
|
-
<select class="form-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
|
|
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
|
-
});
|