meta-ads-mcp 0.2.5__tar.gz → 0.2.6__tar.gz

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.
Files changed (28) hide show
  1. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/PKG-INFO +25 -4
  2. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/README.md +24 -3
  3. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/__init__.py +3 -1
  4. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/core/ads.py +61 -1
  5. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/core/adsets.py +1 -1
  6. meta_ads_mcp-0.2.6/meta_ads_mcp/core/auth.py +446 -0
  7. meta_ads_mcp-0.2.6/meta_ads_mcp/core/callback_server.py +958 -0
  8. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/pyproject.toml +1 -1
  9. meta_ads_mcp-0.2.5/meta_ads_mcp/core/auth.py +0 -1693
  10. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/.gitignore +0 -0
  11. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/META_API_NOTES.md +0 -0
  12. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/future_improvements.md +0 -0
  13. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/images/meta-ads-example.png +0 -0
  14. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta-ads-mcp +0 -0
  15. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/__main__.py +0 -0
  16. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/api.py +0 -0
  17. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/core/__init__.py +0 -0
  18. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/core/accounts.py +0 -0
  19. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/core/api.py +0 -0
  20. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/core/authentication.py +0 -0
  21. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/core/campaigns.py +0 -0
  22. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/core/insights.py +0 -0
  23. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/core/resources.py +0 -0
  24. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/core/server.py +0 -0
  25. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/core/utils.py +0 -0
  26. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/requirements.txt +0 -0
  27. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/setup.py +0 -0
  28. {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/test_meta_ads_auth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meta-ads-mcp
3
- Version: 0.2.5
3
+ Version: 0.2.6
4
4
  Summary: Model Calling Protocol (MCP) plugin for interacting with Meta Ads API
5
5
  Project-URL: Homepage, https://github.com/nictuku/meta-ads-mcp
6
6
  Project-URL: Bug Tracker, https://github.com/nictuku/meta-ads-mcp/issues
@@ -187,7 +187,28 @@ Add this to your `claude_desktop_config.json` to integrate with Claude in Cursor
187
187
  - `ad_id`: Meta Ads ad ID
188
188
  - Returns: The ad image ready for direct visual analysis
189
189
 
190
- 12. `mcp_meta_ads_get_insights`
190
+ 12. `mcp_meta_ads_update_ad`
191
+ - Update an ad with new settings
192
+ - Inputs:
193
+ - `ad_id`: Meta Ads ad ID
194
+ - `status`: Update ad status (ACTIVE, PAUSED, etc.)
195
+ - `bid_amount`: Bid amount in account currency (in cents for USD)
196
+ - `access_token` (optional): Meta API access token (will use cached token if not provided)
197
+ - Returns: Confirmation with updated ad details and a confirmation link
198
+
199
+ 13. `mcp_meta_ads_update_adset`
200
+ - Update an ad set with new settings including frequency caps
201
+ - Inputs:
202
+ - `adset_id`: Meta Ads ad set ID
203
+ - `frequency_control_specs`: List of frequency control specifications
204
+ - `bid_strategy`: Bid strategy (e.g., 'LOWEST_COST_WITH_BID_CAP')
205
+ - `bid_amount`: Bid amount in account currency (in cents for USD)
206
+ - `status`: Update ad set status (ACTIVE, PAUSED, etc.)
207
+ - `targeting`: Targeting specifications including targeting_automation
208
+ - `access_token` (optional): Meta API access token (will use cached token if not provided)
209
+ - Returns: Confirmation with updated ad set details and a confirmation link
210
+
211
+ 14. `mcp_meta_ads_get_insights`
191
212
  - Get performance insights for a campaign, ad set, ad or account
192
213
  - Inputs:
193
214
  - `access_token` (optional): Meta API access token (will use cached token if not provided)
@@ -197,7 +218,7 @@ Add this to your `claude_desktop_config.json` to integrate with Claude in Cursor
197
218
  - `level`: Level of aggregation (ad, adset, campaign, account)
198
219
  - Returns: Performance metrics for the specified object
199
220
 
200
- 13. `mcp_meta_ads_debug_image_download`
221
+ 15. `mcp_meta_ads_debug_image_download`
201
222
  - Debug image download issues and report detailed diagnostics
202
223
  - Inputs:
203
224
  - `access_token` (optional): Meta API access token (will use cached token if not provided)
@@ -205,7 +226,7 @@ Add this to your `claude_desktop_config.json` to integrate with Claude in Cursor
205
226
  - `ad_id`: Meta Ads ad ID (optional, used if url is not provided)
206
227
  - Returns: Diagnostic information about image download attempts
207
228
 
208
- 14. `mcp_meta_ads_get_login_link`
229
+ 16. `mcp_meta_ads_get_login_link`
209
230
  - Get a clickable login link for Meta Ads authentication
210
231
  - Inputs:
211
232
  - `access_token` (optional): Meta API access token (will use cached token if not provided)
@@ -166,7 +166,28 @@ Add this to your `claude_desktop_config.json` to integrate with Claude in Cursor
166
166
  - `ad_id`: Meta Ads ad ID
167
167
  - Returns: The ad image ready for direct visual analysis
168
168
 
169
- 12. `mcp_meta_ads_get_insights`
169
+ 12. `mcp_meta_ads_update_ad`
170
+ - Update an ad with new settings
171
+ - Inputs:
172
+ - `ad_id`: Meta Ads ad ID
173
+ - `status`: Update ad status (ACTIVE, PAUSED, etc.)
174
+ - `bid_amount`: Bid amount in account currency (in cents for USD)
175
+ - `access_token` (optional): Meta API access token (will use cached token if not provided)
176
+ - Returns: Confirmation with updated ad details and a confirmation link
177
+
178
+ 13. `mcp_meta_ads_update_adset`
179
+ - Update an ad set with new settings including frequency caps
180
+ - Inputs:
181
+ - `adset_id`: Meta Ads ad set ID
182
+ - `frequency_control_specs`: List of frequency control specifications
183
+ - `bid_strategy`: Bid strategy (e.g., 'LOWEST_COST_WITH_BID_CAP')
184
+ - `bid_amount`: Bid amount in account currency (in cents for USD)
185
+ - `status`: Update ad set status (ACTIVE, PAUSED, etc.)
186
+ - `targeting`: Targeting specifications including targeting_automation
187
+ - `access_token` (optional): Meta API access token (will use cached token if not provided)
188
+ - Returns: Confirmation with updated ad set details and a confirmation link
189
+
190
+ 14. `mcp_meta_ads_get_insights`
170
191
  - Get performance insights for a campaign, ad set, ad or account
171
192
  - Inputs:
172
193
  - `access_token` (optional): Meta API access token (will use cached token if not provided)
@@ -176,7 +197,7 @@ Add this to your `claude_desktop_config.json` to integrate with Claude in Cursor
176
197
  - `level`: Level of aggregation (ad, adset, campaign, account)
177
198
  - Returns: Performance metrics for the specified object
178
199
 
179
- 13. `mcp_meta_ads_debug_image_download`
200
+ 15. `mcp_meta_ads_debug_image_download`
180
201
  - Debug image download issues and report detailed diagnostics
181
202
  - Inputs:
182
203
  - `access_token` (optional): Meta API access token (will use cached token if not provided)
@@ -184,7 +205,7 @@ Add this to your `claude_desktop_config.json` to integrate with Claude in Cursor
184
205
  - `ad_id`: Meta Ads ad ID (optional, used if url is not provided)
185
206
  - Returns: Diagnostic information about image download attempts
186
207
 
187
- 14. `mcp_meta_ads_get_login_link`
208
+ 16. `mcp_meta_ads_get_login_link`
188
209
  - Get a clickable login link for Meta Ads authentication
189
210
  - Inputs:
190
211
  - `access_token` (optional): Meta API access token (will use cached token if not provided)
@@ -7,7 +7,7 @@ with the Claude LLM.
7
7
 
8
8
  from meta_ads_mcp.core.server import main
9
9
 
10
- __version__ = "0.2.5"
10
+ __version__ = "0.2.6"
11
11
 
12
12
  __all__ = [
13
13
  'get_ad_accounts',
@@ -22,6 +22,7 @@ __all__ = [
22
22
  'get_ad_details',
23
23
  'get_ad_creatives',
24
24
  'get_ad_image',
25
+ 'update_ad',
25
26
  'get_insights',
26
27
  'debug_image_download',
27
28
  'get_login_link',
@@ -43,6 +44,7 @@ from .core import (
43
44
  get_ad_details,
44
45
  get_ad_creatives,
45
46
  get_ad_image,
47
+ update_ad,
46
48
  get_insights,
47
49
  debug_image_download,
48
50
  get_login_link,
@@ -363,4 +363,64 @@ async def get_ad_image(access_token: str = None, ad_id: str = None) -> Image:
363
363
  return Image(data=img_bytes, format="jpeg")
364
364
 
365
365
  except Exception as e:
366
- return f"Error processing image: {str(e)}"
366
+ return f"Error processing image: {str(e)}"
367
+
368
+
369
+ @mcp_server.tool()
370
+ @meta_api_tool
371
+ async def update_ad(ad_id: str, status: str = None, bid_amount: int = None, access_token: str = None) -> str:
372
+ """
373
+ Update an ad with new settings.
374
+
375
+ Args:
376
+ ad_id: Meta Ads ad ID
377
+ status: Update ad status (ACTIVE, PAUSED, etc.)
378
+ bid_amount: Bid amount in account currency (in cents for USD)
379
+ access_token: Meta API access token (optional - will use cached token if not provided)
380
+ """
381
+ if not ad_id:
382
+ return json.dumps({"error": "No ad ID provided"}, indent=2)
383
+
384
+ changes = {}
385
+
386
+ if status is not None:
387
+ changes['status'] = status
388
+
389
+ if bid_amount is not None:
390
+ changes['bid_amount'] = bid_amount
391
+
392
+ if not changes:
393
+ return json.dumps({"error": "No update parameters provided"}, indent=2)
394
+
395
+ # Get current ad details for comparison
396
+ current_details_json = await get_ad_details(ad_id=ad_id, access_token=access_token)
397
+ current_details = json.loads(current_details_json)
398
+
399
+ # Import the callback server components
400
+ from .callback_server import start_callback_server, update_confirmation
401
+ import urllib.parse
402
+
403
+ # Start the callback server if not already running
404
+ port = start_callback_server()
405
+
406
+ # Generate confirmation URL with properly encoded parameters
407
+ changes_json = json.dumps(changes)
408
+ encoded_changes = urllib.parse.quote(changes_json)
409
+ confirmation_url = f"http://localhost:{port}/confirm-update?ad_id={ad_id}&token={access_token}&changes={encoded_changes}"
410
+
411
+ # Reset the update confirmation
412
+ update_confirmation.clear()
413
+ update_confirmation.update({"approved": False})
414
+
415
+ # Return the confirmation link
416
+ response = {
417
+ "message": "Please confirm the ad update",
418
+ "confirmation_url": confirmation_url,
419
+ "markdown_link": f"[Click here to confirm ad update]({confirmation_url})",
420
+ "current_details": current_details,
421
+ "proposed_changes": changes,
422
+ "instructions_for_llm": "You must present this link as clickable Markdown to the user using the markdown_link format provided.",
423
+ "note": "After authenticating, the token will be automatically saved and your ad will be updated. Refresh the browser page if it doesn't load immediately."
424
+ }
425
+
426
+ return json.dumps(response, indent=2)
@@ -6,7 +6,7 @@ from .api import meta_api_tool, make_api_request
6
6
  from .accounts import get_ad_accounts
7
7
  from .server import mcp_server
8
8
  import asyncio
9
- from .auth import start_callback_server, update_confirmation
9
+ from .callback_server import start_callback_server, update_confirmation
10
10
  import urllib.parse
11
11
 
12
12
 
@@ -0,0 +1,446 @@
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
+ token_container,
18
+ update_confirmation
19
+ )
20
+
21
+ # Auth constants
22
+ AUTH_SCOPE = "ads_management,ads_read,business_management"
23
+ AUTH_REDIRECT_URI = "http://localhost:8888/callback"
24
+ AUTH_RESPONSE_TYPE = "token"
25
+
26
+ # Log important configuration information
27
+ logger.info("Authentication module initialized")
28
+ logger.info(f"Auth scope: {AUTH_SCOPE}")
29
+ logger.info(f"Default redirect URI: {AUTH_REDIRECT_URI}")
30
+
31
+ # Global flag for authentication state
32
+ needs_authentication = False
33
+
34
+ # Meta configuration singleton
35
+ class MetaConfig:
36
+ _instance = None
37
+
38
+ def __new__(cls):
39
+ if cls._instance is None:
40
+ logger.debug("Creating new MetaConfig instance")
41
+ cls._instance = super(MetaConfig, cls).__new__(cls)
42
+ cls._instance.app_id = os.environ.get("META_APP_ID", "")
43
+ logger.info(f"MetaConfig initialized with app_id from env: {cls._instance.app_id}")
44
+ return cls._instance
45
+
46
+ def set_app_id(self, app_id):
47
+ """Set the Meta App ID for API calls"""
48
+ logger.info(f"Setting Meta App ID: {app_id}")
49
+ self.app_id = app_id
50
+ # Also update environment variable for modules that might read directly from it
51
+ os.environ["META_APP_ID"] = app_id
52
+ logger.debug(f"Updated META_APP_ID environment variable: {os.environ.get('META_APP_ID')}")
53
+
54
+ def get_app_id(self):
55
+ """Get the current Meta App ID"""
56
+ # Check if we have one set
57
+ if hasattr(self, 'app_id') and self.app_id:
58
+ logger.debug(f"Using app_id from instance: {self.app_id}")
59
+ return self.app_id
60
+
61
+ # If not, try environment variable
62
+ env_app_id = os.environ.get("META_APP_ID", "")
63
+ if env_app_id:
64
+ logger.debug(f"Using app_id from environment: {env_app_id}")
65
+ # Update our instance for future use
66
+ self.app_id = env_app_id
67
+ return env_app_id
68
+
69
+ logger.warning("No app_id found in instance or environment variables")
70
+ return ""
71
+
72
+ def is_configured(self):
73
+ """Check if the Meta configuration is complete"""
74
+ app_id = self.get_app_id()
75
+ configured = bool(app_id)
76
+ logger.debug(f"MetaConfig.is_configured() = {configured} (app_id: {app_id})")
77
+ return configured
78
+
79
+ # Create singleton instance
80
+ meta_config = MetaConfig()
81
+
82
+ class TokenInfo:
83
+ """Stores token information including expiration"""
84
+ def __init__(self, access_token: str, expires_in: int = None, user_id: str = None):
85
+ self.access_token = access_token
86
+ self.expires_in = expires_in
87
+ self.user_id = user_id
88
+ self.created_at = int(time.time())
89
+ logger.debug(f"TokenInfo created. Expires in: {expires_in if expires_in else 'Not specified'}")
90
+
91
+ def is_expired(self) -> bool:
92
+ """Check if the token is expired"""
93
+ if not self.expires_in:
94
+ return False # If no expiration is set, assume it's not expired
95
+
96
+ current_time = int(time.time())
97
+ return current_time > (self.created_at + self.expires_in)
98
+
99
+ def serialize(self) -> Dict[str, Any]:
100
+ """Convert to a dictionary for storage"""
101
+ return {
102
+ "access_token": self.access_token,
103
+ "expires_in": self.expires_in,
104
+ "user_id": self.user_id,
105
+ "created_at": self.created_at
106
+ }
107
+
108
+ @classmethod
109
+ def deserialize(cls, data: Dict[str, Any]) -> 'TokenInfo':
110
+ """Create from a stored dictionary"""
111
+ token = cls(
112
+ access_token=data.get("access_token", ""),
113
+ expires_in=data.get("expires_in"),
114
+ user_id=data.get("user_id")
115
+ )
116
+ token.created_at = data.get("created_at", int(time.time()))
117
+ return token
118
+
119
+
120
+ class AuthManager:
121
+ """Manages authentication with Meta APIs"""
122
+ def __init__(self, app_id: str, redirect_uri: str = AUTH_REDIRECT_URI):
123
+ self.app_id = app_id
124
+ self.redirect_uri = redirect_uri
125
+ self.token_info = None
126
+ self._load_cached_token()
127
+
128
+ def _get_token_cache_path(self) -> pathlib.Path:
129
+ """Get the platform-specific path for token cache file"""
130
+ if platform.system() == "Windows":
131
+ base_path = pathlib.Path(os.environ.get("APPDATA", ""))
132
+ elif platform.system() == "Darwin": # macOS
133
+ base_path = pathlib.Path.home() / "Library" / "Application Support"
134
+ else: # Assume Linux/Unix
135
+ base_path = pathlib.Path.home() / ".config"
136
+
137
+ # Create directory if it doesn't exist
138
+ cache_dir = base_path / "meta-ads-mcp"
139
+ cache_dir.mkdir(parents=True, exist_ok=True)
140
+
141
+ return cache_dir / "token_cache.json"
142
+
143
+ def _load_cached_token(self) -> bool:
144
+ """Load token from cache if available"""
145
+ cache_path = self._get_token_cache_path()
146
+
147
+ if not cache_path.exists():
148
+ return False
149
+
150
+ try:
151
+ with open(cache_path, "r") as f:
152
+ data = json.load(f)
153
+ self.token_info = TokenInfo.deserialize(data)
154
+
155
+ # Check if token is expired
156
+ if self.token_info.is_expired():
157
+ print("Cached token is expired")
158
+ self.token_info = None
159
+ return False
160
+
161
+ print(f"Loaded cached token (expires in {(self.token_info.created_at + self.token_info.expires_in) - int(time.time())} seconds)")
162
+ return True
163
+ except Exception as e:
164
+ print(f"Error loading cached token: {e}")
165
+ return False
166
+
167
+ def _save_token_to_cache(self) -> None:
168
+ """Save token to cache file"""
169
+ if not self.token_info:
170
+ return
171
+
172
+ cache_path = self._get_token_cache_path()
173
+
174
+ try:
175
+ with open(cache_path, "w") as f:
176
+ json.dump(self.token_info.serialize(), f)
177
+ print(f"Token cached at: {cache_path}")
178
+ except Exception as e:
179
+ print(f"Error saving token to cache: {e}")
180
+
181
+ def get_auth_url(self) -> str:
182
+ """Generate the Facebook OAuth URL for desktop app flow"""
183
+ return (
184
+ f"https://www.facebook.com/v18.0/dialog/oauth?"
185
+ f"client_id={self.app_id}&"
186
+ f"redirect_uri={self.redirect_uri}&"
187
+ f"scope={AUTH_SCOPE}&"
188
+ f"response_type={AUTH_RESPONSE_TYPE}"
189
+ )
190
+
191
+ def authenticate(self, force_refresh: bool = False) -> Optional[str]:
192
+ """
193
+ Authenticate with Meta APIs
194
+
195
+ Args:
196
+ force_refresh: Force token refresh even if cached token exists
197
+
198
+ Returns:
199
+ Access token if successful, None otherwise
200
+ """
201
+ # Check if we already have a valid token
202
+ if not force_refresh and self.token_info and not self.token_info.is_expired():
203
+ return self.token_info.access_token
204
+
205
+ # Start the callback server if not already running
206
+ port = start_callback_server()
207
+
208
+ # Update redirect URI with the actual port
209
+ self.redirect_uri = f"http://localhost:{port}/callback"
210
+
211
+ # Generate the auth URL
212
+ auth_url = self.get_auth_url()
213
+
214
+ # Open browser with auth URL
215
+ print(f"Opening browser with URL: {auth_url}")
216
+ webbrowser.open(auth_url)
217
+
218
+ # We don't wait for the token here anymore
219
+ # The token will be processed by the callback server
220
+ # Just return None to indicate we've started the flow
221
+ return None
222
+
223
+ def get_access_token(self) -> Optional[str]:
224
+ """
225
+ Get the current access token, refreshing if necessary
226
+
227
+ Returns:
228
+ Access token if available, None otherwise
229
+ """
230
+ if not self.token_info or self.token_info.is_expired():
231
+ return None
232
+
233
+ return self.token_info.access_token
234
+
235
+ def invalidate_token(self) -> None:
236
+ """Invalidate the current token, usually because it has expired or is invalid"""
237
+ if self.token_info:
238
+ print(f"Invalidating token: {self.token_info.access_token[:10]}...")
239
+ self.token_info = None
240
+
241
+ # Signal that authentication is needed
242
+ global needs_authentication
243
+ needs_authentication = True
244
+
245
+ # Remove the cached token file
246
+ try:
247
+ cache_path = self._get_token_cache_path()
248
+ if cache_path.exists():
249
+ os.remove(cache_path)
250
+ print(f"Removed cached token file: {cache_path}")
251
+ except Exception as e:
252
+ print(f"Error removing cached token: {e}")
253
+
254
+ def clear_token(self) -> None:
255
+ """Clear the current token and remove from cache"""
256
+ self.invalidate_token()
257
+
258
+
259
+ def process_token_response(token_container):
260
+ """Process the token response from Facebook."""
261
+ global needs_authentication, auth_manager
262
+
263
+ if token_container and token_container.get('token'):
264
+ logger.info("Processing token response from Facebook OAuth")
265
+
266
+ # Exchange the short-lived token for a long-lived token
267
+ short_lived_token = token_container['token']
268
+ long_lived_token_info = exchange_token_for_long_lived(short_lived_token)
269
+
270
+ if long_lived_token_info:
271
+ logger.info(f"Successfully exchanged for long-lived token (expires in {long_lived_token_info.expires_in} seconds)")
272
+
273
+ try:
274
+ auth_manager.token_info = long_lived_token_info
275
+ logger.info(f"Long-lived token info set in auth_manager, expires in {long_lived_token_info.expires_in} seconds")
276
+ except NameError:
277
+ logger.error("auth_manager not defined when trying to process token")
278
+
279
+ try:
280
+ logger.info("Attempting to save long-lived token to cache")
281
+ auth_manager._save_token_to_cache()
282
+ logger.info(f"Long-lived token successfully saved to cache at {auth_manager._get_token_cache_path()}")
283
+ except Exception as e:
284
+ logger.error(f"Error saving token to cache: {e}")
285
+
286
+ needs_authentication = False
287
+ return True
288
+ else:
289
+ # Fall back to the short-lived token if exchange fails
290
+ logger.warning("Failed to exchange for long-lived token, using short-lived token instead")
291
+ token_info = TokenInfo(
292
+ access_token=short_lived_token,
293
+ expires_in=token_container.get('expires_in', 0)
294
+ )
295
+
296
+ try:
297
+ auth_manager.token_info = token_info
298
+ logger.info(f"Short-lived token info set in auth_manager, expires in {token_info.expires_in} seconds")
299
+ except NameError:
300
+ logger.error("auth_manager not defined when trying to process token")
301
+
302
+ try:
303
+ logger.info("Attempting to save token to cache")
304
+ auth_manager._save_token_to_cache()
305
+ logger.info(f"Token successfully saved to cache at {auth_manager._get_token_cache_path()}")
306
+ except Exception as e:
307
+ logger.error(f"Error saving token to cache: {e}")
308
+
309
+ needs_authentication = False
310
+ return True
311
+ else:
312
+ logger.warning("Received empty token in process_token_response")
313
+ needs_authentication = True
314
+ return False
315
+
316
+
317
+ def exchange_token_for_long_lived(short_lived_token):
318
+ """
319
+ Exchange a short-lived token for a long-lived token (60 days validity).
320
+
321
+ Args:
322
+ short_lived_token: The short-lived access token received from OAuth flow
323
+
324
+ Returns:
325
+ TokenInfo object with the long-lived token, or None if exchange failed
326
+ """
327
+ logger.info("Attempting to exchange short-lived token for long-lived token")
328
+
329
+ try:
330
+ # Get the app ID from the configuration
331
+ app_id = meta_config.get_app_id()
332
+
333
+ # Get the app secret - this should be securely stored
334
+ app_secret = os.environ.get("META_APP_SECRET", "")
335
+
336
+ if not app_id or not app_secret:
337
+ logger.error("Missing app_id or app_secret for token exchange")
338
+ return None
339
+
340
+ # Make the API request to exchange the token
341
+ url = "https://graph.facebook.com/v18.0/oauth/access_token"
342
+ params = {
343
+ "grant_type": "fb_exchange_token",
344
+ "client_id": app_id,
345
+ "client_secret": app_secret,
346
+ "fb_exchange_token": short_lived_token
347
+ }
348
+
349
+ logger.debug(f"Making token exchange request to {url}")
350
+ response = requests.get(url, params=params)
351
+
352
+ if response.status_code == 200:
353
+ data = response.json()
354
+ logger.debug(f"Token exchange response: {data}")
355
+
356
+ # Create TokenInfo from the response
357
+ # The response includes access_token and expires_in (in seconds)
358
+ new_token = data.get("access_token")
359
+ expires_in = data.get("expires_in")
360
+
361
+ if new_token:
362
+ logger.info(f"Received long-lived token, expires in {expires_in} seconds (~{expires_in//86400} days)")
363
+ return TokenInfo(
364
+ access_token=new_token,
365
+ expires_in=expires_in
366
+ )
367
+ else:
368
+ logger.error("No access_token in exchange response")
369
+ return None
370
+ else:
371
+ logger.error(f"Token exchange failed with status {response.status_code}: {response.text}")
372
+ return None
373
+ except Exception as e:
374
+ logger.error(f"Error exchanging token: {e}")
375
+ return None
376
+
377
+
378
+ async def get_current_access_token() -> Optional[str]:
379
+ """Get the current access token from auth manager"""
380
+ # Use the singleton auth manager
381
+ global auth_manager
382
+
383
+ # Log the function call and current app ID
384
+ logger.debug("get_current_access_token() called")
385
+ app_id = meta_config.get_app_id()
386
+ logger.debug(f"Current app_id: {app_id}")
387
+
388
+ # Attempt to get access token
389
+ try:
390
+ token = auth_manager.get_access_token()
391
+
392
+ if token:
393
+ logger.debug("Access token found in auth_manager")
394
+ return token
395
+ else:
396
+ logger.warning("No valid access token available in auth_manager")
397
+ return None
398
+ except Exception as e:
399
+ logger.error(f"Error getting access token: {str(e)}")
400
+ return None
401
+
402
+
403
+ def login():
404
+ """
405
+ Start the login flow to authenticate with Meta
406
+ """
407
+ print("Starting Meta Ads authentication flow...")
408
+
409
+ try:
410
+ # Start the callback server first
411
+ port = start_callback_server()
412
+
413
+ # Get the auth URL and open the browser
414
+ auth_url = auth_manager.get_auth_url()
415
+ print(f"Opening browser with URL: {auth_url}")
416
+ webbrowser.open(auth_url)
417
+
418
+ # Wait for token to be received
419
+ print("Waiting for authentication to complete...")
420
+ max_wait = 300 # 5 minutes
421
+ wait_interval = 2 # 2 seconds
422
+
423
+ for _ in range(max_wait // wait_interval):
424
+ if token_container["token"]:
425
+ token = token_container["token"]
426
+ print("Authentication successful!")
427
+ # Verify token works by getting basic user info
428
+ try:
429
+ from .api import make_api_request
430
+ result = asyncio.run(make_api_request("me", token, {}))
431
+ print(f"Authenticated as: {result.get('name', 'Unknown')} (ID: {result.get('id', 'Unknown')})")
432
+ return
433
+ except Exception as e:
434
+ print(f"Warning: Could not verify token: {e}")
435
+ return
436
+ time.sleep(wait_interval)
437
+
438
+ print("Authentication timed out. Please try again.")
439
+ except Exception as e:
440
+ print(f"Error during authentication: {e}")
441
+ print(f"Direct authentication URL: {auth_manager.get_auth_url()}")
442
+ print("You can manually open this URL in your browser to complete authentication.")
443
+
444
+ # Initialize auth manager with a placeholder - will be updated at runtime
445
+ META_APP_ID = os.environ.get("META_APP_ID", "YOUR_META_APP_ID")
446
+ auth_manager = AuthManager(META_APP_ID)