marqetive-lib 0.1.1__py3-none-any.whl → 0.1.3__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 +13 -13
- marqetive/core/__init__.py +1 -1
- marqetive/core/account_factory.py +2 -2
- marqetive/core/base_manager.py +4 -4
- marqetive/core/client.py +1 -1
- marqetive/core/registry.py +3 -3
- marqetive/platforms/__init__.py +6 -6
- marqetive/platforms/base.py +3 -3
- marqetive/platforms/exceptions.py +2 -1
- marqetive/platforms/instagram/__init__.py +3 -3
- marqetive/platforms/instagram/client.py +4 -4
- marqetive/platforms/instagram/exceptions.py +1 -1
- marqetive/platforms/instagram/factory.py +5 -5
- marqetive/platforms/instagram/manager.py +4 -4
- marqetive/platforms/instagram/media.py +2 -2
- marqetive/platforms/linkedin/__init__.py +3 -3
- marqetive/platforms/linkedin/client.py +4 -4
- marqetive/platforms/linkedin/exceptions.py +1 -1
- marqetive/platforms/linkedin/factory.py +5 -5
- marqetive/platforms/linkedin/manager.py +4 -4
- marqetive/platforms/linkedin/media.py +4 -4
- marqetive/platforms/models.py +2 -0
- marqetive/platforms/tiktok/__init__.py +7 -0
- marqetive/platforms/tiktok/client.py +492 -0
- marqetive/platforms/tiktok/exceptions.py +284 -0
- marqetive/platforms/tiktok/factory.py +188 -0
- marqetive/platforms/tiktok/manager.py +115 -0
- marqetive/platforms/tiktok/media.py +693 -0
- marqetive/platforms/twitter/__init__.py +3 -3
- marqetive/platforms/twitter/client.py +8 -54
- marqetive/platforms/twitter/exceptions.py +1 -1
- marqetive/platforms/twitter/factory.py +5 -6
- marqetive/platforms/twitter/manager.py +4 -4
- marqetive/platforms/twitter/media.py +4 -4
- marqetive/registry_init.py +10 -8
- marqetive/utils/__init__.py +3 -3
- marqetive/utils/file_handlers.py +1 -1
- marqetive/utils/oauth.py +137 -2
- marqetive/utils/token_validator.py +1 -1
- {marqetive_lib-0.1.1.dist-info → marqetive_lib-0.1.3.dist-info}/METADATA +1 -2
- marqetive_lib-0.1.3.dist-info/RECORD +47 -0
- marqetive/platforms/twitter/threads.py +0 -442
- marqetive_lib-0.1.1.dist-info/RECORD +0 -43
- {marqetive_lib-0.1.1.dist-info → marqetive_lib-0.1.3.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""TikTok-specific exception handling and error code mapping.
|
|
2
|
+
|
|
3
|
+
This module provides error handling for the TikTok API, including HTTP status
|
|
4
|
+
code mapping, TikTok-specific error codes, and user-friendly messages.
|
|
5
|
+
|
|
6
|
+
TikTok API uses string error codes in the response format:
|
|
7
|
+
{"error": {"code": "error_code_string", "message": "...", "log_id": "..."}}
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from marqetive.platforms.exceptions import (
|
|
13
|
+
MediaUploadError,
|
|
14
|
+
PlatformAuthError,
|
|
15
|
+
PlatformError,
|
|
16
|
+
PostNotFoundError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
ValidationError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TikTokErrorCode:
|
|
23
|
+
"""TikTok API error codes (string-based).
|
|
24
|
+
|
|
25
|
+
Reference: https://developers.tiktok.com/doc/content-posting-api-reference-direct-post
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
# Success
|
|
29
|
+
OK = "ok"
|
|
30
|
+
|
|
31
|
+
# Authentication & Authorization
|
|
32
|
+
ACCESS_TOKEN_INVALID = "access_token_invalid"
|
|
33
|
+
ACCESS_TOKEN_EXPIRED = "access_token_expired"
|
|
34
|
+
SCOPE_NOT_AUTHORIZED = "scope_not_authorized"
|
|
35
|
+
TOKEN_NOT_AUTHORIZED_FOR_OPEN_ID = "token_not_authorized_for_this_open_id"
|
|
36
|
+
|
|
37
|
+
# Rate Limiting & Spam
|
|
38
|
+
RATE_LIMIT_EXCEEDED = "rate_limit_exceeded"
|
|
39
|
+
SPAM_RISK_TOO_MANY_POSTS = "spam_risk_too_many_posts"
|
|
40
|
+
SPAM_RISK_USER_BANNED = "spam_risk_user_banned_from_posting"
|
|
41
|
+
|
|
42
|
+
# Resource Not Found
|
|
43
|
+
VIDEO_NOT_FOUND = "video_not_found"
|
|
44
|
+
USER_NOT_FOUND = "user_not_found"
|
|
45
|
+
PUBLISH_ID_NOT_FOUND = "publish_id_not_found"
|
|
46
|
+
|
|
47
|
+
# Validation & Privacy Errors
|
|
48
|
+
INVALID_PARAMS = "invalid_params"
|
|
49
|
+
INVALID_FILE_FORMAT = "invalid_file_format"
|
|
50
|
+
VIDEO_DURATION_TOO_LONG = "video_duration_too_long"
|
|
51
|
+
VIDEO_DURATION_TOO_SHORT = "video_duration_too_short"
|
|
52
|
+
PICTURE_NUMBER_EXCEEDS_LIMIT = "picture_number_exceeds_limit"
|
|
53
|
+
PRIVACY_LEVEL_OPTION_MISMATCH = "privacy_level_option_mismatch"
|
|
54
|
+
TITLE_LENGTH_EXCEEDS_LIMIT = "title_length_exceeds_limit"
|
|
55
|
+
|
|
56
|
+
# Upload & Processing
|
|
57
|
+
FILE_FORMAT_CHECK_FAILED = "file_format_check_failed"
|
|
58
|
+
UPLOAD_FAILED = "upload_failed"
|
|
59
|
+
PROCESSING_FAILED = "processing_failed"
|
|
60
|
+
|
|
61
|
+
# Unaudited Client Restrictions
|
|
62
|
+
UNAUDITED_CLIENT_PRIVATE_ONLY = "unaudited_client_can_only_post_to_private_accounts"
|
|
63
|
+
|
|
64
|
+
# Publish Status Values (not errors, but statuses)
|
|
65
|
+
STATUS_PROCESSING_UPLOAD = "PROCESSING_UPLOAD"
|
|
66
|
+
STATUS_PROCESSING_DOWNLOAD = "PROCESSING_DOWNLOAD"
|
|
67
|
+
STATUS_PUBLISH_COMPLETE = "PUBLISH_COMPLETE"
|
|
68
|
+
STATUS_FAILED = "FAILED"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Mapping of error codes to user-friendly messages
|
|
72
|
+
ERROR_MESSAGES: dict[str, str] = {
|
|
73
|
+
# Success
|
|
74
|
+
TikTokErrorCode.OK: "Request completed successfully.",
|
|
75
|
+
# Authentication
|
|
76
|
+
TikTokErrorCode.ACCESS_TOKEN_INVALID: "Invalid access token. Please re-authenticate.",
|
|
77
|
+
TikTokErrorCode.ACCESS_TOKEN_EXPIRED: "Access token has expired. Please refresh the token.",
|
|
78
|
+
TikTokErrorCode.SCOPE_NOT_AUTHORIZED: "The application is not authorized for the requested scope.",
|
|
79
|
+
TikTokErrorCode.TOKEN_NOT_AUTHORIZED_FOR_OPEN_ID: "Token is not authorized for this user.",
|
|
80
|
+
# Rate Limiting
|
|
81
|
+
TikTokErrorCode.RATE_LIMIT_EXCEEDED: "Rate limit exceeded. Please wait before making more requests.",
|
|
82
|
+
TikTokErrorCode.SPAM_RISK_TOO_MANY_POSTS: "Daily posting limit reached (~15 posts/day).",
|
|
83
|
+
TikTokErrorCode.SPAM_RISK_USER_BANNED: "User has been temporarily banned from posting.",
|
|
84
|
+
# Resource Not Found
|
|
85
|
+
TikTokErrorCode.VIDEO_NOT_FOUND: "The requested video could not be found.",
|
|
86
|
+
TikTokErrorCode.USER_NOT_FOUND: "The specified user does not exist.",
|
|
87
|
+
TikTokErrorCode.PUBLISH_ID_NOT_FOUND: "The publish ID was not found.",
|
|
88
|
+
# Validation
|
|
89
|
+
TikTokErrorCode.INVALID_PARAMS: "Invalid parameter in request.",
|
|
90
|
+
TikTokErrorCode.INVALID_FILE_FORMAT: "Invalid file format. Use MP4 or MOV.",
|
|
91
|
+
TikTokErrorCode.VIDEO_DURATION_TOO_LONG: "Video duration exceeds the maximum allowed.",
|
|
92
|
+
TikTokErrorCode.VIDEO_DURATION_TOO_SHORT: "Video duration is below the minimum (3 seconds).",
|
|
93
|
+
TikTokErrorCode.PICTURE_NUMBER_EXCEEDS_LIMIT: "Too many images in photo post.",
|
|
94
|
+
TikTokErrorCode.PRIVACY_LEVEL_OPTION_MISMATCH: "Privacy level not available for this creator.",
|
|
95
|
+
TikTokErrorCode.TITLE_LENGTH_EXCEEDS_LIMIT: "Video title/description is too long.",
|
|
96
|
+
# Upload
|
|
97
|
+
TikTokErrorCode.FILE_FORMAT_CHECK_FAILED: "File format validation failed.",
|
|
98
|
+
TikTokErrorCode.UPLOAD_FAILED: "Media upload failed.",
|
|
99
|
+
TikTokErrorCode.PROCESSING_FAILED: "Media processing failed after upload.",
|
|
100
|
+
# Unaudited
|
|
101
|
+
TikTokErrorCode.UNAUDITED_CLIENT_PRIVATE_ONLY: "Unaudited apps can only post to private accounts.",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def map_tiktok_error(
|
|
106
|
+
status_code: int | None,
|
|
107
|
+
error_code: str | None = None,
|
|
108
|
+
error_message: str | None = None,
|
|
109
|
+
response_data: dict[str, Any] | None = None,
|
|
110
|
+
) -> PlatformError:
|
|
111
|
+
"""Map TikTok API error to the appropriate exception class.
|
|
112
|
+
|
|
113
|
+
TikTok API returns errors in the format:
|
|
114
|
+
{"error": {"code": "error_code_string", "message": "...", "log_id": "..."}}
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
status_code: HTTP status code.
|
|
118
|
+
error_code: TikTok-specific error code string from the response body.
|
|
119
|
+
error_message: Error message from the API.
|
|
120
|
+
response_data: Full response data from the API.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
An appropriate subclass of PlatformError.
|
|
124
|
+
"""
|
|
125
|
+
if response_data and "error" in response_data:
|
|
126
|
+
error_data = response_data["error"]
|
|
127
|
+
if not error_code:
|
|
128
|
+
error_code = error_data.get("code")
|
|
129
|
+
if not error_message:
|
|
130
|
+
error_message = error_data.get("message")
|
|
131
|
+
|
|
132
|
+
friendly_message = ERROR_MESSAGES.get(
|
|
133
|
+
error_code or "", error_message or "An unknown TikTok API error occurred"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Authentication errors
|
|
137
|
+
auth_error_codes = {
|
|
138
|
+
TikTokErrorCode.ACCESS_TOKEN_INVALID,
|
|
139
|
+
TikTokErrorCode.ACCESS_TOKEN_EXPIRED,
|
|
140
|
+
TikTokErrorCode.SCOPE_NOT_AUTHORIZED,
|
|
141
|
+
TikTokErrorCode.TOKEN_NOT_AUTHORIZED_FOR_OPEN_ID,
|
|
142
|
+
}
|
|
143
|
+
if error_code in auth_error_codes or status_code in (401, 403):
|
|
144
|
+
return PlatformAuthError(
|
|
145
|
+
friendly_message,
|
|
146
|
+
platform="tiktok",
|
|
147
|
+
status_code=status_code,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Rate limiting errors
|
|
151
|
+
rate_limit_codes = {
|
|
152
|
+
TikTokErrorCode.RATE_LIMIT_EXCEEDED,
|
|
153
|
+
TikTokErrorCode.SPAM_RISK_TOO_MANY_POSTS,
|
|
154
|
+
TikTokErrorCode.SPAM_RISK_USER_BANNED,
|
|
155
|
+
}
|
|
156
|
+
if error_code in rate_limit_codes or status_code == 429:
|
|
157
|
+
return RateLimitError(
|
|
158
|
+
friendly_message,
|
|
159
|
+
platform="tiktok",
|
|
160
|
+
status_code=status_code or 429,
|
|
161
|
+
retry_after=None,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Not found errors
|
|
165
|
+
not_found_codes = {
|
|
166
|
+
TikTokErrorCode.VIDEO_NOT_FOUND,
|
|
167
|
+
TikTokErrorCode.USER_NOT_FOUND,
|
|
168
|
+
TikTokErrorCode.PUBLISH_ID_NOT_FOUND,
|
|
169
|
+
}
|
|
170
|
+
if error_code in not_found_codes or status_code == 404:
|
|
171
|
+
return PostNotFoundError(
|
|
172
|
+
post_id="",
|
|
173
|
+
platform="tiktok",
|
|
174
|
+
status_code=status_code,
|
|
175
|
+
message=friendly_message,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Validation errors
|
|
179
|
+
validation_codes = {
|
|
180
|
+
TikTokErrorCode.INVALID_PARAMS,
|
|
181
|
+
TikTokErrorCode.INVALID_FILE_FORMAT,
|
|
182
|
+
TikTokErrorCode.VIDEO_DURATION_TOO_LONG,
|
|
183
|
+
TikTokErrorCode.VIDEO_DURATION_TOO_SHORT,
|
|
184
|
+
TikTokErrorCode.PICTURE_NUMBER_EXCEEDS_LIMIT,
|
|
185
|
+
TikTokErrorCode.PRIVACY_LEVEL_OPTION_MISMATCH,
|
|
186
|
+
TikTokErrorCode.TITLE_LENGTH_EXCEEDS_LIMIT,
|
|
187
|
+
TikTokErrorCode.UNAUDITED_CLIENT_PRIVATE_ONLY,
|
|
188
|
+
}
|
|
189
|
+
if error_code in validation_codes:
|
|
190
|
+
return ValidationError(
|
|
191
|
+
friendly_message,
|
|
192
|
+
platform="tiktok",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Media upload errors
|
|
196
|
+
media_error_codes = {
|
|
197
|
+
TikTokErrorCode.FILE_FORMAT_CHECK_FAILED,
|
|
198
|
+
TikTokErrorCode.UPLOAD_FAILED,
|
|
199
|
+
TikTokErrorCode.PROCESSING_FAILED,
|
|
200
|
+
}
|
|
201
|
+
if error_code in media_error_codes:
|
|
202
|
+
return MediaUploadError(
|
|
203
|
+
friendly_message,
|
|
204
|
+
platform="tiktok",
|
|
205
|
+
status_code=status_code,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Generic platform error for everything else
|
|
209
|
+
return PlatformError(
|
|
210
|
+
friendly_message,
|
|
211
|
+
platform="tiktok",
|
|
212
|
+
status_code=status_code,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class TikTokAPIError(PlatformError):
|
|
217
|
+
"""TikTok API specific error with detailed information."""
|
|
218
|
+
|
|
219
|
+
def __init__(
|
|
220
|
+
self,
|
|
221
|
+
message: str,
|
|
222
|
+
*,
|
|
223
|
+
status_code: int | None = None,
|
|
224
|
+
error_code: str | None = None,
|
|
225
|
+
log_id: str | None = None,
|
|
226
|
+
response_data: dict[str, Any] | None = None,
|
|
227
|
+
) -> None:
|
|
228
|
+
super().__init__(message, platform="tiktok", status_code=status_code)
|
|
229
|
+
self.error_code = error_code
|
|
230
|
+
self.log_id = log_id
|
|
231
|
+
self.response_data = response_data
|
|
232
|
+
|
|
233
|
+
def __repr__(self) -> str:
|
|
234
|
+
return (
|
|
235
|
+
f"TikTokAPIError(message={self.message!r}, "
|
|
236
|
+
f"status_code={self.status_code}, "
|
|
237
|
+
f"error_code={self.error_code!r}, "
|
|
238
|
+
f"log_id={self.log_id!r})"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def is_retryable_error(error_code: str | None, status_code: int | None = None) -> bool:
|
|
243
|
+
"""Check if an error is retryable.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
error_code: TikTok error code string.
|
|
247
|
+
status_code: HTTP status code.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
True if the error should be retried, False otherwise.
|
|
251
|
+
"""
|
|
252
|
+
# Server errors are retryable
|
|
253
|
+
if status_code and 500 <= status_code < 600:
|
|
254
|
+
return True
|
|
255
|
+
|
|
256
|
+
# Rate limits should wait and retry
|
|
257
|
+
if error_code == TikTokErrorCode.RATE_LIMIT_EXCEEDED:
|
|
258
|
+
return True
|
|
259
|
+
|
|
260
|
+
# Upload failures may be transient
|
|
261
|
+
return error_code == TikTokErrorCode.UPLOAD_FAILED
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def get_retry_delay(error_code: str | None, attempt: int = 1) -> float:
|
|
265
|
+
"""Get recommended retry delay for an error.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
error_code: TikTok error code string.
|
|
269
|
+
attempt: Current attempt number (1-based).
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
Recommended delay in seconds before retry.
|
|
273
|
+
"""
|
|
274
|
+
# Rate limits - wait longer
|
|
275
|
+
if error_code == TikTokErrorCode.RATE_LIMIT_EXCEEDED:
|
|
276
|
+
return 60.0 # 1 minute
|
|
277
|
+
|
|
278
|
+
# Daily post limit - wait much longer
|
|
279
|
+
if error_code == TikTokErrorCode.SPAM_RISK_TOO_MANY_POSTS:
|
|
280
|
+
return 3600.0 # 1 hour (user should wait until next day)
|
|
281
|
+
|
|
282
|
+
# Default exponential backoff: 5s, 10s, 20s, max 60s
|
|
283
|
+
delay = min(5 * (2 ** (attempt - 1)), 60)
|
|
284
|
+
return float(delay)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""TikTok account factory for managing credentials and client creation."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
|
|
8
|
+
from marqetive.core.account_factory import BaseAccountFactory
|
|
9
|
+
from marqetive.platforms.exceptions import PlatformAuthError
|
|
10
|
+
from marqetive.platforms.models import AccountStatus, AuthCredentials
|
|
11
|
+
from marqetive.platforms.tiktok.client import TikTokClient
|
|
12
|
+
from marqetive.utils.oauth import fetch_tiktok_token, refresh_tiktok_token
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TikTokAccountFactory(BaseAccountFactory):
|
|
18
|
+
"""Factory for creating and managing TikTok accounts and clients.
|
|
19
|
+
|
|
20
|
+
This factory handles the instantiation of TikTokClients, manages OAuth
|
|
21
|
+
credentials, and provides a mechanism for token refreshing.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
client_id: str | None = None,
|
|
27
|
+
client_secret: str | None = None,
|
|
28
|
+
on_status_update: Callable[[str, AccountStatus], None] | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Initialize TikTok account factory.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
client_id: TikTok App client ID (uses TIKTOK_CLIENT_ID env if None).
|
|
34
|
+
client_secret: TikTok App client secret (uses TIKTOK_CLIENT_SECRET env if None).
|
|
35
|
+
on_status_update: Optional callback when account status changes.
|
|
36
|
+
"""
|
|
37
|
+
super().__init__(on_status_update=on_status_update)
|
|
38
|
+
self.client_id = client_id or os.getenv("TIKTOK_CLIENT_ID")
|
|
39
|
+
self.client_secret = client_secret or os.getenv("TIKTOK_CLIENT_SECRET")
|
|
40
|
+
|
|
41
|
+
if not self.client_id or not self.client_secret:
|
|
42
|
+
logger.warning(
|
|
43
|
+
"TikTok client_id/client_secret not provided. "
|
|
44
|
+
"Token refresh may not work."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def platform_name(self) -> str:
|
|
49
|
+
"""Get the platform name."""
|
|
50
|
+
return "tiktok"
|
|
51
|
+
|
|
52
|
+
async def get_credentials_from_auth_code(
|
|
53
|
+
self,
|
|
54
|
+
auth_code: str,
|
|
55
|
+
redirect_uri: str,
|
|
56
|
+
code_verifier: str | None = None,
|
|
57
|
+
) -> AuthCredentials:
|
|
58
|
+
"""Get credentials from an authorization code.
|
|
59
|
+
|
|
60
|
+
This method exchanges an authorization code for an access token and
|
|
61
|
+
formats it into the standard AuthCredentials object.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
auth_code: The authorization code received from TikTok.
|
|
65
|
+
redirect_uri: The redirect URI used in the initial auth request.
|
|
66
|
+
code_verifier: The PKCE code verifier, if used.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
An AuthCredentials object for the user.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
PlatformAuthError: If the token exchange fails.
|
|
73
|
+
"""
|
|
74
|
+
if not self.client_id or not self.client_secret:
|
|
75
|
+
raise PlatformAuthError(
|
|
76
|
+
"TikTok client_id and client_secret are required.",
|
|
77
|
+
platform=self.platform_name,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
token_data = await fetch_tiktok_token(
|
|
81
|
+
code=auth_code,
|
|
82
|
+
client_id=self.client_id,
|
|
83
|
+
client_secret=self.client_secret,
|
|
84
|
+
redirect_uri=redirect_uri,
|
|
85
|
+
code_verifier=code_verifier,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
expires_at = None
|
|
89
|
+
if "expires_in" in token_data:
|
|
90
|
+
expires_at = datetime.now() + timedelta(seconds=token_data["expires_in"])
|
|
91
|
+
|
|
92
|
+
refresh_expires_at = None
|
|
93
|
+
if "refresh_expires_in" in token_data:
|
|
94
|
+
refresh_expires_at = datetime.now() + timedelta(
|
|
95
|
+
seconds=token_data["refresh_expires_in"]
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
credentials = AuthCredentials(
|
|
99
|
+
platform=self.platform_name,
|
|
100
|
+
access_token=token_data["access_token"],
|
|
101
|
+
refresh_token=token_data.get("refresh_token"),
|
|
102
|
+
expires_at=expires_at,
|
|
103
|
+
scope=token_data.get("scope", []),
|
|
104
|
+
additional_data={
|
|
105
|
+
"open_id": token_data["open_id"],
|
|
106
|
+
"token_type": token_data.get("token_type"),
|
|
107
|
+
"refresh_expires_at": refresh_expires_at,
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
return credentials
|
|
111
|
+
|
|
112
|
+
async def refresh_token(self, credentials: AuthCredentials) -> AuthCredentials:
|
|
113
|
+
"""Refresh a TikTok OAuth2 access token.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
credentials: The current credentials containing the refresh token.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Updated credentials with a new access token.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
PlatformAuthError: If refresh fails or credentials are missing.
|
|
123
|
+
"""
|
|
124
|
+
if not self.client_id or not self.client_secret:
|
|
125
|
+
raise PlatformAuthError(
|
|
126
|
+
"TikTok client_id and client_secret are required for token refresh.",
|
|
127
|
+
platform=self.platform_name,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if not credentials.refresh_token:
|
|
131
|
+
raise PlatformAuthError(
|
|
132
|
+
"No refresh token available for TikTok.", platform=self.platform_name
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
logger.info("Refreshing TikTok access token...")
|
|
136
|
+
# This function would call the actual TikTok token refresh endpoint
|
|
137
|
+
return await refresh_tiktok_token(
|
|
138
|
+
credentials, self.client_id, self.client_secret
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
async def create_client(self, credentials: AuthCredentials) -> TikTokClient:
|
|
142
|
+
"""Create a TikTok API client.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
credentials: Valid TikTok authentication credentials.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
An instance of TikTokClient.
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
PlatformAuthError: If credentials are incomplete or invalid.
|
|
152
|
+
"""
|
|
153
|
+
if not credentials.access_token:
|
|
154
|
+
raise PlatformAuthError(
|
|
155
|
+
"Access token is required for TikTok.", platform=self.platform_name
|
|
156
|
+
)
|
|
157
|
+
if (
|
|
158
|
+
not credentials.additional_data
|
|
159
|
+
or "open_id" not in credentials.additional_data
|
|
160
|
+
):
|
|
161
|
+
raise PlatformAuthError(
|
|
162
|
+
"'open_id' must be provided in additional_data for TikTok.",
|
|
163
|
+
platform=self.platform_name,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return TikTokClient(credentials=credentials)
|
|
167
|
+
|
|
168
|
+
async def validate_credentials(self, credentials: AuthCredentials) -> bool:
|
|
169
|
+
"""Validate TikTok credentials by making a test API call.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
credentials: The credentials to validate.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
True if the credentials are valid, False otherwise.
|
|
176
|
+
"""
|
|
177
|
+
try:
|
|
178
|
+
client = await self.create_client(credentials)
|
|
179
|
+
async with client:
|
|
180
|
+
return await client.is_authenticated()
|
|
181
|
+
except PlatformAuthError as e:
|
|
182
|
+
logger.warning(f"TikTok credential validation failed: {e}")
|
|
183
|
+
return False
|
|
184
|
+
except Exception as e:
|
|
185
|
+
logger.error(
|
|
186
|
+
f"An unexpected error occurred during TikTok credential validation: {e}"
|
|
187
|
+
)
|
|
188
|
+
return False
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""TikTok post manager for handling video post operations."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from marqetive.core.base_manager import BasePostManager
|
|
7
|
+
from marqetive.platforms.models import AuthCredentials, Post, PostCreateRequest
|
|
8
|
+
from marqetive.platforms.tiktok.client import TikTokClient
|
|
9
|
+
from marqetive.platforms.tiktok.factory import TikTokAccountFactory
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TikTokPostManager(BasePostManager):
|
|
15
|
+
"""Manager for TikTok video post operations.
|
|
16
|
+
|
|
17
|
+
This manager coordinates the multi-step process of posting a video to
|
|
18
|
+
TikTok, including media upload, processing, and the final publishing step.
|
|
19
|
+
It provides progress tracking throughout the operation.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
account_factory: TikTokAccountFactory | None = None,
|
|
25
|
+
client_id: str | None = None,
|
|
26
|
+
client_secret: str | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Initialize the TikTok post manager.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
account_factory: An instance of TikTokAccountFactory. If not provided,
|
|
32
|
+
a default one will be created.
|
|
33
|
+
client_id: TikTok App client ID (for the default factory).
|
|
34
|
+
client_secret: TikTok App client secret (for the default factory).
|
|
35
|
+
"""
|
|
36
|
+
if account_factory is None:
|
|
37
|
+
account_factory = TikTokAccountFactory(
|
|
38
|
+
client_id=client_id,
|
|
39
|
+
client_secret=client_secret,
|
|
40
|
+
)
|
|
41
|
+
super().__init__(account_factory=account_factory)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def platform_name(self) -> str:
|
|
45
|
+
"""Get the platform name."""
|
|
46
|
+
return "tiktok"
|
|
47
|
+
|
|
48
|
+
async def _execute_post_impl(
|
|
49
|
+
self,
|
|
50
|
+
client: Any,
|
|
51
|
+
request: PostCreateRequest,
|
|
52
|
+
credentials: AuthCredentials, # noqa: ARG002
|
|
53
|
+
) -> Post:
|
|
54
|
+
"""Execute the TikTok video post creation process.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
client: An authenticated TikTokClient instance.
|
|
58
|
+
request: The post creation request.
|
|
59
|
+
credentials: The credentials used for authentication.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The created Post object representing the TikTok video.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
TypeError: If the provided client is not a TikTokClient.
|
|
66
|
+
InterruptedError: If the operation is cancelled.
|
|
67
|
+
"""
|
|
68
|
+
if not isinstance(client, TikTokClient):
|
|
69
|
+
raise TypeError(f"Expected TikTokClient, got {type(client)}")
|
|
70
|
+
|
|
71
|
+
# The TikTok posting process is primarily about the video upload.
|
|
72
|
+
# The client's create_post method handles the upload and publish steps.
|
|
73
|
+
# We can wrap it here to provide progress updates.
|
|
74
|
+
|
|
75
|
+
self._progress_tracker.emit_start(
|
|
76
|
+
"execute_post", total=100, message="Starting TikTok post..."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if self.is_cancelled():
|
|
80
|
+
raise InterruptedError("Post creation was cancelled before start.")
|
|
81
|
+
|
|
82
|
+
# Media upload progress can be tracked inside the client, but for simplicity,
|
|
83
|
+
# we'll emit high-level progress here.
|
|
84
|
+
self._progress_tracker.emit_progress(
|
|
85
|
+
"execute_post",
|
|
86
|
+
progress=10,
|
|
87
|
+
total=100,
|
|
88
|
+
message="Uploading video to TikTok...",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# The `create_post` method in the client will handle the full flow:
|
|
92
|
+
# 1. Upload media
|
|
93
|
+
# 2. Publish post
|
|
94
|
+
# 3. Fetch final post data
|
|
95
|
+
# A more advanced implementation might involve callbacks from the client
|
|
96
|
+
# to the manager for more granular progress updates.
|
|
97
|
+
post = await client.create_post(request)
|
|
98
|
+
|
|
99
|
+
if self.is_cancelled():
|
|
100
|
+
# If cancellation happened during the client call, we might need cleanup.
|
|
101
|
+
# For now, we just raise.
|
|
102
|
+
raise InterruptedError("Post creation was cancelled during execution.")
|
|
103
|
+
|
|
104
|
+
self._progress_tracker.emit_progress(
|
|
105
|
+
"execute_post",
|
|
106
|
+
progress=90,
|
|
107
|
+
total=100,
|
|
108
|
+
message="Finalizing post...",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
self._progress_tracker.emit_complete(
|
|
112
|
+
"execute_post", message="TikTok post published successfully!"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return post
|