index.js 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  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. class InstagramService extends BasePlatformService {
  9. constructor() {
  10. super('instagram');
  11. }
  12. // Read selected Instagram Business Accounts from MongoDB.
  13. // Falls back to env vars for backwards compatibility.
  14. async _getAccounts(workspaceId = 'default') {
  15. try {
  16. const db = await getDb();
  17. const cred = await getWorkspaceCredential(db, 'instagram', workspaceId);
  18. const dbAccounts = (cred?.accounts || []).filter((a) => a.selected);
  19. if (dbAccounts.length > 0) {
  20. return dbAccounts.map((a) => ({ ...a, accessToken: decryptToken(a.accessToken) })).filter((a) => a.accessToken);
  21. }
  22. } catch (_) { /* fall through */ }
  23. // Env var fallback (legacy single-account mode)
  24. const { INSTAGRAM_ACCESS_TOKEN, INSTAGRAM_BUSINESS_ACCOUNT_ID } = process.env;
  25. if (INSTAGRAM_ACCESS_TOKEN && INSTAGRAM_BUSINESS_ACCOUNT_ID) {
  26. return [{ id: INSTAGRAM_BUSINESS_ACCOUNT_ID, accessToken: INSTAGRAM_ACCESS_TOKEN }];
  27. }
  28. return [];
  29. }
  30. async getStatus(workspaceId = 'default') {
  31. const accounts = await this._getAccounts(workspaceId);
  32. if (accounts.length === 0) {
  33. return { connected: false, platform: 'instagram', error: 'No Instagram accounts connected — use Settings to connect via Facebook OAuth' };
  34. }
  35. try {
  36. const first = accounts[0];
  37. const res = await axios.get(`${GRAPH_API}/${first.id}`, {
  38. params: {
  39. fields: 'id,name,username,profile_picture_url',
  40. access_token: first.accessToken,
  41. },
  42. });
  43. return {
  44. connected: true,
  45. platform: 'instagram',
  46. username: res.data.username || res.data.name,
  47. displayName: res.data.name,
  48. avatar: res.data.profile_picture_url,
  49. accountCount: accounts.length,
  50. };
  51. } catch (err) {
  52. return { connected: false, platform: 'instagram', error: err.response?.data?.error?.message || err.message };
  53. }
  54. }
  55. async fetchFeed({ limit = 20, workspaceId = 'default' } = {}) {
  56. const accounts = await this._getAccounts(workspaceId);
  57. if (accounts.length === 0) throw new Error('No Instagram accounts connected');
  58. const allItems = [];
  59. for (const account of accounts) {
  60. const res = await axios.get(`${GRAPH_API}/${account.id}/media`, {
  61. params: {
  62. fields: 'id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count,username',
  63. limit: Math.min(Number(limit), 100),
  64. access_token: account.accessToken,
  65. },
  66. });
  67. const items = (res.data.data || []).map((post) =>
  68. this.normalizeFeedItem({
  69. originalId: post.id,
  70. author: {
  71. name: post.username || account.username || '',
  72. username: post.username || account.username || '',
  73. profileUrl: `https://www.instagram.com/${post.username || account.username || ''}/`,
  74. },
  75. content: post.caption || '',
  76. media: post.media_url
  77. ? [{
  78. url: post.media_url,
  79. type: (post.media_type || 'IMAGE').toLowerCase(),
  80. thumbnail: post.thumbnail_url || post.media_url,
  81. }]
  82. : [],
  83. metrics: {
  84. likes: post.like_count || 0,
  85. comments: post.comments_count || 0,
  86. },
  87. url: post.permalink,
  88. createdAt: post.timestamp,
  89. })
  90. );
  91. allItems.push(...items);
  92. }
  93. try {
  94. const db = await getDb();
  95. const col = db.collection('feeds');
  96. for (const item of allItems) {
  97. await col.updateOne(
  98. { platform: 'instagram', originalId: item.originalId, workspaceId },
  99. { $set: { ...item, workspaceId } },
  100. { upsert: true }
  101. );
  102. }
  103. } catch (err) {
  104. this.app.log.error({ action: 'feed_write', platform: 'instagram', outcome: 'failure', err: err.message });
  105. }
  106. return allItems;
  107. }
  108. // Instagram requires media (image_url or video_url) — text-only posts are not supported.
  109. async publishPost({ content, imageUrl, videoUrl, accountId, firstComment, workspaceId = 'default' } = {}) {
  110. const allAccounts = await this._getAccounts(workspaceId);
  111. if (allAccounts.length === 0) throw new Error('No Instagram accounts connected');
  112. if (!imageUrl && !videoUrl) {
  113. throw new Error('Instagram requires imageUrl or videoUrl — text-only posts are not supported by the Graph API');
  114. }
  115. // If a specific account is requested, target only that account
  116. const accounts = accountId ? allAccounts.filter((a) => a.id === accountId) : allAccounts;
  117. if (accounts.length === 0) throw new Error(`Instagram account ${accountId} not found or not connected`);
  118. const results = [];
  119. for (const account of accounts) {
  120. const containerParams = {
  121. caption: content,
  122. access_token: account.accessToken,
  123. };
  124. if (videoUrl) {
  125. containerParams.media_type = 'REELS';
  126. containerParams.video_url = videoUrl;
  127. } else {
  128. containerParams.image_url = imageUrl;
  129. }
  130. const containerRes = await axios.post(
  131. `${GRAPH_API}/${account.id}/media`,
  132. null,
  133. { params: containerParams }
  134. );
  135. const publishRes = await axios.post(
  136. `${GRAPH_API}/${account.id}/media_publish`,
  137. null,
  138. { params: { creation_id: containerRes.data.id, access_token: account.accessToken } }
  139. );
  140. const postId = publishRes.data.id;
  141. if (firstComment?.trim()) {
  142. try {
  143. await axios.post(`${GRAPH_API}/${postId}/comments`, null, {
  144. params: { message: firstComment.trim(), access_token: account.accessToken },
  145. timeout: 10000,
  146. });
  147. this.app.log.info({ action: 'first_comment', platform: 'instagram', postId, outcome: 'success' });
  148. } catch (err) {
  149. this.app.log.warn({ action: 'first_comment', platform: 'instagram', postId, outcome: 'failure', err: err.response?.data?.error?.message || err.message });
  150. }
  151. }
  152. results.push({ accountId: account.id, username: account.username, postId });
  153. }
  154. return results;
  155. }
  156. }
  157. const service = new InstagramService();
  158. warnIfNoKey('instagram');
  159. service.start(process.env.PORT || 3005);