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.
@@ -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 have a cached token
23
- cached_token = auth_manager.get_access_token()
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
- # If we already have a valid token and none was provided, just return success
27
- if cached_token and not access_token:
28
- logger.info("get_login_link called with existing valid token")
29
- return json.dumps({
30
- "message": "Already authenticated",
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
- "token_preview": cached_token[:10] + "...",
33
- "created_at": auth_manager.token_info.created_at if hasattr(auth_manager, "token_info") else None,
34
- "expires_in": auth_manager.token_info.expires_in if hasattr(auth_manager, "token_info") else None
35
- }, indent=2)
36
-
37
- # IMPORTANT: Start the callback server first by calling our helper function
38
- # This ensures the server is ready before we provide the URL to the user
39
- logger.info("Starting callback server for authentication")
40
- port = start_callback_server()
41
- logger.info(f"Callback server started on port {port}")
42
-
43
- # Generate direct login URL
44
- auth_manager.redirect_uri = f"http://localhost:{port}/callback" # Ensure port is set correctly
45
- logger.info(f"Setting redirect URI to {auth_manager.redirect_uri}")
46
- login_url = auth_manager.get_auth_url()
47
- logger.info(f"Generated login URL: {login_url}")
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()