Преглед на файлове

Fix Facebook/Instagram 400 errors: proper photo upload and error surfacing

Facebook:
- Replace params.picture in /feed (link-preview only) with the correct
  /photos endpoint for actual photo posts
- Local/relative media URLs (/media/..., localhost) are resolved to the
  internal Docker nginx URL and binary-uploaded via Node 20 native FormData
  + fetch so Facebook's servers never need to reach localhost
- Public external URLs use the /photos url= parameter as before

Instagram:
- resolveInstagramMediaUrl() converts relative /media/ paths to full URLs
  using APP_BASE_URL; throws a clear human-readable error if the resolved
  URL is still localhost (Instagram Container API requires a public URL,
  no binary upload path exists)

BasePlatformService:
- /post and /feed error handlers now extract err.response.data.error.message
  (the actual third-party API error) instead of the generic axios status
  message so the real reason is stored in MongoDB and shown in the UI

Gateway:
- Media upload stores full URL (APP_BASE_URL + /media/filename) when
  APP_BASE_URL is set, so uploads on public deployments are immediately
  usable by platform APIs; falls back to relative path for localhost

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Benjamin Harris преди 3 седмици
родител
ревизия
4e7f23b8c2
променени са 4 файла, в които са добавени 94 реда и са изтрити 11 реда
  1. 56 6
      services/facebook/index.js
  2. 1 1
      services/gateway/server.js
  3. 24 2
      services/instagram/index.js
  4. 13 2
      services/utils/BasePlatformService.js

+ 56 - 6
services/facebook/index.js

@@ -6,6 +6,17 @@ const { decryptToken, warnIfNoKey } = require('./utils/crypto');
 const { getWorkspaceCredential } = require('./utils/credentials');
 
 const GRAPH_API = 'https://graph.facebook.com/v22.0';
+// Internal Docker URL for downloading local media files (nginx serves /media/*)
+const MEDIA_INTERNAL_URL = (process.env.MEDIA_INTERNAL_URL || 'http://nginx:8081').replace(/\/$/, '');
+
+function isLocalUrl(url) {
+  return !url || url.startsWith('/') || /https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/.test(url);
+}
+
+function resolveInternalUrl(url) {
+  if (url.startsWith('/')) return `${MEDIA_INTERNAL_URL}${url}`;
+  return url.replace(/https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/, MEDIA_INTERNAL_URL);
+}
 
 class FacebookService extends BasePlatformService {
   constructor() {
@@ -113,6 +124,39 @@ class FacebookService extends BasePlatformService {
     return allItems;
   }
 
+  // Upload a photo to a Facebook Page.
+  // - Local/relative URLs are downloaded from the internal Docker network and uploaded as binary.
+  // - Public external URLs are submitted via the `url` parameter (Facebook downloads them directly).
+  // Returns the post_id (visible on the Page timeline).
+  async _uploadPhoto(pageId, accessToken, message, imageUrl) {
+    if (isLocalUrl(imageUrl)) {
+      // Download from internal nginx and binary-upload — Facebook's servers cannot reach localhost
+      const internalUrl = resolveInternalUrl(imageUrl);
+      this.app.log.info({ action: 'photo_upload', pageId, method: 'binary', internalUrl });
+      const imgRes = await axios.get(internalUrl, { responseType: 'arraybuffer', timeout: 15000 });
+      const mime = imgRes.headers['content-type'] || 'image/jpeg';
+
+      const form = new FormData();
+      form.append('message', message);
+      form.append('access_token', accessToken);
+      form.append('source', new Blob([imgRes.data], { type: mime }), 'image.jpg');
+
+      const photoRes = await fetch(`${GRAPH_API}/${pageId}/photos`, { method: 'POST', body: form });
+      if (!photoRes.ok) {
+        const errBody = await photoRes.json().catch(() => ({}));
+        throw new Error(errBody.error?.message || `Facebook API error ${photoRes.status}`);
+      }
+      const data = await photoRes.json();
+      return data.post_id || data.id;
+    } else {
+      // External public URL — Facebook downloads it directly
+      const res = await axios.post(`${GRAPH_API}/${pageId}/photos`, null, {
+        params: { message, url: imageUrl, access_token: accessToken },
+      });
+      return res.data.post_id || res.data.id;
+    }
+  }
+
   async publishPost({ content, link, imageUrl, accountId, firstComment, workspaceId = 'default' } = {}) {
     const allPages = await this._getPages(workspaceId);
     if (allPages.length === 0) throw new Error('No Facebook Pages connected');
@@ -124,12 +168,18 @@ class FacebookService extends BasePlatformService {
 
     const results = [];
     for (const page of pages) {
-      const params = { message: content, access_token: page.accessToken };
-      if (link) params.link = link;
-      if (imageUrl) params.picture = imageUrl;
-
-      const res = await axios.post(`${GRAPH_API}/${page.id}/feed`, null, { params });
-      const postId = res.data.id;
+      let postId;
+
+      if (imageUrl) {
+        // Photo post — use /photos endpoint (binary for local URLs, url param for external)
+        postId = await this._uploadPhoto(page.id, page.accessToken, content, imageUrl);
+      } else {
+        // Text-only post (optionally with a link preview)
+        const params = { message: content, access_token: page.accessToken };
+        if (link) params.link = link;
+        const res = await axios.post(`${GRAPH_API}/${page.id}/feed`, null, { params });
+        postId = res.data.id;
+      }
 
       if (firstComment?.trim()) {
         try {

+ 1 - 1
services/gateway/server.js

@@ -316,7 +316,7 @@ app.post('/upload', async (request, reply) => {
   const record = {
     filename,
     originalName: data.filename,
-    url: `/media/${filename}`,
+    url: process.env.APP_BASE_URL ? `${process.env.APP_BASE_URL.replace(/\/$/, '')}/media/${filename}` : `/media/${filename}`,
     mimetype: data.mimetype,
     size: stat.size,
     folder: folder || null,

+ 24 - 2
services/instagram/index.js

@@ -6,6 +6,28 @@ const { decryptToken, warnIfNoKey } = require('./utils/crypto');
 const { getWorkspaceCredential } = require('./utils/credentials');
 
 const GRAPH_API = 'https://graph.facebook.com/v22.0';
+// Instagram's Container API requires a publicly accessible image URL.
+// If APP_BASE_URL is set to a public domain, relative /media/ paths are resolved against it.
+const APP_BASE_URL = (process.env.APP_BASE_URL || '').replace(/\/$/, '');
+
+function resolveInstagramMediaUrl(url) {
+  if (!url) return url;
+  if (url.startsWith('/')) {
+    if (!APP_BASE_URL) {
+      throw new Error('Instagram requires a publicly accessible image URL. Set APP_BASE_URL to your public domain (e.g. https://social.example.com) to post images from the media library.');
+    }
+    const full = `${APP_BASE_URL}${url}`;
+    if (/https?:\/\/(localhost|127\.0\.0\.1)/.test(full)) {
+      throw new Error(`Instagram requires a publicly accessible image URL. The app (${APP_BASE_URL}) is not reachable from the internet. Use an external image URL or deploy to a public server.`);
+    }
+    return full;
+  }
+  // Already a full URL — warn if it looks local but still try
+  if (/https?:\/\/(localhost|127\.0\.0\.1)/.test(url)) {
+    throw new Error(`Instagram requires a publicly accessible image URL. "${url}" is not reachable from the internet. Use an external image URL or deploy to a public server.`);
+  }
+  return url;
+}
 
 class InstagramService extends BasePlatformService {
   constructor() {
@@ -140,9 +162,9 @@ class InstagramService extends BasePlatformService {
       };
       if (videoUrl) {
         containerParams.media_type = 'REELS';
-        containerParams.video_url = videoUrl;
+        containerParams.video_url = resolveInstagramMediaUrl(videoUrl);
       } else {
-        containerParams.image_url = imageUrl;
+        containerParams.image_url = resolveInstagramMediaUrl(imageUrl);
       }
 
       const containerRes = await axios.post(

+ 13 - 2
services/utils/BasePlatformService.js

@@ -33,7 +33,10 @@ class BasePlatformService extends RabbitMQConnector {
         const items = await this.fetchFeed({ ...request.query, workspaceId });
         return { success: true, platform: this.platformName, count: items.length, items };
       } catch (err) {
-        reply.code(500).send({ success: false, error: err.message });
+        const graphMsg = err.response?.data?.error?.message;
+        const apiError = graphMsg || err.message;
+        this.log.error({ action: 'fetch_feed', platform: this.platformName, outcome: 'failure', err: String(apiError) });
+        reply.code(500).send({ success: false, error: String(apiError) });
       }
     });
 
@@ -43,7 +46,15 @@ class BasePlatformService extends RabbitMQConnector {
         const result = await this.publishPost({ ...request.body, workspaceId });
         return { success: true, platform: this.platformName, result };
       } catch (err) {
-        reply.code(500).send({ success: false, error: err.message });
+        // Extract the actual third-party API error (e.g. Facebook Graph API error body)
+        // rather than the generic axios "Request failed with status code 4xx" message.
+        const graphMsg = err.response?.data?.error?.message;
+        const graphCode = err.response?.data?.error?.code;
+        const apiError = graphMsg
+          ? (graphCode ? `[#${graphCode}] ${graphMsg}` : graphMsg)
+          : (typeof err.response?.data?.error === 'string' ? err.response.data.error : err.message);
+        this.log.error({ action: 'publish_post', platform: this.platformName, outcome: 'failure', err: String(apiError) });
+        reply.code(500).send({ success: false, error: String(apiError) });
       }
     });