meta-ads-mcp 0.2.4__py3-none-any.whl → 0.2.6__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.
@@ -0,0 +1,958 @@
1
+ """Callback server for Meta Ads API authentication and confirmations."""
2
+
3
+ import threading
4
+ import socket
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import webbrowser
9
+ import os
10
+ from http.server import HTTPServer, BaseHTTPRequestHandler
11
+ from urllib.parse import urlparse, parse_qs, quote
12
+ from typing import Dict, Any, Optional
13
+
14
+ from .utils import logger
15
+
16
+ # Global token container for communication between threads
17
+ token_container = {"token": None, "expires_in": None, "user_id": None}
18
+
19
+ # Global container for update confirmations
20
+ update_confirmation = {"approved": False}
21
+
22
+ # Global variables for server thread and state
23
+ callback_server_thread = None
24
+ callback_server_lock = threading.Lock()
25
+ callback_server_running = False
26
+ callback_server_port = None
27
+
28
+
29
+ class CallbackHandler(BaseHTTPRequestHandler):
30
+ def do_GET(self):
31
+ try:
32
+ # Print path for debugging
33
+ print(f"Callback server received request: {self.path}")
34
+
35
+ if self.path.startswith("/callback"):
36
+ self._handle_oauth_callback()
37
+ elif self.path.startswith("/token"):
38
+ self._handle_token()
39
+ elif self.path.startswith("/confirm-update"):
40
+ self._handle_update_confirmation()
41
+ elif self.path.startswith("/update-confirm"):
42
+ self._handle_update_execution()
43
+ elif self.path.startswith("/verify-update"):
44
+ self._handle_update_verification()
45
+ elif self.path.startswith("/api/adset"):
46
+ self._handle_adset_api()
47
+ elif self.path.startswith("/api/ad"):
48
+ self._handle_ad_api()
49
+ else:
50
+ # If no matching path, return a 404 error
51
+ self.send_response(404)
52
+ self.end_headers()
53
+ except Exception as e:
54
+ print(f"Error processing request: {e}")
55
+ self.send_response(500)
56
+ self.end_headers()
57
+
58
+ def _handle_oauth_callback(self):
59
+ """Handle the OAuth callback from Meta"""
60
+ self.send_response(200)
61
+ self.send_header("Content-type", "text/html")
62
+ self.end_headers()
63
+
64
+ callback_html = """
65
+ <!DOCTYPE html>
66
+ <html>
67
+ <head>
68
+ <title>Authentication Successful</title>
69
+ <style>
70
+ body {
71
+ font-family: Arial, sans-serif;
72
+ line-height: 1.6;
73
+ color: #333;
74
+ max-width: 800px;
75
+ margin: 0 auto;
76
+ padding: 20px;
77
+ }
78
+ .success {
79
+ color: #4CAF50;
80
+ font-size: 24px;
81
+ margin-bottom: 20px;
82
+ }
83
+ .info {
84
+ background-color: #f5f5f5;
85
+ padding: 15px;
86
+ border-radius: 4px;
87
+ margin-bottom: 20px;
88
+ }
89
+ .button {
90
+ background-color: #4CAF50;
91
+ color: white;
92
+ padding: 10px 15px;
93
+ border: none;
94
+ border-radius: 4px;
95
+ cursor: pointer;
96
+ }
97
+ </style>
98
+ </head>
99
+ <body>
100
+ <div class="success">Authentication Successful!</div>
101
+ <div class="info">
102
+ <p>Your Meta Ads API token has been received.</p>
103
+ <p>You can now close this window and return to the application.</p>
104
+ </div>
105
+ <button class="button" onclick="window.close()">Close Window</button>
106
+
107
+ <script>
108
+ // Function to parse URL parameters including fragments
109
+ function parseURL(url) {
110
+ var params = {};
111
+ var parser = document.createElement('a');
112
+ parser.href = url;
113
+
114
+ // Parse fragment parameters
115
+ var fragment = parser.hash.substring(1);
116
+ var fragmentParams = fragment.split('&');
117
+
118
+ for (var i = 0; i < fragmentParams.length; i++) {
119
+ var pair = fragmentParams[i].split('=');
120
+ params[pair[0]] = decodeURIComponent(pair[1]);
121
+ }
122
+
123
+ return params;
124
+ }
125
+
126
+ // Parse the URL to get the access token
127
+ var params = parseURL(window.location.href);
128
+ var token = params['access_token'];
129
+ var expires_in = params['expires_in'];
130
+
131
+ // Send the token to the server
132
+ if (token) {
133
+ // Create XMLHttpRequest object
134
+ var xhr = new XMLHttpRequest();
135
+
136
+ // Configure it to make a GET request to the /token endpoint
137
+ xhr.open('GET', '/token?token=' + encodeURIComponent(token) +
138
+ '&expires_in=' + encodeURIComponent(expires_in), true);
139
+
140
+ // Set up a handler for when the request is complete
141
+ xhr.onload = function() {
142
+ if (xhr.status === 200) {
143
+ console.log('Token successfully sent to server');
144
+ } else {
145
+ console.error('Failed to send token to server');
146
+ }
147
+ };
148
+
149
+ // Send the request
150
+ xhr.send();
151
+ } else {
152
+ console.error('No token found in URL');
153
+ document.body.innerHTML += '<div style="color: red; margin-top: 20px;">Error: No authentication token found. Please try again.</div>';
154
+ }
155
+ </script>
156
+ </body>
157
+ </html>
158
+ """
159
+ self.wfile.write(callback_html.encode())
160
+
161
+ def _handle_token(self):
162
+ """Handle the token received from the callback"""
163
+ # Extract token from query params
164
+ query = parse_qs(urlparse(self.path).query)
165
+ token_container["token"] = query.get("token", [""])[0]
166
+
167
+ if "expires_in" in query:
168
+ try:
169
+ token_container["expires_in"] = int(query.get("expires_in", ["0"])[0])
170
+ except ValueError:
171
+ token_container["expires_in"] = None
172
+
173
+ # Send success response
174
+ self.send_response(200)
175
+ self.send_header("Content-type", "text/plain")
176
+ self.end_headers()
177
+ self.wfile.write(b"Token received")
178
+
179
+ # The actual token processing is now handled by the auth module
180
+ # that imports this module and accesses token_container
181
+
182
+ def _handle_update_confirmation(self):
183
+ """Handle the update confirmation page"""
184
+ # Extract query parameters
185
+ query = parse_qs(urlparse(self.path).query)
186
+ adset_id = query.get("adset_id", [""])[0]
187
+ ad_id = query.get("ad_id", [""])[0]
188
+ token = query.get("token", [""])[0]
189
+ changes = query.get("changes", ["{}"])[0]
190
+
191
+ # Determine what type of object we're updating
192
+ object_id = ad_id if ad_id else adset_id
193
+ object_type = "Ad" if ad_id else "Ad Set"
194
+
195
+ try:
196
+ changes_dict = json.loads(changes)
197
+ except json.JSONDecodeError:
198
+ changes_dict = {}
199
+
200
+ # Return confirmation page
201
+ self.send_response(200)
202
+ self.send_header("Content-type", "text/html; charset=utf-8")
203
+ self.end_headers()
204
+
205
+ # Create a properly escaped JSON string for JavaScript
206
+ escaped_changes = json.dumps(changes).replace("'", "\\'").replace('"', '\\"')
207
+
208
+ html = """
209
+ <html>
210
+ <head>
211
+ <title>Confirm """ + object_type + """ Update</title>
212
+ <meta charset="utf-8">
213
+ <style>
214
+ body { font-family: Arial, sans-serif; margin: 20px; max-width: 1000px; margin: 0 auto; }
215
+ .warning { color: #d73a49; margin: 20px 0; padding: 15px; border-left: 4px solid #d73a49; background-color: #fff8f8; }
216
+ .changes { background: #f6f8fa; padding: 15px; border-radius: 6px; }
217
+ .buttons { margin-top: 20px; }
218
+ button { padding: 10px 20px; margin-right: 10px; border-radius: 6px; cursor: pointer; }
219
+ .approve { background: #2ea44f; color: white; border: none; }
220
+ .cancel { background: #d73a49; color: white; border: none; }
221
+ .diff-table { width: 100%; border-collapse: collapse; margin: 15px 0; }
222
+ .diff-table td { padding: 8px; border: 1px solid #ddd; }
223
+ .diff-table .header { background: #f1f8ff; font-weight: bold; }
224
+ .status { padding: 15px; margin-top: 20px; border-radius: 6px; display: none; }
225
+ .success { background-color: #e6ffed; border: 1px solid #2ea44f; color: #22863a; }
226
+ .error { background-color: #ffeef0; border: 1px solid #d73a49; color: #d73a49; }
227
+ pre { white-space: pre-wrap; word-break: break-all; }
228
+ </style>
229
+ </head>
230
+ <body>
231
+ <h1>Confirm """ + object_type + """ Update</h1>
232
+ <p>You are about to update """ + object_type + """: <strong>""" + object_id + """</strong></p>
233
+
234
+ <div class="warning">
235
+ <p><strong>Warning:</strong> This action will directly update your """ + object_type.lower() + """ in Meta Ads. Please review the changes carefully before approving.</p>
236
+ </div>
237
+
238
+ <div class="changes">
239
+ <h3>Changes to apply:</h3>
240
+ <table class="diff-table">
241
+ <tr class="header">
242
+ <td>Field</td>
243
+ <td>New Value</td>
244
+ <td>Description</td>
245
+ </tr>
246
+ """
247
+
248
+ # Generate table rows for each change
249
+ for k, v in changes_dict.items():
250
+ description = ""
251
+ if k == "frequency_control_specs" and isinstance(v, list) and len(v) > 0:
252
+ spec = v[0]
253
+ if all(key in spec for key in ["event", "interval_days", "max_frequency"]):
254
+ description = f"Cap to {spec['max_frequency']} {spec['event'].lower()} per {spec['interval_days']} days"
255
+
256
+ # Special handling for targeting_automation
257
+ elif k == "targeting" and isinstance(v, dict) and "targeting_automation" in v:
258
+ targeting_auto = v.get("targeting_automation", {})
259
+ if "advantage_audience" in targeting_auto:
260
+ audience_value = targeting_auto["advantage_audience"]
261
+ description = f"Set Advantage+ audience to {'ON' if audience_value == 1 else 'OFF'}"
262
+ if audience_value == 1:
263
+ description += " (may be restricted for Special Ad Categories)"
264
+
265
+ # Format the value for display
266
+ display_value = json.dumps(v, indent=2) if isinstance(v, (dict, list)) else str(v)
267
+
268
+ html += f"""
269
+ <tr>
270
+ <td>{k}</td>
271
+ <td><pre>{display_value}</pre></td>
272
+ <td>{description}</td>
273
+ </tr>
274
+ """
275
+
276
+ html += """
277
+ </table>
278
+ </div>
279
+
280
+ <div class="buttons">
281
+ <button class="approve" onclick="approveChanges()">Approve Changes</button>
282
+ <button class="cancel" onclick="cancelChanges()">Cancel</button>
283
+ </div>
284
+
285
+ <div id="status" class="status"></div>
286
+
287
+ <script>
288
+ // Get the approve button
289
+ const approveBtn = document.querySelector('.approve');
290
+ const cancelBtn = document.querySelector('.cancel');
291
+ const statusDiv = document.querySelector('.status');
292
+
293
+ let debugMode = false;
294
+ // Function to log debug messages
295
+ function debugLog(...args) {
296
+ if (debugMode) {
297
+ console.log(...args);
298
+ }
299
+ }
300
+
301
+ // Add event listeners to buttons
302
+ approveBtn.addEventListener('click', approveChanges);
303
+ cancelBtn.addEventListener('click', cancelChanges);
304
+
305
+ function showStatus(message, isError = false) {
306
+ statusDiv.textContent = message;
307
+ statusDiv.style.display = 'block';
308
+ statusDiv.className = 'status ' + (isError ? 'error' : 'success');
309
+ }
310
+
311
+ function approveChanges() {
312
+ // Disable buttons to prevent double-submission
313
+ const buttons = [approveBtn, cancelBtn];
314
+ buttons.forEach(button => button.disabled = true);
315
+
316
+ showStatus('Approving changes...');
317
+
318
+ // Determine which object type we're updating
319
+ const isAd = Boolean('""" + ad_id + """');
320
+ const objectType = isAd ? 'ad' : 'adset';
321
+ const objectId = '""" + object_id + """';
322
+
323
+ // Create parameters
324
+ const params = new URLSearchParams({
325
+ action: 'approve',
326
+ token: '""" + token + """',
327
+ changes: '""" + escaped_changes + """'
328
+ });
329
+
330
+ // Add the appropriate ID parameter based on object type
331
+ if (isAd) {
332
+ params.append('ad_id', objectId);
333
+ } else {
334
+ params.append('adset_id', objectId);
335
+ }
336
+
337
+ debugLog("Sending update request with params", {
338
+ objectType,
339
+ objectId,
340
+ changes: JSON.parse('""" + escaped_changes + """')
341
+ });
342
+
343
+ fetch('/update-confirm?' + params)
344
+ .then(response => response.json())
345
+ .then(data => {
346
+ debugLog("Update response data", data);
347
+ buttons.forEach(button => button.disabled = false);
348
+
349
+ // Handle result
350
+ if (data.status === 'error') {
351
+ let errorMessage = data.error || 'Unknown error';
352
+ const originalErrorDetails = data.api_error || {};
353
+
354
+ showStatus('Error: ' + errorMessage, true);
355
+
356
+ // Create a detailed error object for the verification page
357
+ const fullErrorData = {
358
+ message: errorMessage,
359
+ details: data.errorDetails || [],
360
+ apiError: originalErrorDetails || data.apiError || {},
361
+ fullResponse: data.fullResponse || {}
362
+ };
363
+
364
+ debugLog("Redirecting with error message", errorMessage);
365
+ debugLog("Full error data", fullErrorData);
366
+
367
+ // Encode the stringified error object
368
+ const encodedErrorData = encodeURIComponent(JSON.stringify(fullErrorData));
369
+
370
+ // Redirect to verification page with detailed error information
371
+ const errorParams = new URLSearchParams({
372
+ token: '""" + token + """',
373
+ error: errorMessage,
374
+ errorData: encodedErrorData
375
+ });
376
+
377
+ // Add the appropriate ID parameter based on object type
378
+ if (isAd) {
379
+ errorParams.append('ad_id', objectId);
380
+ } else {
381
+ errorParams.append('adset_id', objectId);
382
+ }
383
+
384
+ window.location.href = '/verify-update?' + errorParams;
385
+ } else {
386
+ showStatus('Changes approved and will be applied shortly!');
387
+ setTimeout(() => {
388
+ const verifyParams = new URLSearchParams({
389
+ token: '""" + token + """'
390
+ });
391
+
392
+ // Add the appropriate ID parameter based on object type
393
+ if (isAd) {
394
+ verifyParams.append('ad_id', objectId);
395
+ } else {
396
+ verifyParams.append('adset_id', objectId);
397
+ }
398
+
399
+ window.location.href = '/verify-update?' + verifyParams;
400
+ }, 3000);
401
+ }
402
+ })
403
+ .catch(error => {
404
+ debugLog("Fetch error", error);
405
+ showStatus('Error applying changes: ' + error, true);
406
+ buttons.forEach(button => button.disabled = false);
407
+ });
408
+ }
409
+
410
+ function cancelChanges() {
411
+ showStatus("Cancelling update...");
412
+
413
+ // Determine which object type we're updating
414
+ const isAd = Boolean('""" + ad_id + """');
415
+ const objectId = '""" + object_id + """';
416
+
417
+ // Create parameters
418
+ const params = new URLSearchParams({
419
+ action: 'cancel'
420
+ });
421
+
422
+ // Add the appropriate ID parameter based on object type
423
+ if (isAd) {
424
+ params.append('ad_id', objectId);
425
+ } else {
426
+ params.append('adset_id', objectId);
427
+ }
428
+
429
+ fetch('/update-confirm?' + params)
430
+ .then(() => {
431
+ showStatus('Update cancelled.');
432
+ setTimeout(() => window.close(), 2000);
433
+ });
434
+ }
435
+ </script>
436
+ </body>
437
+ </html>
438
+ """
439
+ self.wfile.write(html.encode('utf-8'))
440
+
441
+ def _handle_update_execution(self):
442
+ """Handle the update execution after user confirmation"""
443
+ # Handle update confirmation response
444
+ query = parse_qs(urlparse(self.path).query)
445
+ action = query.get("action", [""])[0]
446
+
447
+ self.send_response(200)
448
+ self.send_header("Content-type", "application/json")
449
+ self.end_headers()
450
+
451
+ if action == "approve":
452
+ adset_id = query.get("adset_id", [""])[0]
453
+ ad_id = query.get("ad_id", [""])[0]
454
+ token = query.get("token", [""])[0]
455
+ changes = query.get("changes", ["{}"])[0]
456
+
457
+ # Determine what type of object we're updating
458
+ object_id = ad_id if ad_id else adset_id
459
+ object_type = "ad" if ad_id else "adset"
460
+
461
+ # Store the approval in the global variable for the main thread to process
462
+ update_confirmation.clear()
463
+ update_confirmation.update({
464
+ "approved": True,
465
+ "object_id": object_id,
466
+ "object_type": object_type,
467
+ "token": token,
468
+ "changes": changes
469
+ })
470
+
471
+ # Process the update asynchronously
472
+ result = asyncio.run(self._perform_update(object_id, token, changes))
473
+ self.wfile.write(json.dumps(result).encode())
474
+ else:
475
+ # Store the cancellation
476
+ update_confirmation.clear()
477
+ update_confirmation.update({"approved": False})
478
+ self.wfile.write(json.dumps({"status": "cancelled"}).encode())
479
+
480
+ async def _perform_update(self, object_id, token, changes):
481
+ """Perform the actual update of the adset or ad"""
482
+ from .api import make_api_request
483
+
484
+ try:
485
+ # Handle potential multiple encoding of JSON
486
+ decoded_changes = changes
487
+ # Try multiple decode attempts to handle various encoding scenarios
488
+ for _ in range(3): # Try up to 3 levels of decoding
489
+ try:
490
+ # Try to parse as JSON
491
+ changes_obj = json.loads(decoded_changes)
492
+ # If we got a string back, we need to decode again
493
+ if isinstance(changes_obj, str):
494
+ decoded_changes = changes_obj
495
+ continue
496
+ else:
497
+ # We have a dictionary, break the loop
498
+ changes_dict = changes_obj
499
+ break
500
+ except json.JSONDecodeError:
501
+ # Try unescaping first
502
+ import html
503
+ decoded_changes = html.unescape(decoded_changes)
504
+ try:
505
+ changes_dict = json.loads(decoded_changes)
506
+ break
507
+ except:
508
+ # Failed to parse, will try again in the next iteration
509
+ pass
510
+ else:
511
+ # If we got here, we couldn't parse the JSON
512
+ return {"status": "error", "error": f"Failed to decode changes JSON: {changes}"}
513
+
514
+ endpoint = f"{object_id}"
515
+
516
+ # Create API parameters properly
517
+ api_params = {}
518
+
519
+ # Add each change parameter
520
+ for key, value in changes_dict.items():
521
+ api_params[key] = value
522
+
523
+ # Add the access token
524
+ api_params["access_token"] = token
525
+
526
+ # Log what we're about to send
527
+ object_type = "ad set" if object_id.startswith("23") else "ad" # Simple heuristic based on ID prefix
528
+ logger.info(f"Sending update to Meta API for {object_type} {object_id}")
529
+ logger.info(f"Parameters: {json.dumps(api_params)}")
530
+
531
+ # Make the API request to update the object
532
+ result = await make_api_request(endpoint, token, api_params, method="POST")
533
+
534
+ # Log the result
535
+ logger.info(f"Meta API update result: {json.dumps(result) if isinstance(result, dict) else result}")
536
+
537
+ # Handle various result formats
538
+ if result is None:
539
+ logger.error("Empty response from Meta API")
540
+ return {"status": "error", "error": "Empty response from Meta API"}
541
+
542
+ # Check if the result contains an error
543
+ if isinstance(result, dict) and 'error' in result:
544
+ # Extract detailed error message from Meta API
545
+ error_obj = result['error']
546
+ error_msg = error_obj.get('message', 'Unknown API error')
547
+ detailed_error = ""
548
+
549
+ # Check for more detailed error messages
550
+ if 'error_user_msg' in error_obj and error_obj['error_user_msg']:
551
+ detailed_error = error_obj['error_user_msg']
552
+ logger.error(f"Meta API user-facing error message: {detailed_error}")
553
+
554
+ # Extract error data if available
555
+ error_specs = []
556
+ if 'error_data' in error_obj and isinstance(error_obj['error_data'], str):
557
+ try:
558
+ error_data = json.loads(error_obj['error_data'])
559
+ if 'blame_field_specs' in error_data and error_data['blame_field_specs']:
560
+ blame_specs = error_data['blame_field_specs']
561
+ if isinstance(blame_specs, list) and blame_specs:
562
+ if isinstance(blame_specs[0], list):
563
+ error_specs = [msg for msg in blame_specs[0] if msg]
564
+ else:
565
+ error_specs = [str(spec) for spec in blame_specs if spec]
566
+
567
+ if error_specs:
568
+ logger.error(f"Meta API blame field specs: {'; '.join(error_specs)}")
569
+ except Exception as e:
570
+ logger.error(f"Error parsing error_data: {e}")
571
+
572
+ # Construct most descriptive error message
573
+ if detailed_error:
574
+ error_msg = detailed_error
575
+ elif error_specs:
576
+ error_msg = "; ".join(error_specs)
577
+
578
+ # Log the detailed error information
579
+ logger.error(f"Meta API error: {error_msg}")
580
+ logger.error(f"Full error object: {json.dumps(error_obj)}")
581
+
582
+ return {
583
+ "status": "error",
584
+ "error": error_msg,
585
+ "api_error": result['error'],
586
+ "detailed_errors": error_specs,
587
+ "full_response": result
588
+ }
589
+
590
+ # Handle string results (which might be error messages)
591
+ if isinstance(result, str):
592
+ try:
593
+ # Try to parse as JSON
594
+ result_obj = json.loads(result)
595
+ if isinstance(result_obj, dict) and 'error' in result_obj:
596
+ return {"status": "error", "error": result_obj['error'].get('message', 'Unknown API error')}
597
+ except:
598
+ # If not parseable as JSON, return as error message
599
+ return {"status": "error", "error": result}
600
+
601
+ # If we got here, assume success
602
+ return {"status": "approved", "api_result": result}
603
+ except Exception as e:
604
+ # Log the exception for debugging
605
+ logger.error(f"Error in perform_update: {str(e)}")
606
+ import traceback
607
+ logger.error(traceback.format_exc())
608
+ return {"status": "error", "error": str(e)}
609
+
610
+ def _handle_update_verification(self):
611
+ """Handle the verification page for updates"""
612
+ query = parse_qs(urlparse(self.path).query)
613
+ adset_id = query.get("adset_id", [""])[0]
614
+ ad_id = query.get("ad_id", [""])[0]
615
+ object_id = query.get("object_id", [""])[0]
616
+
617
+ # For backward compatibility - use object_id if available, otherwise check for adset_id or ad_id
618
+ if not object_id:
619
+ object_id = ad_id if ad_id else adset_id
620
+
621
+ object_type = query.get("object_type", [""])[0]
622
+ if not object_type:
623
+ object_type = "Ad" if ad_id else "Ad Set"
624
+
625
+ token = query.get("token", [""])[0]
626
+ error_message = query.get("error", [""])[0]
627
+ error_data_encoded = query.get("errorData", [""])[0]
628
+
629
+ # Respond with verification page
630
+ self.send_response(200)
631
+ self.send_header("Content-type", "text/html; charset=utf-8")
632
+ self.end_headers()
633
+
634
+ html = """
635
+ <!DOCTYPE html>
636
+ <html>
637
+ <head>
638
+ <title>Verification - """ + object_type + """ Update</title>
639
+ <meta charset="utf-8">
640
+ <style>
641
+ body {
642
+ font-family: Arial, sans-serif;
643
+ margin: 20px;
644
+ max-width: 1000px;
645
+ margin: 0 auto;
646
+ line-height: 1.6;
647
+ }
648
+ .header {
649
+ margin-bottom: 20px;
650
+ padding-bottom: 10px;
651
+ border-bottom: 1px solid #ccc;
652
+ }
653
+ .success {
654
+ color: #2ea44f;
655
+ background-color: #e6ffed;
656
+ padding: 15px;
657
+ border-radius: 6px;
658
+ margin: 20px 0;
659
+ }
660
+ .error {
661
+ color: #d73a49;
662
+ background-color: #ffeef0;
663
+ padding: 15px;
664
+ border-radius: 6px;
665
+ margin: 20px 0;
666
+ }
667
+ .details {
668
+ background: #f6f8fa;
669
+ padding: 15px;
670
+ border-radius: 6px;
671
+ margin: 20px 0;
672
+ }
673
+ .btn {
674
+ display: inline-block;
675
+ padding: 10px 20px;
676
+ background-color: #0366d6;
677
+ color: white;
678
+ border-radius: 6px;
679
+ text-decoration: none;
680
+ margin-top: 20px;
681
+ }
682
+ pre {
683
+ white-space: pre-wrap;
684
+ word-break: break-word;
685
+ background: #f8f8f8;
686
+ padding: 10px;
687
+ border-radius: 4px;
688
+ overflow: auto;
689
+ }
690
+ #currentDetails {
691
+ display: none;
692
+ margin-top: 20px;
693
+ }
694
+ .toggle-btn {
695
+ background-color: #f1f8ff;
696
+ border: 1px solid #c8e1ff;
697
+ padding: 8px 16px;
698
+ border-radius: 4px;
699
+ cursor: pointer;
700
+ margin-top: 20px;
701
+ display: inline-block;
702
+ }
703
+ </style>
704
+ </head>
705
+ <body>
706
+ <div class="header">
707
+ <h1>""" + object_type + """ Update Verification</h1>
708
+ <p>Object ID: <strong>""" + object_id + """</strong></p>
709
+ </div>
710
+ """
711
+
712
+ # If there's an error message, display it
713
+ if error_message:
714
+ html += """
715
+ <div class="error">
716
+ <h3>❌ Update Failed</h3>
717
+ <p><strong>Error:</strong> """ + error_message + """</p>
718
+ """
719
+
720
+ # If there's detailed error data, decode and display it
721
+ if error_data_encoded:
722
+ try:
723
+ error_data = json.loads(urllib.parse.unquote(error_data_encoded))
724
+ html += """
725
+ <div class="details">
726
+ <h4>Error Details:</h4>
727
+ <pre>""" + json.dumps(error_data, indent=2) + """</pre>
728
+ </div>
729
+ """
730
+ except:
731
+ pass
732
+
733
+ html += """</div>"""
734
+ else:
735
+ # No error, display success message
736
+ html += """
737
+ <div class="success">
738
+ <h3>✅ Update Successful</h3>
739
+ <p>Your """ + object_type.lower() + """ has been updated successfully.</p>
740
+ </div>
741
+ """
742
+
743
+ # Add JavaScript to fetch the current details of the ad/adset
744
+ html += """
745
+ <button class="toggle-btn" id="toggleDetails">Show Current Details</button>
746
+ <div id="currentDetails">
747
+ <h3>Current """ + object_type + """ Details:</h3>
748
+ <pre id="detailsJson">Loading...</pre>
749
+ </div>
750
+
751
+ <a href="#" class="btn" onclick="window.close()">Close Window</a>
752
+
753
+ <script>
754
+ document.getElementById('toggleDetails').addEventListener('click', function() {
755
+ const detailsDiv = document.getElementById('currentDetails');
756
+ const toggleBtn = document.getElementById('toggleDetails');
757
+
758
+ if (detailsDiv.style.display === 'block') {
759
+ detailsDiv.style.display = 'none';
760
+ toggleBtn.textContent = 'Show Current Details';
761
+ } else {
762
+ detailsDiv.style.display = 'block';
763
+ toggleBtn.textContent = 'Hide Current Details';
764
+
765
+ // Fetch current details if not already loaded
766
+ if (document.getElementById('detailsJson').textContent === 'Loading...') {
767
+ fetchCurrentDetails();
768
+ }
769
+ }
770
+ });
771
+
772
+ function fetchCurrentDetails() {
773
+ const objectId = '""" + object_id + """';
774
+ const objectType = '""" + object_type.lower() + """';
775
+ const endpoint = objectType === 'ad' ?
776
+ `/api/ad?ad_id=${objectId}&token=""" + token + """` :
777
+ `/api/adset?adset_id=${objectId}&token=""" + token + """`;
778
+
779
+ fetch(endpoint)
780
+ .then(response => response.json())
781
+ .then(data => {
782
+ document.getElementById('detailsJson').textContent = JSON.stringify(data, null, 2);
783
+ })
784
+ .catch(error => {
785
+ document.getElementById('detailsJson').textContent = `Error fetching details: ${error}`;
786
+ });
787
+ }
788
+ </script>
789
+ </body>
790
+ </html>
791
+ """
792
+
793
+ self.wfile.write(html.encode('utf-8'))
794
+
795
+ def _handle_adset_api(self):
796
+ """Handle API requests for adset data"""
797
+ # Parse query parameters
798
+ query = parse_qs(urlparse(self.path).query)
799
+ adset_id = query.get("adset_id", [""])[0]
800
+ token = query.get("token", [""])[0]
801
+
802
+ from .api import make_api_request
803
+
804
+ # Call the Graph API directly
805
+ async def get_adset_data():
806
+ try:
807
+ endpoint = f"{adset_id}"
808
+ params = {
809
+ "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"
810
+ }
811
+
812
+ result = await make_api_request(endpoint, token, params)
813
+
814
+ # Check if result is a string (possibly an error message)
815
+ if isinstance(result, str):
816
+ try:
817
+ # Try to parse as JSON
818
+ parsed_result = json.loads(result)
819
+ return parsed_result
820
+ except json.JSONDecodeError:
821
+ # Return error object if can't parse as JSON
822
+ return {"error": {"message": result}}
823
+
824
+ # If the result is None, return an error object
825
+ if result is None:
826
+ return {"error": {"message": "Empty response from API"}}
827
+
828
+ return result
829
+ except Exception as e:
830
+ logger.error(f"Error in get_adset_data: {str(e)}")
831
+ return {"error": {"message": f"Error fetching ad set data: {str(e)}"}}
832
+
833
+ # Run the async function
834
+ loop = asyncio.new_event_loop()
835
+ asyncio.set_event_loop(loop)
836
+ result = loop.run_until_complete(get_adset_data())
837
+ loop.close()
838
+
839
+ # Return the result
840
+ self.send_response(200)
841
+ self.send_header("Content-type", "application/json")
842
+ self.end_headers()
843
+ self.wfile.write(json.dumps(result, indent=2).encode())
844
+
845
+ def _handle_ad_api(self):
846
+ """Handle API requests for ad data"""
847
+ # Parse query parameters
848
+ query = parse_qs(urlparse(self.path).query)
849
+ ad_id = query.get("ad_id", [""])[0]
850
+ token = query.get("token", [""])[0]
851
+
852
+ from .api import make_api_request
853
+
854
+ # Call the Graph API directly
855
+ async def get_ad_data():
856
+ endpoint = f"{ad_id}"
857
+ params = {
858
+ "fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs,preview_shareable_link"
859
+ }
860
+ return await make_api_request(endpoint, token, params)
861
+
862
+ # Run the async function to get data
863
+ result = asyncio.run(get_ad_data())
864
+
865
+ # Send the response
866
+ self.send_response(200)
867
+ self.send_header("Content-type", "application/json")
868
+ self.end_headers()
869
+
870
+ if isinstance(result, dict):
871
+ self.wfile.write(json.dumps(result, indent=2).encode())
872
+ else:
873
+ self.wfile.write(json.dumps({"error": "Failed to get ad data"}, indent=2).encode())
874
+
875
+ # Silence server logs
876
+ def log_message(self, format, *args):
877
+ return
878
+
879
+
880
+ def start_callback_server() -> int:
881
+ """
882
+ Start the callback server if it's not already running
883
+
884
+ Returns:
885
+ Port number the server is running on
886
+ """
887
+ global callback_server_thread, callback_server_running, callback_server_port
888
+
889
+ with callback_server_lock:
890
+ if callback_server_running:
891
+ print(f"Callback server already running on port {callback_server_port}")
892
+ return callback_server_port
893
+
894
+ # Find an available port
895
+ port = 8888
896
+ max_attempts = 10
897
+ for attempt in range(max_attempts):
898
+ try:
899
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
900
+ s.bind(('localhost', port))
901
+ break
902
+ except OSError:
903
+ port += 1
904
+ if attempt == max_attempts - 1:
905
+ raise Exception(f"Could not find an available port after {max_attempts} attempts")
906
+
907
+ callback_server_port = port
908
+
909
+ try:
910
+ # Create and start server in a daemon thread
911
+ server = HTTPServer(('localhost', port), CallbackHandler)
912
+ print(f"Callback server starting on port {port}")
913
+
914
+ # Create a simple flag to signal when the server is ready
915
+ server_ready = threading.Event()
916
+
917
+ def server_thread():
918
+ try:
919
+ # Signal that the server thread has started
920
+ server_ready.set()
921
+ print(f"Callback server is now ready on port {port}")
922
+ # Start serving HTTP requests
923
+ server.serve_forever()
924
+ except Exception as e:
925
+ print(f"Server error: {e}")
926
+ finally:
927
+ with callback_server_lock:
928
+ global callback_server_running
929
+ callback_server_running = False
930
+
931
+ callback_server_thread = threading.Thread(target=server_thread)
932
+ callback_server_thread.daemon = True
933
+ callback_server_thread.start()
934
+
935
+ # Wait for server to be ready (up to 5 seconds)
936
+ if not server_ready.wait(timeout=5):
937
+ print("Warning: Timeout waiting for server to start, but continuing anyway")
938
+
939
+ callback_server_running = True
940
+
941
+ # Verify the server is actually accepting connections
942
+ try:
943
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
944
+ s.settimeout(2)
945
+ s.connect(('localhost', port))
946
+ print(f"Confirmed server is accepting connections on port {port}")
947
+ except Exception as e:
948
+ print(f"Warning: Could not verify server connection: {e}")
949
+
950
+ return port
951
+
952
+ except Exception as e:
953
+ print(f"Error starting callback server: {e}")
954
+ # Try again with a different port in case of bind issues
955
+ if "address already in use" in str(e).lower():
956
+ print("Port may be in use, trying a different port...")
957
+ return start_callback_server() # Recursive call with a new port
958
+ raise e