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.
- meta_ads_mcp/__init__.py +3 -1
- meta_ads_mcp/core/ads.py +61 -1
- meta_ads_mcp/core/adsets.py +33 -3
- meta_ads_mcp/core/api.py +68 -24
- meta_ads_mcp/core/auth.py +109 -526
- meta_ads_mcp/core/authentication.py +10 -1
- meta_ads_mcp/core/callback_server.py +958 -0
- meta_ads_mcp/core/utils.py +10 -0
- {meta_ads_mcp-0.2.4.dist-info → meta_ads_mcp-0.2.6.dist-info}/METADATA +45 -14
- meta_ads_mcp-0.2.6.dist-info/RECORD +20 -0
- meta_ads_mcp-0.2.4.dist-info/RECORD +0 -19
- {meta_ads_mcp-0.2.4.dist-info → meta_ads_mcp-0.2.6.dist-info}/WHEEL +0 -0
- {meta_ads_mcp-0.2.4.dist-info → meta_ads_mcp-0.2.6.dist-info}/entry_points.txt +0 -0
meta_ads_mcp/core/auth.py
CHANGED
|
@@ -5,14 +5,18 @@ import time
|
|
|
5
5
|
import platform
|
|
6
6
|
import pathlib
|
|
7
7
|
import os
|
|
8
|
-
import threading
|
|
9
|
-
import socket
|
|
10
8
|
import webbrowser
|
|
11
9
|
import asyncio
|
|
12
|
-
from urllib.parse import urlparse, parse_qs
|
|
13
|
-
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
14
10
|
import json
|
|
15
11
|
from .utils import logger
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
# Import from the new callback server module
|
|
15
|
+
from .callback_server import (
|
|
16
|
+
start_callback_server,
|
|
17
|
+
token_container,
|
|
18
|
+
update_confirmation
|
|
19
|
+
)
|
|
16
20
|
|
|
17
21
|
# Auth constants
|
|
18
22
|
AUTH_SCOPE = "ads_management,ads_read,business_management"
|
|
@@ -24,21 +28,9 @@ logger.info("Authentication module initialized")
|
|
|
24
28
|
logger.info(f"Auth scope: {AUTH_SCOPE}")
|
|
25
29
|
logger.info(f"Default redirect URI: {AUTH_REDIRECT_URI}")
|
|
26
30
|
|
|
27
|
-
# Global token container for communication between threads
|
|
28
|
-
token_container = {"token": None, "expires_in": None, "user_id": None}
|
|
29
|
-
|
|
30
|
-
# Global container for update confirmations
|
|
31
|
-
update_confirmation = {"approved": False}
|
|
32
|
-
|
|
33
31
|
# Global flag for authentication state
|
|
34
32
|
needs_authentication = False
|
|
35
33
|
|
|
36
|
-
# Global variable for server thread and state
|
|
37
|
-
callback_server_thread = None
|
|
38
|
-
callback_server_lock = threading.Lock()
|
|
39
|
-
callback_server_running = False
|
|
40
|
-
callback_server_port = None
|
|
41
|
-
|
|
42
34
|
# Meta configuration singleton
|
|
43
35
|
class MetaConfig:
|
|
44
36
|
_instance = None
|
|
@@ -213,6 +205,9 @@ class AuthManager:
|
|
|
213
205
|
# Start the callback server if not already running
|
|
214
206
|
port = start_callback_server()
|
|
215
207
|
|
|
208
|
+
# Update redirect URI with the actual port
|
|
209
|
+
self.redirect_uri = f"http://localhost:{port}/callback"
|
|
210
|
+
|
|
216
211
|
# Generate the auth URL
|
|
217
212
|
auth_url = self.get_auth_url()
|
|
218
213
|
|
|
@@ -261,535 +256,123 @@ class AuthManager:
|
|
|
261
256
|
self.invalidate_token()
|
|
262
257
|
|
|
263
258
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
259
|
+
def process_token_response(token_container):
|
|
260
|
+
"""Process the token response from Facebook."""
|
|
261
|
+
global needs_authentication, auth_manager
|
|
262
|
+
|
|
263
|
+
if token_container and token_container.get('token'):
|
|
264
|
+
logger.info("Processing token response from Facebook OAuth")
|
|
268
265
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
self.send_header("Content-type", "text/html")
|
|
273
|
-
self.end_headers()
|
|
274
|
-
|
|
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');
|
|
287
|
-
|
|
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
|
|
266
|
+
# Exchange the short-lived token for a long-lived token
|
|
267
|
+
short_lived_token = token_container['token']
|
|
268
|
+
long_lived_token_info = exchange_token_for_long_lived(short_lived_token)
|
|
300
269
|
|
|
301
|
-
if
|
|
302
|
-
|
|
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]
|
|
270
|
+
if long_lived_token_info:
|
|
271
|
+
logger.info(f"Successfully exchanged for long-lived token (expires in {long_lived_token_info.expires_in} seconds)")
|
|
307
272
|
|
|
308
273
|
try:
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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>
|
|
342
|
-
|
|
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>
|
|
347
|
-
|
|
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"
|
|
365
|
-
|
|
366
|
-
# Format the value for display
|
|
367
|
-
display_value = json.dumps(v, indent=2) if isinstance(v, (dict, list)) else str(v)
|
|
368
|
-
|
|
369
|
-
html += f"""
|
|
370
|
-
<tr>
|
|
371
|
-
<td>{k}</td>
|
|
372
|
-
<td><pre>{display_value}</pre></td>
|
|
373
|
-
<td>{description}</td>
|
|
374
|
-
</tr>
|
|
375
|
-
"""
|
|
376
|
-
|
|
377
|
-
html += """
|
|
378
|
-
</table>
|
|
379
|
-
</div>
|
|
380
|
-
|
|
381
|
-
<div class="buttons">
|
|
382
|
-
<button class="approve" onclick="approveChanges()">Approve Changes</button>
|
|
383
|
-
<button class="cancel" onclick="cancelChanges()">Cancel</button>
|
|
384
|
-
</div>
|
|
385
|
-
|
|
386
|
-
<div id="status" class="status"></div>
|
|
387
|
-
|
|
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');
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
function approveChanges() {
|
|
403
|
-
showStatus("Processing update...");
|
|
404
|
-
|
|
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);
|
|
419
|
-
} else {
|
|
420
|
-
showStatus('Changes approved and will be applied shortly!');
|
|
421
|
-
setTimeout(() => {
|
|
422
|
-
window.location.href = '/verify-update?' + new URLSearchParams({
|
|
423
|
-
adset_id: '""" + adset_id + """',
|
|
424
|
-
token: '""" + token + """'
|
|
425
|
-
});
|
|
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...");
|
|
437
|
-
|
|
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()
|
|
464
|
-
|
|
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>
|
|
485
|
-
|
|
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>
|
|
490
|
-
|
|
491
|
-
<div id="status" class="status loading">
|
|
492
|
-
<div class="spinner"></div> Verifying update...
|
|
493
|
-
</div>
|
|
274
|
+
auth_manager.token_info = long_lived_token_info
|
|
275
|
+
logger.info(f"Long-lived token info set in auth_manager, expires in {long_lived_token_info.expires_in} seconds")
|
|
276
|
+
except NameError:
|
|
277
|
+
logger.error("auth_manager not defined when trying to process token")
|
|
494
278
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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);
|
|
516
|
-
|
|
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
|
-
const statusElement = document.getElementById('status');
|
|
523
|
-
statusElement.classList.remove('loading');
|
|
524
|
-
statusElement.classList.add('error');
|
|
525
|
-
statusElement.textContent = 'Error verifying update: ' + error;
|
|
526
|
-
}
|
|
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()
|
|
576
|
-
|
|
577
|
-
if action == "approve":
|
|
578
|
-
adset_id = query.get("adset_id", [""])[0]
|
|
579
|
-
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
|
-
|
|
591
|
-
# Prepare the API call to actually execute the update
|
|
592
|
-
from .api import make_api_request
|
|
593
|
-
|
|
594
|
-
# Function to perform the actual update
|
|
595
|
-
async def perform_update():
|
|
596
|
-
try:
|
|
597
|
-
changes_dict = json.loads(changes)
|
|
598
|
-
endpoint = f"{adset_id}"
|
|
599
|
-
|
|
600
|
-
# Create a copy of changes_dict for the API call
|
|
601
|
-
api_params = dict(changes_dict)
|
|
602
|
-
api_params["access_token"] = token
|
|
603
|
-
|
|
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}
|
|
607
|
-
except Exception as e:
|
|
608
|
-
return {"status": "error", "error": str(e)}
|
|
279
|
+
try:
|
|
280
|
+
logger.info("Attempting to save long-lived token to cache")
|
|
281
|
+
auth_manager._save_token_to_cache()
|
|
282
|
+
logger.info(f"Long-lived token successfully saved to cache at {auth_manager._get_token_cache_path()}")
|
|
283
|
+
except Exception as e:
|
|
284
|
+
logger.error(f"Error saving token to cache: {e}")
|
|
609
285
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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")
|
|
286
|
+
needs_authentication = False
|
|
287
|
+
return True
|
|
288
|
+
else:
|
|
289
|
+
# Fall back to the short-lived token if exchange fails
|
|
290
|
+
logger.warning("Failed to exchange for long-lived token, using short-lived token instead")
|
|
291
|
+
token_info = TokenInfo(
|
|
292
|
+
access_token=short_lived_token,
|
|
293
|
+
expires_in=token_container.get('expires_in', 0)
|
|
294
|
+
)
|
|
644
295
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
access_token=token_container["token"],
|
|
651
|
-
expires_in=token_container["expires_in"]
|
|
652
|
-
)
|
|
653
|
-
|
|
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}")
|
|
296
|
+
try:
|
|
297
|
+
auth_manager.token_info = token_info
|
|
298
|
+
logger.info(f"Short-lived token info set in auth_manager, expires in {token_info.expires_in} seconds")
|
|
299
|
+
except NameError:
|
|
300
|
+
logger.error("auth_manager not defined when trying to process token")
|
|
665
301
|
|
|
666
|
-
|
|
667
|
-
|
|
302
|
+
try:
|
|
303
|
+
logger.info("Attempting to save token to cache")
|
|
304
|
+
auth_manager._save_token_to_cache()
|
|
305
|
+
logger.info(f"Token successfully saved to cache at {auth_manager._get_token_cache_path()}")
|
|
306
|
+
except Exception as e:
|
|
307
|
+
logger.error(f"Error saving token to cache: {e}")
|
|
668
308
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
# Silence server logs
|
|
676
|
-
def log_message(self, format, *args):
|
|
677
|
-
return
|
|
309
|
+
needs_authentication = False
|
|
310
|
+
return True
|
|
311
|
+
else:
|
|
312
|
+
logger.warning("Received empty token in process_token_response")
|
|
313
|
+
needs_authentication = True
|
|
314
|
+
return False
|
|
678
315
|
|
|
679
316
|
|
|
680
|
-
def
|
|
681
|
-
"""
|
|
682
|
-
|
|
317
|
+
def exchange_token_for_long_lived(short_lived_token):
|
|
318
|
+
"""
|
|
319
|
+
Exchange a short-lived token for a long-lived token (60 days validity).
|
|
683
320
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
print(f"Callback server already running on port {callback_server_port}")
|
|
687
|
-
return callback_server_port
|
|
321
|
+
Args:
|
|
322
|
+
short_lived_token: The short-lived access token received from OAuth flow
|
|
688
323
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
except OSError:
|
|
698
|
-
port += 1
|
|
699
|
-
if attempt == max_attempts - 1:
|
|
700
|
-
raise Exception(f"Could not find an available port after {max_attempts} attempts")
|
|
324
|
+
Returns:
|
|
325
|
+
TokenInfo object with the long-lived token, or None if exchange failed
|
|
326
|
+
"""
|
|
327
|
+
logger.info("Attempting to exchange short-lived token for long-lived token")
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
# Get the app ID from the configuration
|
|
331
|
+
app_id = meta_config.get_app_id()
|
|
701
332
|
|
|
702
|
-
#
|
|
703
|
-
|
|
704
|
-
auth_manager.redirect_uri = f"http://localhost:{port}/callback"
|
|
705
|
-
callback_server_port = port
|
|
333
|
+
# Get the app secret - this should be securely stored
|
|
334
|
+
app_secret = os.environ.get("META_APP_SECRET", "")
|
|
706
335
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
# Create and start server in a daemon thread
|
|
712
|
-
server = HTTPServer(('localhost', port), handler_class)
|
|
713
|
-
print(f"Callback server starting on port {port}")
|
|
714
|
-
|
|
715
|
-
# Create a simple flag to signal when the server is ready
|
|
716
|
-
server_ready = threading.Event()
|
|
717
|
-
|
|
718
|
-
def server_thread():
|
|
719
|
-
try:
|
|
720
|
-
# Signal that the server thread has started
|
|
721
|
-
server_ready.set()
|
|
722
|
-
print(f"Callback server is now ready on port {port}")
|
|
723
|
-
# Start serving HTTP requests
|
|
724
|
-
server.serve_forever()
|
|
725
|
-
except Exception as e:
|
|
726
|
-
print(f"Server error: {e}")
|
|
727
|
-
finally:
|
|
728
|
-
with callback_server_lock:
|
|
729
|
-
global callback_server_running
|
|
730
|
-
callback_server_running = False
|
|
731
|
-
|
|
732
|
-
callback_server_thread = threading.Thread(target=server_thread)
|
|
733
|
-
callback_server_thread.daemon = True
|
|
734
|
-
callback_server_thread.start()
|
|
735
|
-
|
|
736
|
-
# Wait for server to be ready (up to 5 seconds)
|
|
737
|
-
if not server_ready.wait(timeout=5):
|
|
738
|
-
print("Warning: Timeout waiting for server to start, but continuing anyway")
|
|
739
|
-
|
|
740
|
-
callback_server_running = True
|
|
741
|
-
|
|
742
|
-
# Verify the server is actually accepting connections
|
|
743
|
-
try:
|
|
744
|
-
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
745
|
-
s.settimeout(2)
|
|
746
|
-
s.connect(('localhost', port))
|
|
747
|
-
print(f"Confirmed server is accepting connections on port {port}")
|
|
748
|
-
except Exception as e:
|
|
749
|
-
print(f"Warning: Could not verify server connection: {e}")
|
|
750
|
-
|
|
751
|
-
return port
|
|
336
|
+
if not app_id or not app_secret:
|
|
337
|
+
logger.error("Missing app_id or app_secret for token exchange")
|
|
338
|
+
return None
|
|
752
339
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
def process_token_response(token_container):
|
|
763
|
-
"""Process the token response from Facebook."""
|
|
764
|
-
global needs_authentication
|
|
765
|
-
|
|
766
|
-
if token_container and token_container.get('token'):
|
|
767
|
-
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
|
-
)
|
|
340
|
+
# Make the API request to exchange the token
|
|
341
|
+
url = "https://graph.facebook.com/v18.0/oauth/access_token"
|
|
342
|
+
params = {
|
|
343
|
+
"grant_type": "fb_exchange_token",
|
|
344
|
+
"client_id": app_id,
|
|
345
|
+
"client_secret": app_secret,
|
|
346
|
+
"fb_exchange_token": short_lived_token
|
|
347
|
+
}
|
|
772
348
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
logger.
|
|
349
|
+
logger.debug(f"Making token exchange request to {url}")
|
|
350
|
+
response = requests.get(url, params=params)
|
|
351
|
+
|
|
352
|
+
if response.status_code == 200:
|
|
353
|
+
data = response.json()
|
|
354
|
+
logger.debug(f"Token exchange response: {data}")
|
|
779
355
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
except Exception as e:
|
|
785
|
-
logger.error(f"Error saving token to cache: {e}")
|
|
356
|
+
# Create TokenInfo from the response
|
|
357
|
+
# The response includes access_token and expires_in (in seconds)
|
|
358
|
+
new_token = data.get("access_token")
|
|
359
|
+
expires_in = data.get("expires_in")
|
|
786
360
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
361
|
+
if new_token:
|
|
362
|
+
logger.info(f"Received long-lived token, expires in {expires_in} seconds (~{expires_in//86400} days)")
|
|
363
|
+
return TokenInfo(
|
|
364
|
+
access_token=new_token,
|
|
365
|
+
expires_in=expires_in
|
|
366
|
+
)
|
|
367
|
+
else:
|
|
368
|
+
logger.error("No access_token in exchange response")
|
|
369
|
+
return None
|
|
370
|
+
else:
|
|
371
|
+
logger.error(f"Token exchange failed with status {response.status_code}: {response.text}")
|
|
372
|
+
return None
|
|
373
|
+
except Exception as e:
|
|
374
|
+
logger.error(f"Error exchanging token: {e}")
|
|
375
|
+
return None
|
|
793
376
|
|
|
794
377
|
|
|
795
378
|
async def get_current_access_token() -> Optional[str]:
|