marqetive-lib 0.1.0__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.
- marqetive/__init__.py +113 -0
- marqetive/core/__init__.py +5 -0
- marqetive/core/account_factory.py +212 -0
- marqetive/core/base_manager.py +303 -0
- marqetive/core/client.py +108 -0
- marqetive/core/progress.py +291 -0
- marqetive/core/registry.py +257 -0
- marqetive/platforms/__init__.py +55 -0
- marqetive/platforms/base.py +390 -0
- marqetive/platforms/exceptions.py +238 -0
- marqetive/platforms/instagram/__init__.py +7 -0
- marqetive/platforms/instagram/client.py +786 -0
- marqetive/platforms/instagram/exceptions.py +311 -0
- marqetive/platforms/instagram/factory.py +106 -0
- marqetive/platforms/instagram/manager.py +112 -0
- marqetive/platforms/instagram/media.py +669 -0
- marqetive/platforms/linkedin/__init__.py +7 -0
- marqetive/platforms/linkedin/client.py +733 -0
- marqetive/platforms/linkedin/exceptions.py +335 -0
- marqetive/platforms/linkedin/factory.py +130 -0
- marqetive/platforms/linkedin/manager.py +119 -0
- marqetive/platforms/linkedin/media.py +549 -0
- marqetive/platforms/models.py +345 -0
- marqetive/platforms/tiktok/__init__.py +0 -0
- marqetive/platforms/twitter/__init__.py +7 -0
- marqetive/platforms/twitter/client.py +647 -0
- marqetive/platforms/twitter/exceptions.py +311 -0
- marqetive/platforms/twitter/factory.py +151 -0
- marqetive/platforms/twitter/manager.py +121 -0
- marqetive/platforms/twitter/media.py +779 -0
- marqetive/platforms/twitter/threads.py +442 -0
- marqetive/py.typed +0 -0
- marqetive/registry_init.py +66 -0
- marqetive/utils/__init__.py +45 -0
- marqetive/utils/file_handlers.py +438 -0
- marqetive/utils/helpers.py +99 -0
- marqetive/utils/media.py +399 -0
- marqetive/utils/oauth.py +265 -0
- marqetive/utils/retry.py +239 -0
- marqetive/utils/token_validator.py +240 -0
- marqetive_lib-0.1.0.dist-info/METADATA +261 -0
- marqetive_lib-0.1.0.dist-info/RECORD +43 -0
- marqetive_lib-0.1.0.dist-info/WHEEL +4 -0
marqetive/utils/media.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Media utilities for file validation, MIME type detection, and chunking.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for working with media files including:
|
|
4
|
+
- MIME type detection using multiple methods
|
|
5
|
+
- File validation (size, type, format)
|
|
6
|
+
- File chunking for large uploads
|
|
7
|
+
- File hashing for integrity verification
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import mimetypes
|
|
12
|
+
import os
|
|
13
|
+
from collections.abc import AsyncGenerator
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
# Initialize mimetypes database
|
|
18
|
+
mimetypes.init()
|
|
19
|
+
|
|
20
|
+
# Common MIME type mappings
|
|
21
|
+
MIME_TYPE_MAP = {
|
|
22
|
+
".jpg": "image/jpeg",
|
|
23
|
+
".jpeg": "image/jpeg",
|
|
24
|
+
".png": "image/png",
|
|
25
|
+
".gif": "image/gif",
|
|
26
|
+
".webp": "image/webp",
|
|
27
|
+
".mp4": "video/mp4",
|
|
28
|
+
".mov": "video/quicktime",
|
|
29
|
+
".avi": "video/x-msvideo",
|
|
30
|
+
".mkv": "video/x-matroska",
|
|
31
|
+
".webm": "video/webm",
|
|
32
|
+
".pdf": "application/pdf",
|
|
33
|
+
".doc": "application/msword",
|
|
34
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
35
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
36
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Magic number signatures for file type detection
|
|
40
|
+
MAGIC_NUMBERS = {
|
|
41
|
+
b"\xff\xd8\xff": "image/jpeg",
|
|
42
|
+
b"\x89PNG\r\n\x1a\n": "image/png",
|
|
43
|
+
b"GIF87a": "image/gif",
|
|
44
|
+
b"GIF89a": "image/gif",
|
|
45
|
+
b"RIFF": "image/webp", # Needs additional check for WEBP
|
|
46
|
+
b"\x00\x00\x00\x18ftypmp4": "video/mp4", # Offset at byte 4
|
|
47
|
+
b"\x00\x00\x00\x1cftypiso": "video/mp4", # Alternative MP4
|
|
48
|
+
b"%PDF": "application/pdf",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Platform-specific file size limits (in bytes)
|
|
52
|
+
PLATFORM_LIMITS = {
|
|
53
|
+
"twitter": {
|
|
54
|
+
"image": 5 * 1024 * 1024, # 5 MB
|
|
55
|
+
"gif": 15 * 1024 * 1024, # 15 MB
|
|
56
|
+
"video": 512 * 1024 * 1024, # 512 MB
|
|
57
|
+
},
|
|
58
|
+
"instagram": {
|
|
59
|
+
"image": 8 * 1024 * 1024, # 8 MB
|
|
60
|
+
"video": 100 * 1024 * 1024, # 100 MB
|
|
61
|
+
"reel": 100 * 1024 * 1024, # 100 MB
|
|
62
|
+
"story": 100 * 1024 * 1024, # 100 MB
|
|
63
|
+
},
|
|
64
|
+
"linkedin": {
|
|
65
|
+
"image": 10 * 1024 * 1024, # 10 MB
|
|
66
|
+
"video": 200 * 1024 * 1024, # 200 MB
|
|
67
|
+
"document": 10 * 1024 * 1024, # 10 MB
|
|
68
|
+
},
|
|
69
|
+
"tiktok": {
|
|
70
|
+
"video": 4 * 1024 * 1024 * 1024, # 4 GB
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Supported media types by platform
|
|
75
|
+
PLATFORM_MEDIA_TYPES = {
|
|
76
|
+
"twitter": {
|
|
77
|
+
"image": [".jpg", ".jpeg", ".png", ".gif", ".webp"],
|
|
78
|
+
"video": [".mp4", ".mov"],
|
|
79
|
+
},
|
|
80
|
+
"instagram": {
|
|
81
|
+
"image": [".jpg", ".jpeg", ".png"],
|
|
82
|
+
"video": [".mp4", ".mov"],
|
|
83
|
+
},
|
|
84
|
+
"linkedin": {
|
|
85
|
+
"image": [".jpg", ".jpeg", ".png", ".gif"],
|
|
86
|
+
"video": [".mp4", ".mov", ".avi", ".webm"],
|
|
87
|
+
"document": [".pdf", ".doc", ".docx", ".ppt", ".pptx"],
|
|
88
|
+
},
|
|
89
|
+
"tiktok": {
|
|
90
|
+
"video": [".mp4", ".mov", ".webm"],
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class MediaValidator:
|
|
96
|
+
"""Validator for media files with platform-specific rules."""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
platform: Literal["twitter", "instagram", "linkedin", "tiktok"],
|
|
101
|
+
media_type: Literal["image", "video", "document", "gif", "reel", "story"],
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Initialize the media validator.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
platform: Target platform name.
|
|
107
|
+
media_type: Type of media being validated.
|
|
108
|
+
"""
|
|
109
|
+
self.platform = platform
|
|
110
|
+
self.media_type = media_type
|
|
111
|
+
self.max_size = PLATFORM_LIMITS.get(platform, {}).get(media_type, 0)
|
|
112
|
+
self.allowed_extensions = PLATFORM_MEDIA_TYPES.get(platform, {}).get(
|
|
113
|
+
media_type, []
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def validate(self, file_path: str) -> tuple[bool, str | None]:
|
|
117
|
+
"""Validate a media file.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
file_path: Path to the file to validate.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Tuple of (is_valid, error_message). If valid, error_message is None.
|
|
124
|
+
"""
|
|
125
|
+
# Check file exists
|
|
126
|
+
if not os.path.exists(file_path):
|
|
127
|
+
return False, f"File not found: {file_path}"
|
|
128
|
+
|
|
129
|
+
# Check file extension
|
|
130
|
+
extension = Path(file_path).suffix.lower()
|
|
131
|
+
if extension not in self.allowed_extensions:
|
|
132
|
+
return (
|
|
133
|
+
False,
|
|
134
|
+
f"Invalid file type '{extension}' for {self.platform} "
|
|
135
|
+
f"{self.media_type}. Allowed: {', '.join(self.allowed_extensions)}",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Check file size
|
|
139
|
+
file_size = os.path.getsize(file_path)
|
|
140
|
+
if self.max_size and file_size > self.max_size:
|
|
141
|
+
max_mb = self.max_size / (1024 * 1024)
|
|
142
|
+
actual_mb = file_size / (1024 * 1024)
|
|
143
|
+
return (
|
|
144
|
+
False,
|
|
145
|
+
f"File size {actual_mb:.2f}MB exceeds {self.platform} "
|
|
146
|
+
f"{self.media_type} limit of {max_mb:.2f}MB",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Check MIME type matches extension
|
|
150
|
+
detected_mime = detect_mime_type(file_path)
|
|
151
|
+
expected_mime = MIME_TYPE_MAP.get(extension)
|
|
152
|
+
if expected_mime and detected_mime != expected_mime:
|
|
153
|
+
return (
|
|
154
|
+
False,
|
|
155
|
+
f"File content type '{detected_mime}' doesn't match "
|
|
156
|
+
f"extension '{extension}' (expected '{expected_mime}')",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return True, None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def detect_mime_type(file_path: str) -> str:
|
|
163
|
+
"""Detect MIME type of a file using multiple methods.
|
|
164
|
+
|
|
165
|
+
Uses a combination of:
|
|
166
|
+
1. Magic number (file signature) detection
|
|
167
|
+
2. Extension-based lookup
|
|
168
|
+
3. Python's mimetypes module
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
file_path: Path to the file.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
MIME type string (e.g., 'image/jpeg').
|
|
175
|
+
|
|
176
|
+
Example:
|
|
177
|
+
>>> mime_type = detect_mime_type('/path/to/image.jpg')
|
|
178
|
+
>>> print(mime_type)
|
|
179
|
+
image/jpeg
|
|
180
|
+
"""
|
|
181
|
+
# Try magic number detection first (most reliable)
|
|
182
|
+
try:
|
|
183
|
+
with open(file_path, "rb") as f:
|
|
184
|
+
header = f.read(32) # Read first 32 bytes
|
|
185
|
+
|
|
186
|
+
# Check for magic numbers
|
|
187
|
+
for magic, mime_type in MAGIC_NUMBERS.items():
|
|
188
|
+
if header.startswith(magic):
|
|
189
|
+
# Special case for WEBP
|
|
190
|
+
if magic == b"RIFF" and b"WEBP" in header[:16]:
|
|
191
|
+
return "image/webp"
|
|
192
|
+
if "ftyp" not in magic.decode("latin-1", errors="ignore"):
|
|
193
|
+
return mime_type
|
|
194
|
+
|
|
195
|
+
# Check for MP4 variants (ftyp at offset 4)
|
|
196
|
+
if len(header) >= 12:
|
|
197
|
+
ftyp_check = header[4:12]
|
|
198
|
+
if b"ftyp" in ftyp_check:
|
|
199
|
+
return "video/mp4"
|
|
200
|
+
|
|
201
|
+
except OSError:
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
# Try extension-based lookup
|
|
205
|
+
extension = Path(file_path).suffix.lower()
|
|
206
|
+
if extension in MIME_TYPE_MAP:
|
|
207
|
+
return MIME_TYPE_MAP[extension]
|
|
208
|
+
|
|
209
|
+
# Fall back to mimetypes module
|
|
210
|
+
mime_type, _ = mimetypes.guess_type(file_path)
|
|
211
|
+
return mime_type or "application/octet-stream"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def validate_file_size(
|
|
215
|
+
file_path: str, max_size: int, *, raise_error: bool = False
|
|
216
|
+
) -> bool:
|
|
217
|
+
"""Validate that a file doesn't exceed the maximum size.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
file_path: Path to the file.
|
|
221
|
+
max_size: Maximum size in bytes.
|
|
222
|
+
raise_error: If True, raise ValueError instead of returning False.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
True if file size is within limit, False otherwise.
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
ValueError: If file exceeds size limit and raise_error=True.
|
|
229
|
+
FileNotFoundError: If file doesn't exist.
|
|
230
|
+
|
|
231
|
+
Example:
|
|
232
|
+
>>> is_valid = validate_file_size('image.jpg', 5 * 1024 * 1024)
|
|
233
|
+
>>> if not is_valid:
|
|
234
|
+
... print("File too large")
|
|
235
|
+
"""
|
|
236
|
+
if not os.path.exists(file_path):
|
|
237
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
238
|
+
|
|
239
|
+
file_size = os.path.getsize(file_path)
|
|
240
|
+
|
|
241
|
+
if file_size > max_size:
|
|
242
|
+
if raise_error:
|
|
243
|
+
max_mb = max_size / (1024 * 1024)
|
|
244
|
+
actual_mb = file_size / (1024 * 1024)
|
|
245
|
+
raise ValueError(
|
|
246
|
+
f"File size {actual_mb:.2f}MB exceeds limit of {max_mb:.2f}MB"
|
|
247
|
+
)
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
return True
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def validate_media_type(
|
|
254
|
+
file_path: str, allowed_types: list[str], *, raise_error: bool = False
|
|
255
|
+
) -> bool:
|
|
256
|
+
"""Validate that a file is one of the allowed media types.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
file_path: Path to the file.
|
|
260
|
+
allowed_types: List of allowed MIME types or extensions.
|
|
261
|
+
raise_error: If True, raise ValueError instead of returning False.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
True if file type is allowed, False otherwise.
|
|
265
|
+
|
|
266
|
+
Raises:
|
|
267
|
+
ValueError: If file type not allowed and raise_error=True.
|
|
268
|
+
|
|
269
|
+
Example:
|
|
270
|
+
>>> allowed = ['image/jpeg', 'image/png', '.jpg', '.png']
|
|
271
|
+
>>> is_valid = validate_media_type('photo.jpg', allowed)
|
|
272
|
+
>>> print(is_valid)
|
|
273
|
+
True
|
|
274
|
+
"""
|
|
275
|
+
mime_type = detect_mime_type(file_path)
|
|
276
|
+
extension = Path(file_path).suffix.lower()
|
|
277
|
+
|
|
278
|
+
# Check against both MIME types and extensions
|
|
279
|
+
is_allowed = mime_type in allowed_types or extension in allowed_types
|
|
280
|
+
|
|
281
|
+
if not is_allowed and raise_error:
|
|
282
|
+
raise ValueError(
|
|
283
|
+
f"File type '{mime_type}' (extension '{extension}') not allowed. "
|
|
284
|
+
f"Allowed types: {', '.join(allowed_types)}"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
return is_allowed
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
async def chunk_file(
|
|
291
|
+
file_path: str, chunk_size: int = 1024 * 1024
|
|
292
|
+
) -> AsyncGenerator[bytes, None]:
|
|
293
|
+
"""Asynchronously read file in chunks.
|
|
294
|
+
|
|
295
|
+
Yields file content in chunks of specified size. Useful for uploading
|
|
296
|
+
large files without loading entire file into memory.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
file_path: Path to the file.
|
|
300
|
+
chunk_size: Size of each chunk in bytes (default: 1MB).
|
|
301
|
+
|
|
302
|
+
Yields:
|
|
303
|
+
Bytes chunks of the file.
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
FileNotFoundError: If file doesn't exist.
|
|
307
|
+
|
|
308
|
+
Example:
|
|
309
|
+
>>> async for chunk in chunk_file('large_video.mp4', chunk_size=5*1024*1024):
|
|
310
|
+
... await upload_chunk(chunk)
|
|
311
|
+
"""
|
|
312
|
+
import aiofiles
|
|
313
|
+
|
|
314
|
+
if not os.path.exists(file_path):
|
|
315
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
316
|
+
|
|
317
|
+
async with aiofiles.open(file_path, "rb") as f:
|
|
318
|
+
while True:
|
|
319
|
+
chunk = await f.read(chunk_size)
|
|
320
|
+
if not chunk:
|
|
321
|
+
break
|
|
322
|
+
yield chunk
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def get_file_hash(file_path: str, algorithm: str = "sha256") -> str:
|
|
326
|
+
"""Calculate hash of a file for integrity verification.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
file_path: Path to the file.
|
|
330
|
+
algorithm: Hash algorithm to use (default: 'sha256').
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
Hexadecimal hash string.
|
|
334
|
+
|
|
335
|
+
Raises:
|
|
336
|
+
FileNotFoundError: If file doesn't exist.
|
|
337
|
+
|
|
338
|
+
Example:
|
|
339
|
+
>>> file_hash = get_file_hash('document.pdf')
|
|
340
|
+
>>> print(file_hash)
|
|
341
|
+
a1b2c3d4e5f6...
|
|
342
|
+
"""
|
|
343
|
+
if not os.path.exists(file_path):
|
|
344
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
345
|
+
|
|
346
|
+
hash_obj = hashlib.new(algorithm)
|
|
347
|
+
|
|
348
|
+
with open(file_path, "rb") as f:
|
|
349
|
+
# Read in chunks to handle large files
|
|
350
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
351
|
+
hash_obj.update(chunk)
|
|
352
|
+
|
|
353
|
+
return hash_obj.hexdigest()
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def format_file_size(size_bytes: int) -> str:
|
|
357
|
+
"""Format file size in human-readable format.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
size_bytes: File size in bytes.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
Formatted string (e.g., '1.5 MB').
|
|
364
|
+
|
|
365
|
+
Example:
|
|
366
|
+
>>> size = format_file_size(1536000)
|
|
367
|
+
>>> print(size)
|
|
368
|
+
1.46 MB
|
|
369
|
+
"""
|
|
370
|
+
size: float = float(size_bytes)
|
|
371
|
+
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
|
372
|
+
if size < 1024.0:
|
|
373
|
+
return f"{size:.2f} {unit}"
|
|
374
|
+
size /= 1024.0
|
|
375
|
+
return f"{size:.2f} PB"
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def get_chunk_count(file_path: str, chunk_size: int) -> int:
|
|
379
|
+
"""Calculate number of chunks needed to upload a file.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
file_path: Path to the file.
|
|
383
|
+
chunk_size: Size of each chunk in bytes.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
Number of chunks needed.
|
|
387
|
+
|
|
388
|
+
Raises:
|
|
389
|
+
FileNotFoundError: If file doesn't exist.
|
|
390
|
+
|
|
391
|
+
Example:
|
|
392
|
+
>>> chunks = get_chunk_count('video.mp4', 5 * 1024 * 1024)
|
|
393
|
+
>>> print(f"Will upload in {chunks} chunks")
|
|
394
|
+
"""
|
|
395
|
+
if not os.path.exists(file_path):
|
|
396
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
397
|
+
|
|
398
|
+
file_size = os.path.getsize(file_path)
|
|
399
|
+
return (file_size + chunk_size - 1) // chunk_size # Ceiling division
|
marqetive/utils/oauth.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""OAuth token refresh utilities for social media platforms.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for refreshing OAuth2 access tokens across
|
|
4
|
+
different social media platforms.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from marqetive.platforms.exceptions import PlatformAuthError
|
|
14
|
+
from marqetive.platforms.models import AuthCredentials
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def refresh_oauth2_token(
|
|
20
|
+
refresh_token: str,
|
|
21
|
+
client_id: str,
|
|
22
|
+
client_secret: str,
|
|
23
|
+
token_url: str,
|
|
24
|
+
additional_params: dict[str, Any] | None = None,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
"""Refresh an OAuth2 access token.
|
|
27
|
+
|
|
28
|
+
Generic OAuth2 token refresh implementation that works with most providers.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
refresh_token: The refresh token.
|
|
32
|
+
client_id: OAuth client ID.
|
|
33
|
+
client_secret: OAuth client secret.
|
|
34
|
+
token_url: Token endpoint URL.
|
|
35
|
+
additional_params: Additional parameters to include in request.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Token response dictionary with access_token, expires_in, etc.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
PlatformAuthError: If token refresh fails.
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
>>> token_data = await refresh_oauth2_token(
|
|
45
|
+
... refresh_token="refresh_token_here",
|
|
46
|
+
... client_id="my_client_id",
|
|
47
|
+
... client_secret="my_client_secret",
|
|
48
|
+
... token_url="https://oauth.example.com/token"
|
|
49
|
+
... )
|
|
50
|
+
>>> print(token_data["access_token"])
|
|
51
|
+
"""
|
|
52
|
+
params = {
|
|
53
|
+
"grant_type": "refresh_token",
|
|
54
|
+
"refresh_token": refresh_token,
|
|
55
|
+
"client_id": client_id,
|
|
56
|
+
"client_secret": client_secret,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if additional_params:
|
|
60
|
+
params.update(additional_params)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
async with httpx.AsyncClient() as client:
|
|
64
|
+
response = await client.post(
|
|
65
|
+
token_url,
|
|
66
|
+
data=params,
|
|
67
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
68
|
+
timeout=30.0,
|
|
69
|
+
)
|
|
70
|
+
response.raise_for_status()
|
|
71
|
+
return response.json()
|
|
72
|
+
|
|
73
|
+
except httpx.HTTPStatusError as e:
|
|
74
|
+
logger.error(f"HTTP error refreshing token: {e.response.status_code}")
|
|
75
|
+
raise PlatformAuthError(
|
|
76
|
+
f"Failed to refresh token: {e.response.text}",
|
|
77
|
+
platform="oauth2",
|
|
78
|
+
status_code=e.response.status_code,
|
|
79
|
+
) from e
|
|
80
|
+
|
|
81
|
+
except httpx.HTTPError as e:
|
|
82
|
+
logger.error(f"Network error refreshing token: {e}")
|
|
83
|
+
raise PlatformAuthError(
|
|
84
|
+
f"Network error refreshing token: {e}",
|
|
85
|
+
platform="oauth2",
|
|
86
|
+
) from e
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def refresh_twitter_token(
|
|
90
|
+
credentials: AuthCredentials,
|
|
91
|
+
client_id: str,
|
|
92
|
+
client_secret: str,
|
|
93
|
+
) -> AuthCredentials:
|
|
94
|
+
"""Refresh Twitter OAuth2 access token.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
credentials: Current credentials with refresh token.
|
|
98
|
+
client_id: Twitter OAuth client ID.
|
|
99
|
+
client_secret: Twitter OAuth client secret.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Updated credentials with new access token.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
PlatformAuthError: If refresh fails.
|
|
106
|
+
|
|
107
|
+
Example:
|
|
108
|
+
>>> import os
|
|
109
|
+
>>> creds = AuthCredentials(
|
|
110
|
+
... platform="twitter",
|
|
111
|
+
... access_token="old_token",
|
|
112
|
+
... refresh_token="refresh_token_here"
|
|
113
|
+
... )
|
|
114
|
+
>>> refreshed = await refresh_twitter_token(
|
|
115
|
+
... creds,
|
|
116
|
+
... os.getenv("TWITTER_CLIENT_ID"),
|
|
117
|
+
... os.getenv("TWITTER_CLIENT_SECRET")
|
|
118
|
+
... )
|
|
119
|
+
"""
|
|
120
|
+
if not credentials.refresh_token:
|
|
121
|
+
raise PlatformAuthError(
|
|
122
|
+
"No refresh token available",
|
|
123
|
+
platform="twitter",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
token_url = "https://api.x.com/2/oauth2/token"
|
|
127
|
+
|
|
128
|
+
token_data = await refresh_oauth2_token(
|
|
129
|
+
refresh_token=credentials.refresh_token,
|
|
130
|
+
client_id=client_id,
|
|
131
|
+
client_secret=client_secret,
|
|
132
|
+
token_url=token_url,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Update credentials
|
|
136
|
+
credentials.access_token = token_data["access_token"]
|
|
137
|
+
|
|
138
|
+
# Update refresh token if provided
|
|
139
|
+
if "refresh_token" in token_data:
|
|
140
|
+
credentials.refresh_token = token_data["refresh_token"]
|
|
141
|
+
|
|
142
|
+
# Calculate expiry
|
|
143
|
+
if "expires_in" in token_data:
|
|
144
|
+
expires_in = int(token_data["expires_in"])
|
|
145
|
+
credentials.expires_at = datetime.now() + timedelta(seconds=expires_in)
|
|
146
|
+
|
|
147
|
+
return credentials
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def refresh_linkedin_token(
|
|
151
|
+
credentials: AuthCredentials,
|
|
152
|
+
client_id: str,
|
|
153
|
+
client_secret: str,
|
|
154
|
+
) -> AuthCredentials:
|
|
155
|
+
"""Refresh LinkedIn OAuth2 access token.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
credentials: Current credentials with refresh token.
|
|
159
|
+
client_id: LinkedIn OAuth client ID.
|
|
160
|
+
client_secret: LinkedIn OAuth client secret.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Updated credentials with new access token.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
PlatformAuthError: If refresh fails.
|
|
167
|
+
|
|
168
|
+
Example:
|
|
169
|
+
>>> creds = AuthCredentials(
|
|
170
|
+
... platform="linkedin",
|
|
171
|
+
... access_token="old_token",
|
|
172
|
+
... refresh_token="refresh_token_here"
|
|
173
|
+
... )
|
|
174
|
+
>>> refreshed = await refresh_linkedin_token(creds, client_id, client_secret)
|
|
175
|
+
"""
|
|
176
|
+
if not credentials.refresh_token:
|
|
177
|
+
raise PlatformAuthError(
|
|
178
|
+
"No refresh token available",
|
|
179
|
+
platform="linkedin",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
token_url = "https://www.linkedin.com/oauth/v2/accessToken"
|
|
183
|
+
|
|
184
|
+
token_data = await refresh_oauth2_token(
|
|
185
|
+
refresh_token=credentials.refresh_token,
|
|
186
|
+
client_id=client_id,
|
|
187
|
+
client_secret=client_secret,
|
|
188
|
+
token_url=token_url,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Update credentials
|
|
192
|
+
credentials.access_token = token_data["access_token"]
|
|
193
|
+
|
|
194
|
+
# LinkedIn might provide new refresh token
|
|
195
|
+
if "refresh_token" in token_data:
|
|
196
|
+
credentials.refresh_token = token_data["refresh_token"]
|
|
197
|
+
|
|
198
|
+
# Calculate expiry
|
|
199
|
+
if "expires_in" in token_data:
|
|
200
|
+
expires_in = int(token_data["expires_in"])
|
|
201
|
+
credentials.expires_at = datetime.now() + timedelta(seconds=expires_in)
|
|
202
|
+
|
|
203
|
+
return credentials
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def refresh_instagram_token(
|
|
207
|
+
credentials: AuthCredentials,
|
|
208
|
+
) -> AuthCredentials:
|
|
209
|
+
"""Refresh Instagram long-lived access token.
|
|
210
|
+
|
|
211
|
+
Instagram uses a different refresh mechanism - exchanging the current
|
|
212
|
+
long-lived token for a new one.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
credentials: Current credentials.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Updated credentials with refreshed token.
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
PlatformAuthError: If refresh fails.
|
|
222
|
+
|
|
223
|
+
Example:
|
|
224
|
+
>>> creds = AuthCredentials(
|
|
225
|
+
... platform="instagram",
|
|
226
|
+
... access_token="current_token"
|
|
227
|
+
... )
|
|
228
|
+
>>> refreshed = await refresh_instagram_token(creds)
|
|
229
|
+
"""
|
|
230
|
+
url = "https://graph.instagram.com/refresh_access_token"
|
|
231
|
+
params = {
|
|
232
|
+
"grant_type": "ig_refresh_token",
|
|
233
|
+
"access_token": credentials.access_token,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
async with httpx.AsyncClient() as client:
|
|
238
|
+
response = await client.get(url, params=params, timeout=30.0)
|
|
239
|
+
response.raise_for_status()
|
|
240
|
+
data = response.json()
|
|
241
|
+
|
|
242
|
+
# Update credentials
|
|
243
|
+
credentials.access_token = data["access_token"]
|
|
244
|
+
|
|
245
|
+
# Instagram returns expires_in
|
|
246
|
+
if "expires_in" in data:
|
|
247
|
+
expires_in = int(data["expires_in"])
|
|
248
|
+
credentials.expires_at = datetime.now() + timedelta(seconds=expires_in)
|
|
249
|
+
|
|
250
|
+
return credentials
|
|
251
|
+
|
|
252
|
+
except httpx.HTTPStatusError as e:
|
|
253
|
+
logger.error(f"HTTP error refreshing Instagram token: {e.response.status_code}")
|
|
254
|
+
raise PlatformAuthError(
|
|
255
|
+
f"Failed to refresh Instagram token: {e.response.text}",
|
|
256
|
+
platform="instagram",
|
|
257
|
+
status_code=e.response.status_code,
|
|
258
|
+
) from e
|
|
259
|
+
|
|
260
|
+
except httpx.HTTPError as e:
|
|
261
|
+
logger.error(f"Network error refreshing Instagram token: {e}")
|
|
262
|
+
raise PlatformAuthError(
|
|
263
|
+
f"Network error refreshing Instagram token: {e}",
|
|
264
|
+
platform="instagram",
|
|
265
|
+
) from e
|