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.
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/PKG-INFO +25 -4
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/README.md +24 -3
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/__init__.py +3 -1
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/core/ads.py +61 -1
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/core/adsets.py +1 -1
- meta_ads_mcp-0.2.6/meta_ads_mcp/core/auth.py +446 -0
- meta_ads_mcp-0.2.6/meta_ads_mcp/core/callback_server.py +958 -0
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/pyproject.toml +1 -1
- meta_ads_mcp-0.2.5/meta_ads_mcp/core/auth.py +0 -1693
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/.gitignore +0 -0
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/META_API_NOTES.md +0 -0
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/future_improvements.md +0 -0
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/images/meta-ads-example.png +0 -0
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta-ads-mcp +0 -0
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/__main__.py +0 -0
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/api.py +0 -0
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/core/__init__.py +0 -0
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/core/accounts.py +0 -0
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/core/api.py +0 -0
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/core/authentication.py +0 -0
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/core/campaigns.py +0 -0
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/core/insights.py +0 -0
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/core/resources.py +0 -0
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/core/server.py +0 -0
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/meta_ads_mcp/core/utils.py +0 -0
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/requirements.txt +0 -0
- {meta_ads_mcp-0.2.5 → meta_ads_mcp-0.2.6}/setup.py +0 -0
- {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.
|
|
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. `
|
|
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
|
-
|
|
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
|
-
|
|
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. `
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 .
|
|
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)
|