meta-ads-mcp 0.2.4__py3-none-any.whl → 0.2.5__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/core/auth.py CHANGED
@@ -13,6 +13,7 @@ from urllib.parse import urlparse, parse_qs
13
13
  from http.server import HTTPServer, BaseHTTPRequestHandler
14
14
  import json
15
15
  from .utils import logger
16
+ import requests
16
17
 
17
18
  # Auth constants
18
19
  AUTH_SCOPE = "ads_management,ads_read,business_management"
@@ -264,413 +265,1156 @@ class AuthManager:
264
265
  # Callback Handler class definition
265
266
  class CallbackHandler(BaseHTTPRequestHandler):
266
267
  def do_GET(self):
267
- global token_container, auth_manager, needs_authentication
268
+ global token_container, auth_manager, needs_authentication, update_confirmation
268
269
 
269
- if self.path.startswith("/callback"):
270
- # Return a page that extracts token from URL hash fragment
271
- self.send_response(200)
272
- self.send_header("Content-type", "text/html")
273
- self.end_headers()
270
+ try:
271
+ # Print path for debugging
272
+ print(f"Callback server received request: {self.path}")
274
273
 
275
- html = """
276
- <html>
277
- <head><title>Authentication Successful</title></head>
278
- <body>
279
- <h1>Authentication Successful!</h1>
280
- <p>You can close this window and return to the application.</p>
281
- <script>
282
- // Extract token from URL hash
283
- const hash = window.location.hash.substring(1);
284
- const params = new URLSearchParams(hash);
285
- const token = params.get('access_token');
286
- const expires_in = params.get('expires_in');
274
+ if self.path.startswith("/callback"):
275
+ self.send_response(200)
276
+ self.send_header("Content-type", "text/html")
277
+ self.end_headers()
278
+
279
+ # Get the token from the fragment
280
+ # We need to handle it via JS since the fragment is not sent to the server
281
+ callback_html = """
282
+ <!DOCTYPE html>
283
+ <html>
284
+ <head>
285
+ <title>Authentication Successful</title>
286
+ <style>
287
+ body {
288
+ font-family: Arial, sans-serif;
289
+ line-height: 1.6;
290
+ color: #333;
291
+ max-width: 800px;
292
+ margin: 0 auto;
293
+ padding: 20px;
294
+ }
295
+ .success {
296
+ color: #4CAF50;
297
+ font-size: 24px;
298
+ margin-bottom: 20px;
299
+ }
300
+ .info {
301
+ background-color: #f5f5f5;
302
+ padding: 15px;
303
+ border-radius: 4px;
304
+ margin-bottom: 20px;
305
+ }
306
+ .button {
307
+ background-color: #4CAF50;
308
+ color: white;
309
+ padding: 10px 15px;
310
+ border: none;
311
+ border-radius: 4px;
312
+ cursor: pointer;
313
+ }
314
+ </style>
315
+ </head>
316
+ <body>
317
+ <div class="success">Authentication Successful!</div>
318
+ <div class="info">
319
+ <p>Your Meta Ads API token has been received.</p>
320
+ <p>You can now close this window and return to the application.</p>
321
+ </div>
322
+ <button class="button" onclick="window.close()">Close Window</button>
287
323
 
288
- // Send token back to server using fetch
289
- fetch('/token?' + new URLSearchParams({
290
- token: token,
291
- expires_in: expires_in
292
- }))
293
- .then(response => console.log('Token sent to app'));
294
- </script>
295
- </body>
296
- </html>
297
- """
298
- self.wfile.write(html.encode())
299
- return
300
-
301
- if self.path.startswith("/confirm-update"):
302
- # Parse query parameters
303
- query = parse_qs(urlparse(self.path).query)
304
- adset_id = query.get("adset_id", [""])[0]
305
- token = query.get("token", [""])[0]
306
- changes = query.get("changes", ["{}"])[0]
307
-
308
- try:
309
- changes_dict = json.loads(changes)
310
- except json.JSONDecodeError:
311
- changes_dict = {}
312
-
313
- # Return confirmation page
314
- self.send_response(200)
315
- self.send_header("Content-type", "text/html")
316
- self.end_headers()
317
-
318
- html = """
319
- <html>
320
- <head>
321
- <title>Confirm Ad Set Update</title>
322
- <style>
323
- body { font-family: Arial, sans-serif; margin: 20px; max-width: 1000px; margin: 0 auto; }
324
- .warning { color: #d73a49; margin: 20px 0; padding: 15px; border-left: 4px solid #d73a49; background-color: #fff8f8; }
325
- .changes { background: #f6f8fa; padding: 15px; border-radius: 6px; }
326
- .buttons { margin-top: 20px; }
327
- button { padding: 10px 20px; margin-right: 10px; border-radius: 6px; cursor: pointer; }
328
- .approve { background: #2ea44f; color: white; border: none; }
329
- .cancel { background: #d73a49; color: white; border: none; }
330
- .diff-table { width: 100%; border-collapse: collapse; margin: 15px 0; }
331
- .diff-table td { padding: 8px; border: 1px solid #ddd; }
332
- .diff-table .header { background: #f1f8ff; font-weight: bold; }
333
- .status { padding: 15px; margin-top: 20px; border-radius: 6px; display: none; }
334
- .success { background-color: #e6ffed; border: 1px solid #2ea44f; color: #22863a; }
335
- .error { background-color: #ffeef0; border: 1px solid #d73a49; color: #d73a49; }
336
- pre { white-space: pre-wrap; word-break: break-all; }
337
- </style>
338
- </head>
339
- <body>
340
- <h1>Confirm Ad Set Update</h1>
341
- <p>You are about to update Ad Set: <strong>""" + adset_id + """</strong></p>
324
+ <script>
325
+ // Function to parse URL parameters including fragments
326
+ function parseURL(url) {
327
+ var params = {};
328
+ var parser = document.createElement('a');
329
+ parser.href = url;
330
+
331
+ // Parse fragment parameters
332
+ var fragment = parser.hash.substring(1);
333
+ var fragmentParams = fragment.split('&');
334
+
335
+ for (var i = 0; i < fragmentParams.length; i++) {
336
+ var pair = fragmentParams[i].split('=');
337
+ params[pair[0]] = decodeURIComponent(pair[1]);
338
+ }
339
+
340
+ return params;
341
+ }
342
+
343
+ // Parse the URL to get the access token
344
+ var params = parseURL(window.location.href);
345
+ var token = params['access_token'];
346
+ var expires_in = params['expires_in'];
347
+
348
+ // Send the token to the server
349
+ if (token) {
350
+ // Create XMLHttpRequest object
351
+ var xhr = new XMLHttpRequest();
352
+
353
+ // Configure it to make a GET request to the /token endpoint
354
+ xhr.open('GET', '/token?token=' + encodeURIComponent(token) +
355
+ '&expires_in=' + encodeURIComponent(expires_in), true);
356
+
357
+ // Set up a handler for when the request is complete
358
+ xhr.onload = function() {
359
+ if (xhr.status === 200) {
360
+ console.log('Token successfully sent to server');
361
+ } else {
362
+ console.error('Failed to send token to server');
363
+ }
364
+ };
365
+
366
+ // Send the request
367
+ xhr.send();
368
+ } else {
369
+ console.error('No token found in URL');
370
+ document.body.innerHTML += '<div style="color: red; margin-top: 20px;">Error: No authentication token found. Please try again.</div>';
371
+ }
372
+ </script>
373
+ </body>
374
+ </html>
375
+ """
376
+ self.wfile.write(callback_html.encode())
377
+ return
342
378
 
343
- <div class="warning">
344
- <strong>⚠️ Warning:</strong> These changes will be applied immediately upon approval.
345
- """ + ("<br><strong>🔴 Important:</strong> This update includes status changes that may affect billing." if "status" in changes_dict else "") + """
346
- </div>
379
+ elif self.path.startswith("/token"):
380
+ # Extract token from query params
381
+ query = parse_qs(urlparse(self.path).query)
382
+ token_container["token"] = query.get("token", [""])[0]
347
383
 
348
- <h2>Proposed Changes:</h2>
349
- <div class="changes">
350
- <table class="diff-table">
351
- <tr class="header">
352
- <td>Setting</td>
353
- <td>New Value</td>
354
- <td>Description</td>
355
- </tr>
356
- """
357
-
358
- # Special handling for frequency_control_specs
359
- for k, v in changes_dict.items():
360
- description = ""
361
- if k == "frequency_control_specs" and isinstance(v, list) and len(v) > 0:
362
- spec = v[0]
363
- if all(key in spec for key in ["event", "interval_days", "max_frequency"]):
364
- description = f"Cap to {spec['max_frequency']} {spec['event'].lower()} per {spec['interval_days']} days"
384
+ if "expires_in" in query:
385
+ try:
386
+ token_container["expires_in"] = int(query.get("expires_in", ["0"])[0])
387
+ except ValueError:
388
+ token_container["expires_in"] = None
365
389
 
366
- # Format the value for display
367
- display_value = json.dumps(v, indent=2) if isinstance(v, (dict, list)) else str(v)
390
+ # Send success response
391
+ self.send_response(200)
392
+ self.send_header("Content-type", "text/plain")
393
+ self.end_headers()
394
+ self.wfile.write(b"Token received")
368
395
 
369
- html += f"""
370
- <tr>
371
- <td>{k}</td>
372
- <td><pre>{display_value}</pre></td>
373
- <td>{description}</td>
374
- </tr>
375
- """
396
+ # Process the token (save it) immediately
397
+ if token_container["token"]:
398
+ # Get the short-lived token
399
+ short_lived_token = token_container["token"]
400
+
401
+ # Try to exchange for a long-lived token
402
+ long_lived_token_info = exchange_token_for_long_lived(short_lived_token)
403
+
404
+ if long_lived_token_info:
405
+ # Successfully exchanged for long-lived token
406
+ logger.info(f"Token received and exchanged for long-lived token (expires in {long_lived_token_info.expires_in} seconds)")
407
+
408
+ try:
409
+ # Set the token info in the auth_manager
410
+ auth_manager.token_info = long_lived_token_info
411
+ logger.info(f"Long-lived token info set in auth_manager, expires in {long_lived_token_info.expires_in} seconds")
412
+
413
+ # Save to cache
414
+ auth_manager._save_token_to_cache()
415
+ logger.info(f"Long-lived token successfully saved to cache at {auth_manager._get_token_cache_path()}")
416
+ except Exception as e:
417
+ logger.error(f"Error saving long-lived token to cache: {e}")
418
+ else:
419
+ # Fall back to the short-lived token
420
+ logger.warning("Failed to exchange for long-lived token, using short-lived token instead")
421
+ token_info = TokenInfo(
422
+ access_token=token_container["token"],
423
+ expires_in=token_container["expires_in"]
424
+ )
425
+
426
+ try:
427
+ # Set the token info in the auth_manager
428
+ auth_manager.token_info = token_info
429
+ logger.info(f"Token info set in auth_manager, expires in {token_info.expires_in} seconds")
430
+
431
+ # Save to cache
432
+ auth_manager._save_token_to_cache()
433
+ logger.info(f"Token successfully saved to cache at {auth_manager._get_token_cache_path()}")
434
+ except Exception as e:
435
+ logger.error(f"Error saving token to cache: {e}")
436
+
437
+ # Reset auth needed flag
438
+ needs_authentication = False
439
+
440
+ return token_container["token"]
441
+ else:
442
+ logger.warning("Received empty token in callback")
443
+ needs_authentication = True
444
+ return None
376
445
 
377
- html += """
378
- </table>
379
- </div>
446
+ elif self.path.startswith("/confirm-update"):
447
+ # Generate confirmation URL with properly encoded parameters
448
+ query = parse_qs(urlparse(self.path).query)
449
+ adset_id = query.get("adset_id", [""])[0]
450
+ token = query.get("token", [""])[0]
451
+ changes = query.get("changes", ["{}"])[0]
452
+
453
+ try:
454
+ changes_dict = json.loads(changes)
455
+ except json.JSONDecodeError:
456
+ changes_dict = {}
457
+
458
+ # Return confirmation page
459
+ self.send_response(200)
460
+ self.send_header("Content-type", "text/html; charset=utf-8")
461
+ self.end_headers()
380
462
 
381
- <div class="buttons">
382
- <button class="approve" onclick="approveChanges()">Approve Changes</button>
383
- <button class="cancel" onclick="cancelChanges()">Cancel</button>
384
- </div>
463
+ html = """
464
+ <html>
465
+ <head>
466
+ <title>Confirm Ad Set Update</title>
467
+ <meta charset="utf-8">
468
+ <style>
469
+ body { font-family: Arial, sans-serif; margin: 20px; max-width: 1000px; margin: 0 auto; }
470
+ .warning { color: #d73a49; margin: 20px 0; padding: 15px; border-left: 4px solid #d73a49; background-color: #fff8f8; }
471
+ .changes { background: #f6f8fa; padding: 15px; border-radius: 6px; }
472
+ .buttons { margin-top: 20px; }
473
+ button { padding: 10px 20px; margin-right: 10px; border-radius: 6px; cursor: pointer; }
474
+ .approve { background: #2ea44f; color: white; border: none; }
475
+ .cancel { background: #d73a49; color: white; border: none; }
476
+ .diff-table { width: 100%; border-collapse: collapse; margin: 15px 0; }
477
+ .diff-table td { padding: 8px; border: 1px solid #ddd; }
478
+ .diff-table .header { background: #f1f8ff; font-weight: bold; }
479
+ .status { padding: 15px; margin-top: 20px; border-radius: 6px; display: none; }
480
+ .success { background-color: #e6ffed; border: 1px solid #2ea44f; color: #22863a; }
481
+ .error { background-color: #ffeef0; border: 1px solid #d73a49; color: #d73a49; }
482
+ pre { white-space: pre-wrap; word-break: break-all; }
483
+ </style>
484
+ </head>
485
+ <body>
486
+ <h1>Confirm Ad Set Update</h1>
487
+ <p>You are about to update Ad Set: <strong>""" + adset_id + """</strong></p>
488
+
489
+ <div class="warning">
490
+ <p><strong>Warning:</strong> This action will directly update your ad set in Meta Ads. Please review the changes carefully before approving.</p>
491
+ </div>
492
+
493
+ <div class="changes">
494
+ <h3>Changes to apply:</h3>
495
+ <table class="diff-table">
496
+ <tr class="header">
497
+ <td>Field</td>
498
+ <td>New Value</td>
499
+ <td>Description</td>
500
+ </tr>
501
+ """
502
+
503
+ # Special handling for frequency_control_specs
504
+ for k, v in changes_dict.items():
505
+ description = ""
506
+ if k == "frequency_control_specs" and isinstance(v, list) and len(v) > 0:
507
+ spec = v[0]
508
+ if all(key in spec for key in ["event", "interval_days", "max_frequency"]):
509
+ description = f"Cap to {spec['max_frequency']} {spec['event'].lower()} per {spec['interval_days']} days"
510
+
511
+ # Special handling for targeting_automation
512
+ elif k == "targeting" and isinstance(v, dict) and "targeting_automation" in v:
513
+ targeting_auto = v.get("targeting_automation", {})
514
+ if "advantage_audience" in targeting_auto:
515
+ audience_value = targeting_auto["advantage_audience"]
516
+ description = f"Set Advantage+ audience to {'ON' if audience_value == 1 else 'OFF'}"
517
+ if audience_value == 1:
518
+ description += " (may be restricted for Special Ad Categories)"
519
+
520
+ # Format the value for display
521
+ display_value = json.dumps(v, indent=2) if isinstance(v, (dict, list)) else str(v)
522
+
523
+ html += f"""
524
+ <tr>
525
+ <td>{k}</td>
526
+ <td><pre>{display_value}</pre></td>
527
+ <td>{description}</td>
528
+ </tr>
529
+ """
530
+
531
+ # Create a properly escaped JSON string for JavaScript
532
+ escaped_changes = json.dumps(changes).replace("'", "\\'").replace('"', '\\"')
385
533
 
386
- <div id="status" class="status"></div>
534
+ html += """
535
+ </table>
536
+ </div>
537
+
538
+ <div class="buttons">
539
+ <button class="approve" onclick="approveChanges()">Approve Changes</button>
540
+ <button class="cancel" onclick="cancelChanges()">Cancel</button>
541
+ </div>
542
+
543
+ <div id="status" class="status"></div>
387
544
 
388
- <script>
389
- function showStatus(message, isError = false) {
390
- const statusElement = document.getElementById('status');
391
- statusElement.textContent = message;
392
- statusElement.style.display = 'block';
393
- if (isError) {
394
- statusElement.classList.add('error');
395
- statusElement.classList.remove('success');
396
- } else {
397
- statusElement.classList.add('success');
398
- statusElement.classList.remove('error');
545
+ <script>
546
+ // Enable debug logging
547
+ const DEBUG = true;
548
+ function debugLog(message, data) {
549
+ if (DEBUG) {
550
+ if (data) {
551
+ console.log(`[DEBUG-CONFIRM] ${message}:`, data);
552
+ } else {
553
+ console.log(`[DEBUG-CONFIRM] ${message}`);
554
+ }
555
+ }
399
556
  }
400
- }
401
-
402
- function approveChanges() {
403
- showStatus("Processing update...");
404
557
 
405
- const buttons = document.querySelectorAll('button');
406
- buttons.forEach(button => button.disabled = true);
407
-
408
- fetch('/update-confirm?' + new URLSearchParams({
409
- adset_id: '""" + adset_id + """',
410
- token: '""" + token + """',
411
- changes: '""" + changes + """',
412
- action: 'approve'
413
- }))
414
- .then(response => response.json())
415
- .then(data => {
416
- if (data.error) {
417
- showStatus('Error applying changes: ' + data.error, true);
418
- buttons.forEach(button => button.disabled = false);
558
+ function showStatus(message, isError = false) {
559
+ const statusElement = document.getElementById('status');
560
+ statusElement.textContent = message;
561
+ statusElement.style.display = 'block';
562
+ if (isError) {
563
+ statusElement.classList.add('error');
564
+ statusElement.classList.remove('success');
419
565
  } else {
420
- showStatus('Changes approved and will be applied shortly!');
421
- setTimeout(() => {
422
- window.location.href = '/verify-update?' + new URLSearchParams({
566
+ statusElement.classList.add('success');
567
+ statusElement.classList.remove('error');
568
+ }
569
+ }
570
+
571
+ function approveChanges() {
572
+ showStatus("Processing update...");
573
+ debugLog("Approving changes");
574
+
575
+ const buttons = document.querySelectorAll('button');
576
+ buttons.forEach(button => button.disabled = true);
577
+
578
+ const params = new URLSearchParams({
579
+ adset_id: '""" + adset_id + """',
580
+ token: '""" + token + """',
581
+ changes: '""" + escaped_changes + """',
582
+ action: 'approve'
583
+ });
584
+
585
+ debugLog("Sending update request with params", {
586
+ adset_id: '""" + adset_id + """',
587
+ changes: JSON.parse('""" + escaped_changes + """')
588
+ });
589
+
590
+ fetch('/update-confirm?' + params)
591
+ .then(response => {
592
+ debugLog("Received response", { status: response.status });
593
+ return response.text().then(text => {
594
+ debugLog("Raw response text", text);
595
+ try {
596
+ return JSON.parse(text);
597
+ } catch (e) {
598
+ debugLog("Error parsing JSON response", e);
599
+ return { status: "error", error: "Invalid response format from server" };
600
+ }
601
+ });
602
+ })
603
+ .then(data => {
604
+ debugLog("Parsed response data", data);
605
+
606
+ if (data.status === "error") {
607
+ // Build a properly encoded and detailed error message
608
+ let errorMessage = data.error || "Unknown error";
609
+
610
+ // Extract the most appropriate error message for display
611
+ const extractBestErrorMessage = (errorData) => {
612
+ // Try to find the most user-friendly message in the error data
613
+ if (!errorData) return null;
614
+
615
+ // Meta often puts the most user-friendly message in error_user_msg
616
+ if (errorData.apiError && errorData.apiError.error_user_msg) {
617
+ return errorData.apiError.error_user_msg;
618
+ }
619
+
620
+ // Look for detailed error messages in blame_field_specs
621
+ try {
622
+ if (errorData.apiError && errorData.apiError.error_data) {
623
+ const errorDataObj = typeof errorData.apiError.error_data === 'string'
624
+ ? JSON.parse(errorData.apiError.error_data)
625
+ : errorData.apiError.error_data;
626
+
627
+ if (errorDataObj.blame_field_specs && errorDataObj.blame_field_specs.length > 0) {
628
+ // Handle nested array structure
629
+ const specs = errorDataObj.blame_field_specs[0];
630
+ if (Array.isArray(specs)) {
631
+ return specs.filter(Boolean).join("; ");
632
+ } else if (typeof specs === 'string') {
633
+ return specs;
634
+ }
635
+ }
636
+ }
637
+ } catch (e) {
638
+ debugLog("Error extracting blame_field_specs", e);
639
+ }
640
+
641
+ // Fall back to standard error message if available
642
+ if (errorData.apiError && errorData.apiError.message) {
643
+ return errorData.apiError.message;
644
+ }
645
+
646
+ // If we have error details as an array, join them
647
+ if (errorData.details && Array.isArray(errorData.details) && errorData.details.length > 0) {
648
+ return errorData.details.join("; ");
649
+ }
650
+
651
+ // No better message found
652
+ return null;
653
+ };
654
+
655
+ // Find the best error message
656
+ const bestMessage = extractBestErrorMessage(data);
657
+ if (bestMessage) {
658
+ errorMessage = bestMessage;
659
+ }
660
+
661
+ debugLog("Selected error message", errorMessage);
662
+
663
+ // Get the original error from the nested structure if possible
664
+ const originalErrorDetails = data.apiError?.details?.error || data.apiError;
665
+
666
+ // Create a detailed error object for the verification page
667
+ const fullErrorData = {
668
+ message: errorMessage,
669
+ details: data.errorDetails || [],
670
+ apiError: originalErrorDetails || data.apiError || {},
671
+ fullResponse: data.fullResponse || {}
672
+ };
673
+
674
+ debugLog("Redirecting with error message", errorMessage);
675
+ debugLog("Full error data", fullErrorData);
676
+
677
+ // Encode the stringified error object
678
+ const encodedErrorData = encodeURIComponent(JSON.stringify(fullErrorData));
679
+
680
+ // Redirect to verification page with detailed error information
681
+ const errorParams = new URLSearchParams({
423
682
  adset_id: '""" + adset_id + """',
424
- token: '""" + token + """'
683
+ token: '""" + token + """',
684
+ error: errorMessage,
685
+ errorData: encodedErrorData
425
686
  });
426
- }, 3000);
427
- }
428
- })
429
- .catch(error => {
430
- showStatus('Error applying changes: ' + error, true);
431
- buttons.forEach(button => button.disabled = false);
432
- });
433
- }
434
-
435
- function cancelChanges() {
436
- showStatus("Cancelling update...");
687
+ window.location.href = '/verify-update?' + errorParams;
688
+ } else {
689
+ showStatus('Changes approved and will be applied shortly!');
690
+ setTimeout(() => {
691
+ window.location.href = '/verify-update?' + new URLSearchParams({
692
+ adset_id: '""" + adset_id + """',
693
+ token: '""" + token + """'
694
+ });
695
+ }, 3000);
696
+ }
697
+ })
698
+ .catch(error => {
699
+ debugLog("Fetch error", error);
700
+ showStatus('Error applying changes: ' + error, true);
701
+ buttons.forEach(button => button.disabled = false);
702
+ });
703
+ }
437
704
 
438
- fetch('/update-confirm?' + new URLSearchParams({
439
- adset_id: '""" + adset_id + """',
440
- action: 'cancel'
441
- }))
442
- .then(() => {
443
- showStatus('Update cancelled.');
444
- setTimeout(() => window.close(), 2000);
445
- });
446
- }
447
- </script>
448
- </body>
449
- </html>
450
- """
451
- self.wfile.write(html.encode())
452
- return
453
-
454
- if self.path.startswith("/verify-update"):
455
- # Parse query parameters
456
- query = parse_qs(urlparse(self.path).query)
457
- adset_id = query.get("adset_id", [""])[0]
458
- token = query.get("token", [""])[0]
459
-
460
- # Respond with a verification page
461
- self.send_response(200)
462
- self.send_header("Content-type", "text/html")
463
- self.end_headers()
705
+ function cancelChanges() {
706
+ showStatus("Cancelling update...");
707
+
708
+ fetch('/update-confirm?' + new URLSearchParams({
709
+ adset_id: '""" + adset_id + """',
710
+ action: 'cancel'
711
+ }))
712
+ .then(() => {
713
+ showStatus('Update cancelled.');
714
+ setTimeout(() => window.close(), 2000);
715
+ });
716
+ }
717
+ </script>
718
+ </body>
719
+ </html>
720
+ """
721
+ self.wfile.write(html.encode('utf-8'))
722
+ return
464
723
 
465
- html = """
466
- <html>
467
- <head>
468
- <title>Verifying Ad Set Update</title>
469
- <style>
470
- body { font-family: Arial, sans-serif; margin: 20px; max-width: 800px; margin: 0 auto; }
471
- .status { padding: 15px; margin-top: 20px; border-radius: 6px; }
472
- .loading { background-color: #f1f8ff; border: 1px solid #0366d6; }
473
- .success { background-color: #e6ffed; border: 1px solid #2ea44f; color: #22863a; }
474
- .error { background-color: #ffeef0; border: 1px solid #d73a49; color: #d73a49; }
475
- .details { background: #f6f8fa; padding: 15px; border-radius: 6px; margin-top: 20px; }
476
- .note { background-color: #fff8c5; border: 1px solid #e36209; padding: 15px; border-radius: 6px; margin: 20px 0; }
477
- pre { white-space: pre-wrap; word-break: break-all; }
478
- .spinner { display: inline-block; width: 20px; height: 20px; border: 3px solid rgba(0, 0, 0, 0.1); border-radius: 50%; border-top-color: #0366d6; animation: spin 1s ease-in-out infinite; margin-right: 10px; }
479
- @keyframes spin { to { transform: rotate(360deg); } }
480
- </style>
481
- </head>
482
- <body>
483
- <h1>Verifying Ad Set Update</h1>
484
- <p>Checking the status of your update for Ad Set <strong>""" + adset_id + """</strong></p>
724
+ elif self.path.startswith("/verify-update"):
725
+ # Parse query parameters
726
+ query = parse_qs(urlparse(self.path).query)
727
+ adset_id = query.get("adset_id", [""])[0]
728
+ token = query.get("token", [""])[0]
729
+
730
+ # Check if there was an error in the update process
731
+ error_message = query.get("error", [""])[0]
732
+ error_data_encoded = query.get("errorData", [""])[0]
485
733
 
486
- <div class="note">
487
- <strong>Note about Meta API Visibility:</strong>
488
- <p>Some fields (like frequency caps) may not be visible in the API response even when successfully set. This is a limitation of the Meta API and depends on factors like the ad set's optimization goal. You can verify these settings in the Meta Ads Manager UI or by monitoring metrics like frequency in the ad insights.</p>
489
- </div>
734
+ # Try to decode detailed error data if available
735
+ error_data = {}
736
+ if error_data_encoded:
737
+ try:
738
+ error_data = json.loads(urllib.parse.unquote(error_data_encoded))
739
+ except:
740
+ logger.error("Failed to parse errorData parameter")
490
741
 
491
- <div id="status" class="status loading">
492
- <div class="spinner"></div> Verifying update...
493
- </div>
742
+ # Respond with a verification page
743
+ self.send_response(200)
744
+ self.send_header("Content-type", "text/html; charset=utf-8")
745
+ self.end_headers()
494
746
 
495
- <div id="details" class="details" style="display: none;">
496
- <h3>Updated Ad Set Details:</h3>
497
- <pre id="adset-details">Loading...</pre>
498
- </div>
747
+ html = """
748
+ <html>
749
+ <head>
750
+ <title>Verifying Ad Set Update</title>
751
+ <meta charset="utf-8">
752
+ <style>
753
+ body { font-family: Arial, sans-serif; margin: 20px; max-width: 800px; margin: 0 auto; }
754
+ .status { padding: 15px; margin-top: 20px; border-radius: 6px; }
755
+ .loading { background-color: #f1f8ff; border: 1px solid #0366d6; }
756
+ .success { background-color: #e6ffed; border: 1px solid #2ea44f; color: #22863a; }
757
+ .error { background-color: #ffeef0; border: 1px solid #d73a49; color: #d73a49; }
758
+ .details { background: #f6f8fa; padding: 15px; border-radius: 6px; margin-top: 20px; }
759
+ .note { background-color: #fff8c5; border: 1px solid #e36209; padding: 15px; border-radius: 6px; margin: 20px 0; }
760
+ pre { white-space: pre-wrap; word-break: break-all; }
761
+ .spinner { display: inline-block; width: 20px; height: 20px; border: 3px solid rgba(0, 0, 0, 0.1); border-radius: 50%; border-top-color: #0366d6; animation: spin 1s ease-in-out infinite; margin-right: 10px; }
762
+ @keyframes spin { to { transform: rotate(360deg); } }
763
+ .fix-suggestion { background-color: #e6f6ff; border: 1px solid #79b8ff; padding: 15px; border-radius: 6px; margin-top: 10px; }
764
+ .code-block { background-color: #f6f8fa; padding: 8px; border-radius: 4px; font-family: monospace; }
765
+ .error-list { margin-top: 10px; }
766
+ .error-list li { margin-bottom: 8px; }
767
+ .debug-section { background-color: #f0f0f0; margin-top: 30px; padding: 15px; border: 1px dashed #666; }
768
+ .debug-section h3 { color: #333; }
769
+ .raw-response { font-family: monospace; font-size: 12px; max-height: 300px; overflow: auto; }
770
+ .error-details { margin-top: 15px; background-color: #fff5f5; padding: 15px; border-left: 3px solid #d73a49; }
771
+ </style>
772
+ </head>
773
+ <body>
774
+ <h1>Verifying Ad Set Update</h1>
775
+ <p>Checking the status of your update for Ad Set <strong>""" + adset_id + """</strong></p>
776
+
777
+ <div class="note">
778
+ <strong>Note about Meta API Visibility:</strong>
779
+ <p>Some fields (like frequency caps) may not be visible in the API response even when successfully set. This is a limitation of the Meta API and depends on factors like the ad set's optimization goal. You can verify these settings in the Meta Ads Manager UI or by monitoring metrics like frequency in the ad insights.</p>
780
+ </div>
781
+
782
+ <div id="status" class="status loading">
783
+ <div class="spinner"></div> Verifying update...
784
+ </div>
785
+
786
+ <div id="details" class="details" style="display: none;">
787
+ <h3>Updated Ad Set Details:</h3>
788
+ <pre id="adset-details">Loading...</pre>
789
+ </div>
790
+
791
+ <div id="debug-section" class="debug-section" style="display: none;">
792
+ <h3>Debug Information</h3>
793
+ <div>
794
+ <h4>URL Parameters:</h4>
795
+ <pre id="url-params">Loading...</pre>
796
+ </div>
797
+ <div>
798
+ <h4>Error Data:</h4>
799
+ <pre id="error-data-debug">""" + json.dumps(error_data, indent=2) + """</pre>
800
+ </div>
801
+ <div>
802
+ <h4>Raw API Response:</h4>
803
+ <pre id="raw-response" class="raw-response">Loading...</pre>
804
+ </div>
805
+ </div>
499
806
 
500
- <script>
501
- // Function to fetch the ad set details and check if frequency_control_specs was updated
502
- async function verifyUpdate() {
503
- try {
504
- const response = await fetch('/api/adset?' + new URLSearchParams({
505
- adset_id: '""" + adset_id + """',
506
- token: '""" + token + """'
507
- }));
508
-
509
- const data = await response.json();
510
- const statusElement = document.getElementById('status');
511
- const detailsElement = document.getElementById('details');
512
- const adsetDetailsElement = document.getElementById('adset-details');
513
-
514
- detailsElement.style.display = 'block';
515
- adsetDetailsElement.textContent = JSON.stringify(data, null, 2);
807
+ <script>
808
+ // Enable debug mode
809
+ const DEBUG = true;
810
+
811
+ // Debug logging helper
812
+ function debugLog(message, data) {
813
+ if (DEBUG) {
814
+ if (data) {
815
+ console.log(`[DEBUG] ${message}:`, data);
816
+ } else {
817
+ console.log(`[DEBUG] ${message}`);
818
+ }
819
+ }
820
+ }
821
+
822
+ // Show debug section
823
+ if (DEBUG) {
824
+ document.getElementById('debug-section').style.display = 'block';
825
+ }
826
+
827
+ // Parse and display URL parameters
828
+ const urlParams = new URLSearchParams(window.location.search);
829
+ const urlParamsObj = {};
830
+ for (const [key, value] of urlParams.entries()) {
831
+ urlParamsObj[key] = value;
832
+ }
833
+
834
+ debugLog('URL parameters', urlParamsObj);
835
+ document.getElementById('url-params').textContent = JSON.stringify(urlParamsObj, null, 2);
836
+
837
+ // Try to parse error data if available
838
+ let errorData = null;
839
+ const errorDataParam = urlParams.get('errorData');
840
+ if (errorDataParam) {
841
+ try {
842
+ errorData = JSON.parse(decodeURIComponent(errorDataParam));
843
+ debugLog('Parsed error data', errorData);
844
+ } catch (e) {
845
+ debugLog('Failed to parse errorData', e);
846
+ }
847
+ }
848
+
849
+ // Check if there was an error passed in the URL
850
+ const errorParam = urlParams.get('error');
851
+ debugLog('Error parameter found', errorParam);
852
+
853
+ if (errorParam) {
854
+ // If there's an error, show it immediately
855
+ const errorMessage = decodeURIComponent(errorParam);
856
+ debugLog('Decoded error message', errorMessage);
516
857
 
517
- // Update success message to reflect API visibility limitations
518
- statusElement.classList.remove('loading');
519
- statusElement.classList.add('success');
520
- statusElement.innerHTML = '✅ Update request was processed successfully. Please verify the changes in Meta Ads Manager UI or monitor ad performance metrics.';
521
- } catch (error) {
522
858
  const statusElement = document.getElementById('status');
523
859
  statusElement.classList.remove('loading');
524
860
  statusElement.classList.add('error');
525
- statusElement.textContent = 'Error verifying update: ' + error;
861
+
862
+ // Check if this is a Special Ad Category error
863
+ const isSpecialAdCategoryError =
864
+ errorMessage.includes("Special Ad Category") ||
865
+ errorMessage.includes("Advantage+") ||
866
+ errorMessage.includes("advantage_audience");
867
+
868
+ debugLog('Is Special Ad Category error', isSpecialAdCategoryError);
869
+
870
+ if (isSpecialAdCategoryError) {
871
+ // Format special ad category errors with better explanation
872
+ debugLog('Displaying Special Ad Category error');
873
+ statusElement.innerHTML = `
874
+ <h3>❌ Special Ad Category Restriction</h3>
875
+ <p>${errorMessage}</p>
876
+ <div class="note" style="margin-top:10px">
877
+ <strong>What does this mean?</strong><br>
878
+ Meta restricts certain targeting features like Advantage+ audience for ads in Special Ad Categories
879
+ (housing, employment, credit, social issues, etc.). You need to use standard targeting options instead.
880
+ </div>
881
+ <div class="fix-suggestion">
882
+ <strong>How to fix:</strong><br>
883
+ To update this ad set, try setting <span class="code-block">targeting.targeting_automation.advantage_audience</span> to <span class="code-block">0</span> instead of <span class="code-block">1</span>.
884
+ </div>
885
+ `;
886
+ } else {
887
+ // Standard error display with more details
888
+ debugLog('Displaying standard error');
889
+
890
+ // Start with basic error display
891
+ let errorHtml = `
892
+ <h3>❌ Error updating ad set</h3>
893
+ <p>${errorMessage}</p>
894
+ `;
895
+
896
+ // Add detailed error information if available
897
+ if (errorData) {
898
+ errorHtml += `<div class="error-details">`;
899
+
900
+ // If we have a nice error title from Meta, display it
901
+ if (errorData.apiError && errorData.apiError.error_user_title) {
902
+ errorHtml += `<strong>Error Type:</strong> ${errorData.apiError.error_user_title}<br>`;
903
+ }
904
+
905
+ // Add error codes if available
906
+ if (errorData.apiError) {
907
+ const apiError = errorData.apiError;
908
+ if (apiError.code) {
909
+ errorHtml += `<strong>Error Code:</strong> ${apiError.code}`;
910
+ if (apiError.error_subcode) {
911
+ errorHtml += ` (Subcode: ${apiError.error_subcode})`;
912
+ }
913
+ errorHtml += `<br>`;
914
+ }
915
+ }
916
+
917
+ // Add detailed error list
918
+ if (errorData.details && errorData.details.length > 0) {
919
+ errorHtml += `
920
+ <strong>Error Details:</strong>
921
+ <ul class="error-list">
922
+ ${errorData.details.map(detail => `<li>${detail}</li>`).join('')}
923
+ </ul>
924
+ `;
925
+ }
926
+
927
+ // Try to extract and display blame_field_specs
928
+ try {
929
+ if (errorData.apiError && errorData.apiError.error_data) {
930
+ const error_data = typeof errorData.apiError.error_data === 'string'
931
+ ? JSON.parse(errorData.apiError.error_data)
932
+ : errorData.apiError.error_data;
933
+
934
+ if (error_data.blame_field_specs && error_data.blame_field_specs.length > 0) {
935
+ errorHtml += `<strong>Field-Specific Errors:</strong><ul class="error-list">`;
936
+
937
+ // Handle different formats of blame_field_specs
938
+ if (Array.isArray(error_data.blame_field_specs[0])) {
939
+ // Format: [[error1, error2, ...]]
940
+ error_data.blame_field_specs[0].forEach(spec => {
941
+ if (spec) errorHtml += `<li>${spec}</li>`;
942
+ });
943
+ } else {
944
+ // Format: [error1, error2, ...]
945
+ error_data.blame_field_specs.forEach(spec => {
946
+ if (spec) errorHtml += `<li>${spec}</li>`;
947
+ });
948
+ }
949
+
950
+ errorHtml += `</ul>`;
951
+ }
952
+ }
953
+ } catch (e) {
954
+ debugLog('Error parsing blame_field_specs', e);
955
+ }
956
+
957
+ errorHtml += `</div>`;
958
+ }
959
+
960
+ statusElement.innerHTML = errorHtml;
961
+ }
962
+
963
+ // Just display current state, don't try to verify the update since it failed
964
+ document.getElementById('details').innerHTML = `
965
+ <h3>Current Ad Set Details (Changes Not Applied):</h3>
966
+ <p>Fetching current state...</p>
967
+ <pre id="adset-details">Loading...</pre>
968
+ `;
969
+ document.getElementById('details').style.display = 'block';
970
+
971
+ // Fetch the current ad set details to show what wasn't changed
972
+ fetchAdSetDetails();
973
+ } else {
974
+ // Otherwise proceed with normal verification
975
+ debugLog('No error parameter found, proceeding with verification');
976
+ setTimeout(verifyUpdate, 3000);
526
977
  }
527
- }
528
-
529
- // Wait a moment before verifying
530
- setTimeout(verifyUpdate, 3000);
531
- </script>
532
- </body>
533
- </html>
534
- """
535
- self.wfile.write(html.encode())
536
- return
537
-
538
- if self.path.startswith("/api/adset"):
539
- # Parse query parameters
540
- query = parse_qs(urlparse(self.path).query)
541
- adset_id = query.get("adset_id", [""])[0]
542
- token = query.get("token", [""])[0]
543
-
544
- from .api import make_api_request
545
-
546
- # Call the Graph API directly
547
- async def get_adset_data():
548
- endpoint = f"{adset_id}"
549
- params = {
550
- "fields": "id,name,campaign_id,status,daily_budget,lifetime_budget,targeting,bid_amount,bid_strategy,optimization_goal,billing_event,start_time,end_time,created_time,updated_time,attribution_spec,destination_type,promoted_object,pacing_type,budget_remaining,frequency_control_specs"
551
- }
552
-
553
- return await make_api_request(endpoint, token, params)
554
-
555
- # Run the async function
556
- loop = asyncio.new_event_loop()
557
- asyncio.set_event_loop(loop)
558
- result = loop.run_until_complete(get_adset_data())
559
- loop.close()
560
-
561
- # Return the result
562
- self.send_response(200)
563
- self.send_header("Content-type", "application/json")
564
- self.end_headers()
565
- self.wfile.write(json.dumps(result, indent=2).encode())
566
- return
567
-
568
- if self.path.startswith("/update-confirm"):
569
- # Handle update confirmation response
570
- query = parse_qs(urlparse(self.path).query)
571
- action = query.get("action", [""])[0]
572
-
573
- self.send_response(200)
574
- self.send_header("Content-type", "application/json")
575
- self.end_headers()
978
+
979
+ // Function to just fetch the ad set details
980
+ async function fetchAdSetDetails() {
981
+ try {
982
+ debugLog('Fetching ad set details');
983
+ const apiUrl = '/api/adset?' + new URLSearchParams({
984
+ adset_id: '""" + adset_id + """',
985
+ token: '""" + token + """'
986
+ });
987
+
988
+ debugLog('Fetching from URL', apiUrl);
989
+ const response = await fetch(apiUrl);
990
+
991
+ // Log raw response
992
+ const responseText = await response.text();
993
+ debugLog('Raw API response text', responseText);
994
+ document.getElementById('raw-response').textContent = responseText;
995
+
996
+ // Parse JSON
997
+ let data;
998
+ try {
999
+ data = JSON.parse(responseText);
1000
+ debugLog('Parsed API response', data);
1001
+ } catch (parseError) {
1002
+ debugLog('Error parsing JSON response', parseError);
1003
+ throw new Error(`Failed to parse API response: ${parseError.message}`);
1004
+ }
1005
+
1006
+ const detailsElement = document.getElementById('details');
1007
+ const adsetDetailsElement = document.getElementById('adset-details');
1008
+
1009
+ // Check if there's an error in the response
1010
+ if (data.error) {
1011
+ debugLog('Error in API response', data.error);
1012
+ adsetDetailsElement.textContent = JSON.stringify({
1013
+ error: "Error fetching ad set details",
1014
+ details: typeof data.error === 'object' ?
1015
+ data.error.message || JSON.stringify(data.error) :
1016
+ data.error
1017
+ }, null, 2);
1018
+ } else {
1019
+ // No errors, display the ad set details
1020
+ debugLog('Successfully fetched ad set details');
1021
+ detailsElement.style.display = 'block';
1022
+ adsetDetailsElement.textContent = JSON.stringify(data, null, 2);
1023
+ }
1024
+ } catch (error) {
1025
+ debugLog('Error in fetchAdSetDetails', error);
1026
+ console.error('Error fetching ad set details:', error);
1027
+ const adsetDetailsElement = document.getElementById('adset-details');
1028
+ if (adsetDetailsElement) {
1029
+ adsetDetailsElement.textContent = "Error fetching ad set details: " + error.message;
1030
+ }
1031
+ }
1032
+ }
1033
+
1034
+ // Function to fetch the ad set details and check if frequency_control_specs was updated
1035
+ async function verifyUpdate() {
1036
+ try {
1037
+ debugLog('Starting verification of update');
1038
+ const apiUrl = '/api/adset?' + new URLSearchParams({
1039
+ adset_id: '""" + adset_id + """',
1040
+ token: '""" + token + """'
1041
+ });
1042
+
1043
+ debugLog('Verifying update from URL', apiUrl);
1044
+ const response = await fetch(apiUrl);
1045
+
1046
+ // Log raw response
1047
+ const responseText = await response.text();
1048
+ debugLog('Raw verification response text', responseText);
1049
+ document.getElementById('raw-response').textContent = responseText;
1050
+
1051
+ // Parse JSON
1052
+ let data;
1053
+ try {
1054
+ data = JSON.parse(responseText);
1055
+ debugLog('Parsed verification response', data);
1056
+ } catch (parseError) {
1057
+ debugLog('Error parsing JSON verification response', parseError);
1058
+ throw new Error(`Failed to parse verification response: ${parseError.message}`);
1059
+ }
1060
+
1061
+ const statusElement = document.getElementById('status');
1062
+ const detailsElement = document.getElementById('details');
1063
+ const adsetDetailsElement = document.getElementById('adset-details');
1064
+
1065
+ detailsElement.style.display = 'block';
1066
+ adsetDetailsElement.textContent = JSON.stringify(data, null, 2);
1067
+
1068
+ // Check if there's an error in the response
1069
+ if (data.error) {
1070
+ debugLog('Error in verification response', data.error);
1071
+ statusElement.classList.remove('loading');
1072
+ statusElement.classList.add('error');
1073
+
1074
+ // Extract error message from various possible formats
1075
+ let errorMessage = "Unknown error occurred";
1076
+
1077
+ if (typeof data.error === 'string') {
1078
+ errorMessage = data.error;
1079
+ debugLog('Error is string', errorMessage);
1080
+ } else if (data.error.message) {
1081
+ errorMessage = data.error.message;
1082
+ debugLog('Error has message property', errorMessage);
1083
+ } else if (data.error.error_message) {
1084
+ errorMessage = data.error.error_message;
1085
+ debugLog('Error has error_message property', errorMessage);
1086
+ } else {
1087
+ debugLog('Error format unknown', data.error);
1088
+ }
1089
+
1090
+ // Check if this is a Special Ad Category error
1091
+ if (errorMessage.includes("Special Ad Category") ||
1092
+ errorMessage.includes("Advantage+") ||
1093
+ errorMessage.includes("advantage_audience")) {
1094
+
1095
+ // Format special ad category errors with better explanation
1096
+ debugLog('Displaying Special Ad Category verification error');
1097
+ statusElement.innerHTML = `
1098
+ <h3>❌ Special Ad Category Restriction</h3>
1099
+ <p>${errorMessage}</p>
1100
+ <div class="note" style="margin-top:10px">
1101
+ <strong>What does this mean?</strong><br>
1102
+ Meta restricts certain targeting features like Advantage+ audience for ads in Special Ad Categories
1103
+ (housing, employment, credit, social issues, etc.). You need to use standard targeting options instead.
1104
+ </div>
1105
+ <div class="fix-suggestion">
1106
+ <strong>How to fix:</strong><br>
1107
+ To update this ad set, try setting <span class="code-block">targeting.targeting_automation.advantage_audience</span> to <span class="code-block">0</span> instead of <span class="code-block">1</span>.
1108
+ </div>
1109
+ `;
1110
+ } else {
1111
+ debugLog('Displaying standard verification error');
1112
+ statusElement.innerHTML = `
1113
+ <h3>❌ Error retrieving ad set details</h3>
1114
+ <p>${errorMessage}</p>
1115
+ <div class="raw-error" style="margin-top: 10px;">
1116
+ <strong>Raw Error:</strong>
1117
+ <pre>${JSON.stringify(data.error, null, 2)}</pre>
1118
+ </div>
1119
+ `;
1120
+ }
1121
+ } else {
1122
+ // Update success message to reflect API visibility limitations
1123
+ debugLog('Verification successful');
1124
+ statusElement.classList.remove('loading');
1125
+ statusElement.classList.add('success');
1126
+ statusElement.innerHTML = '✅ Update request was processed successfully. Please verify the changes in Meta Ads Manager UI or monitor ad performance metrics.';
1127
+ }
1128
+ } catch (error) {
1129
+ debugLog('Error in verifyUpdate', error);
1130
+ const statusElement = document.getElementById('status');
1131
+ statusElement.classList.remove('loading');
1132
+ statusElement.classList.add('error');
1133
+ statusElement.innerHTML = `
1134
+ <h3>❌ Error verifying update</h3>
1135
+ <p>${error.message}</p>
1136
+ <pre>${error.stack}</pre>
1137
+ `;
1138
+ }
1139
+ }
1140
+ </script>
1141
+ </body>
1142
+ </html>
1143
+ """
1144
+ self.wfile.write(html.encode('utf-8'))
1145
+ return
576
1146
 
577
- if action == "approve":
1147
+ elif self.path.startswith("/api/adset"):
1148
+ # Parse query parameters
1149
+ query = parse_qs(urlparse(self.path).query)
578
1150
  adset_id = query.get("adset_id", [""])[0]
579
1151
  token = query.get("token", [""])[0]
580
- changes = query.get("changes", ["{}"])[0]
581
-
582
- # Store the approval in a global variable for the main thread to process
583
- global update_confirmation
584
- update_confirmation = {
585
- "approved": True,
586
- "adset_id": adset_id,
587
- "token": token,
588
- "changes": changes
589
- }
590
1152
 
591
- # Prepare the API call to actually execute the update
592
1153
  from .api import make_api_request
593
1154
 
594
- # Function to perform the actual update
595
- async def perform_update():
1155
+ # Call the Graph API directly
1156
+ async def get_adset_data():
596
1157
  try:
597
- changes_dict = json.loads(changes)
598
1158
  endpoint = f"{adset_id}"
1159
+ params = {
1160
+ "fields": "id,name,campaign_id,status,daily_budget,lifetime_budget,targeting,bid_amount,bid_strategy,optimization_goal,billing_event,start_time,end_time,created_time,updated_time,attribution_spec,destination_type,promoted_object,pacing_type,budget_remaining,frequency_control_specs"
1161
+ }
599
1162
 
600
- # Create a copy of changes_dict for the API call
601
- api_params = dict(changes_dict)
602
- api_params["access_token"] = token
1163
+ result = await make_api_request(endpoint, token, params)
603
1164
 
604
- # Make the API request to update the ad set
605
- result = await make_api_request(endpoint, token, api_params, method="POST")
606
- return {"status": "approved", "api_result": result}
1165
+ # Check if result is a string (possibly an error message)
1166
+ if isinstance(result, str):
1167
+ try:
1168
+ # Try to parse as JSON
1169
+ parsed_result = json.loads(result)
1170
+ return parsed_result
1171
+ except json.JSONDecodeError:
1172
+ # Return error object if can't parse as JSON
1173
+ return {"error": {"message": result}}
1174
+
1175
+ # If the result is None, return an error object
1176
+ if result is None:
1177
+ return {"error": {"message": "Empty response from API"}}
1178
+
1179
+ return result
607
1180
  except Exception as e:
608
- return {"status": "error", "error": str(e)}
1181
+ logger.error(f"Error in get_adset_data: {str(e)}")
1182
+ return {"error": {"message": f"Error fetching ad set data: {str(e)}"}}
609
1183
 
610
1184
  # Run the async function
611
- try:
612
- loop = asyncio.new_event_loop()
613
- asyncio.set_event_loop(loop)
614
- result = loop.run_until_complete(perform_update())
615
- loop.close()
616
-
617
- self.wfile.write(json.dumps(result).encode())
618
- except Exception as e:
619
- self.wfile.write(json.dumps({"status": "error", "error": str(e)}).encode())
620
- else:
621
- # Store the cancellation
622
- update_confirmation = {
623
- "approved": False
624
- }
625
- self.wfile.write(json.dumps({"status": "cancelled"}).encode())
626
- return
627
-
628
- if self.path.startswith("/token"):
629
- # Extract token from query params
630
- query = parse_qs(urlparse(self.path).query)
631
- token_container["token"] = query.get("token", [""])[0]
632
-
633
- if "expires_in" in query:
634
- try:
635
- token_container["expires_in"] = int(query.get("expires_in", ["0"])[0])
636
- except ValueError:
637
- token_container["expires_in"] = None
638
-
639
- # Send success response
640
- self.send_response(200)
641
- self.send_header("Content-type", "text/plain")
642
- self.end_headers()
643
- self.wfile.write(b"Token received")
644
-
645
- # Process the token (save it) immediately
646
- if token_container["token"]:
647
- # Create token info and save to cache
648
- logger.info("Token received in callback handler, attempting to save to cache")
649
- token_info = TokenInfo(
650
- access_token=token_container["token"],
651
- expires_in=token_container["expires_in"]
652
- )
1185
+ loop = asyncio.new_event_loop()
1186
+ asyncio.set_event_loop(loop)
1187
+ result = loop.run_until_complete(get_adset_data())
1188
+ loop.close()
653
1189
 
654
- try:
655
- # Set the token info in the auth_manager first
656
- global auth_manager
657
- auth_manager.token_info = token_info
658
- logger.info(f"Token info set in auth_manager, expires in {token_info.expires_in} seconds")
659
-
660
- # Save to cache
661
- auth_manager._save_token_to_cache()
662
- logger.info(f"Token successfully saved to cache at {auth_manager._get_token_cache_path()}")
663
- except Exception as e:
664
- logger.error(f"Error saving token to cache: {e}")
1190
+ # Return the result
1191
+ self.send_response(200)
1192
+ self.send_header("Content-type", "application/json")
1193
+ self.end_headers()
1194
+ self.wfile.write(json.dumps(result, indent=2).encode())
1195
+ return
1196
+
1197
+ elif self.path.startswith("/update-confirm"):
1198
+ # Handle update confirmation response
1199
+ query = parse_qs(urlparse(self.path).query)
1200
+ action = query.get("action", [""])[0]
665
1201
 
666
- # Reset auth needed flag
667
- needs_authentication = False
1202
+ self.send_response(200)
1203
+ self.send_header("Content-type", "application/json")
1204
+ self.end_headers()
668
1205
 
669
- return token_container["token"]
1206
+ if action == "approve":
1207
+ adset_id = query.get("adset_id", [""])[0]
1208
+ token = query.get("token", [""])[0]
1209
+ changes = query.get("changes", ["{}"])[0]
1210
+
1211
+ # Store the approval in a global variable for the main thread to process
1212
+ global update_confirmation
1213
+ update_confirmation = {
1214
+ "approved": True,
1215
+ "adset_id": adset_id,
1216
+ "token": token,
1217
+ "changes": changes
1218
+ }
1219
+
1220
+ # Prepare the API call to actually execute the update
1221
+ from .api import make_api_request
1222
+
1223
+ # Function to perform the actual update
1224
+ async def perform_update():
1225
+ try:
1226
+ # Handle potential multiple encoding of JSON
1227
+ decoded_changes = changes
1228
+ # Try multiple decode attempts to handle various encoding scenarios
1229
+ for _ in range(3): # Try up to 3 levels of decoding
1230
+ try:
1231
+ # Try to parse as JSON
1232
+ changes_obj = json.loads(decoded_changes)
1233
+ # If we got a string back, we need to decode again
1234
+ if isinstance(changes_obj, str):
1235
+ decoded_changes = changes_obj
1236
+ continue
1237
+ else:
1238
+ # We have a dictionary, break the loop
1239
+ changes_dict = changes_obj
1240
+ break
1241
+ except json.JSONDecodeError:
1242
+ # Try unescaping first
1243
+ import html
1244
+ decoded_changes = html.unescape(decoded_changes)
1245
+ try:
1246
+ changes_dict = json.loads(decoded_changes)
1247
+ break
1248
+ except:
1249
+ # Failed to parse, will try again in the next iteration
1250
+ pass
1251
+ else:
1252
+ # If we got here, we couldn't parse the JSON
1253
+ return {"status": "error", "error": f"Failed to decode changes JSON: {changes}"}
1254
+
1255
+ endpoint = f"{adset_id}"
1256
+
1257
+ # Create API parameters properly
1258
+ api_params = {}
1259
+
1260
+ # Add each change parameter
1261
+ for key, value in changes_dict.items():
1262
+ api_params[key] = value
1263
+
1264
+ # Add the access token
1265
+ api_params["access_token"] = token
1266
+
1267
+ # Log what we're about to send
1268
+ logger.info(f"Sending update to Meta API for ad set {adset_id}")
1269
+ logger.info(f"Parameters: {json.dumps(api_params)}")
1270
+
1271
+ # Make the API request to update the ad set
1272
+ result = await make_api_request(endpoint, token, api_params, method="POST")
1273
+
1274
+ # Log the result
1275
+ logger.info(f"Meta API update result: {json.dumps(result) if isinstance(result, dict) else result}")
1276
+
1277
+ # Handle various result formats
1278
+ if result is None:
1279
+ logger.error("Empty response from Meta API")
1280
+ return {"status": "error", "error": "Empty response from Meta API"}
1281
+
1282
+ # Check if the result contains an error
1283
+ if isinstance(result, dict) and 'error' in result:
1284
+ # Extract detailed error message from Meta API
1285
+ error_obj = result['error']
1286
+ error_msg = error_obj.get('message', 'Unknown API error')
1287
+ detailed_error = ""
1288
+
1289
+ # Check for more detailed error messages
1290
+ if 'error_user_msg' in error_obj and error_obj['error_user_msg']:
1291
+ detailed_error = error_obj['error_user_msg']
1292
+ logger.error(f"Meta API user-facing error message: {detailed_error}")
1293
+
1294
+ # Extract error data if available
1295
+ error_specs = []
1296
+ if 'error_data' in error_obj and isinstance(error_obj['error_data'], str):
1297
+ try:
1298
+ error_data = json.loads(error_obj['error_data'])
1299
+ if 'blame_field_specs' in error_data and error_data['blame_field_specs']:
1300
+ blame_specs = error_data['blame_field_specs']
1301
+ if isinstance(blame_specs, list) and blame_specs:
1302
+ if isinstance(blame_specs[0], list):
1303
+ error_specs = [msg for msg in blame_specs[0] if msg]
1304
+ else:
1305
+ error_specs = [str(spec) for spec in blame_specs if spec]
1306
+
1307
+ if error_specs:
1308
+ logger.error(f"Meta API blame field specs: {'; '.join(error_specs)}")
1309
+ except Exception as e:
1310
+ logger.error(f"Error parsing error_data: {e}")
1311
+
1312
+ # Construct most descriptive error message
1313
+ if detailed_error:
1314
+ error_msg = detailed_error
1315
+ elif error_specs:
1316
+ error_msg = "; ".join(error_specs)
1317
+
1318
+ # Log the detailed error information
1319
+ logger.error(f"Meta API error: {error_msg}")
1320
+ logger.error(f"Full error object: {json.dumps(error_obj)}")
1321
+
1322
+ return {
1323
+ "status": "error",
1324
+ "error": error_msg,
1325
+ "api_error": result['error'],
1326
+ "detailed_errors": error_specs,
1327
+ "full_response": result
1328
+ }
1329
+
1330
+ # Handle string results (which might be error messages)
1331
+ if isinstance(result, str):
1332
+ try:
1333
+ # Try to parse as JSON
1334
+ result_obj = json.loads(result)
1335
+ if isinstance(result_obj, dict) and 'error' in result_obj:
1336
+ return {"status": "error", "error": result_obj['error'].get('message', 'Unknown API error')}
1337
+ except:
1338
+ # If not parseable as JSON, return as error message
1339
+ return {"status": "error", "error": result}
1340
+
1341
+ # If we got here, assume success
1342
+ return {"status": "approved", "api_result": result}
1343
+ except Exception as e:
1344
+ # Log the exception for debugging
1345
+ logger.error(f"Error in perform_update: {str(e)}")
1346
+ import traceback
1347
+ logger.error(traceback.format_exc())
1348
+ return {"status": "error", "error": str(e)}
1349
+
1350
+ # Run the async function
1351
+ try:
1352
+ loop = asyncio.new_event_loop()
1353
+ asyncio.set_event_loop(loop)
1354
+ result = loop.run_until_complete(perform_update())
1355
+ loop.close()
1356
+
1357
+ # Ensure result is a dictionary
1358
+ if not isinstance(result, dict):
1359
+ logger.error(f"Unexpected result type: {type(result)}")
1360
+ self.wfile.write(json.dumps({"status": "error", "error": str(result)}).encode())
1361
+ return
1362
+
1363
+ # Check if the API call returned an error
1364
+ if result.get("status") == "error":
1365
+ error_message = result.get("error", "Unknown error")
1366
+ detailed_errors = result.get("detailed_errors", [])
1367
+
1368
+ # Log the detailed error
1369
+ logger.error(f"Meta API error during ad set update: {error_message}")
1370
+ if "api_error" in result:
1371
+ logger.error(f"Detailed API error: {json.dumps(result['api_error'])}")
1372
+
1373
+ # Prepare error response with all available details
1374
+ error_response = {
1375
+ "status": "error",
1376
+ "error": error_message,
1377
+ "errorDetails": detailed_errors
1378
+ }
1379
+
1380
+ # Include the full API error object for complete details
1381
+ if "api_error" in result:
1382
+ error_response["apiError"] = result["api_error"]
1383
+
1384
+ # Include the full response if available
1385
+ if "full_response" in result:
1386
+ error_response["fullResponse"] = result["full_response"]
1387
+
1388
+ logger.info(f"Returning error response: {json.dumps(error_response)}")
1389
+ self.wfile.write(json.dumps(error_response).encode())
1390
+ else:
1391
+ logger.info("Update successful, returning result")
1392
+ self.wfile.write(json.dumps(result).encode())
1393
+ except Exception as e:
1394
+ logger.error(f"Exception in update-confirm handler: {str(e)}")
1395
+ import traceback
1396
+ logger.error(traceback.format_exc())
1397
+ self.wfile.write(json.dumps({
1398
+ "status": "error",
1399
+ "error": str(e),
1400
+ "traceback": traceback.format_exc()
1401
+ }).encode())
1402
+ else:
1403
+ # Store the cancellation
1404
+ update_confirmation = {
1405
+ "approved": False
1406
+ }
1407
+ self.wfile.write(json.dumps({"status": "cancelled"}).encode())
1408
+ return
1409
+
670
1410
  else:
671
- logger.warning("Received empty token in callback")
672
- needs_authentication = True
673
- return None
1411
+ # If no matching path, return a 404 error
1412
+ self.send_response(404)
1413
+ self.end_headers()
1414
+ except Exception as e:
1415
+ print(f"Error processing request: {e}")
1416
+ self.send_response(500)
1417
+ self.end_headers()
674
1418
 
675
1419
  # Silence server logs
676
1420
  def log_message(self, format, *args):
@@ -761,37 +1505,123 @@ def start_callback_server():
761
1505
 
762
1506
  def process_token_response(token_container):
763
1507
  """Process the token response from Facebook."""
764
- global needs_authentication
1508
+ global needs_authentication, auth_manager
765
1509
 
766
1510
  if token_container and token_container.get('token'):
767
1511
  logger.info("Processing token response from Facebook OAuth")
768
- token_info = TokenInfo(
769
- access_token=token_container['token'],
770
- expires_in=token_container.get('expires_in', 0)
771
- )
772
1512
 
773
- try:
774
- global auth_manager
775
- auth_manager.token_info = token_info
776
- logger.info(f"Token info set in auth_manager, expires in {token_info.expires_in} seconds")
777
- except NameError:
778
- logger.error("auth_manager not defined when trying to process token")
1513
+ # Exchange the short-lived token for a long-lived token
1514
+ short_lived_token = token_container['token']
1515
+ long_lived_token_info = exchange_token_for_long_lived(short_lived_token)
1516
+
1517
+ if long_lived_token_info:
1518
+ logger.info(f"Successfully exchanged for long-lived token (expires in {long_lived_token_info.expires_in} seconds)")
779
1519
 
780
- try:
781
- logger.info("Attempting to save token to cache")
782
- auth_manager._save_token_to_cache()
783
- logger.info(f"Token successfully saved to cache at {auth_manager._get_token_cache_path()}")
784
- except Exception as e:
785
- logger.error(f"Error saving token to cache: {e}")
1520
+ try:
1521
+ auth_manager.token_info = long_lived_token_info
1522
+ logger.info(f"Long-lived token info set in auth_manager, expires in {long_lived_token_info.expires_in} seconds")
1523
+ except NameError:
1524
+ logger.error("auth_manager not defined when trying to process token")
1525
+
1526
+ try:
1527
+ logger.info("Attempting to save long-lived token to cache")
1528
+ auth_manager._save_token_to_cache()
1529
+ logger.info(f"Long-lived token successfully saved to cache at {auth_manager._get_token_cache_path()}")
1530
+ except Exception as e:
1531
+ logger.error(f"Error saving token to cache: {e}")
1532
+
1533
+ needs_authentication = False
1534
+ return True
1535
+ else:
1536
+ # Fall back to the short-lived token if exchange fails
1537
+ logger.warning("Failed to exchange for long-lived token, using short-lived token instead")
1538
+ token_info = TokenInfo(
1539
+ access_token=short_lived_token,
1540
+ expires_in=token_container.get('expires_in', 0)
1541
+ )
786
1542
 
787
- needs_authentication = False
788
- return True
1543
+ try:
1544
+ auth_manager.token_info = token_info
1545
+ logger.info(f"Short-lived token info set in auth_manager, expires in {token_info.expires_in} seconds")
1546
+ except NameError:
1547
+ logger.error("auth_manager not defined when trying to process token")
1548
+
1549
+ try:
1550
+ logger.info("Attempting to save token to cache")
1551
+ auth_manager._save_token_to_cache()
1552
+ logger.info(f"Token successfully saved to cache at {auth_manager._get_token_cache_path()}")
1553
+ except Exception as e:
1554
+ logger.error(f"Error saving token to cache: {e}")
1555
+
1556
+ needs_authentication = False
1557
+ return True
789
1558
  else:
790
1559
  logger.warning("Received empty token in process_token_response")
791
1560
  needs_authentication = True
792
1561
  return False
793
1562
 
794
1563
 
1564
+ def exchange_token_for_long_lived(short_lived_token):
1565
+ """
1566
+ Exchange a short-lived token for a long-lived token (60 days validity).
1567
+
1568
+ Args:
1569
+ short_lived_token: The short-lived access token received from OAuth flow
1570
+
1571
+ Returns:
1572
+ TokenInfo object with the long-lived token, or None if exchange failed
1573
+ """
1574
+ logger.info("Attempting to exchange short-lived token for long-lived token")
1575
+
1576
+ try:
1577
+ # Get the app ID from the configuration
1578
+ app_id = meta_config.get_app_id()
1579
+
1580
+ # Get the app secret - this should be securely stored
1581
+ app_secret = os.environ.get("META_APP_SECRET", "")
1582
+
1583
+ if not app_id or not app_secret:
1584
+ logger.error("Missing app_id or app_secret for token exchange")
1585
+ return None
1586
+
1587
+ # Make the API request to exchange the token
1588
+ url = "https://graph.facebook.com/v18.0/oauth/access_token"
1589
+ params = {
1590
+ "grant_type": "fb_exchange_token",
1591
+ "client_id": app_id,
1592
+ "client_secret": app_secret,
1593
+ "fb_exchange_token": short_lived_token
1594
+ }
1595
+
1596
+ logger.debug(f"Making token exchange request to {url}")
1597
+ response = requests.get(url, params=params)
1598
+
1599
+ if response.status_code == 200:
1600
+ data = response.json()
1601
+ logger.debug(f"Token exchange response: {data}")
1602
+
1603
+ # Create TokenInfo from the response
1604
+ # The response includes access_token and expires_in (in seconds)
1605
+ new_token = data.get("access_token")
1606
+ expires_in = data.get("expires_in")
1607
+
1608
+ if new_token:
1609
+ logger.info(f"Received long-lived token, expires in {expires_in} seconds (~{expires_in//86400} days)")
1610
+ return TokenInfo(
1611
+ access_token=new_token,
1612
+ expires_in=expires_in
1613
+ )
1614
+ else:
1615
+ logger.error("No access_token in exchange response")
1616
+ return None
1617
+ else:
1618
+ logger.error(f"Token exchange failed with status {response.status_code}: {response.text}")
1619
+ return None
1620
+ except Exception as e:
1621
+ logger.error(f"Error exchanging token: {e}")
1622
+ return None
1623
+
1624
+
795
1625
  async def get_current_access_token() -> Optional[str]:
796
1626
  """Get the current access token from auth manager"""
797
1627
  # Use the singleton auth manager