meta-ads-mcp-python 1.0.79__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- meta_ads_mcp/__init__.py +79 -0
- meta_ads_mcp/__main__.py +10 -0
- meta_ads_mcp/core/__init__.py +55 -0
- meta_ads_mcp/core/accounts.py +141 -0
- meta_ads_mcp/core/ads.py +2751 -0
- meta_ads_mcp/core/ads_library.py +74 -0
- meta_ads_mcp/core/adsets.py +666 -0
- meta_ads_mcp/core/api.py +431 -0
- meta_ads_mcp/core/auth.py +567 -0
- meta_ads_mcp/core/authentication.py +207 -0
- meta_ads_mcp/core/budget_schedules.py +70 -0
- meta_ads_mcp/core/callback_server.py +256 -0
- meta_ads_mcp/core/campaigns.py +379 -0
- meta_ads_mcp/core/duplication.py +523 -0
- meta_ads_mcp/core/http_auth_integration.py +307 -0
- meta_ads_mcp/core/insights.py +161 -0
- meta_ads_mcp/core/mcc.py +232 -0
- meta_ads_mcp/core/openai_deep_research.py +418 -0
- meta_ads_mcp/core/pipeboard_auth.py +510 -0
- meta_ads_mcp/core/reports.py +135 -0
- meta_ads_mcp/core/resources.py +46 -0
- meta_ads_mcp/core/server.py +391 -0
- meta_ads_mcp/core/targeting.py +542 -0
- meta_ads_mcp/core/utils.py +225 -0
- meta_ads_mcp/settings.py +33 -0
- meta_ads_mcp_python-1.0.79.dist-info/METADATA +187 -0
- meta_ads_mcp_python-1.0.79.dist-info/RECORD +29 -0
- meta_ads_mcp_python-1.0.79.dist-info/WHEEL +4 -0
- meta_ads_mcp_python-1.0.79.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
"""Authentication related functionality for Meta Ads API."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
import time
|
|
5
|
+
import platform
|
|
6
|
+
import pathlib
|
|
7
|
+
import os
|
|
8
|
+
import webbrowser
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
from .utils import logger
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
# Import from the new callback server module
|
|
15
|
+
from .callback_server import (
|
|
16
|
+
start_callback_server,
|
|
17
|
+
shutdown_callback_server,
|
|
18
|
+
token_container,
|
|
19
|
+
callback_server_port
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Import the new Pipeboard authentication
|
|
23
|
+
from .pipeboard_auth import pipeboard_auth_manager
|
|
24
|
+
|
|
25
|
+
# Auth constants
|
|
26
|
+
# Scope includes pages_show_list and pages_read_engagement to fix issue #16
|
|
27
|
+
# where get_account_pages failed for regular users due to missing page permissions
|
|
28
|
+
AUTH_SCOPE = "business_management,public_profile,pages_show_list,pages_read_engagement"
|
|
29
|
+
AUTH_REDIRECT_URI = "http://localhost:8888/callback"
|
|
30
|
+
AUTH_RESPONSE_TYPE = "token"
|
|
31
|
+
|
|
32
|
+
# Log important configuration information
|
|
33
|
+
logger.info("Authentication module initialized")
|
|
34
|
+
logger.info(f"Auth scope: {AUTH_SCOPE}")
|
|
35
|
+
logger.info(f"Default redirect URI: {AUTH_REDIRECT_URI}")
|
|
36
|
+
|
|
37
|
+
# Global flag for authentication state
|
|
38
|
+
needs_authentication = False
|
|
39
|
+
|
|
40
|
+
# Meta configuration singleton
|
|
41
|
+
class MetaConfig:
|
|
42
|
+
_instance = None
|
|
43
|
+
|
|
44
|
+
def __new__(cls):
|
|
45
|
+
if cls._instance is None:
|
|
46
|
+
logger.debug("Creating new MetaConfig instance")
|
|
47
|
+
cls._instance = super(MetaConfig, cls).__new__(cls)
|
|
48
|
+
cls._instance.app_id = os.environ.get("META_APP_ID", "779761636818489")
|
|
49
|
+
logger.info(f"MetaConfig initialized with app_id from env/default: {cls._instance.app_id}")
|
|
50
|
+
return cls._instance
|
|
51
|
+
|
|
52
|
+
def set_app_id(self, app_id):
|
|
53
|
+
"""Set the Meta App ID for API calls"""
|
|
54
|
+
logger.info(f"Setting Meta App ID: {app_id}")
|
|
55
|
+
self.app_id = app_id
|
|
56
|
+
# Also update environment variable for modules that might read directly from it
|
|
57
|
+
os.environ["META_APP_ID"] = app_id
|
|
58
|
+
logger.debug(f"Updated META_APP_ID environment variable: {os.environ.get('META_APP_ID')}")
|
|
59
|
+
|
|
60
|
+
def get_app_id(self):
|
|
61
|
+
"""Get the current Meta App ID"""
|
|
62
|
+
# Check if we have one set
|
|
63
|
+
if hasattr(self, 'app_id') and self.app_id:
|
|
64
|
+
logger.debug(f"Using app_id from instance: {self.app_id}")
|
|
65
|
+
return self.app_id
|
|
66
|
+
|
|
67
|
+
# If not, try environment variable
|
|
68
|
+
env_app_id = os.environ.get("META_APP_ID", "")
|
|
69
|
+
if env_app_id:
|
|
70
|
+
logger.debug(f"Using app_id from environment: {env_app_id}")
|
|
71
|
+
# Update our instance for future use
|
|
72
|
+
self.app_id = env_app_id
|
|
73
|
+
return env_app_id
|
|
74
|
+
|
|
75
|
+
logger.warning("No app_id found in instance or environment variables")
|
|
76
|
+
return ""
|
|
77
|
+
|
|
78
|
+
def is_configured(self):
|
|
79
|
+
"""Check if the Meta configuration is complete"""
|
|
80
|
+
app_id = self.get_app_id()
|
|
81
|
+
configured = bool(app_id)
|
|
82
|
+
logger.debug(f"MetaConfig.is_configured() = {configured} (app_id: {app_id})")
|
|
83
|
+
return configured
|
|
84
|
+
|
|
85
|
+
# Create singleton instance
|
|
86
|
+
meta_config = MetaConfig()
|
|
87
|
+
|
|
88
|
+
class TokenInfo:
|
|
89
|
+
"""Stores token information including expiration"""
|
|
90
|
+
def __init__(self, access_token: str, expires_in: Optional[int] = None, user_id: Optional[str] = None):
|
|
91
|
+
self.access_token = access_token
|
|
92
|
+
self.expires_in = expires_in
|
|
93
|
+
self.user_id = user_id
|
|
94
|
+
self.created_at = int(time.time())
|
|
95
|
+
logger.debug(f"TokenInfo created. Expires in: {expires_in if expires_in else 'Not specified'}")
|
|
96
|
+
|
|
97
|
+
def is_expired(self) -> bool:
|
|
98
|
+
"""Check if the token is expired"""
|
|
99
|
+
if not self.expires_in:
|
|
100
|
+
return False # If no expiration is set, assume it's not expired
|
|
101
|
+
|
|
102
|
+
current_time = int(time.time())
|
|
103
|
+
return current_time > (self.created_at + self.expires_in)
|
|
104
|
+
|
|
105
|
+
def serialize(self) -> Dict[str, Any]:
|
|
106
|
+
"""Convert to a dictionary for storage"""
|
|
107
|
+
return {
|
|
108
|
+
"access_token": self.access_token,
|
|
109
|
+
"expires_in": self.expires_in,
|
|
110
|
+
"user_id": self.user_id,
|
|
111
|
+
"created_at": self.created_at
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def deserialize(cls, data: Dict[str, Any]) -> 'TokenInfo':
|
|
116
|
+
"""Create from a stored dictionary"""
|
|
117
|
+
token = cls(
|
|
118
|
+
access_token=data.get("access_token", ""),
|
|
119
|
+
expires_in=data.get("expires_in"),
|
|
120
|
+
user_id=data.get("user_id")
|
|
121
|
+
)
|
|
122
|
+
token.created_at = data.get("created_at", int(time.time()))
|
|
123
|
+
return token
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class AuthManager:
|
|
127
|
+
"""Manages authentication with Meta APIs"""
|
|
128
|
+
def __init__(self, app_id: str, redirect_uri: str = AUTH_REDIRECT_URI):
|
|
129
|
+
self.app_id = app_id
|
|
130
|
+
self.redirect_uri = redirect_uri
|
|
131
|
+
self.token_info = None
|
|
132
|
+
# Check for Pipeboard token first
|
|
133
|
+
self.use_pipeboard = bool(os.environ.get("PIPEBOARD_API_TOKEN", ""))
|
|
134
|
+
if not self.use_pipeboard:
|
|
135
|
+
self._load_cached_token()
|
|
136
|
+
|
|
137
|
+
def _get_token_cache_path(self) -> pathlib.Path:
|
|
138
|
+
"""Get the platform-specific path for token cache file"""
|
|
139
|
+
if platform.system() == "Windows":
|
|
140
|
+
base_path = pathlib.Path(os.environ.get("APPDATA", ""))
|
|
141
|
+
elif platform.system() == "Darwin": # macOS
|
|
142
|
+
base_path = pathlib.Path.home() / "Library" / "Application Support"
|
|
143
|
+
else: # Assume Linux/Unix
|
|
144
|
+
base_path = pathlib.Path.home() / ".config"
|
|
145
|
+
|
|
146
|
+
# Create directory if it doesn't exist
|
|
147
|
+
cache_dir = base_path / "meta-ads-mcp"
|
|
148
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
|
|
150
|
+
return cache_dir / "token_cache.json"
|
|
151
|
+
|
|
152
|
+
def _load_cached_token(self) -> bool:
|
|
153
|
+
"""Load token from cache if available"""
|
|
154
|
+
cache_path = self._get_token_cache_path()
|
|
155
|
+
|
|
156
|
+
if not cache_path.exists():
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
with open(cache_path, "r") as f:
|
|
161
|
+
data = json.load(f)
|
|
162
|
+
|
|
163
|
+
# Validate the cached data structure
|
|
164
|
+
required_fields = ["access_token", "created_at"]
|
|
165
|
+
if not all(field in data for field in required_fields):
|
|
166
|
+
logger.warning("Cached token data is missing required fields")
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
# Check if the token looks valid (basic format check)
|
|
170
|
+
if not data.get("access_token") or len(data["access_token"]) < 20:
|
|
171
|
+
logger.warning("Cached token appears malformed")
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
self.token_info = TokenInfo.deserialize(data)
|
|
175
|
+
|
|
176
|
+
# Check if token is expired
|
|
177
|
+
if self.token_info.is_expired():
|
|
178
|
+
logger.info("Cached token is expired, removing cache file")
|
|
179
|
+
# Remove the expired cache file
|
|
180
|
+
try:
|
|
181
|
+
cache_path.unlink()
|
|
182
|
+
logger.info(f"Removed expired token cache: {cache_path}")
|
|
183
|
+
except Exception as e:
|
|
184
|
+
logger.warning(f"Could not remove expired cache file: {e}")
|
|
185
|
+
self.token_info = None
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
# Additional validation: check if token is too old (more than 60 days)
|
|
189
|
+
current_time = int(time.time())
|
|
190
|
+
if self.token_info.created_at and (current_time - self.token_info.created_at) > (60 * 24 * 3600):
|
|
191
|
+
logger.warning("Cached token is too old (more than 60 days), removing cache file")
|
|
192
|
+
try:
|
|
193
|
+
cache_path.unlink()
|
|
194
|
+
logger.info(f"Removed old token cache: {cache_path}")
|
|
195
|
+
except Exception as e:
|
|
196
|
+
logger.warning(f"Could not remove old cache file: {e}")
|
|
197
|
+
self.token_info = None
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
logger.info(f"Loaded cached token (expires in {(self.token_info.created_at + self.token_info.expires_in) - int(time.time())} seconds)")
|
|
201
|
+
return True
|
|
202
|
+
except Exception as e:
|
|
203
|
+
logger.error(f"Error loading cached token: {e}")
|
|
204
|
+
# If there's any error reading the cache, try to remove the corrupted file
|
|
205
|
+
try:
|
|
206
|
+
cache_path.unlink()
|
|
207
|
+
logger.info(f"Removed corrupted token cache: {cache_path}")
|
|
208
|
+
except Exception as cleanup_error:
|
|
209
|
+
logger.warning(f"Could not remove corrupted cache file: {cleanup_error}")
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
def _save_token_to_cache(self) -> None:
|
|
213
|
+
"""Save token to cache file"""
|
|
214
|
+
if not self.token_info:
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
cache_path = self._get_token_cache_path()
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
with open(cache_path, "w") as f:
|
|
221
|
+
json.dump(self.token_info.serialize(), f)
|
|
222
|
+
logger.info(f"Token cached at: {cache_path}")
|
|
223
|
+
except Exception as e:
|
|
224
|
+
logger.error(f"Error saving token to cache: {e}")
|
|
225
|
+
|
|
226
|
+
def get_auth_url(self) -> str:
|
|
227
|
+
"""Generate the Facebook OAuth URL for desktop app flow"""
|
|
228
|
+
return (
|
|
229
|
+
f"https://www.facebook.com/v24.0/dialog/oauth?"
|
|
230
|
+
f"client_id={self.app_id}&"
|
|
231
|
+
f"redirect_uri={self.redirect_uri}&"
|
|
232
|
+
f"scope={AUTH_SCOPE}&"
|
|
233
|
+
f"response_type={AUTH_RESPONSE_TYPE}"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def authenticate(self, force_refresh: bool = False) -> Optional[str]:
|
|
237
|
+
"""
|
|
238
|
+
Authenticate with Meta APIs
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
force_refresh: Force token refresh even if cached token exists
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Access token if successful, None otherwise
|
|
245
|
+
"""
|
|
246
|
+
# If Pipeboard auth is available, use that instead
|
|
247
|
+
if self.use_pipeboard:
|
|
248
|
+
logger.info("Using Pipeboard authentication")
|
|
249
|
+
return pipeboard_auth_manager.get_access_token(force_refresh=force_refresh)
|
|
250
|
+
|
|
251
|
+
# Otherwise, use the original OAuth flow
|
|
252
|
+
# Check if we already have a valid token
|
|
253
|
+
if not force_refresh and self.token_info and not self.token_info.is_expired():
|
|
254
|
+
return self.token_info.access_token
|
|
255
|
+
|
|
256
|
+
# Start the callback server if not already running
|
|
257
|
+
try:
|
|
258
|
+
port = start_callback_server()
|
|
259
|
+
|
|
260
|
+
# Update redirect URI with the actual port
|
|
261
|
+
self.redirect_uri = f"http://localhost:{port}/callback"
|
|
262
|
+
|
|
263
|
+
# Generate the auth URL
|
|
264
|
+
auth_url = self.get_auth_url()
|
|
265
|
+
|
|
266
|
+
# Open browser with auth URL
|
|
267
|
+
logger.info(f"Opening browser with URL: {auth_url}")
|
|
268
|
+
webbrowser.open(auth_url)
|
|
269
|
+
|
|
270
|
+
# We don't wait for the token here anymore
|
|
271
|
+
# The token will be processed by the callback server
|
|
272
|
+
# Just return None to indicate we've started the flow
|
|
273
|
+
return None
|
|
274
|
+
except Exception as e:
|
|
275
|
+
logger.error(f"Failed to start callback server: {e}")
|
|
276
|
+
logger.info("Callback server disabled. OAuth authentication flow cannot be used.")
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
def get_access_token(self) -> Optional[str]:
|
|
280
|
+
"""
|
|
281
|
+
Get the current access token, refreshing if necessary
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
Access token if available, None otherwise
|
|
285
|
+
"""
|
|
286
|
+
# If using Pipeboard, always delegate to the Pipeboard auth manager
|
|
287
|
+
if self.use_pipeboard:
|
|
288
|
+
return pipeboard_auth_manager.get_access_token()
|
|
289
|
+
|
|
290
|
+
if not self.token_info or self.token_info.is_expired():
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
return self.token_info.access_token
|
|
294
|
+
|
|
295
|
+
def invalidate_token(self) -> None:
|
|
296
|
+
"""Invalidate the current token, usually because it has expired or is invalid"""
|
|
297
|
+
# If using Pipeboard, delegate to the Pipeboard auth manager
|
|
298
|
+
if self.use_pipeboard:
|
|
299
|
+
pipeboard_auth_manager.invalidate_token()
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
if self.token_info:
|
|
303
|
+
logger.info(f"Invalidating token: {self.token_info.access_token[:10]}...")
|
|
304
|
+
self.token_info = None
|
|
305
|
+
|
|
306
|
+
# Signal that authentication is needed
|
|
307
|
+
global needs_authentication
|
|
308
|
+
needs_authentication = True
|
|
309
|
+
|
|
310
|
+
# Remove the cached token file
|
|
311
|
+
try:
|
|
312
|
+
cache_path = self._get_token_cache_path()
|
|
313
|
+
if cache_path.exists():
|
|
314
|
+
os.remove(cache_path)
|
|
315
|
+
logger.info(f"Removed cached token file: {cache_path}")
|
|
316
|
+
except Exception as e:
|
|
317
|
+
logger.error(f"Error removing cached token file: {e}")
|
|
318
|
+
|
|
319
|
+
def clear_token(self) -> None:
|
|
320
|
+
"""Alias for invalidate_token for consistency with other APIs"""
|
|
321
|
+
self.invalidate_token()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def process_token_response(token_container):
|
|
325
|
+
"""Process the token response from Facebook."""
|
|
326
|
+
global needs_authentication, auth_manager
|
|
327
|
+
|
|
328
|
+
if token_container and token_container.get('token'):
|
|
329
|
+
logger.info("Processing token response from Facebook OAuth")
|
|
330
|
+
|
|
331
|
+
# Exchange the short-lived token for a long-lived token
|
|
332
|
+
short_lived_token = token_container['token']
|
|
333
|
+
long_lived_token_info = exchange_token_for_long_lived(short_lived_token)
|
|
334
|
+
|
|
335
|
+
if long_lived_token_info:
|
|
336
|
+
logger.info(f"Successfully exchanged for long-lived token (expires in {long_lived_token_info.expires_in} seconds)")
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
auth_manager.token_info = long_lived_token_info
|
|
340
|
+
logger.info(f"Long-lived token info set in auth_manager, expires in {long_lived_token_info.expires_in} seconds")
|
|
341
|
+
except NameError:
|
|
342
|
+
logger.error("auth_manager not defined when trying to process token")
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
logger.info("Attempting to save long-lived token to cache")
|
|
346
|
+
auth_manager._save_token_to_cache()
|
|
347
|
+
logger.info(f"Long-lived token successfully saved to cache at {auth_manager._get_token_cache_path()}")
|
|
348
|
+
except Exception as e:
|
|
349
|
+
logger.error(f"Error saving token to cache: {e}")
|
|
350
|
+
|
|
351
|
+
needs_authentication = False
|
|
352
|
+
return True
|
|
353
|
+
else:
|
|
354
|
+
# Fall back to the short-lived token if exchange fails
|
|
355
|
+
logger.warning("Failed to exchange for long-lived token, using short-lived token instead")
|
|
356
|
+
token_info = TokenInfo(
|
|
357
|
+
access_token=short_lived_token,
|
|
358
|
+
expires_in=token_container.get('expires_in', 0)
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
auth_manager.token_info = token_info
|
|
363
|
+
logger.info(f"Short-lived token info set in auth_manager, expires in {token_info.expires_in} seconds")
|
|
364
|
+
except NameError:
|
|
365
|
+
logger.error("auth_manager not defined when trying to process token")
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
logger.info("Attempting to save token to cache")
|
|
369
|
+
auth_manager._save_token_to_cache()
|
|
370
|
+
logger.info(f"Token successfully saved to cache at {auth_manager._get_token_cache_path()}")
|
|
371
|
+
except Exception as e:
|
|
372
|
+
logger.error(f"Error saving token to cache: {e}")
|
|
373
|
+
|
|
374
|
+
needs_authentication = False
|
|
375
|
+
return True
|
|
376
|
+
else:
|
|
377
|
+
logger.warning("Received empty token in process_token_response")
|
|
378
|
+
needs_authentication = True
|
|
379
|
+
return False
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def exchange_token_for_long_lived(short_lived_token):
|
|
383
|
+
"""
|
|
384
|
+
Exchange a short-lived token for a long-lived token (60 days validity).
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
short_lived_token: The short-lived access token received from OAuth flow
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
TokenInfo object with the long-lived token, or None if exchange failed
|
|
391
|
+
"""
|
|
392
|
+
logger.info("Attempting to exchange short-lived token for long-lived token")
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
# Get the app ID from the configuration
|
|
396
|
+
app_id = meta_config.get_app_id()
|
|
397
|
+
|
|
398
|
+
# Get the app secret - this should be securely stored
|
|
399
|
+
app_secret = os.environ.get("META_APP_SECRET", "")
|
|
400
|
+
|
|
401
|
+
if not app_id or not app_secret:
|
|
402
|
+
logger.error("Missing app_id or app_secret for token exchange")
|
|
403
|
+
return None
|
|
404
|
+
|
|
405
|
+
# Make the API request to exchange the token
|
|
406
|
+
url = "https://graph.facebook.com/v24.0/oauth/access_token"
|
|
407
|
+
params = {
|
|
408
|
+
"grant_type": "fb_exchange_token",
|
|
409
|
+
"client_id": app_id,
|
|
410
|
+
"client_secret": app_secret,
|
|
411
|
+
"fb_exchange_token": short_lived_token
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
logger.debug(f"Making token exchange request to {url}")
|
|
415
|
+
response = requests.get(url, params=params)
|
|
416
|
+
|
|
417
|
+
if response.status_code == 200:
|
|
418
|
+
data = response.json()
|
|
419
|
+
logger.debug(f"Token exchange response: {data}")
|
|
420
|
+
|
|
421
|
+
# Create TokenInfo from the response
|
|
422
|
+
# The response includes access_token and expires_in (in seconds)
|
|
423
|
+
new_token = data.get("access_token")
|
|
424
|
+
expires_in = data.get("expires_in")
|
|
425
|
+
|
|
426
|
+
if new_token:
|
|
427
|
+
logger.info(f"Received long-lived token, expires in {expires_in} seconds (~{expires_in//86400} days)")
|
|
428
|
+
return TokenInfo(
|
|
429
|
+
access_token=new_token,
|
|
430
|
+
expires_in=expires_in
|
|
431
|
+
)
|
|
432
|
+
else:
|
|
433
|
+
logger.error("No access_token in exchange response")
|
|
434
|
+
return None
|
|
435
|
+
else:
|
|
436
|
+
logger.error(f"Token exchange failed with status {response.status_code}: {response.text}")
|
|
437
|
+
return None
|
|
438
|
+
except Exception as e:
|
|
439
|
+
logger.error(f"Error exchanging token: {e}")
|
|
440
|
+
return None
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
async def get_current_access_token() -> Optional[str]:
|
|
444
|
+
"""Get the current access token from auth manager"""
|
|
445
|
+
# Check for environment variable first - this takes highest precedence
|
|
446
|
+
env_token = os.environ.get("META_ACCESS_TOKEN")
|
|
447
|
+
if env_token:
|
|
448
|
+
logger.debug("Using access token from META_ACCESS_TOKEN environment variable")
|
|
449
|
+
# Basic validation
|
|
450
|
+
if len(env_token) < 20: # Most Meta tokens are much longer
|
|
451
|
+
logger.error(f"TOKEN VALIDATION FAILED: Token from environment variable appears malformed (length: {len(env_token)})")
|
|
452
|
+
return None
|
|
453
|
+
return env_token
|
|
454
|
+
|
|
455
|
+
# Use the singleton auth manager
|
|
456
|
+
global auth_manager
|
|
457
|
+
|
|
458
|
+
# Log the function call and current app ID
|
|
459
|
+
logger.debug("get_current_access_token() called")
|
|
460
|
+
app_id = meta_config.get_app_id()
|
|
461
|
+
logger.debug(f"Current app_id: {app_id}")
|
|
462
|
+
|
|
463
|
+
# Check if using Pipeboard authentication
|
|
464
|
+
using_pipeboard = auth_manager.use_pipeboard
|
|
465
|
+
|
|
466
|
+
# Check if app_id is valid - but only if not using Pipeboard authentication
|
|
467
|
+
if not app_id and not using_pipeboard:
|
|
468
|
+
logger.error("TOKEN VALIDATION FAILED: No valid app_id configured")
|
|
469
|
+
logger.error("Please set META_APP_ID environment variable or configure via meta_config.set_app_id()")
|
|
470
|
+
return None
|
|
471
|
+
|
|
472
|
+
# Attempt to get access token
|
|
473
|
+
try:
|
|
474
|
+
token = auth_manager.get_access_token()
|
|
475
|
+
|
|
476
|
+
if token:
|
|
477
|
+
# Add basic token validation - check if it looks like a valid token
|
|
478
|
+
if len(token) < 20: # Most Meta tokens are much longer
|
|
479
|
+
logger.error(f"TOKEN VALIDATION FAILED: Token appears malformed (length: {len(token)})")
|
|
480
|
+
auth_manager.invalidate_token()
|
|
481
|
+
return None
|
|
482
|
+
|
|
483
|
+
logger.debug(f"Access token found in auth_manager (starts with: {token[:10]}...)")
|
|
484
|
+
return token
|
|
485
|
+
else:
|
|
486
|
+
logger.warning("No valid access token available in auth_manager")
|
|
487
|
+
|
|
488
|
+
# Check why token might be missing
|
|
489
|
+
if hasattr(auth_manager, 'token_info') and auth_manager.token_info:
|
|
490
|
+
if auth_manager.token_info.is_expired():
|
|
491
|
+
logger.error("TOKEN VALIDATION FAILED: Token is expired")
|
|
492
|
+
# Add expiration details
|
|
493
|
+
if hasattr(auth_manager.token_info, 'expires_in') and auth_manager.token_info.expires_in:
|
|
494
|
+
expiry_time = auth_manager.token_info.created_at + auth_manager.token_info.expires_in
|
|
495
|
+
current_time = int(time.time())
|
|
496
|
+
expired_seconds_ago = current_time - expiry_time
|
|
497
|
+
logger.error(f"Token expired {expired_seconds_ago} seconds ago")
|
|
498
|
+
elif not auth_manager.token_info.access_token:
|
|
499
|
+
logger.error("TOKEN VALIDATION FAILED: Token object exists but access_token is empty")
|
|
500
|
+
else:
|
|
501
|
+
logger.error("TOKEN VALIDATION FAILED: Token exists but was rejected for unknown reason")
|
|
502
|
+
else:
|
|
503
|
+
logger.error("TOKEN VALIDATION FAILED: No token information available")
|
|
504
|
+
|
|
505
|
+
# Suggest next steps for troubleshooting
|
|
506
|
+
logger.error("To fix: Try re-authenticating or check if your token has been revoked")
|
|
507
|
+
return None
|
|
508
|
+
except Exception as e:
|
|
509
|
+
logger.error(f"Error getting access token: {str(e)}")
|
|
510
|
+
import traceback
|
|
511
|
+
logger.error(f"Token validation stacktrace: {traceback.format_exc()}")
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def login():
|
|
516
|
+
"""
|
|
517
|
+
Start the login flow to authenticate with Meta
|
|
518
|
+
"""
|
|
519
|
+
print("Starting Meta Ads authentication flow...")
|
|
520
|
+
|
|
521
|
+
try:
|
|
522
|
+
# Start the callback server first
|
|
523
|
+
try:
|
|
524
|
+
port = start_callback_server()
|
|
525
|
+
except Exception as callback_error:
|
|
526
|
+
print(f"Error: {callback_error}")
|
|
527
|
+
print("Callback server is disabled. Please use alternative authentication methods:")
|
|
528
|
+
print("- Set PIPEBOARD_API_TOKEN environment variable for Pipeboard authentication")
|
|
529
|
+
print("- Or provide a direct META_ACCESS_TOKEN environment variable")
|
|
530
|
+
return
|
|
531
|
+
|
|
532
|
+
# Get the auth URL and open the browser
|
|
533
|
+
auth_url = auth_manager.get_auth_url()
|
|
534
|
+
print(f"Opening browser with URL: {auth_url}")
|
|
535
|
+
webbrowser.open(auth_url)
|
|
536
|
+
|
|
537
|
+
# Wait for token to be received
|
|
538
|
+
print("Waiting for authentication to complete...")
|
|
539
|
+
max_wait = 300 # 5 minutes
|
|
540
|
+
wait_interval = 2 # 2 seconds
|
|
541
|
+
|
|
542
|
+
for _ in range(max_wait // wait_interval):
|
|
543
|
+
if token_container["token"]:
|
|
544
|
+
token = token_container["token"]
|
|
545
|
+
print("Authentication successful!")
|
|
546
|
+
# Verify token works by getting basic user info
|
|
547
|
+
try:
|
|
548
|
+
from .api import make_api_request
|
|
549
|
+
result = asyncio.run(make_api_request("me", token, {}))
|
|
550
|
+
print(f"Authenticated as: {result.get('name', 'Unknown')} (ID: {result.get('id', 'Unknown')})")
|
|
551
|
+
return
|
|
552
|
+
except Exception as e:
|
|
553
|
+
print(f"Warning: Could not verify token: {e}")
|
|
554
|
+
return
|
|
555
|
+
time.sleep(wait_interval)
|
|
556
|
+
|
|
557
|
+
print("Authentication timed out. Please try again.")
|
|
558
|
+
except Exception as e:
|
|
559
|
+
print(f"Error during authentication: {e}")
|
|
560
|
+
print(f"Direct authentication URL: {auth_manager.get_auth_url()}")
|
|
561
|
+
print("You can manually open this URL in your browser to complete authentication.")
|
|
562
|
+
|
|
563
|
+
# Initialize auth manager with a placeholder - will be updated at runtime
|
|
564
|
+
META_APP_ID = os.environ.get("META_APP_ID", "YOUR_META_APP_ID")
|
|
565
|
+
|
|
566
|
+
# Create the auth manager
|
|
567
|
+
auth_manager = AuthManager(META_APP_ID)
|