Benjamin Harris 1 месяц назад
Родитель
Сommit
9fa5a80e7b

+ 5 - 0
.env.example

@@ -19,6 +19,11 @@ MONGODB_DB=socialmedia
 # ─── Redis ─────────────────────────────────────────────────────────────────────
 REDIS_URL=redis://redis:6379
 
+# ─── Internal Service URLs ─────────────────────────────────────────────────────
+# Used by the scheduler to call the gateway for background jobs (e.g. token refresh).
+# Change only if you are running services on custom ports outside Docker.
+GATEWAY_URL=http://gateway:8084
+
 # ─── Twitter / X ───────────────────────────────────────────────────────────────
 # Taken from the Developer portal: developer.twitter.com
 TWITTER_API_KEY=

+ 92 - 1
services/gateway/server.js

@@ -186,11 +186,12 @@ app.delete('/drafts/:id', async (request, reply) => {
   return { success: true };
 });
 
-// ─── Meta Token Expiry ───────────────────────────────────────────────────────
+// ─── Meta Token Expiry & Auto-Refresh ────────────────────────────────────────
 
 let _tokenExpiryCache = null;
 let _tokenExpiryCacheAt = 0;
 const TOKEN_EXPIRY_TTL = 60 * 60 * 1000; // 1 hour
+const TOKEN_REFRESH_THRESHOLD_DAYS = 7;  // refresh when ≤ this many days remain
 
 app.get('/meta/token-expiry', async (request, reply) => {
   if (_tokenExpiryCache && Date.now() - _tokenExpiryCacheAt < TOKEN_EXPIRY_TTL) {
@@ -233,6 +234,96 @@ app.get('/meta/token-expiry', async (request, reply) => {
   return _tokenExpiryCache;
 });
 
+// Refresh Instagram long-lived tokens that are within TOKEN_REFRESH_THRESHOLD_DAYS of expiry.
+// Called by the scheduler's daily BullMQ job; can also be triggered manually from Settings.
+app.post('/meta/token-refresh', async (request, reply) => {
+  const appCred = await getCredentials('meta_app');
+  if (!appCred?.appId || !appCred?.appSecret) {
+    return reply.code(400).send({ success: false, error: 'Meta app credentials not configured' });
+  }
+  const plainAppSecret = decryptToken(appCred.appSecret);
+  if (!plainAppSecret) {
+    return reply.code(500).send({ success: false, error: 'Failed to decrypt app secret' });
+  }
+
+  const ig = await getCredentials('instagram');
+  const allAccounts = ig?.accounts || [];
+  const selectedAccounts = allAccounts.filter((a) => a.selected && a.accessToken);
+  if (!selectedAccounts.length) {
+    return { success: true, refreshed: 0, skipped: 0, errors: 0 };
+  }
+
+  const appToken = `${appCred.appId}|${plainAppSecret}`;
+  const refreshed = [];
+  const skipped = [];
+  const errors = [];
+
+  for (const account of selectedAccounts) {
+    const plainToken = decryptToken(account.accessToken);
+    if (!plainToken) {
+      errors.push({ username: account.username, error: 'decrypt_failed' });
+      continue;
+    }
+
+    // Check current token expiry via debug_token
+    let daysLeft = null;
+    try {
+      const debugRes = await axios.get(`${GRAPH_API}/debug_token`, {
+        params: { input_token: plainToken, access_token: appToken },
+        timeout: 10000,
+      });
+      const data = debugRes.data.data;
+      if (!data.is_valid) {
+        app.log.warn({ action: 'token_refresh', platform: 'instagram', username: account.username, outcome: 'skip', reason: 'invalid_token' });
+        errors.push({ username: account.username, error: 'token_invalid' });
+        continue;
+      }
+      // expires_at is a Unix timestamp; null means never-expiring (page token etc.)
+      daysLeft = data.expires_at
+        ? Math.ceil((data.expires_at * 1000 - Date.now()) / (1000 * 60 * 60 * 24))
+        : null;
+    } catch (err) {
+      app.log.warn({ action: 'token_refresh', platform: 'instagram', username: account.username, step: 'debug_token', outcome: 'failure', err: err.message });
+      errors.push({ username: account.username, error: err.message });
+      continue;
+    }
+
+    // Token never expires or has plenty of time — skip
+    if (daysLeft !== null && daysLeft > TOKEN_REFRESH_THRESHOLD_DAYS) {
+      skipped.push({ username: account.username, daysLeft });
+      continue;
+    }
+
+    // Refresh: exchange current long-lived token for a new one
+    try {
+      const refreshRes = await axios.get(`${GRAPH_API}/oauth/access_token`, {
+        params: {
+          grant_type: 'fb_exchange_token',
+          client_id: appCred.appId,
+          client_secret: plainAppSecret,
+          fb_exchange_token: plainToken,
+        },
+        timeout: 15000,
+      });
+      // Mutates the element inside allAccounts (same object reference)
+      account.accessToken = encryptToken(refreshRes.data.access_token);
+      refreshed.push({ username: account.username, previousDaysLeft: daysLeft });
+      app.log.info({ action: 'token_refresh', platform: 'instagram', username: account.username, outcome: 'success', previousDaysLeft: daysLeft });
+    } catch (err) {
+      app.log.error({ action: 'token_refresh', platform: 'instagram', username: account.username, outcome: 'failure', err: err.message });
+      errors.push({ username: account.username, error: err.message });
+    }
+  }
+
+  if (refreshed.length > 0) {
+    await setCredentials('instagram', { accounts: allAccounts });
+    _tokenExpiryCache = null; // force fresh expiry check on next poll
+  }
+
+  app.log.info({ action: 'token_refresh', platform: 'meta', outcome: 'complete', refreshed: refreshed.length, skipped: skipped.length, errors: errors.length });
+  return { success: true, refreshed: refreshed.length, skipped: skipped.length, errors: errors.length };
+});
+
 // ─── Account Profiles ────────────────────────────────────────────────────────
 
 app.get('/profiles', async () => {

+ 27 - 0
services/scheduler/index.js

@@ -7,6 +7,7 @@ const { getDb, connect } = require('./utils/MongoDBConnector');
 const { createLogger } = require('./utils/logger');
 
 const REDIS_URL = process.env.REDIS_URL || 'redis://redis:6379';
+const GATEWAY_URL = process.env.GATEWAY_URL || 'http://gateway:8084';
 
 const PLATFORM_SERVICES = {
   twitter:   process.env.TWITTER_SERVICE_URL   || 'http://twitter:3001',
@@ -86,6 +87,17 @@ async function processPostJob(job) {
   return results;
 }
 
+// ─── System Job Worker ────────────────────────────────────────────────────────
+
+async function processSystemJob(job) {
+  if (job.name === 'meta-token-refresh') {
+    log.info({ action: 'token_refresh', trigger: 'scheduled', outcome: 'start' });
+    const res = await axios.post(`${GATEWAY_URL}/meta/token-refresh`, {}, { timeout: 60000 });
+    log.info({ action: 'token_refresh', trigger: 'scheduled', outcome: 'success', refreshed: res.data.refreshed, skipped: res.data.skipped, errors: res.data.errors });
+    return res.data;
+  }
+}
+
 // ─── HTTP Endpoints ──────────────────────────────────────────────────────────
 
 app.get('/health', async () => ({ status: 'ok', service: 'scheduler' }));
@@ -172,6 +184,21 @@ async function start() {
     log.error({ action: 'job_process', jobId: job?.id, outcome: 'failure', err: err.message });
   });
 
+  // Daily system jobs (housekeeping, token refresh, etc.)
+  const systemQueue = new Queue('system-queue', { connection: redis });
+  const systemWorker = new Worker('system-queue', processSystemJob, { connection: redis });
+  systemWorker.on('failed', (job, err) => {
+    log.error({ action: 'system_job', jobId: job?.id, jobName: job?.name, outcome: 'failure', err: err.message });
+  });
+
+  // Register daily Meta token auto-refresh — BullMQ deduplicates by repeat key on restart
+  await systemQueue.add(
+    'meta-token-refresh',
+    {},
+    { repeat: { every: 24 * 60 * 60 * 1000 }, removeOnComplete: 5, removeOnFail: 5 }
+  );
+  log.info({ action: 'system_job_register', job: 'meta-token-refresh', interval: '24h', outcome: 'success' });
+
   await app.listen({ port: process.env.PORT || 3011, host: '0.0.0.0' });
   log.info({ action: 'service_start', port: 3011, outcome: 'success' }, 'Scheduler started');
 }

+ 5 - 1
ui/src/locales/en.ts

@@ -195,7 +195,11 @@ export default {
 
       expiryWarningTitle: 'Instagram token expiring soon',
       expiryWarningBody: '{username} expires in {days} day | {username} expires in {days} days',
-      expiryReconnect: 'Reconnect now',
+      expiryRefreshToken: 'Refresh Token',
+      expiryRefreshing: 'Refreshing…',
+      expiryRefreshDone: 'Token refreshed',
+      expiryAutoNote: 'Tokens are refreshed automatically when ≤ 7 days remain.',
+      expiryReconnect: 'Reconnect',
       expiryDismiss: 'Dismiss',
     },
   },

+ 5 - 1
ui/src/locales/tr.ts

@@ -195,7 +195,11 @@ export default {
 
       expiryWarningTitle: 'Instagram token\'ı yakında sona eriyor',
       expiryWarningBody: '{username} {days} gün içinde sona eriyor | {username} {days} gün içinde sona eriyor',
-      expiryReconnect: 'Şimdi yeniden bağlan',
+      expiryRefreshToken: 'Token\'ı Yenile',
+      expiryRefreshing: 'Yenileniyor…',
+      expiryRefreshDone: 'Token yenilendi',
+      expiryAutoNote: 'Token\'lar, sona ermesine ≤ 7 gün kaldığında otomatik olarak yenilenir.',
+      expiryReconnect: 'Yeniden Bağlan',
       expiryDismiss: 'Kapat',
     },
   },

+ 8 - 1
ui/src/stores/platforms.ts

@@ -95,6 +95,13 @@ export const usePlatformsStore = defineStore('platforms', () => {
     tokenExpiryDismissed.value = true
   }
 
+  async function refreshMetaTokens() {
+    const res = await axios.post('/api/meta/token-refresh', {})
+    // Re-fetch expiry so the banner updates immediately
+    await fetchTokenExpiry()
+    return res.data
+  }
+
   async function fetchMetaConnections() {
     try {
       const res = await fetch('/api/credentials')
@@ -207,6 +214,6 @@ export const usePlatformsStore = defineStore('platforms', () => {
     fetchMetaCredentials, saveMetaApp, startMetaOAuth,
     fetchMetaDiscovery, saveMetaSelection, disconnectMeta,
     tokenExpiry, expiringAccounts, hasExpiryWarning,
-    fetchTokenExpiry, dismissTokenWarning,
+    fetchTokenExpiry, dismissTokenWarning, refreshMetaTokens,
   }
 })

+ 49 - 0
ui/src/views/Settings.vue

@@ -105,6 +105,37 @@
                 <span class="ml-auto w-2 h-2 rounded-full bg-green-400"></span>
               </div>
             </div>
+            <!-- Token expiry warning banner -->
+            <div
+              v-if="platformsStore.hasExpiryWarning"
+              class="rounded-lg bg-yellow-900/30 border border-yellow-700/50 p-3 space-y-2"
+            >
+              <p class="text-xs font-semibold text-yellow-400">{{ $t('settings.meta.expiryWarningTitle') }}</p>
+              <p
+                v-for="account in platformsStore.expiringAccounts"
+                :key="account.id"
+                class="text-xs text-yellow-300"
+              >
+                {{ $tc('settings.meta.expiryWarningBody', account.daysLeft ?? 0, { username: '@' + account.username, days: account.daysLeft }) }}
+              </p>
+              <p class="text-xs text-gray-500">{{ $t('settings.meta.expiryAutoNote') }}</p>
+              <div class="flex gap-2 pt-1">
+                <button
+                  @click="handleTokenRefresh"
+                  :disabled="tokenRefreshing"
+                  class="px-3 py-1.5 bg-yellow-700 hover:bg-yellow-600 disabled:opacity-40 rounded-md text-xs font-medium transition-colors"
+                >
+                  {{ tokenRefreshing ? $t('settings.meta.expiryRefreshing') : tokenRefreshDone ? $t('settings.meta.expiryRefreshDone') : $t('settings.meta.expiryRefreshToken') }}
+                </button>
+                <button
+                  @click="platformsStore.dismissTokenWarning()"
+                  class="px-3 py-1.5 text-gray-400 hover:text-gray-300 text-xs font-medium transition-colors"
+                >
+                  {{ $t('settings.meta.expiryDismiss') }}
+                </button>
+              </div>
+            </div>
+
             <div class="flex gap-2 pt-2">
               <button
                 @click="platformsStore.startMetaOAuth()"
@@ -656,6 +687,23 @@ function confirmDisconnect() {
   }
 }
 
+// ─── Token auto-refresh ───────────────────────────────────────────────────────
+
+const tokenRefreshing = ref(false)
+const tokenRefreshDone = ref(false)
+
+async function handleTokenRefresh() {
+  tokenRefreshing.value = true
+  tokenRefreshDone.value = false
+  try {
+    await platformsStore.refreshMetaTokens()
+    tokenRefreshDone.value = true
+    setTimeout(() => { tokenRefreshDone.value = false }, 3000)
+  } finally {
+    tokenRefreshing.value = false
+  }
+}
+
 // ─── Account Profiles ────────────────────────────────────────────────────────
 
 const TONE_OPTIONS = [
@@ -797,6 +845,7 @@ onMounted(async () => {
     platformsStore.fetchStatuses(),
     platformsStore.fetchMetaCredentials(),
     loadMetaConnections(),
+    platformsStore.fetchTokenExpiry(),
     aiStore.fetchConfig(),
   ])