index.js 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  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. // Instagram's Container API requires a publicly accessible image URL.
  9. // If APP_BASE_URL is set to a public domain, relative /media/ paths are resolved against it.
  10. const APP_BASE_URL = (process.env.APP_BASE_URL || '').replace(/\/$/, '');
  11. function resolveInstagramMediaUrl(url) {
  12. if (!url) return url;
  13. if (url.startsWith('/')) {
  14. if (!APP_BASE_URL) {
  15. 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.');
  16. }
  17. const full = `${APP_BASE_URL}${url}`;
  18. if (/https?:\/\/(localhost|127\.0\.0\.1)/.test(full)) {
  19. 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.`);
  20. }
  21. return full;
  22. }
  23. // Already a full URL — warn if it looks local but still try
  24. if (/https?:\/\/(localhost|127\.0\.0\.1)/.test(url)) {
  25. 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.`);
  26. }
  27. return url;
  28. }
  29. // Instagram's two-step publish requires the container to reach FINISHED status before
  30. // calling media_publish. Poll up to maxAttempts × intervalMs ms before giving up.
  31. async function waitForContainerReady(creationId, accessToken, maxAttempts = 12, intervalMs = 3000) {
  32. for (let i = 0; i < maxAttempts; i++) {
  33. const res = await axios.get(`${GRAPH_API}/${creationId}`, {
  34. params: { fields: 'status_code,status', access_token: accessToken },
  35. });
  36. const { status_code, status } = res.data;
  37. if (status_code === 'FINISHED') return;
  38. if (status_code === 'ERROR') throw new Error(`Instagram media processing failed: ${status || 'unknown error'}`);
  39. if (status_code === 'EXPIRED') throw new Error('Instagram media container expired before it could be published');
  40. // IN_PROGRESS — wait and retry
  41. await new Promise((r) => setTimeout(r, intervalMs));
  42. }
  43. throw new Error('Instagram media container did not finish processing in time (36s). Try again or use a faster-loading image URL.');
  44. }
  45. class InstagramService extends BasePlatformService {
  46. constructor() {
  47. super('instagram');
  48. }
  49. // Read selected Instagram Business Accounts from MongoDB.
  50. // Falls back to env vars for backwards compatibility.
  51. async _getAccounts(workspaceId = 'default') {
  52. try {
  53. const db = await getDb();
  54. const cred = await getWorkspaceCredential(db, 'instagram', workspaceId);
  55. const dbAccounts = (cred?.accounts || []).filter((a) => a.selected);
  56. if (dbAccounts.length > 0) {
  57. return dbAccounts.map((a) => ({ ...a, accessToken: decryptToken(a.accessToken) })).filter((a) => a.accessToken);
  58. }
  59. } catch (_) { /* fall through */ }
  60. // Env var fallback (legacy single-account mode)
  61. const { INSTAGRAM_ACCESS_TOKEN, INSTAGRAM_BUSINESS_ACCOUNT_ID } = process.env;
  62. if (INSTAGRAM_ACCESS_TOKEN && INSTAGRAM_BUSINESS_ACCOUNT_ID) {
  63. return [{ id: INSTAGRAM_BUSINESS_ACCOUNT_ID, accessToken: INSTAGRAM_ACCESS_TOKEN }];
  64. }
  65. return [];
  66. }
  67. async getStatus(workspaceId = 'default') {
  68. const accounts = await this._getAccounts(workspaceId);
  69. if (accounts.length === 0) {
  70. return { connected: false, platform: 'instagram', error: 'No Instagram accounts connected — use Settings to connect via Facebook OAuth' };
  71. }
  72. try {
  73. const first = accounts[0];
  74. const res = await axios.get(`${GRAPH_API}/${first.id}`, {
  75. params: {
  76. fields: 'id,name,username,profile_picture_url',
  77. access_token: first.accessToken,
  78. },
  79. });
  80. return {
  81. connected: true,
  82. platform: 'instagram',
  83. username: res.data.username || res.data.name,
  84. displayName: res.data.name,
  85. avatar: res.data.profile_picture_url,
  86. accountCount: accounts.length,
  87. };
  88. } catch (err) {
  89. return { connected: false, platform: 'instagram', error: err.response?.data?.error?.message || err.message };
  90. }
  91. }
  92. async fetchFeed({ limit = 20, workspaceId = 'default' } = {}) {
  93. const accounts = await this._getAccounts(workspaceId);
  94. if (accounts.length === 0) throw new Error('No Instagram accounts connected');
  95. const allItems = [];
  96. for (const account of accounts) {
  97. const res = await axios.get(`${GRAPH_API}/${account.id}/media`, {
  98. params: {
  99. fields: 'id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count,username',
  100. limit: Math.min(Number(limit), 100),
  101. access_token: account.accessToken,
  102. },
  103. });
  104. const items = (res.data.data || []).map((post) =>
  105. this.normalizeFeedItem({
  106. originalId: post.id,
  107. author: {
  108. name: post.username || account.username || '',
  109. username: post.username || account.username || '',
  110. profileUrl: `https://www.instagram.com/${post.username || account.username || ''}/`,
  111. },
  112. content: post.caption || '',
  113. media: post.media_url
  114. ? [{
  115. url: post.media_url,
  116. type: (post.media_type || 'IMAGE').toLowerCase(),
  117. thumbnail: post.thumbnail_url || post.media_url,
  118. }]
  119. : [],
  120. metrics: {
  121. likes: post.like_count || 0,
  122. comments: post.comments_count || 0,
  123. },
  124. url: post.permalink,
  125. createdAt: post.timestamp,
  126. })
  127. );
  128. allItems.push(...items);
  129. }
  130. try {
  131. const db = await getDb();
  132. const col = db.collection('feeds');
  133. for (const item of allItems) {
  134. await col.updateOne(
  135. { platform: 'instagram', originalId: item.originalId, workspaceId },
  136. { $set: { ...item, workspaceId } },
  137. { upsert: true }
  138. );
  139. }
  140. } catch (err) {
  141. this.app.log.error({ action: 'feed_write', platform: 'instagram', outcome: 'failure', err: err.message });
  142. }
  143. return allItems;
  144. }
  145. // Instagram requires media (image_url or video_url) — text-only posts are not supported.
  146. async publishPost({ content, imageUrl, videoUrl, accountId, firstComment, workspaceId = 'default' } = {}) {
  147. const allAccounts = await this._getAccounts(workspaceId);
  148. if (allAccounts.length === 0) throw new Error('No Instagram accounts connected');
  149. if (!imageUrl && !videoUrl) {
  150. throw new Error('Instagram requires imageUrl or videoUrl — text-only posts are not supported by the Graph API');
  151. }
  152. // If a specific account is requested, target only that account
  153. const accounts = accountId ? allAccounts.filter((a) => a.id === accountId) : allAccounts;
  154. if (accounts.length === 0) throw new Error(`Instagram account ${accountId} not found or not connected`);
  155. const results = [];
  156. for (const account of accounts) {
  157. const containerParams = {
  158. caption: content,
  159. access_token: account.accessToken,
  160. };
  161. if (videoUrl) {
  162. containerParams.media_type = 'REELS';
  163. containerParams.video_url = resolveInstagramMediaUrl(videoUrl);
  164. } else {
  165. containerParams.image_url = resolveInstagramMediaUrl(imageUrl);
  166. }
  167. const containerRes = await axios.post(
  168. `${GRAPH_API}/${account.id}/media`,
  169. null,
  170. { params: containerParams }
  171. );
  172. const creationId = containerRes.data.id;
  173. // Wait for Instagram to finish processing the media before publishing
  174. await waitForContainerReady(creationId, account.accessToken);
  175. const publishRes = await axios.post(
  176. `${GRAPH_API}/${account.id}/media_publish`,
  177. null,
  178. { params: { creation_id: creationId, access_token: account.accessToken } }
  179. );
  180. const postId = publishRes.data.id;
  181. if (firstComment?.trim()) {
  182. try {
  183. await axios.post(`${GRAPH_API}/${postId}/comments`, null, {
  184. params: { message: firstComment.trim(), access_token: account.accessToken },
  185. timeout: 10000,
  186. });
  187. this.app.log.info({ action: 'first_comment', platform: 'instagram', postId, outcome: 'success' });
  188. } catch (err) {
  189. this.app.log.warn({ action: 'first_comment', platform: 'instagram', postId, outcome: 'failure', err: err.response?.data?.error?.message || err.message });
  190. }
  191. }
  192. results.push({ accountId: account.id, username: account.username, postId });
  193. }
  194. return results;
  195. }
  196. }
  197. const service = new InstagramService();
  198. warnIfNoKey('instagram');
  199. service.start(process.env.PORT || 3005);