index.js 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. require('dotenv').config();
  2. const axios = require('axios');
  3. const BasePlatformService = require('./utils/BasePlatformService');
  4. const { getDb } = require('./utils/MongoDBConnector');
  5. const { decryptToken, warnIfNoKey } = require('./utils/crypto');
  6. const { getWorkspaceCredential } = require('./utils/credentials');
  7. const GRAPH_API = 'https://graph.facebook.com/v22.0';
  8. // Internal Docker URL for downloading local media files (nginx serves /media/*)
  9. const MEDIA_INTERNAL_URL = (process.env.MEDIA_INTERNAL_URL || 'http://nginx:8081').replace(/\/$/, '');
  10. function isLocalUrl(url) {
  11. return !url || url.startsWith('/') || /https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/.test(url);
  12. }
  13. function resolveInternalUrl(url) {
  14. if (url.startsWith('/')) return `${MEDIA_INTERNAL_URL}${url}`;
  15. return url.replace(/https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/, MEDIA_INTERNAL_URL);
  16. }
  17. class FacebookService extends BasePlatformService {
  18. constructor() {
  19. super('facebook');
  20. }
  21. // Read selected Facebook Pages from MongoDB.
  22. // Falls back to env vars for backwards compatibility.
  23. async _getPages(workspaceId = 'default') {
  24. try {
  25. const db = await getDb();
  26. const cred = await getWorkspaceCredential(db, 'facebook', workspaceId);
  27. const dbPages = (cred?.pages || []).filter((p) => p.selected);
  28. if (dbPages.length > 0) {
  29. return dbPages.map((p) => ({ ...p, accessToken: decryptToken(p.accessToken) })).filter((p) => p.accessToken);
  30. }
  31. } catch (_) { /* fall through */ }
  32. // Env var fallback (legacy single-page mode)
  33. const { FACEBOOK_PAGE_ACCESS_TOKEN, FACEBOOK_PAGE_ID } = process.env;
  34. if (FACEBOOK_PAGE_ACCESS_TOKEN && FACEBOOK_PAGE_ID) {
  35. return [{ id: FACEBOOK_PAGE_ID, accessToken: FACEBOOK_PAGE_ACCESS_TOKEN }];
  36. }
  37. return [];
  38. }
  39. async getStatus(workspaceId = 'default') {
  40. const pages = await this._getPages(workspaceId);
  41. if (pages.length === 0) {
  42. return { connected: false, platform: 'facebook', error: 'No Facebook Pages connected — use Settings to connect via Facebook OAuth' };
  43. }
  44. try {
  45. const first = pages[0];
  46. const res = await axios.get(`${GRAPH_API}/${first.id}`, {
  47. params: {
  48. fields: 'id,name,username,picture',
  49. access_token: first.accessToken,
  50. },
  51. });
  52. return {
  53. connected: true,
  54. platform: 'facebook',
  55. username: res.data.username || res.data.name,
  56. displayName: res.data.name,
  57. avatar: res.data.picture?.data?.url,
  58. pageCount: pages.length,
  59. };
  60. } catch (err) {
  61. return { connected: false, platform: 'facebook', error: err.response?.data?.error?.message || err.message };
  62. }
  63. }
  64. async fetchFeed({ limit = 20, workspaceId = 'default' } = {}) {
  65. const pages = await this._getPages(workspaceId);
  66. if (pages.length === 0) throw new Error('No Facebook Pages connected');
  67. const allItems = [];
  68. for (const page of pages) {
  69. const res = await axios.get(`${GRAPH_API}/${page.id}/feed`, {
  70. params: {
  71. fields: 'id,message,story,full_picture,created_time,permalink_url,likes.summary(true),comments.summary(true),shares',
  72. limit: Math.min(Number(limit), 100),
  73. access_token: page.accessToken,
  74. },
  75. });
  76. const items = (res.data.data || []).map((post) =>
  77. this.normalizeFeedItem({
  78. originalId: post.id,
  79. author: {
  80. name: page.name || '',
  81. username: page.name || '',
  82. },
  83. content: post.message || post.story || '',
  84. media: post.full_picture ? [{ url: post.full_picture, type: 'image' }] : [],
  85. metrics: {
  86. likes: post.likes?.summary?.total_count || 0,
  87. comments: post.comments?.summary?.total_count || 0,
  88. shares: post.shares?.count || 0,
  89. },
  90. url: post.permalink_url,
  91. createdAt: post.created_time,
  92. })
  93. );
  94. allItems.push(...items);
  95. }
  96. try {
  97. const db = await getDb();
  98. const col = db.collection('feeds');
  99. for (const item of allItems) {
  100. await col.updateOne(
  101. { platform: 'facebook', originalId: item.originalId, workspaceId },
  102. { $set: { ...item, workspaceId } },
  103. { upsert: true }
  104. );
  105. }
  106. } catch (err) {
  107. this.app.log.error({ action: 'feed_write', platform: 'facebook', outcome: 'failure', err: err.message });
  108. }
  109. return allItems;
  110. }
  111. // Upload a photo to a Facebook Page.
  112. // - Local/relative URLs are downloaded from the internal Docker network and uploaded as binary.
  113. // - Public external URLs are submitted via the `url` parameter (Facebook downloads them directly).
  114. // Returns the post_id (visible on the Page timeline).
  115. async _uploadPhoto(pageId, accessToken, message, imageUrl) {
  116. if (isLocalUrl(imageUrl)) {
  117. // Download from internal nginx and binary-upload — Facebook's servers cannot reach localhost
  118. const internalUrl = resolveInternalUrl(imageUrl);
  119. this.app.log.info({ action: 'photo_upload', pageId, method: 'binary', internalUrl });
  120. const imgRes = await axios.get(internalUrl, { responseType: 'arraybuffer', timeout: 15000 });
  121. const mime = imgRes.headers['content-type'] || 'image/jpeg';
  122. const form = new FormData();
  123. form.append('message', message);
  124. form.append('access_token', accessToken);
  125. form.append('source', new Blob([imgRes.data], { type: mime }), 'image.jpg');
  126. const photoRes = await fetch(`${GRAPH_API}/${pageId}/photos`, { method: 'POST', body: form });
  127. if (!photoRes.ok) {
  128. const errBody = await photoRes.json().catch(() => ({}));
  129. throw new Error(errBody.error?.message || `Facebook API error ${photoRes.status}`);
  130. }
  131. const data = await photoRes.json();
  132. return data.post_id || data.id;
  133. } else {
  134. // External public URL — Facebook downloads it directly
  135. const res = await axios.post(`${GRAPH_API}/${pageId}/photos`, null, {
  136. params: { message, url: imageUrl, access_token: accessToken },
  137. });
  138. return res.data.post_id || res.data.id;
  139. }
  140. }
  141. async publishPost({ content, link, imageUrl, accountId, firstComment, workspaceId = 'default' } = {}) {
  142. const allPages = await this._getPages(workspaceId);
  143. if (allPages.length === 0) throw new Error('No Facebook Pages connected');
  144. if (!content) throw new Error('content is required');
  145. // If a specific page is requested, target only that page
  146. const pages = accountId ? allPages.filter((p) => p.id === accountId) : allPages;
  147. if (pages.length === 0) throw new Error(`Facebook page ${accountId} not found or not connected`);
  148. const results = [];
  149. for (const page of pages) {
  150. let postId;
  151. if (imageUrl) {
  152. // Photo post — use /photos endpoint (binary for local URLs, url param for external)
  153. postId = await this._uploadPhoto(page.id, page.accessToken, content, imageUrl);
  154. } else {
  155. // Text-only post (optionally with a link preview)
  156. const params = { message: content, access_token: page.accessToken };
  157. if (link) params.link = link;
  158. const res = await axios.post(`${GRAPH_API}/${page.id}/feed`, null, { params });
  159. postId = res.data.id;
  160. }
  161. if (firstComment?.trim()) {
  162. try {
  163. await axios.post(`${GRAPH_API}/${postId}/comments`, null, {
  164. params: { message: firstComment.trim(), access_token: page.accessToken },
  165. timeout: 10000,
  166. });
  167. this.app.log.info({ action: 'first_comment', platform: 'facebook', postId, outcome: 'success' });
  168. } catch (err) {
  169. this.app.log.warn({ action: 'first_comment', platform: 'facebook', postId, outcome: 'failure', err: err.response?.data?.error?.message || err.message });
  170. }
  171. }
  172. results.push({ pageId: page.id, pageName: page.name, postId });
  173. }
  174. return results;
  175. }
  176. }
  177. const service = new FacebookService();
  178. warnIfNoKey('facebook');
  179. service.start(process.env.PORT || 3006);