meta-ads-mcp 0.2.6__py3-none-any.whl → 0.2.8__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 +1 -1
- meta_ads_mcp/api.py +122 -53
- meta_ads_mcp/core/__init__.py +2 -1
- meta_ads_mcp/core/adsets.py +7 -2
- meta_ads_mcp/core/api.py +29 -1
- meta_ads_mcp/core/auth.py +81 -13
- meta_ads_mcp/core/authentication.py +103 -49
- meta_ads_mcp/core/pipeboard_auth.py +484 -0
- meta_ads_mcp/core/server.py +49 -4
- meta_ads_mcp/core/utils.py +11 -5
- {meta_ads_mcp-0.2.6.dist-info → meta_ads_mcp-0.2.8.dist-info}/METADATA +115 -29
- meta_ads_mcp-0.2.8.dist-info/RECORD +21 -0
- meta_ads_mcp-0.2.6.dist-info/RECORD +0 -20
- {meta_ads_mcp-0.2.6.dist-info → meta_ads_mcp-0.2.8.dist-info}/WHEEL +0 -0
- {meta_ads_mcp-0.2.6.dist-info → meta_ads_mcp-0.2.8.dist-info}/entry_points.txt +0 -0
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import asyncio
|
|
5
|
+
import os
|
|
5
6
|
from .api import meta_api_tool
|
|
6
7
|
from .auth import start_callback_server, auth_manager, get_current_access_token
|
|
7
8
|
from .server import mcp_server
|
|
8
9
|
from .utils import logger, META_APP_SECRET
|
|
10
|
+
from .pipeboard_auth import pipeboard_auth_manager
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
@mcp_server.tool()
|
|
@@ -13,60 +15,112 @@ async def get_login_link(access_token: str = None) -> str:
|
|
|
13
15
|
"""
|
|
14
16
|
Get a clickable login link for Meta Ads authentication.
|
|
15
17
|
|
|
18
|
+
NOTE: This method should only be used if you're using your own Facebook app.
|
|
19
|
+
If using Pipeboard authentication (recommended), set the PIPEBOARD_API_TOKEN
|
|
20
|
+
environment variable instead (token obtainable via https://pipeboard.co).
|
|
21
|
+
|
|
16
22
|
Args:
|
|
17
23
|
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
18
24
|
|
|
19
25
|
Returns:
|
|
20
26
|
A clickable resource link for Meta authentication
|
|
21
27
|
"""
|
|
22
|
-
# Check if we
|
|
23
|
-
|
|
24
|
-
token_status = "No token" if not cached_token else "Valid token"
|
|
28
|
+
# Check if we're using pipeboard authentication
|
|
29
|
+
using_pipeboard = bool(os.environ.get("PIPEBOARD_API_TOKEN", ""))
|
|
25
30
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
if using_pipeboard:
|
|
32
|
+
# Handle Pipeboard authentication
|
|
33
|
+
# Check if we have a cached token
|
|
34
|
+
cached_token = pipeboard_auth_manager.get_access_token()
|
|
35
|
+
token_status = "No token" if not cached_token else "Valid token"
|
|
36
|
+
|
|
37
|
+
# If we already have a valid token and none was provided, just return success
|
|
38
|
+
if cached_token and not access_token:
|
|
39
|
+
logger.info("get_login_link called with existing valid Pipeboard token")
|
|
40
|
+
return json.dumps({
|
|
41
|
+
"message": "Already authenticated with Pipeboard",
|
|
42
|
+
"token_status": token_status,
|
|
43
|
+
"token_preview": cached_token[:10] + "..." if cached_token else None,
|
|
44
|
+
"authentication_method": "pipeboard"
|
|
45
|
+
}, indent=2)
|
|
46
|
+
|
|
47
|
+
# Initiate the auth flow via Pipeboard
|
|
48
|
+
try:
|
|
49
|
+
auth_data = pipeboard_auth_manager.initiate_auth_flow()
|
|
50
|
+
login_url = auth_data.get("loginUrl")
|
|
51
|
+
|
|
52
|
+
# Return a special format that helps the LLM format the response properly
|
|
53
|
+
response = {
|
|
54
|
+
"login_url": login_url,
|
|
55
|
+
"token_status": token_status,
|
|
56
|
+
"markdown_link": f"[Click here to authenticate with Meta Ads via Pipeboard]({login_url})",
|
|
57
|
+
"message": "IMPORTANT: Please use the Markdown link format in your response to allow the user to click it.",
|
|
58
|
+
"instructions_for_llm": "You must present this link as clickable Markdown to the user using the markdown_link format provided.",
|
|
59
|
+
"authentication_method": "pipeboard",
|
|
60
|
+
"token_duration": "Approximately 60 days",
|
|
61
|
+
"note": "After authenticating, the token will be automatically saved."
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return json.dumps(response, indent=2)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.error(f"Error initiating Pipeboard auth flow: {e}")
|
|
67
|
+
return json.dumps({
|
|
68
|
+
"error": f"Failed to initiate Pipeboard authentication: {str(e)}",
|
|
69
|
+
"message": "Please check your PIPEBOARD_API_TOKEN environment variable.",
|
|
70
|
+
"authentication_method": "pipeboard"
|
|
71
|
+
}, indent=2)
|
|
72
|
+
else:
|
|
73
|
+
# Original Meta authentication flow
|
|
74
|
+
# Check if we have a cached token
|
|
75
|
+
cached_token = auth_manager.get_access_token()
|
|
76
|
+
token_status = "No token" if not cached_token else "Valid token"
|
|
77
|
+
|
|
78
|
+
# If we already have a valid token and none was provided, just return success
|
|
79
|
+
if cached_token and not access_token:
|
|
80
|
+
logger.info("get_login_link called with existing valid token")
|
|
81
|
+
return json.dumps({
|
|
82
|
+
"message": "Already authenticated",
|
|
83
|
+
"token_status": token_status,
|
|
84
|
+
"token_preview": cached_token[:10] + "...",
|
|
85
|
+
"created_at": auth_manager.token_info.created_at if hasattr(auth_manager, "token_info") else None,
|
|
86
|
+
"expires_in": auth_manager.token_info.expires_in if hasattr(auth_manager, "token_info") else None,
|
|
87
|
+
"authentication_method": "meta_oauth"
|
|
88
|
+
}, indent=2)
|
|
89
|
+
|
|
90
|
+
# IMPORTANT: Start the callback server first by calling our helper function
|
|
91
|
+
# This ensures the server is ready before we provide the URL to the user
|
|
92
|
+
logger.info("Starting callback server for authentication")
|
|
93
|
+
port = start_callback_server()
|
|
94
|
+
logger.info(f"Callback server started on port {port}")
|
|
95
|
+
|
|
96
|
+
# Generate direct login URL
|
|
97
|
+
auth_manager.redirect_uri = f"http://localhost:{port}/callback" # Ensure port is set correctly
|
|
98
|
+
logger.info(f"Setting redirect URI to {auth_manager.redirect_uri}")
|
|
99
|
+
login_url = auth_manager.get_auth_url()
|
|
100
|
+
logger.info(f"Generated login URL: {login_url}")
|
|
101
|
+
|
|
102
|
+
# Check if we can exchange for long-lived tokens
|
|
103
|
+
token_exchange_supported = bool(META_APP_SECRET)
|
|
104
|
+
token_duration = "60 days" if token_exchange_supported else "1-2 hours"
|
|
105
|
+
|
|
106
|
+
# Return a special format that helps the LLM format the response properly
|
|
107
|
+
response = {
|
|
108
|
+
"login_url": login_url,
|
|
31
109
|
"token_status": token_status,
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
# Check if we can exchange for long-lived tokens
|
|
50
|
-
token_exchange_supported = bool(META_APP_SECRET)
|
|
51
|
-
token_duration = "60 days" if token_exchange_supported else "1-2 hours"
|
|
52
|
-
|
|
53
|
-
# Return a special format that helps the LLM format the response properly
|
|
54
|
-
response = {
|
|
55
|
-
"login_url": login_url,
|
|
56
|
-
"token_status": token_status,
|
|
57
|
-
"server_status": f"Callback server running on port {port}",
|
|
58
|
-
"markdown_link": f"[Click here to authenticate with Meta Ads]({login_url})",
|
|
59
|
-
"message": "IMPORTANT: Please use the Markdown link format in your response to allow the user to click it.",
|
|
60
|
-
"instructions_for_llm": "You must present this link as clickable Markdown to the user using the markdown_link format provided.",
|
|
61
|
-
"token_exchange": "enabled" if token_exchange_supported else "disabled",
|
|
62
|
-
"token_duration": token_duration,
|
|
63
|
-
"token_exchange_message": f"Your authentication token will be valid for approximately {token_duration}." +
|
|
64
|
-
(" Long-lived token exchange is enabled." if token_exchange_supported else
|
|
65
|
-
" To enable long-lived tokens (60 days), set the META_APP_SECRET environment variable."),
|
|
66
|
-
"note": "After authenticating, the token will be automatically saved."
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
# Wait a moment to ensure the server is fully started
|
|
70
|
-
await asyncio.sleep(1)
|
|
71
|
-
|
|
72
|
-
return json.dumps(response, indent=2)
|
|
110
|
+
"server_status": f"Callback server running on port {port}",
|
|
111
|
+
"markdown_link": f"[Click here to authenticate with Meta Ads]({login_url})",
|
|
112
|
+
"message": "IMPORTANT: Please use the Markdown link format in your response to allow the user to click it.",
|
|
113
|
+
"instructions_for_llm": "You must present this link as clickable Markdown to the user using the markdown_link format provided.",
|
|
114
|
+
"token_exchange": "enabled" if token_exchange_supported else "disabled",
|
|
115
|
+
"token_duration": token_duration,
|
|
116
|
+
"authentication_method": "meta_oauth",
|
|
117
|
+
"token_exchange_message": f"Your authentication token will be valid for approximately {token_duration}." +
|
|
118
|
+
(" Long-lived token exchange is enabled." if token_exchange_supported else
|
|
119
|
+
" To enable long-lived tokens (60 days), set the META_APP_SECRET environment variable."),
|
|
120
|
+
"note": "After authenticating, the token will be automatically saved."
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# Wait a moment to ensure the server is fully started
|
|
124
|
+
await asyncio.sleep(1)
|
|
125
|
+
|
|
126
|
+
return json.dumps(response, indent=2)
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""Authentication with Meta Ads API via pipeboard.co."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import requests
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import platform
|
|
9
|
+
from typing import Optional, Dict, Any
|
|
10
|
+
from .utils import logger
|
|
11
|
+
|
|
12
|
+
# Enable more detailed logging
|
|
13
|
+
import logging
|
|
14
|
+
logger.setLevel(logging.DEBUG)
|
|
15
|
+
|
|
16
|
+
# Base URL for pipeboard API
|
|
17
|
+
PIPEBOARD_API_BASE = "https://pipeboard.co/api"
|
|
18
|
+
|
|
19
|
+
# Debug message about API base URL
|
|
20
|
+
logger.info(f"Pipeboard API base URL: {PIPEBOARD_API_BASE}")
|
|
21
|
+
|
|
22
|
+
class TokenInfo:
|
|
23
|
+
"""Stores token information including expiration"""
|
|
24
|
+
def __init__(self, access_token: str, expires_at: str = None, token_type: str = None):
|
|
25
|
+
self.access_token = access_token
|
|
26
|
+
self.expires_at = expires_at
|
|
27
|
+
self.token_type = token_type
|
|
28
|
+
self.created_at = int(time.time())
|
|
29
|
+
logger.debug(f"TokenInfo created. Expires at: {expires_at if expires_at else 'Not specified'}")
|
|
30
|
+
|
|
31
|
+
def is_expired(self) -> bool:
|
|
32
|
+
"""Check if the token is expired"""
|
|
33
|
+
if not self.expires_at:
|
|
34
|
+
logger.debug("No expiration date set for token, assuming not expired")
|
|
35
|
+
return False # If no expiration is set, assume it's not expired
|
|
36
|
+
|
|
37
|
+
# Parse ISO 8601 date format to timestamp
|
|
38
|
+
try:
|
|
39
|
+
# Convert the expires_at string to a timestamp
|
|
40
|
+
# Format is like "2023-12-31T23:59:59.999Z" or "2023-12-31T23:59:59.999+00:00"
|
|
41
|
+
from datetime import datetime
|
|
42
|
+
|
|
43
|
+
# Remove the Z suffix if present and handle +00:00 format
|
|
44
|
+
expires_at_str = self.expires_at
|
|
45
|
+
if expires_at_str.endswith('Z'):
|
|
46
|
+
expires_at_str = expires_at_str[:-1] # Remove Z
|
|
47
|
+
|
|
48
|
+
# Handle microseconds if present
|
|
49
|
+
if '.' in expires_at_str:
|
|
50
|
+
datetime_format = "%Y-%m-%dT%H:%M:%S.%f"
|
|
51
|
+
else:
|
|
52
|
+
datetime_format = "%Y-%m-%dT%H:%M:%S"
|
|
53
|
+
|
|
54
|
+
# Handle timezone offset
|
|
55
|
+
timezone_offset = "+00:00"
|
|
56
|
+
if "+" in expires_at_str:
|
|
57
|
+
expires_at_str, timezone_offset = expires_at_str.split("+")
|
|
58
|
+
timezone_offset = "+" + timezone_offset
|
|
59
|
+
|
|
60
|
+
# Parse the datetime without timezone info
|
|
61
|
+
expires_datetime = datetime.strptime(expires_at_str, datetime_format)
|
|
62
|
+
|
|
63
|
+
# Convert to timestamp (assume UTC)
|
|
64
|
+
expires_timestamp = expires_datetime.timestamp()
|
|
65
|
+
current_time = time.time()
|
|
66
|
+
|
|
67
|
+
# Check if token is expired and log result
|
|
68
|
+
is_expired = current_time > expires_timestamp
|
|
69
|
+
time_diff = expires_timestamp - current_time
|
|
70
|
+
if is_expired:
|
|
71
|
+
logger.debug(f"Token is expired! Current time: {datetime.fromtimestamp(current_time)}, "
|
|
72
|
+
f"Expires at: {datetime.fromtimestamp(expires_timestamp)}, "
|
|
73
|
+
f"Expired {abs(time_diff):.0f} seconds ago")
|
|
74
|
+
else:
|
|
75
|
+
logger.debug(f"Token is still valid. Expires at: {datetime.fromtimestamp(expires_timestamp)}, "
|
|
76
|
+
f"Time remaining: {time_diff:.0f} seconds")
|
|
77
|
+
|
|
78
|
+
return is_expired
|
|
79
|
+
except Exception as e:
|
|
80
|
+
logger.error(f"Error parsing expiration date: {e}")
|
|
81
|
+
# Log the actual value to help diagnose format issues
|
|
82
|
+
logger.error(f"Invalid expires_at value: '{self.expires_at}'")
|
|
83
|
+
# Log detailed error information
|
|
84
|
+
import traceback
|
|
85
|
+
logger.error(f"Traceback: {traceback.format_exc()}")
|
|
86
|
+
return False # If we can't parse the date, assume it's not expired
|
|
87
|
+
|
|
88
|
+
def serialize(self) -> Dict[str, Any]:
|
|
89
|
+
"""Convert to a dictionary for storage"""
|
|
90
|
+
return {
|
|
91
|
+
"access_token": self.access_token,
|
|
92
|
+
"expires_at": self.expires_at,
|
|
93
|
+
"token_type": self.token_type,
|
|
94
|
+
"created_at": self.created_at
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def deserialize(cls, data: Dict[str, Any]) -> 'TokenInfo':
|
|
99
|
+
"""Create from a stored dictionary"""
|
|
100
|
+
logger.debug(f"Deserializing token data with keys: {', '.join(data.keys())}")
|
|
101
|
+
if 'expires_at' in data:
|
|
102
|
+
logger.debug(f"Token expires_at from cache: {data['expires_at']}")
|
|
103
|
+
|
|
104
|
+
token = cls(
|
|
105
|
+
access_token=data.get("access_token", ""),
|
|
106
|
+
expires_at=data.get("expires_at"),
|
|
107
|
+
token_type=data.get("token_type")
|
|
108
|
+
)
|
|
109
|
+
token.created_at = data.get("created_at", int(time.time()))
|
|
110
|
+
return token
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class PipeboardAuthManager:
|
|
114
|
+
"""Manages authentication with Meta APIs via pipeboard.co"""
|
|
115
|
+
def __init__(self):
|
|
116
|
+
self.api_token = os.environ.get("PIPEBOARD_API_TOKEN", "")
|
|
117
|
+
logger.debug(f"PipeboardAuthManager initialized with API token: {self.api_token[:5]}..." if self.api_token else "No API token")
|
|
118
|
+
if self.api_token:
|
|
119
|
+
logger.info("Pipeboard authentication enabled. Will use pipeboard.co for Meta authentication.")
|
|
120
|
+
else:
|
|
121
|
+
logger.info("Pipeboard authentication not enabled. Set PIPEBOARD_API_TOKEN environment variable to enable.")
|
|
122
|
+
self.token_info = None
|
|
123
|
+
self._load_cached_token()
|
|
124
|
+
|
|
125
|
+
def _get_token_cache_path(self) -> Path:
|
|
126
|
+
"""Get the platform-specific path for token cache file"""
|
|
127
|
+
if platform.system() == "Windows":
|
|
128
|
+
base_path = Path(os.environ.get("APPDATA", ""))
|
|
129
|
+
elif platform.system() == "Darwin": # macOS
|
|
130
|
+
base_path = Path.home() / "Library" / "Application Support"
|
|
131
|
+
else: # Assume Linux/Unix
|
|
132
|
+
base_path = Path.home() / ".config"
|
|
133
|
+
|
|
134
|
+
# Create directory if it doesn't exist
|
|
135
|
+
cache_dir = base_path / "meta-ads-mcp"
|
|
136
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
|
|
138
|
+
cache_path = cache_dir / "pipeboard_token_cache.json"
|
|
139
|
+
logger.debug(f"Token cache path: {cache_path}")
|
|
140
|
+
return cache_path
|
|
141
|
+
|
|
142
|
+
def _load_cached_token(self) -> bool:
|
|
143
|
+
"""Load token from cache if available"""
|
|
144
|
+
cache_path = self._get_token_cache_path()
|
|
145
|
+
|
|
146
|
+
if not cache_path.exists():
|
|
147
|
+
logger.debug(f"Token cache file not found at {cache_path}")
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
with open(cache_path, "r") as f:
|
|
152
|
+
logger.debug(f"Reading token cache from {cache_path}")
|
|
153
|
+
data = json.load(f)
|
|
154
|
+
self.token_info = TokenInfo.deserialize(data)
|
|
155
|
+
|
|
156
|
+
# Log token details (partial token for security)
|
|
157
|
+
masked_token = self.token_info.access_token[:10] + "..." + self.token_info.access_token[-5:] if self.token_info.access_token else "None"
|
|
158
|
+
logger.debug(f"Loaded token: {masked_token}")
|
|
159
|
+
|
|
160
|
+
# Check if token is expired
|
|
161
|
+
if self.token_info.is_expired():
|
|
162
|
+
logger.info("Cached token is expired")
|
|
163
|
+
self.token_info = None
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
logger.info(f"Loaded cached token (expires at {self.token_info.expires_at})")
|
|
167
|
+
return True
|
|
168
|
+
except json.JSONDecodeError as e:
|
|
169
|
+
logger.error(f"Error parsing token cache file: {e}")
|
|
170
|
+
logger.debug("Token cache file might be corrupted, trying to read raw content")
|
|
171
|
+
try:
|
|
172
|
+
with open(cache_path, "r") as f:
|
|
173
|
+
raw_content = f.read()
|
|
174
|
+
logger.debug(f"Raw cache file content (first 100 chars): {raw_content[:100]}")
|
|
175
|
+
except Exception as e2:
|
|
176
|
+
logger.error(f"Could not read raw cache file: {e2}")
|
|
177
|
+
return False
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.error(f"Error loading cached token: {e}")
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
def _save_token_to_cache(self) -> None:
|
|
183
|
+
"""Save token to cache file"""
|
|
184
|
+
if not self.token_info:
|
|
185
|
+
logger.debug("No token to save to cache")
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
cache_path = self._get_token_cache_path()
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
token_data = self.token_info.serialize()
|
|
192
|
+
logger.debug(f"Saving token to cache. Expires at: {token_data.get('expires_at')}")
|
|
193
|
+
|
|
194
|
+
with open(cache_path, "w") as f:
|
|
195
|
+
json.dump(token_data, f)
|
|
196
|
+
logger.info(f"Token cached at: {cache_path}")
|
|
197
|
+
except Exception as e:
|
|
198
|
+
logger.error(f"Error saving token to cache: {e}")
|
|
199
|
+
|
|
200
|
+
def initiate_auth_flow(self) -> Dict[str, str]:
|
|
201
|
+
"""
|
|
202
|
+
Initiate the Meta OAuth flow via pipeboard.co
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Dict with loginUrl and status info
|
|
206
|
+
"""
|
|
207
|
+
if not self.api_token:
|
|
208
|
+
logger.error("No PIPEBOARD_API_TOKEN environment variable set")
|
|
209
|
+
raise ValueError("No PIPEBOARD_API_TOKEN environment variable set")
|
|
210
|
+
|
|
211
|
+
# Exactly match the format used in meta_auth_test.sh
|
|
212
|
+
url = f"{PIPEBOARD_API_BASE}/meta/auth?api_token={self.api_token}"
|
|
213
|
+
headers = {
|
|
214
|
+
"Content-Type": "application/json"
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
logger.info(f"Initiating auth flow with POST request to {url}")
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
# Make the POST request exactly as in the working meta_auth_test.sh script
|
|
221
|
+
response = requests.post(url, headers=headers)
|
|
222
|
+
logger.info(f"Auth flow response status: {response.status_code}")
|
|
223
|
+
|
|
224
|
+
# Better error handling
|
|
225
|
+
if response.status_code != 200:
|
|
226
|
+
logger.error(f"Auth flow error: HTTP {response.status_code}")
|
|
227
|
+
error_text = response.text if response.text else "No response content"
|
|
228
|
+
logger.error(f"Response content: {error_text}")
|
|
229
|
+
if response.status_code == 404:
|
|
230
|
+
raise ValueError(f"Pipeboard API endpoint not found. Check if the server is running at {PIPEBOARD_API_BASE}")
|
|
231
|
+
elif response.status_code == 401:
|
|
232
|
+
raise ValueError(f"Unauthorized: Invalid API token. Check your PIPEBOARD_API_TOKEN.")
|
|
233
|
+
|
|
234
|
+
response.raise_for_status()
|
|
235
|
+
|
|
236
|
+
# Parse the response
|
|
237
|
+
try:
|
|
238
|
+
data = response.json()
|
|
239
|
+
logger.info(f"Received response keys: {', '.join(data.keys())}")
|
|
240
|
+
except json.JSONDecodeError:
|
|
241
|
+
logger.error(f"Could not parse JSON response: {response.text}")
|
|
242
|
+
raise ValueError(f"Invalid JSON response from auth endpoint: {response.text[:100]}")
|
|
243
|
+
|
|
244
|
+
# Log auth flow response (without sensitive information)
|
|
245
|
+
if 'loginUrl' in data:
|
|
246
|
+
logger.info(f"Auth flow initiated successfully with login URL: {data['loginUrl'][:30]}...")
|
|
247
|
+
else:
|
|
248
|
+
logger.warning(f"Auth flow response missing loginUrl field. Response keys: {', '.join(data.keys())}")
|
|
249
|
+
|
|
250
|
+
return data
|
|
251
|
+
except requests.exceptions.ConnectionError as e:
|
|
252
|
+
logger.error(f"Connection error to Pipeboard: {e}")
|
|
253
|
+
logger.debug(f"Attempting to connect to: {PIPEBOARD_API_BASE}")
|
|
254
|
+
raise
|
|
255
|
+
except requests.exceptions.Timeout as e:
|
|
256
|
+
logger.error(f"Timeout connecting to Pipeboard: {e}")
|
|
257
|
+
raise
|
|
258
|
+
except requests.exceptions.RequestException as e:
|
|
259
|
+
logger.error(f"Error initiating auth flow: {e}")
|
|
260
|
+
raise
|
|
261
|
+
except Exception as e:
|
|
262
|
+
logger.error(f"Unexpected error initiating auth flow: {e}")
|
|
263
|
+
raise
|
|
264
|
+
|
|
265
|
+
def get_access_token(self, force_refresh: bool = False) -> Optional[str]:
|
|
266
|
+
"""
|
|
267
|
+
Get the current access token, refreshing if necessary or if forced
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
force_refresh: Force token refresh even if cached token exists
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
Access token if available, None otherwise
|
|
274
|
+
"""
|
|
275
|
+
# First check if API token is configured
|
|
276
|
+
if not self.api_token:
|
|
277
|
+
logger.error("TOKEN VALIDATION FAILED: No Pipeboard API token configured")
|
|
278
|
+
logger.error("Please set PIPEBOARD_API_TOKEN environment variable")
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
# Check if we already have a valid token
|
|
282
|
+
if not force_refresh and self.token_info and not self.token_info.is_expired():
|
|
283
|
+
logger.debug("Using existing valid token")
|
|
284
|
+
return self.token_info.access_token
|
|
285
|
+
|
|
286
|
+
# If we have a token but it's expired, log that information
|
|
287
|
+
if not force_refresh and self.token_info and self.token_info.is_expired():
|
|
288
|
+
logger.error("TOKEN VALIDATION FAILED: Existing token is expired")
|
|
289
|
+
if self.token_info.expires_at:
|
|
290
|
+
logger.error(f"Token expiration time: {self.token_info.expires_at}")
|
|
291
|
+
|
|
292
|
+
logger.info(f"Getting new token (force_refresh={force_refresh})")
|
|
293
|
+
|
|
294
|
+
# If force refresh or no token/expired token, get a new one from Pipeboard
|
|
295
|
+
try:
|
|
296
|
+
# Make a request to get the token, using the same URL format as initiate_auth_flow
|
|
297
|
+
url = f"{PIPEBOARD_API_BASE}/meta/token?api_token={self.api_token}"
|
|
298
|
+
headers = {
|
|
299
|
+
"Content-Type": "application/json"
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
logger.info(f"Requesting token from {url}")
|
|
303
|
+
|
|
304
|
+
# Add timeout for better error messages
|
|
305
|
+
try:
|
|
306
|
+
response = requests.get(url, headers=headers, timeout=10)
|
|
307
|
+
except requests.exceptions.Timeout:
|
|
308
|
+
logger.error("TOKEN VALIDATION FAILED: Timeout while connecting to Pipeboard API")
|
|
309
|
+
logger.error(f"Could not connect to {PIPEBOARD_API_BASE} within 10 seconds")
|
|
310
|
+
return None
|
|
311
|
+
except requests.exceptions.ConnectionError:
|
|
312
|
+
logger.error("TOKEN VALIDATION FAILED: Connection error with Pipeboard API")
|
|
313
|
+
logger.error(f"Could not connect to {PIPEBOARD_API_BASE} - check if service is running")
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
logger.info(f"Token request response status: {response.status_code}")
|
|
317
|
+
|
|
318
|
+
# Better error handling with response content
|
|
319
|
+
if response.status_code != 200:
|
|
320
|
+
logger.error(f"TOKEN VALIDATION FAILED: HTTP error {response.status_code}")
|
|
321
|
+
error_text = response.text if response.text else "No response content"
|
|
322
|
+
logger.error(f"Response content: {error_text}")
|
|
323
|
+
|
|
324
|
+
# Add more specific error messages for common status codes
|
|
325
|
+
if response.status_code == 401:
|
|
326
|
+
logger.error("Authentication failed: Invalid Pipeboard API token")
|
|
327
|
+
elif response.status_code == 404:
|
|
328
|
+
logger.error("Endpoint not found: Check if Pipeboard API service is running correctly")
|
|
329
|
+
elif response.status_code == 400:
|
|
330
|
+
logger.error("Bad request: The request to Pipeboard API was malformed")
|
|
331
|
+
|
|
332
|
+
response.raise_for_status()
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
data = response.json()
|
|
336
|
+
logger.info(f"Received token response with keys: {', '.join(data.keys())}")
|
|
337
|
+
except json.JSONDecodeError:
|
|
338
|
+
logger.error("TOKEN VALIDATION FAILED: Invalid JSON response from Pipeboard API")
|
|
339
|
+
logger.error(f"Response content (first 100 chars): {response.text[:100]}")
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
# Validate response data
|
|
343
|
+
if "access_token" not in data:
|
|
344
|
+
logger.error("TOKEN VALIDATION FAILED: No access_token in Pipeboard API response")
|
|
345
|
+
logger.error(f"Response keys: {', '.join(data.keys())}")
|
|
346
|
+
if "error" in data:
|
|
347
|
+
logger.error(f"Error details: {data['error']}")
|
|
348
|
+
else:
|
|
349
|
+
logger.error("No error information available in response")
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
# Create new token info
|
|
353
|
+
self.token_info = TokenInfo(
|
|
354
|
+
access_token=data.get("access_token"),
|
|
355
|
+
expires_at=data.get("expires_at"),
|
|
356
|
+
token_type=data.get("token_type", "bearer")
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Save to cache
|
|
360
|
+
self._save_token_to_cache()
|
|
361
|
+
|
|
362
|
+
masked_token = self.token_info.access_token[:10] + "..." + self.token_info.access_token[-5:] if self.token_info.access_token else "None"
|
|
363
|
+
logger.info(f"Successfully retrieved access token: {masked_token}")
|
|
364
|
+
return self.token_info.access_token
|
|
365
|
+
except requests.RequestException as e:
|
|
366
|
+
status_code = e.response.status_code if hasattr(e, 'response') and e.response else None
|
|
367
|
+
response_text = e.response.text if hasattr(e, 'response') and e.response else "No response"
|
|
368
|
+
|
|
369
|
+
if status_code == 401:
|
|
370
|
+
logger.error(f"Unauthorized: Check your PIPEBOARD_API_TOKEN. Response: {response_text}")
|
|
371
|
+
elif status_code == 404:
|
|
372
|
+
logger.error(f"No token available: You might need to complete authorization first. Response: {response_text}")
|
|
373
|
+
# Return None so caller can handle the auth flow
|
|
374
|
+
return None
|
|
375
|
+
else:
|
|
376
|
+
logger.error(f"Error getting access token (status {status_code}): {e}")
|
|
377
|
+
logger.error(f"Response content: {response_text}")
|
|
378
|
+
return None
|
|
379
|
+
except Exception as e:
|
|
380
|
+
logger.error(f"Unexpected error getting access token: {e}")
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
def invalidate_token(self) -> None:
|
|
384
|
+
"""Invalidate the current token, usually because it has expired or is invalid"""
|
|
385
|
+
if self.token_info:
|
|
386
|
+
logger.info(f"Invalidating token: {self.token_info.access_token[:10]}...")
|
|
387
|
+
self.token_info = None
|
|
388
|
+
|
|
389
|
+
# Remove the cached token file
|
|
390
|
+
try:
|
|
391
|
+
cache_path = self._get_token_cache_path()
|
|
392
|
+
if cache_path.exists():
|
|
393
|
+
os.remove(cache_path)
|
|
394
|
+
logger.info(f"Removed cached token file: {cache_path}")
|
|
395
|
+
else:
|
|
396
|
+
logger.debug(f"No token cache file to remove: {cache_path}")
|
|
397
|
+
except Exception as e:
|
|
398
|
+
logger.error(f"Error removing cached token file: {e}")
|
|
399
|
+
else:
|
|
400
|
+
logger.debug("No token to invalidate")
|
|
401
|
+
|
|
402
|
+
def test_token_validity(self) -> bool:
|
|
403
|
+
"""
|
|
404
|
+
Test if the current token is valid with the Meta Graph API
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
True if valid, False otherwise
|
|
408
|
+
"""
|
|
409
|
+
if not self.token_info or not self.token_info.access_token:
|
|
410
|
+
logger.debug("No token to test")
|
|
411
|
+
logger.error("TOKEN VALIDATION FAILED: Missing token to test")
|
|
412
|
+
return False
|
|
413
|
+
|
|
414
|
+
# Log token details for debugging (partial token for security)
|
|
415
|
+
masked_token = self.token_info.access_token[:5] + "..." + self.token_info.access_token[-5:] if self.token_info.access_token else "None"
|
|
416
|
+
token_type = self.token_info.token_type if hasattr(self.token_info, 'token_type') and self.token_info.token_type else "bearer"
|
|
417
|
+
logger.debug(f"Testing token validity (token: {masked_token}, type: {token_type})")
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
# Make a simple request to the /me endpoint to test the token
|
|
421
|
+
META_GRAPH_API_VERSION = "v20.0"
|
|
422
|
+
url = f"https://graph.facebook.com/{META_GRAPH_API_VERSION}/me"
|
|
423
|
+
headers = {"Authorization": f"Bearer {self.token_info.access_token}"}
|
|
424
|
+
|
|
425
|
+
logger.debug(f"Testing token validity with request to {url}")
|
|
426
|
+
|
|
427
|
+
# Add timeout and better error handling
|
|
428
|
+
try:
|
|
429
|
+
response = requests.get(url, headers=headers, timeout=10)
|
|
430
|
+
except requests.exceptions.Timeout:
|
|
431
|
+
logger.error("TOKEN VALIDATION FAILED: Timeout while connecting to Meta API")
|
|
432
|
+
logger.error("The Graph API did not respond within 10 seconds")
|
|
433
|
+
return False
|
|
434
|
+
except requests.exceptions.ConnectionError:
|
|
435
|
+
logger.error("TOKEN VALIDATION FAILED: Connection error with Meta API")
|
|
436
|
+
logger.error("Could not establish connection to Graph API - check network connectivity")
|
|
437
|
+
return False
|
|
438
|
+
|
|
439
|
+
if response.status_code == 200:
|
|
440
|
+
data = response.json()
|
|
441
|
+
logger.debug(f"Token is valid. User ID: {data.get('id')}")
|
|
442
|
+
# Add more useful user information for debugging
|
|
443
|
+
user_info = f"User ID: {data.get('id')}"
|
|
444
|
+
if 'name' in data:
|
|
445
|
+
user_info += f", Name: {data.get('name')}"
|
|
446
|
+
logger.info(f"Meta API token validated successfully ({user_info})")
|
|
447
|
+
return True
|
|
448
|
+
else:
|
|
449
|
+
logger.error(f"TOKEN VALIDATION FAILED: API returned status {response.status_code}")
|
|
450
|
+
|
|
451
|
+
# Try to parse the error response for more detailed information
|
|
452
|
+
try:
|
|
453
|
+
error_data = response.json()
|
|
454
|
+
if 'error' in error_data:
|
|
455
|
+
error_obj = error_data.get('error', {})
|
|
456
|
+
error_code = error_obj.get('code', 'unknown')
|
|
457
|
+
error_message = error_obj.get('message', 'Unknown error')
|
|
458
|
+
logger.error(f"Meta API error: Code {error_code} - {error_message}")
|
|
459
|
+
|
|
460
|
+
# Add specific guidance for common error codes
|
|
461
|
+
if error_code == 190:
|
|
462
|
+
logger.error("Error indicates the token is invalid or has expired")
|
|
463
|
+
elif error_code == 4:
|
|
464
|
+
logger.error("Error indicates rate limiting - too many requests")
|
|
465
|
+
elif error_code == 200:
|
|
466
|
+
logger.error("Error indicates API permissions or configuration issue")
|
|
467
|
+
else:
|
|
468
|
+
logger.error(f"No error object in response: {error_data}")
|
|
469
|
+
except json.JSONDecodeError:
|
|
470
|
+
logger.error(f"Could not parse error response: {response.text[:200]}")
|
|
471
|
+
|
|
472
|
+
return False
|
|
473
|
+
except Exception as e:
|
|
474
|
+
logger.error(f"TOKEN VALIDATION FAILED: Unexpected error: {str(e)}")
|
|
475
|
+
|
|
476
|
+
# Add stack trace for debugging complex issues
|
|
477
|
+
import traceback
|
|
478
|
+
logger.error(f"Stack trace: {traceback.format_exc()}")
|
|
479
|
+
|
|
480
|
+
return False
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
# Create singleton instance
|
|
484
|
+
pipeboard_auth_manager = PipeboardAuthManager()
|