From e195d383f957a0267815c040d397846b1689710a Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Thu, 15 Jan 2026 00:38:31 -0800 Subject: [PATCH 01/10] Remove performance config, hardcode maximum throughput values Simplify configuration by removing most performance-related env vars and hardcoding values optimized for maximum resource usage: - ThreadPoolExecutor: 500 workers (vs default 32) - aiohttp connections: unlimited (limit=0) - curl_cffi pool: 10000 max_clients - Image downloads: no concurrency limit (removed semaphore) Keep only 3 user-configurable limits via env vars: - MAX_USER_QUEUE_SIZE (default 0 = no limit) - STREAMING_DURATION_THRESHOLD (default 300s) - MAX_VIDEO_DURATION (default 0 = no limit) --- .env.example | 56 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/.env.example b/.env.example index da4dce6..f56eb79 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,10 @@ -TG_SERVER=http://telegram-bot-api:8081 -TELEGRAM_API_ID=1234567 -TELEGRAM_API_HASH=abc123 - -DB_URL=postgresql://postgres:postgres@db/ttbot-db # tt-bot BOT_TOKEN=12345:abcde ADMIN_IDS=[1234567] # SECOND_IDS=[1234567] JOIN_LOGS=-1234567 STORAGE_CHANNEL_ID=12345 -# API settings -BOTSTAT=abcdefg12345 -MONETAG_URL=https://example.com/your-monetag-link/ + # stats-bot STATS_BOT_TOKEN=12345:abcde STATS_IDS=[-1234567] @@ -19,32 +12,53 @@ STATS_CHAT=-1234567 STATS_MESSAGE_ID=23 DAILY_STATS_MESSAGE_ID=24 +# API settings +BOTSTAT=abcdefg12345 +MONETAG_URL=https://example.com/your-monetag-link/ + # Logging settings (optional) # LOG_LEVEL=INFO # Options: DEBUG, INFO, WARNING, ERROR, CRITICAL # yt-dlp settings (optional) -YTDLP_COOKIES=cookies.txt +# YTDLP_COOKIES=cookies.txt # Proxy settings (load balancing with multiple proxies) # Path to file with proxy list (one proxy URL per line) -PROXY_FILE=proxies.txt +# PROXY_FILE=proxies.txt # Use proxy only for TikTok API requests, not for media downloads # PROXY_DATA_ONLY=false # Include host machine's direct IP in round-robin rotation # PROXY_INCLUDE_HOST=false +# Performance settings (for high-throughput scenarios) +# ThreadPoolExecutor workers for sync yt-dlp extraction (default: 128) +# THREAD_POOL_SIZE=128 +# Total aiohttp connection pool size for URL resolution (default: 200) +# AIOHTTP_POOL_SIZE=200 +# Per-host connection limit (default: 50) +# AIOHTTP_LIMIT_PER_HOST=50 +# Max parallel image downloads per slideshow (default: 20) +# MAX_CONCURRENT_IMAGES=20 +# curl_cffi connection pool size for media downloads (default: 200) +# CURL_POOL_SIZE=200 +# Use streaming for videos longer than this (seconds, default: 300 = 5 min) +# STREAMING_DURATION_THRESHOLD=300 +# Maximum video duration in seconds (default: 1800 = 30 min, 0 = no limit) +# MAX_VIDEO_DURATION=1800 + +# Queue settings (optional, defaults shown) +# MAX_USER_QUEUE_SIZE=3 + # Retry settings - 3-part retry strategy with proxy rotation # Part 1: URL resolution retries (short URLs to full URLs) -URL_RESOLVE_MAX_RETRIES=3 +# URL_RESOLVE_MAX_RETRIES=3 # Part 2: Video info extraction retries (metadata) -VIDEO_INFO_MAX_RETRIES=3 +# VIDEO_INFO_MAX_RETRIES=3 # Part 3: Download retries (video/images/audio) -DOWNLOAD_MAX_RETRIES=3 - -# Limits (optional, 0 = no limit) -# Max concurrent videos per user in queue -MAX_USER_QUEUE_SIZE=0 -# Use streaming for videos longer than this (seconds, 0 = never stream) -STREAMING_DURATION_THRESHOLD=300 -# Maximum video duration in seconds (0 = no limit) -MAX_VIDEO_DURATION=0 +# DOWNLOAD_MAX_RETRIES=3 + +# Telegram Bot API +TG_SERVER=http://telegram-bot-api:8081 + +# db +DB_URL=postgresql://postgres:postgres@db/ttbot-db From 7295e49416700e1d0134e6e86d0a025cdcac24c2 Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Thu, 15 Jan 2026 00:49:11 -0800 Subject: [PATCH 02/10] Change positions of values in .env.example --- .env.example | 53 ++++++++++++++++++---------------------------------- 1 file changed, 18 insertions(+), 35 deletions(-) diff --git a/.env.example b/.env.example index f56eb79..2ba7825 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,14 @@ +TG_SERVER=http://telegram-bot-api:8081 +DB_URL=postgresql://postgres:postgres@db/ttbot-db # tt-bot BOT_TOKEN=12345:abcde ADMIN_IDS=[1234567] # SECOND_IDS=[1234567] JOIN_LOGS=-1234567 STORAGE_CHANNEL_ID=12345 - +# API settings +BOTSTAT=abcdefg12345 +MONETAG_URL=https://example.com/your-monetag-link/ # stats-bot STATS_BOT_TOKEN=12345:abcde STATS_IDS=[-1234567] @@ -12,53 +16,32 @@ STATS_CHAT=-1234567 STATS_MESSAGE_ID=23 DAILY_STATS_MESSAGE_ID=24 -# API settings -BOTSTAT=abcdefg12345 -MONETAG_URL=https://example.com/your-monetag-link/ - # Logging settings (optional) # LOG_LEVEL=INFO # Options: DEBUG, INFO, WARNING, ERROR, CRITICAL # yt-dlp settings (optional) -# YTDLP_COOKIES=cookies.txt +YTDLP_COOKIES=cookies.txt # Proxy settings (load balancing with multiple proxies) # Path to file with proxy list (one proxy URL per line) -# PROXY_FILE=proxies.txt +PROXY_FILE=proxies.txt # Use proxy only for TikTok API requests, not for media downloads # PROXY_DATA_ONLY=false # Include host machine's direct IP in round-robin rotation # PROXY_INCLUDE_HOST=false -# Performance settings (for high-throughput scenarios) -# ThreadPoolExecutor workers for sync yt-dlp extraction (default: 128) -# THREAD_POOL_SIZE=128 -# Total aiohttp connection pool size for URL resolution (default: 200) -# AIOHTTP_POOL_SIZE=200 -# Per-host connection limit (default: 50) -# AIOHTTP_LIMIT_PER_HOST=50 -# Max parallel image downloads per slideshow (default: 20) -# MAX_CONCURRENT_IMAGES=20 -# curl_cffi connection pool size for media downloads (default: 200) -# CURL_POOL_SIZE=200 -# Use streaming for videos longer than this (seconds, default: 300 = 5 min) -# STREAMING_DURATION_THRESHOLD=300 -# Maximum video duration in seconds (default: 1800 = 30 min, 0 = no limit) -# MAX_VIDEO_DURATION=1800 - -# Queue settings (optional, defaults shown) -# MAX_USER_QUEUE_SIZE=3 - # Retry settings - 3-part retry strategy with proxy rotation # Part 1: URL resolution retries (short URLs to full URLs) -# URL_RESOLVE_MAX_RETRIES=3 +URL_RESOLVE_MAX_RETRIES=3 # Part 2: Video info extraction retries (metadata) -# VIDEO_INFO_MAX_RETRIES=3 +VIDEO_INFO_MAX_RETRIES=3 # Part 3: Download retries (video/images/audio) -# DOWNLOAD_MAX_RETRIES=3 - -# Telegram Bot API -TG_SERVER=http://telegram-bot-api:8081 - -# db -DB_URL=postgresql://postgres:postgres@db/ttbot-db +DOWNLOAD_MAX_RETRIES=3 + +# Limits (optional, 0 = no limit) +# Max concurrent videos per user in queue +MAX_USER_QUEUE_SIZE=0 +# Use streaming for videos longer than this (seconds, 0 = never stream) +STREAMING_DURATION_THRESHOLD=300 +# Maximum video duration in seconds (0 = no limit) +MAX_VIDEO_DURATION=0 From 8d109a8f6c3c58b6bdc0722858f586846a91ef5e Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Thu, 15 Jan 2026 00:53:16 -0800 Subject: [PATCH 03/10] Add Telegram API credentials to .env.example --- .env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.example b/.env.example index 2ba7825..da4dce6 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,7 @@ TG_SERVER=http://telegram-bot-api:8081 +TELEGRAM_API_ID=1234567 +TELEGRAM_API_HASH=abc123 + DB_URL=postgresql://postgres:postgres@db/ttbot-db # tt-bot BOT_TOKEN=12345:abcde From acff6be9cc76c9a79f9f80ce12f1718c3537b0e8 Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Thu, 15 Jan 2026 21:10:54 -0800 Subject: [PATCH 04/10] Update CODEBASE_MAP.md --- docs/CODEBASE_MAP.md | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/docs/CODEBASE_MAP.md b/docs/CODEBASE_MAP.md index 720af88..4e769f6 100644 --- a/docs/CODEBASE_MAP.md +++ b/docs/CODEBASE_MAP.md @@ -1,12 +1,12 @@ --- -last_mapped: 2026-01-14T22:45:00Z +last_mapped: 2026-01-15T12:00:00Z total_files: 61 -total_tokens: 71358 +total_tokens: 69443 --- # Codebase Map -> Auto-generated by Cartographer. Last mapped: 2026-01-14 +> Auto-generated by Cartographer. Last mapped: 2026-01-15 ## System Overview @@ -168,11 +168,13 @@ tt-bot/ | File | Purpose | Tokens | |------|---------|--------| -| client.py | Main TikTokClient + ProxySession + 3-part retry | 18,676 | +| client.py | Main TikTokClient + ProxySession + 3-part retry | 16,499 | | proxy_manager.py | Thread-safe round-robin proxy rotation | 1,303 | -| models.py | VideoInfo, MusicInfo dataclasses | 1,091 | +| models.py | VideoInfo, MusicInfo dataclasses | 1,079 | | exceptions.py | Exception hierarchy (9 error types) | 233 | +**Note:** `VideoInfo.author` field was removed (unused in codebase). + **Key Classes:** - `TikTokClient`: Main extraction client with integrated retry - `ProxySession`: Manages proxy state per request flow (sticky until retry) @@ -263,8 +265,8 @@ tt-bot/ | File | Purpose | Tokens | |------|---------|--------| -| video_types.py | Video/image sending, slideshow retry, HEIC conversion | 6,374 | -| queue_manager.py | Per-user concurrency limits | 1,021 | +| video_types.py | Video/image sending, slideshow retry, HEIC conversion | 6,322 | +| queue_manager.py | Per-user concurrency limits | 1,029 | | utils.py | Helpers (lang resolution, user registration) | 692 | **Key Functions:** @@ -273,6 +275,10 @@ tt-bot/ - `send_music_result()`: Send audio with cover - `QueueManager.info_queue()`: Acquire/release queue slot +**Note:** Thumbnail download thresholds: +- Inline messages: >30s (lowered from 60s) +- Regular messages: >60s + --- ### Stats Module (`stats/`) @@ -516,6 +522,8 @@ sequenceDiagram | `BOT_TOKEN` | Main bot token | | `DB_URL` | PostgreSQL connection string | | `TG_SERVER` | Telegram API server URL | +| `TELEGRAM_API_ID` | Telegram API ID (for custom Bot API server) | +| `TELEGRAM_API_HASH` | Telegram API hash (for custom Bot API server) | ### Retry Configuration (NEW) | Variable | Default | Description | @@ -527,10 +535,11 @@ sequenceDiagram ### Performance | Variable | Default | Description | |----------|---------|-------------| -| `THREAD_POOL_SIZE` | 128 | ThreadPoolExecutor workers | -| `MAX_USER_QUEUE_SIZE` | 3 | Max concurrent per user | -| `MAX_CONCURRENT_IMAGES` | 20 | Max parallel image downloads | -| `MAX_VIDEO_DURATION` | 1800 | Max video duration (seconds, 0=unlimited) | +| `MAX_USER_QUEUE_SIZE` | 0 | Max concurrent per user (0=unlimited) | +| `MAX_VIDEO_DURATION` | 0 | Max video duration (seconds, 0=unlimited) | +| `STREAMING_DURATION_THRESHOLD` | 300 | Stream videos longer than this (seconds) | | `LOG_LEVEL` | INFO | Logging level | +**Note:** Thread pool (500 workers) and curl_cffi connections (10,000) are hardcoded for maximum throughput. + See `.env.example` for complete list. From 8745d5a8c071d0ee40357e80add728d86a0df366 Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Thu, 15 Jan 2026 21:30:22 -0800 Subject: [PATCH 05/10] Fix TikTok extraction with proxies by using direct connection for metadata TikTok's browser impersonation (impersonate=True) doesn't work through HTTP proxies, causing extraction to fail with "Unable to extract webpage video data". Changed approach: - Use direct connection (no proxy) for video info extraction with impersonate - Use proxy for media downloads to hide server IP This fixes the issue where all proxy attempts would fail due to TikTok's JavaScript challenge blocking non-browser requests through proxies. --- tiktok_api/client.py | 99 +++++++++++--------------------------------- 1 file changed, 25 insertions(+), 74 deletions(-) diff --git a/tiktok_api/client.py b/tiktok_api/client.py index c36aab5..8ba7359 100644 --- a/tiktok_api/client.py +++ b/tiktok_api/client.py @@ -848,76 +848,25 @@ def _extract_with_context_sync( try: # Use yt-dlp's internal method to get raw webpage data # This also sets up all necessary cookies - # NOTE: When using a proxy, yt-dlp's impersonate=True feature - # doesn't work correctly. We need to download without impersonate. + # NOTE: TikTok's impersonate feature doesn't work through HTTP proxies. + # Always use direct connection for extraction, proxy is used for downloads. + saved_proxy = None # Will store proxy for download context if self.proxy_manager and self.proxy_manager.has_proxies(): - # Download webpage without impersonate to avoid proxy issues - res = ie._download_webpage_handle( - normalized_url, video_id, fatal=False, impersonate=False - ) - if res is False: - raise TikTokExtractionError( - f"Failed to download webpage for video {video_id}" - ) - - webpage, urlh = res - - # Check for login redirect - import urllib.parse - - if urllib.parse.urlparse(urlh.url).path == "/login": - raise TikTokExtractionError( - "TikTok is requiring login for access to this content" - ) - - # Extract data manually using yt-dlp's helper methods - video_data = None - status = -1 - - # Try universal data first - if universal_data := ie._get_universal_data(webpage, video_id): - from yt_dlp.utils import traverse_obj - - status = ( - traverse_obj( - universal_data, - ("webapp.video-detail", "statusCode", {int}), - ) - or 0 - ) - video_data = traverse_obj( - universal_data, - ("webapp.video-detail", "itemInfo", "itemStruct", {dict}), - ) - - # Try sigi state data - elif sigi_data := ie._get_sigi_state(webpage, video_id): - from yt_dlp.utils import traverse_obj - - status = ( - traverse_obj(sigi_data, ("VideoPage", "statusCode", {int})) - or 0 - ) - video_data = traverse_obj( - sigi_data, ("ItemModule", video_id, {dict}) - ) - - # Try next.js data - elif next_data := ie._search_nextjs_data( - webpage, video_id, default={} - ): - from yt_dlp.utils import traverse_obj + # Download webpage without proxy but with impersonate + # Save current proxy setting and temporarily disable it + saved_proxy = ydl_opts.get("proxy") + if "proxy" in ydl_opts: + del ydl_opts["proxy"] + # Recreate YDL without proxy for extraction + ydl.close() + ydl = yt_dlp.YoutubeDL(ydl_opts) + ie = ydl.get_info_extractor("TikTok") + ie.set_downloader(ydl) - status = ( - traverse_obj( - next_data, ("props", "pageProps", "statusCode", {int}) - ) - or 0 - ) - video_data = traverse_obj( - next_data, - ("props", "pageProps", "itemInfo", "itemStruct", {dict}), - ) + # Use standard extraction with impersonate (no proxy) + video_data, status = ie._extract_web_data_and_status( + normalized_url, video_id + ) # Check TikTok status codes for errors # 10204 = Video not found / deleted @@ -929,11 +878,6 @@ def _extract_with_context_sync( return None, "private", None elif status == 10216: return None, "deleted", None # Treat under review as deleted - - if not video_data: - raise TikTokExtractionError( - f"Unable to extract webpage video data (status: {status})" - ) else: # No proxy, use the standard method with impersonate video_data, status = ie._extract_web_data_and_status( @@ -958,11 +902,18 @@ def _extract_with_context_sync( ) from e # Create download context with the live instances + # For proxy path, use the saved_proxy (extraction was without proxy, downloads use proxy) + # For non-proxy path, use request_proxy as before + context_proxy = ( + saved_proxy + if self.proxy_manager and self.proxy_manager.has_proxies() + else request_proxy + ) download_context = { "ydl": ydl, "ie": ie, "referer_url": url, - "proxy": request_proxy, # Store proxy for per-request assignment + "proxy": context_proxy, # Store proxy for per-request assignment } # Success - transfer ownership of ydl to caller via download_context From 248c0501626cf520f70a79dbcf32834a4187edaa Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Thu, 15 Jan 2026 21:51:22 -0800 Subject: [PATCH 06/10] Improve error handling in ydl recreation for proxy extraction Create the new YoutubeDL instance before closing the old one to ensure we have a valid ydl even if initialization fails. --- tiktok_api/client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tiktok_api/client.py b/tiktok_api/client.py index 8ba7359..f00d911 100644 --- a/tiktok_api/client.py +++ b/tiktok_api/client.py @@ -858,8 +858,11 @@ def _extract_with_context_sync( if "proxy" in ydl_opts: del ydl_opts["proxy"] # Recreate YDL without proxy for extraction - ydl.close() + # Create new instance first to ensure we have a valid ydl + # even if something goes wrong during recreation + old_ydl = ydl ydl = yt_dlp.YoutubeDL(ydl_opts) + old_ydl.close() # Close old instance after new one is ready ie = ydl.get_info_extractor("TikTok") ie.set_downloader(ydl) From 14c1abe74bdebc14145996fa9f738e43f908a82a Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Thu, 15 Jan 2026 21:56:26 -0800 Subject: [PATCH 07/10] Add video_data validation after TikTok extraction Return extraction error if video_data is None despite a non-error status code, preventing downstream issues from invalid data. --- tiktok_api/client.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tiktok_api/client.py b/tiktok_api/client.py index f00d911..ecb7f76 100644 --- a/tiktok_api/client.py +++ b/tiktok_api/client.py @@ -881,6 +881,11 @@ def _extract_with_context_sync( return None, "private", None elif status == 10216: return None, "deleted", None # Treat under review as deleted + + # Validate that we got video data + if not video_data: + logger.error(f"No video data returned for {video_id} (status={status})") + return None, "extraction", None else: # No proxy, use the standard method with impersonate video_data, status = ie._extract_web_data_and_status( @@ -894,6 +899,11 @@ def _extract_with_context_sync( return None, "private", None elif status == 10216: return None, "deleted", None # Treat under review as deleted + + # Validate that we got video data + if not video_data: + logger.error(f"No video data returned for {video_id} (status={status})") + return None, "extraction", None except AttributeError as e: logger.error( f"Failed to call yt-dlp internal method: {e}. " From 74283324b15c2ab4be33bec3e3746863b7cb89bd Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Thu, 15 Jan 2026 22:26:05 -0800 Subject: [PATCH 08/10] Always use proxy for TikTok extraction Remove logic that stripped proxy from ydl_opts during extraction. Datacenter IPs are typically blocked by TikTok, so extraction must use the configured proxy to work on servers. --- tiktok_api/client.py | 75 ++++++++++++-------------------------------- 1 file changed, 20 insertions(+), 55 deletions(-) diff --git a/tiktok_api/client.py b/tiktok_api/client.py index ecb7f76..8186ff2 100644 --- a/tiktok_api/client.py +++ b/tiktok_api/client.py @@ -848,62 +848,27 @@ def _extract_with_context_sync( try: # Use yt-dlp's internal method to get raw webpage data # This also sets up all necessary cookies - # NOTE: TikTok's impersonate feature doesn't work through HTTP proxies. - # Always use direct connection for extraction, proxy is used for downloads. - saved_proxy = None # Will store proxy for download context - if self.proxy_manager and self.proxy_manager.has_proxies(): - # Download webpage without proxy but with impersonate - # Save current proxy setting and temporarily disable it - saved_proxy = ydl_opts.get("proxy") - if "proxy" in ydl_opts: - del ydl_opts["proxy"] - # Recreate YDL without proxy for extraction - # Create new instance first to ensure we have a valid ydl - # even if something goes wrong during recreation - old_ydl = ydl - ydl = yt_dlp.YoutubeDL(ydl_opts) - old_ydl.close() # Close old instance after new one is ready - ie = ydl.get_info_extractor("TikTok") - ie.set_downloader(ydl) - - # Use standard extraction with impersonate (no proxy) - video_data, status = ie._extract_web_data_and_status( - normalized_url, video_id - ) - - # Check TikTok status codes for errors - # 10204 = Video not found / deleted - # 10216 = Video under review - # 10222 = Private video - if status == 10204: - return None, "deleted", None - elif status == 10222: - return None, "private", None - elif status == 10216: - return None, "deleted", None # Treat under review as deleted - - # Validate that we got video data - if not video_data: - logger.error(f"No video data returned for {video_id} (status={status})") - return None, "extraction", None - else: - # No proxy, use the standard method with impersonate - video_data, status = ie._extract_web_data_and_status( - normalized_url, video_id - ) + # NOTE: Always use proxy for extraction if configured, as datacenter + # IPs are typically blocked by TikTok. + video_data, status = ie._extract_web_data_and_status( + normalized_url, video_id + ) - # Check TikTok status codes for errors (same as proxy path) - if status == 10204: - return None, "deleted", None - elif status == 10222: - return None, "private", None - elif status == 10216: - return None, "deleted", None # Treat under review as deleted - - # Validate that we got video data - if not video_data: - logger.error(f"No video data returned for {video_id} (status={status})") - return None, "extraction", None + # Check TikTok status codes for errors + # 10204 = Video not found / deleted + # 10216 = Video under review + # 10222 = Private video + if status == 10204: + return None, "deleted", None + elif status == 10222: + return None, "private", None + elif status == 10216: + return None, "deleted", None # Treat under review as deleted + + # Validate that we got video data + if not video_data: + logger.error(f"No video data returned for {video_id} (status={status})") + return None, "extraction", None except AttributeError as e: logger.error( f"Failed to call yt-dlp internal method: {e}. " From bbc59c1e357c84643cc3b80ed756196df97737d5 Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Thu, 15 Jan 2026 23:28:09 -0800 Subject: [PATCH 09/10] Fix TikTok WAF blocking by using Chrome 120 impersonation TikTok's WAF blocks newer Chrome versions (136+) when used with proxies due to TLS fingerprint / User-Agent mismatches. This commit: - Use fixed Chrome 120 impersonation target instead of auto-selecting newest - Set matching User-Agent header for yt-dlp extraction and media downloads - Add per-proxy session pool to avoid proxy contamination between requests - Bake proxy into curl_cffi sessions at construction time --- tiktok_api/client.py | 171 ++++++++++++++++++++----------------------- 1 file changed, 78 insertions(+), 93 deletions(-) diff --git a/tiktok_api/client.py b/tiktok_api/client.py index 8186ff2..8d292d3 100644 --- a/tiktok_api/client.py +++ b/tiktok_api/client.py @@ -29,10 +29,22 @@ # This ensures impersonation targets update automatically with yt-dlp try: from yt_dlp.networking._curlcffi import BROWSER_TARGETS, _TARGETS_COMPAT_LOOKUP + from yt_dlp.networking.impersonate import ImpersonateTarget except ImportError: # Fallback if yt-dlp structure changes or curl_cffi not available during import BROWSER_TARGETS = {} _TARGETS_COMPAT_LOOKUP = {} + ImpersonateTarget = None + +# Note: yt-dlp's CurlCFFIRH already handles proxies correctly per-request via +# session.curl.setopt(CurlOpt.PROXY, proxy) in _send(). No monkey-patching needed. +# The session caching by cookiejar is fine because proxy is set on each request. + +# TikTok WAF blocks newer Chrome versions (136+) when used with proxies due to +# TLS fingerprint / User-Agent mismatches. Use Chrome 120 which is known to work. +# The User-Agent must match the impersonation target to avoid WAF detection. +TIKTOK_IMPERSONATE_TARGET = "chrome120" +TIKTOK_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" from .exceptions import ( TikTokDeletedError, @@ -179,112 +191,76 @@ class TikTokClient: _aiohttp_connector: Optional[TCPConnector] = None _connector_lock = threading.Lock() - # curl_cffi session for browser-impersonated media downloads - _curl_session: Optional[CurlAsyncSession] = None + # curl_cffi session pool for browser-impersonated media downloads + # Keyed by proxy URL (None for direct connection) to avoid proxy contamination + _curl_session_pool: dict[Optional[str], CurlAsyncSession] = {} _curl_session_lock = threading.Lock() _impersonate_target: Optional[str] = None @classmethod def _get_impersonate_target(cls) -> str: - """Get the best impersonation target from yt-dlp's BROWSER_TARGETS. - - Uses the same priority as yt-dlp: - 1. Prioritize desktop over mobile (non-ios, non-android) - 2. Prioritize Chrome > Safari > Firefox > Edge > Tor - 3. Prioritize newest version + """Get the impersonation target for TikTok requests. - This ensures the impersonation target updates automatically when you - update yt-dlp, without any hardcoded values. + TikTok's WAF blocks newer Chrome versions (136+) when used with proxies + due to TLS fingerprint / User-Agent mismatches. Chrome 120 is known to + work reliably with proxies. Returns: - curl_cffi-compatible impersonate string (e.g., "chrome136") + curl_cffi-compatible impersonate string (e.g., "chrome120") """ - import itertools - - # Get curl_cffi version as tuple for comparison - try: - curl_cffi_version = tuple( - int(x) for x in curl_cffi.__version__.split(".")[:2] - ) - except (ValueError, AttributeError): - curl_cffi_version = (0, 9) # Minimum supported version - - # Collect all available targets for our curl_cffi version - available_targets: dict[str, Any] = {} - for version, targets in BROWSER_TARGETS.items(): - if curl_cffi_version >= version: - available_targets.update(targets) - - if not available_targets: - # Fallback to a common target if BROWSER_TARGETS is empty - logger.warning( - "No BROWSER_TARGETS available from yt-dlp, using 'chrome' fallback" - ) - return "chrome" - - # Sort by yt-dlp's priority (same logic as _curlcffi.py) - # This ensures we pick the same target yt-dlp would use - sorted_targets = sorted( - available_targets.items(), - key=lambda x: ( - # deprioritize mobile targets since they give very different behavior - x[1].os not in ("ios", "android"), - # prioritize tor < edge < firefox < safari < chrome - ("tor", "edge", "firefox", "safari", "chrome").index(x[1].client) - if x[1].client in ("tor", "edge", "firefox", "safari", "chrome") - else -1, - # prioritize newest version - float(x[1].version) if x[1].version else 0, - # group by os name - x[1].os or "", - ), - reverse=True, - ) - - # Get the best target name - best_name = sorted_targets[0][0] - - # Apply compatibility lookup for older curl_cffi versions - if curl_cffi_version < (0, 11): - best_name = _TARGETS_COMPAT_LOOKUP.get(best_name, best_name) - + # Use fixed Chrome 120 target that works with TikTok's WAF + # This must match TIKTOK_IMPERSONATE_TARGET and TIKTOK_USER_AGENT logger.debug( - f"Selected impersonation target: {best_name} " + f"Using impersonation target: {TIKTOK_IMPERSONATE_TARGET} " f"(curl_cffi {curl_cffi.__version__})" ) - return best_name + return TIKTOK_IMPERSONATE_TARGET @classmethod - def _get_curl_session(cls) -> CurlAsyncSession: - """Get or create shared curl_cffi AsyncSession with browser impersonation. + def _get_curl_session(cls, proxy: Optional[str] = None) -> CurlAsyncSession: + """Get or create curl_cffi AsyncSession for a specific proxy. + + Sessions are pooled by proxy URL to avoid proxy contamination. + curl_cffi bakes the proxy into the session at creation time, so we need + separate sessions for different proxies. The session uses yt-dlp's BROWSER_TARGETS to select the best impersonation target, ensuring TLS fingerprint matches a real browser. + + Args: + proxy: Proxy URL string, or None for direct connection. + + Returns: + CurlAsyncSession configured with the specified proxy. """ with cls._curl_session_lock: - # Check if session needs to be created - # Note: CurlAsyncSession doesn't have is_closed, we track via _curl_session being None - if cls._curl_session is None: - pool_size = 10000 # High value for maximum throughput - cls._impersonate_target = cls._get_impersonate_target() - cls._curl_session = CurlAsyncSession( + # Check if session exists for this proxy + if proxy not in cls._curl_session_pool: + pool_size = 1000 # Per-proxy pool size + if cls._impersonate_target is None: + cls._impersonate_target = cls._get_impersonate_target() + + # Create session with proxy baked in at construction time + cls._curl_session_pool[proxy] = CurlAsyncSession( impersonate=cls._impersonate_target, + proxy=proxy, # curl_cffi converts this to {"all": proxy} max_clients=pool_size, ) logger.info( - f"Created curl_cffi session with impersonate={cls._impersonate_target}, " - f"max_clients={pool_size}" + f"Created curl_cffi session for proxy={_strip_proxy_auth(proxy)}, " + f"impersonate={cls._impersonate_target}, max_clients={pool_size}" ) - return cls._curl_session + return cls._curl_session_pool[proxy] @classmethod async def close_curl_session(cls) -> None: - """Close shared curl_cffi session. Call on application shutdown.""" + """Close all curl_cffi sessions in the pool. Call on application shutdown.""" with cls._curl_session_lock: - session = cls._curl_session - cls._curl_session = None + sessions = list(cls._curl_session_pool.values()) + cls._curl_session_pool.clear() cls._impersonate_target = None - if session is not None: + + for session in sessions: try: await session.close() except Exception as e: @@ -382,10 +358,11 @@ def _get_proxy_info(self) -> str: return "None" def _get_bypass_headers(self, referer_url: str) -> dict[str, str]: - """Get bypass headers dynamically from yt-dlp. + """Get bypass headers for TikTok media downloads. - Uses yt-dlp's standard headers which are updated with each yt-dlp release. - We add Origin and Referer for CORS compliance with TikTok CDN. + Uses headers matching our impersonation target (Chrome 120) to avoid + TikTok WAF detection. The User-Agent must match the curl_cffi + impersonation target. Args: referer_url: The referer URL to set in headers @@ -394,6 +371,8 @@ def _get_bypass_headers(self, referer_url: str) -> dict[str, str]: Dict of headers for media download """ headers = dict(YTDLP_STD_HEADERS) # Copy to avoid mutation + # Override User-Agent to match our impersonation target + headers["User-Agent"] = TIKTOK_USER_AGENT headers["Referer"] = referer_url headers["Origin"] = "https://www.tiktok.com" headers["Accept"] = "*/*" @@ -487,17 +466,21 @@ async def _download_media_async( if not self.data_only_proxy: proxy = download_context.get("proxy") - session = self._get_curl_session() + # Get session with proxy baked in (avoids proxy contamination between requests) + session = self._get_curl_session(proxy=proxy) for attempt in range(1, max_retries + 1): - logger.debug(f"CDN download attempt {attempt}/{max_retries} for media URL") + logger.debug( + f"CDN download attempt {attempt}/{max_retries} for media URL " + f"via {_strip_proxy_auth(proxy)}" + ) response = None try: + # Note: proxy is already configured in the session (baked in at creation) response = await session.get( media_url, headers=headers, cookies=cookies, - proxy=proxy, timeout=60, allow_redirects=True, stream=use_streaming, @@ -770,6 +753,13 @@ def _get_ydl_opts( "no_warnings": True, } + # Set impersonation target and matching User-Agent to avoid TikTok WAF detection. + # TikTok blocks newer Chrome versions (136+) when used with proxies due to + # TLS fingerprint mismatches. Chrome 120 is known to work reliably. + if ImpersonateTarget is not None: + opts["impersonate"] = ImpersonateTarget("chrome", "120", "macos", None) + opts["http_headers"] = {"User-Agent": TIKTOK_USER_AGENT} + # Use explicit proxy decision if it was provided (even if None = direct connection) if explicit_proxy is not ...: if explicit_proxy is not None: @@ -880,18 +870,12 @@ def _extract_with_context_sync( ) from e # Create download context with the live instances - # For proxy path, use the saved_proxy (extraction was without proxy, downloads use proxy) - # For non-proxy path, use request_proxy as before - context_proxy = ( - saved_proxy - if self.proxy_manager and self.proxy_manager.has_proxies() - else request_proxy - ) + # Use the same proxy for downloads that was used for extraction download_context = { "ydl": ydl, "ie": ie, "referer_url": url, - "proxy": context_proxy, # Store proxy for per-request assignment + "proxy": request_proxy if request_proxy is not ... else None, } # Success - transfer ownership of ydl to caller via download_context @@ -1423,15 +1407,16 @@ async def detect_image_format(self, image_url: str, video_info: VideoInfo) -> st if not self.data_only_proxy: proxy = video_info._download_context.get("proxy") - session = self._get_curl_session() + # Get session with proxy baked in (avoids proxy contamination between requests) + session = self._get_curl_session(proxy=proxy) response = None try: + # Note: proxy is already configured in the session (baked in at creation) response = await session.get( image_url, headers=headers, cookies=cookies, - proxy=proxy, timeout=10, allow_redirects=True, ) From acf2e4894f66813001f564266e9924c6642250eb Mon Sep 17 00:00:00 2001 From: Kyryl Andreiev Date: Sat, 17 Jan 2026 15:43:21 -0800 Subject: [PATCH 10/10] Add handlers for unsupported content types in private chats Extend message handling to respond with helpful guidance when users send images, voice messages, audio files, or other unsupported content in private chats. Group chats remain silent for these content types. - Add F.photo handler for image uploads - Add F.voice | F.audio handler for voice/audio messages - Add catch-all handler for any other unsupported content - Add tiktok_links_only locale key in all 8 languages --- data/locale/ar.json | 14 +++++--- data/locale/en.json | 14 +++++--- data/locale/hi.json | 14 +++++--- data/locale/id.json | 14 +++++--- data/locale/ru.json | 14 +++++--- data/locale/so.json | 14 +++++--- data/locale/uk.json | 14 +++++--- data/locale/vi.json | 14 +++++--- handlers/get_video.py | 77 ++++++++++++++++++++++++++++++++++++++++--- 9 files changed, 144 insertions(+), 45 deletions(-) diff --git a/data/locale/ar.json b/data/locale/ar.json index c63610e..120bfbb 100644 --- a/data/locale/ar.json +++ b/data/locale/ar.json @@ -1,7 +1,10 @@ { "lang_name": "العربية🇸🇦", - "error": "حدث خطأ ما🥲\nهل يمكنك المحاولة مرة أخرى؟…", - "link_error": "ربما يوجد خطأ في الرابط الذي أرسلته🥲", + "error": "عفوًا! حدث خطأ ما 😅\nيرجى المحاولة مرة أخرى بعد قليل.", + "link_error": "هممم، لم أتمكن من التعرف على هذا الرابط 🤔\nتأكد من أنه رابط فيديو TikTok صالح!", + "video_upload_hint": "يمكنني فقط إزالة العلامات المائية من روابط TikTok، وليس من الفيديوهات المرفوعة 🎬\n\nيرجى إرسال رابط فيديو TikTok بدلاً من ذلك!", + "non_tiktok_link": "هذا البوت يعمل فقط مع روابط TikTok 🔗\n\nيرجى إرسال رابط من TikTok (tiktok.com أو vm.tiktok.com)", + "send_link_prompt": "أرسل لي رابط فيديو TikTok لتنزيله بدون علامة مائية! 🎥", "lang_start": "تم تعيين اللغة إلى العربية🇸🇦.\nيمكن تغييرها باستخدام الأمر /lang", "lang": "تم تعيين اللغة إلى العربية🇸🇦", "start": "لقد قمت بتشغيل No Watermark TikTok🤖\n\nيدعم هذا البوت تنزيل:\n📹الفيديو، 🖼الصور و 🔈الصوت\nمن TikTok بدون علامة مائية\n\nيمكنك أيضًا الاشتراك في قناتنا للحصول على آخر الأخبار حول حالة البوت والتحديثات والإعلانات!\n@ttgrab\n\nأرسل رابط الفيديو للبدء", @@ -38,10 +41,11 @@ "error_deleted": "تم حذف هذا الفيديو من قبل المنشئ.", "error_private": "هذا الفيديو خاص ولا يمكن الوصول إليه.", "error_region": "هذا الفيديو غير متوفر في منطقتك.", - "error_network": "حدث خطأ في الشبكة.\nيرجى المحاولة مرة أخرى.", - "error_rate_limit": "طلبات كثيرة جدًا.\nيرجى الانتظار قليلاً والمحاولة مرة أخرى.", + "error_network": "مشكلة في الاتصال 📡\nيرجى المحاولة مرة أخرى بعد قليل.", + "error_rate_limit": "تمهل قليلاً! 🐢\nيرجى الانتظار قليلاً والمحاولة مرة أخرى.", "error_too_long": "هذا الفيديو طويل جدًا.\nالحد الأقصى المسموح به هو 30 دقيقة.", "error_queue_full": "يرجى الانتظار!\nلديك بالفعل {0} فيديوهات قيد المعالجة. حاول مرة أخرى بعد قليل.", "try_again_button": "حاول مرة أخرى", - "inline_retry_attempt": "🔄 المحاولة {0} من {1}..." + "inline_retry_attempt": "🔄 المحاولة {0} من {1}...", + "tiktok_links_only": "هذا البوت يدعم فقط روابط TikTok 🔗\n\nيرجى إرسال رابط فيديو TikTok لتنزيله بدون علامة مائية!" } diff --git a/data/locale/en.json b/data/locale/en.json index ec370c4..cdc3f2c 100644 --- a/data/locale/en.json +++ b/data/locale/en.json @@ -1,7 +1,10 @@ { "lang_name": "English🇺🇸", - "error": "Something went wrong🥲\nCould you please try again?…", - "link_error": "Probably something wrong with your link🥲", + "error": "Oops! Something went wrong 😅\nPlease try again in a moment.", + "link_error": "Hmm, I couldn't recognize this link 🤔\nMake sure it's a valid TikTok video link!", + "video_upload_hint": "I can only remove watermarks from TikTok links, not from uploaded videos 🎬\n\nPlease send me a TikTok video link instead!", + "non_tiktok_link": "This bot only works with TikTok links 🔗\n\nPlease send a link from TikTok (tiktok.com or vm.tiktok.com)", + "send_link_prompt": "Send me a TikTok video link to download it without watermark! 🎥", "lang_start": "The language is set to English🇺🇸.\nIt can be changed with the /lang command", "lang": "The language is set to English🇺🇸", "start": "You have launched No Watermark TikTok🤖\n\nThis bot supports download of:\n📹Video, 🖼Images and 🔈Audio\nfrom TikTok without watermark\n\nYou can also subscribe to our channel to get the latest news about bot status, updates and news!\n@ttgrab\n\nSend video link to get started", @@ -38,10 +41,11 @@ "error_deleted": "This video has been deleted by the creator.", "error_private": "This video is private and cannot be accessed.", "error_region": "This video is not available in your region.", - "error_network": "Network error occurred.\nPlease try again.", - "error_rate_limit": "Too many requests.\nPlease wait a moment and try again.", + "error_network": "Connection issue 📡\nPlease try again in a moment.", + "error_rate_limit": "Whoa, slow down! 🐢\nPlease wait a moment and try again.", "error_too_long": "This video is too long.\nMaximum allowed duration is 30 minutes.", "error_queue_full": "Please wait!\nYou already have {0} videos processing. Try again in a moment.", "try_again_button": "Try Again", - "inline_retry_attempt": "🔄 Attempt {0} of {1}..." + "inline_retry_attempt": "🔄 Attempt {0} of {1}...", + "tiktok_links_only": "This bot only supports TikTok links 🔗\n\nPlease send a TikTok video link to download it without watermark!" } diff --git a/data/locale/hi.json b/data/locale/hi.json index d9c8729..720bf7b 100644 --- a/data/locale/hi.json +++ b/data/locale/hi.json @@ -1,7 +1,10 @@ { "lang_name": "हिन्दी🇮🇳", - "error": "कुछ गलत हो गया🥲\nक्या आप फिर से कोशिश कर सकते हैं?…", - "link_error": "शायद आपकी लिंक में कुछ गड़बड़ है🥲", + "error": "उफ़! कुछ गलत हो गया 😅\nकृपया थोड़ी देर बाद फिर से प्रयास करें।", + "link_error": "हम्म, मैं इस लिंक को नहीं पहचान पा रहा 🤔\nसुनिश्चित करें कि यह एक वैध TikTok वीडियो लिंक है!", + "video_upload_hint": "मैं केवल TikTok लिंक से वॉटरमार्क हटा सकता हूं, अपलोड किए गए वीडियो से नहीं 🎬\n\nकृपया मुझे TikTok वीडियो लिंक भेजें!", + "non_tiktok_link": "यह बॉट केवल TikTok लिंक के साथ काम करता है 🔗\n\nकृपया TikTok (tiktok.com या vm.tiktok.com) से लिंक भेजें", + "send_link_prompt": "मुझे TikTok वीडियो लिंक भेजें ताकि इसे बिना वॉटरमार्क के डाउनलोड किया जा सके! 🎥", "lang_start": "भाषा हिन्दी🇮🇳 पर सेट कर दी गई है।\nइसे /lang कमांड से बदला जा सकता है", "lang": "भाषा हिन्दी🇮🇳 पर सेट है", "start": "आपने No Watermark TikTok🤖 शुरू किया है\n\nयह बॉट TikTok से बिना वॉटरमार्क:\n📹वीडियो, 🖼छवियाँ और 🔈ऑडियो\nडाउनलोड करने का समर्थन करता है\n\nआप बॉट की स्थिति, अपडेट और खबरों के लिए हमारे चैनल को भी सब्सक्राइब कर सकते हैं!\n@ttgrab\n\nशुरू करने के लिए वीडियो लिंक भेजें", @@ -38,10 +41,11 @@ "error_deleted": "यह वीडियो निर्माता द्वारा हटा दिया गया है।", "error_private": "यह वीडियो निजी है और एक्सेस नहीं किया जा सकता।", "error_region": "यह वीडियो आपके क्षेत्र में उपलब्ध नहीं है।", - "error_network": "नेटवर्क त्रुटि हुई।\nकृपया फिर से प्रयास करें।", - "error_rate_limit": "बहुत अधिक अनुरोध।\nकृपया कुछ देर प्रतीक्षा करें और फिर से प्रयास करें।", + "error_network": "कनेक्शन समस्या 📡\nकृपया थोड़ी देर बाद फिर से प्रयास करें।", + "error_rate_limit": "अरे, धीरे चलो! 🐢\nकृपया कुछ देर प्रतीक्षा करें और फिर से प्रयास करें।", "error_too_long": "यह वीडियो बहुत लंबा है।\nअधिकतम अनुमत अवधि 30 मिनट है।", "error_queue_full": "कृपया प्रतीक्षा करें!\nआपके पास पहले से {0} वीडियो प्रोसेस हो रहे हैं। कुछ देर बाद फिर से प्रयास करें।", "try_again_button": "फिर से प्रयास करें", - "inline_retry_attempt": "🔄 प्रयास {0} में से {1}..." + "inline_retry_attempt": "🔄 प्रयास {0} में से {1}...", + "tiktok_links_only": "यह बॉट केवल TikTok लिंक का समर्थन करता है 🔗\n\nकृपया वॉटरमार्क के बिना डाउनलोड करने के लिए TikTok वीडियो लिंक भेजें!" } diff --git a/data/locale/id.json b/data/locale/id.json index 6097c29..20113e3 100644 --- a/data/locale/id.json +++ b/data/locale/id.json @@ -1,7 +1,10 @@ { "lang_name": "Bahasa Indonesia🇮🇩", - "error": "Terjadi kesalahan🥲\nBisa coba lagi?…", - "link_error": "Mungkin ada yang salah dengan tautanmu🥲", + "error": "Ups! Terjadi kesalahan 😅\nSilakan coba lagi sebentar.", + "link_error": "Hmm, saya tidak bisa mengenali tautan ini 🤔\nPastikan ini adalah tautan video TikTok yang valid!", + "video_upload_hint": "Saya hanya bisa menghapus watermark dari tautan TikTok, bukan dari video yang diunggah 🎬\n\nSilakan kirim tautan video TikTok!", + "non_tiktok_link": "Bot ini hanya bekerja dengan tautan TikTok 🔗\n\nSilakan kirim tautan dari TikTok (tiktok.com atau vm.tiktok.com)", + "send_link_prompt": "Kirim tautan video TikTok untuk mengunduhnya tanpa watermark! 🎥", "lang_start": "Bahasa disetel ke Bahasa Indonesia🇮🇩.\nBisa diubah dengan perintah /lang", "lang": "Bahasa disetel ke Bahasa Indonesia🇮🇩", "start": "Kamu telah menjalankan No Watermark TikTok🤖\n\nBot ini mendukung unduhan:\n📹Video, 🖼Gambar, dan 🔈Audio\ndari TikTok tanpa watermark\n\nKamu juga bisa berlangganan channel kami untuk mendapatkan kabar terbaru tentang status bot, pembaruan, dan berita!\n@ttgrab\n\nKirim tautan video untuk memulai", @@ -38,10 +41,11 @@ "error_deleted": "Video ini sudah dihapus oleh pembuatnya.", "error_private": "Video ini privat dan tidak bisa diakses.", "error_region": "Video ini tidak tersedia di wilayahmu.", - "error_network": "Terjadi kesalahan jaringan.\nSilakan coba lagi.", - "error_rate_limit": "Terlalu banyak permintaan.\nSilakan tunggu sebentar dan coba lagi.", + "error_network": "Masalah koneksi 📡\nSilakan coba lagi sebentar.", + "error_rate_limit": "Pelan-pelan! 🐢\nSilakan tunggu sebentar dan coba lagi.", "error_too_long": "Video ini terlalu panjang.\nDurasi maksimum yang diizinkan adalah 30 menit.", "error_queue_full": "Mohon tunggu!\nKamu sudah memiliki {0} video yang sedang diproses. Coba lagi sebentar.", "try_again_button": "Coba Lagi", - "inline_retry_attempt": "🔄 Percobaan {0} dari {1}..." + "inline_retry_attempt": "🔄 Percobaan {0} dari {1}...", + "tiktok_links_only": "Bot ini hanya mendukung tautan TikTok 🔗\n\nSilakan kirim tautan video TikTok untuk mengunduhnya tanpa watermark!" } diff --git a/data/locale/ru.json b/data/locale/ru.json index 3c27903..64c192d 100644 --- a/data/locale/ru.json +++ b/data/locale/ru.json @@ -1,7 +1,10 @@ { "lang_name": "Русский🇷🇺", - "error": "Что-то пошло не так🥲\nНе могли бы вы попробовать еще раз?…", - "link_error": "Возможно что-то не так с вашей ссылкой🥲", + "error": "Упс! Что-то пошло не так 😅\nПожалуйста, попробуйте через мгновение.", + "link_error": "Хм, не могу распознать эту ссылку 🤔\nУбедитесь, что это действительная ссылка на видео TikTok!", + "video_upload_hint": "Я могу удалять водяные знаки только по ссылкам TikTok, а не из загруженных видео 🎬\n\nПожалуйста, отправьте мне ссылку на видео TikTok!", + "non_tiktok_link": "Этот бот работает только со ссылками TikTok 🔗\n\nПожалуйста, отправьте ссылку с TikTok (tiktok.com или vm.tiktok.com)", + "send_link_prompt": "Отправьте мне ссылку на видео TikTok, чтобы скачать его без водяного знака! 🎥", "lang_start": "Установлен язык Русский🇷🇺.\nЕго можно изменить командой /lang", "lang": "Установлен язык Русский🇷🇺", "start": "Вы запустили No Watermark TikTok🤖\n\nЭтот бот поддерживает загрузку:\n📹Видео, 🖼Изображений и 🔈Аудио\nс TikTok без водяного знака\n\nВы также можете подписаться на наш канал, чтобы получать последние новости о статусе бота, обновлениях и новостях!\n@ttgrab\n\nОтправьте ссылку на видео, чтобы начать", @@ -38,10 +41,11 @@ "error_deleted": "Это видео было удалено автором.", "error_private": "Это видео приватное и недоступно.", "error_region": "Это видео недоступно в вашем регионе.", - "error_network": "Произошла сетевая ошибка.\nПожалуйста, попробуйте снова.", - "error_rate_limit": "Слишком много запросов.\nПожалуйста, подождите немного и попробуйте снова.", + "error_network": "Проблемы с соединением 📡\nПожалуйста, попробуйте через мгновение.", + "error_rate_limit": "Полегче! 🐢\nПожалуйста, подождите немного и попробуйте снова.", "error_too_long": "Это видео слишком длинное.\nМаксимальная продолжительность — 30 минут.", "error_queue_full": "Пожалуйста, подождите!\nУ вас уже {0} видео в обработке. Попробуйте через мгновение.", "try_again_button": "Попробовать снова", - "inline_retry_attempt": "🔄 Попытка {0} из {1}..." + "inline_retry_attempt": "🔄 Попытка {0} из {1}...", + "tiktok_links_only": "Этот бот поддерживает только ссылки TikTok 🔗\n\nПожалуйста, отправьте ссылку на видео TikTok, чтобы скачать его без водяного знака!" } diff --git a/data/locale/so.json b/data/locale/so.json index 5c2e242..4a2aa72 100644 --- a/data/locale/so.json +++ b/data/locale/so.json @@ -1,7 +1,10 @@ { "lang_name": "Soomaali🇸🇴", - "error": "Waxbaa qaldamay🥲\nFadlan mar kale isku day?…", - "link_error": "Malaha waxbaa ka qaldan link-gaaga🥲", + "error": "Waxyaabaa qaldamay 😅\nFadlan isku day mar kale.", + "link_error": "Hmm, ma garan karo link-kan 🤔\nHubi in ay tahay link fiidiyow TikTok oo sax ah!", + "video_upload_hint": "Waxaan kaliya ka saari karaa watermark-a link-yada TikTok, ma aha fiidiyowyada la soo dhigay 🎬\n\nFadlan ii soo dir link fiidiyow TikTok!", + "non_tiktok_link": "Bot-kan waxa uu la shaqeeyaa oo keliya link-yada TikTok 🔗\n\nFadlan soo dir link ka TikTok (tiktok.com ama vm.tiktok.com)", + "send_link_prompt": "Ii soo dir link fiidiyow TikTok si aad u soo dejiso isagoon watermark lahayn! 🎥", "lang_start": "Luqadda waxaa loo dejiyey Soomaali🇸🇴.\nWaxaad ku beddeli kartaa amarka /lang", "lang": "Luqadda waxaa loo dejiyey Soomaali🇸🇴", "start": "Waxaad bilowday No Watermark TikTok🤖\n\nBot-kan wuxuu taageeraa soo dejinta:\n📹Fiidiyow, 🖼Sawirro iyo 🔈Cod\nTikTok aan watermark lahayn\n\nWaxaad sidoo kale raaci kartaa kanaalkeenna si aad u hesho wararkii ugu dambeeyay ee xaaladda bot-ka, cusboonaysiinta iyo wararka!\n@ttgrab\n\nDir link-ga fiidiyowga si aad u bilowdo", @@ -38,10 +41,11 @@ "error_deleted": "Fiidiyowgan waxaa tirtiray abuurahiisa.", "error_private": "Fiidiyowgan waa qarsoon yahay mana la heli karo.", "error_region": "Fiidiyowgan laguma heli karo aagaaga.", - "error_network": "Khalad shabakad ayaa dhacay.\nFadlan isku day mar kale.", - "error_rate_limit": "Codsiyo badan ayaa la sameeyay.\nFadlan sug wakhti yar oo isku day mar kale.", + "error_network": "Dhibaato isku xirka 📡\nFadlan isku day mar kale.", + "error_rate_limit": "Hoos u dhig! 🐢\nFadlan sug wakhti yar oo isku day mar kale.", "error_too_long": "Fiidiyowgan waa dheer yahay.\nMudda ugu badan ee la ogol yahay waa 30 daqiiqo.", "error_queue_full": "Fadlan sug!\nWaxaad horeba u leedahay {0} fiidiyow oo la hawlgalayo. Isku day mar kale.", "try_again_button": "Isku day mar kale", - "inline_retry_attempt": "🔄 Isku day {0} ee {1}..." + "inline_retry_attempt": "🔄 Isku day {0} ee {1}...", + "tiktok_links_only": "Bot-kan waxa uu taageeraa oo keliya link-yada TikTok 🔗\n\nFadlan soo dir link fiidiyow TikTok si aad u soo dejiso isagoon watermark lahayn!" } diff --git a/data/locale/uk.json b/data/locale/uk.json index c68e610..3940e4e 100644 --- a/data/locale/uk.json +++ b/data/locale/uk.json @@ -1,7 +1,10 @@ { "lang_name": "Українська🇺🇦", - "error": "Щось пішло не так 🥲\nНе могли б ви спробувати ще раз?…", - "link_error": "Можливо, щось не так з вашим посиланням🥲", + "error": "Ой! Щось пішло не так 😅\nБудь ласка, спробуйте через мить.", + "link_error": "Хм, не можу розпізнати це посилання 🤔\nПереконайтеся, що це дійсне посилання на відео TikTok!", + "video_upload_hint": "Я можу видаляти водяні знаки лише з посилань TikTok, а не із завантажених відео 🎬\n\nБудь ласка, надішліть мені посилання на відео TikTok!", + "non_tiktok_link": "Цей бот працює лише з посиланнями TikTok 🔗\n\nБудь ласка, надішліть посилання з TikTok (tiktok.com або vm.tiktok.com)", + "send_link_prompt": "Надішліть мені посилання на відео TikTok, щоб завантажити його без водяного знака! 🎥", "lang_start": "Встановлено мову Українська🇺🇦.\nЇї можна змінити командою /lang", "lang": "Встановлено мову Українська🇺🇦", "start": "Ви запустили No Watermark TikTok🤖\n\nЦей бот підтримує завантаження:\n📹Відео, 🖼Зображень та 🔈Аудіо\nз TikTok без водяного знака\n\nВи також можете підписатися на наш канал, щоб отримувати останні новини про статус бота, оновлення та новини!\n@ttgrab\n\nНадішліть посилання на відео, щоб почати", @@ -38,10 +41,11 @@ "error_deleted": "Це відео було видалено автором.", "error_private": "Це відео приватне і недоступне.", "error_region": "Це відео недоступне у вашому регіоні.", - "error_network": "Сталася мережева помилка.\nБудь ласка, спробуйте ще раз.", - "error_rate_limit": "Занадто багато запитів.\nБудь ласка, зачекайте трохи і спробуйте знову.", + "error_network": "Проблеми з з'єднанням 📡\nБудь ласка, спробуйте через мить.", + "error_rate_limit": "Полегше! 🐢\nБудь ласка, зачекайте трохи і спробуйте знову.", "error_too_long": "Це відео занадто довге.\nМаксимальна тривалість — 30 хвилин.", "error_queue_full": "Будь ласка, зачекайте!\nУ вас вже {0} відео в обробці. Спробуйте через мить.", "try_again_button": "Спробувати знову", - "inline_retry_attempt": "🔄 Спроба {0} з {1}..." + "inline_retry_attempt": "🔄 Спроба {0} з {1}...", + "tiktok_links_only": "Цей бот підтримує лише посилання TikTok 🔗\n\nБудь ласка, надішліть посилання на відео TikTok, щоб завантажити його без водяного знака!" } diff --git a/data/locale/vi.json b/data/locale/vi.json index dfc3864..fa5f3b3 100644 --- a/data/locale/vi.json +++ b/data/locale/vi.json @@ -1,7 +1,10 @@ { "lang_name": "Tiếng Việt🇻🇳", - "error": "Có lỗi xảy ra🥲\nBạn có thể thử lại không?…", - "link_error": "Có lẽ liên kết của bạn có vấn đề🥲", + "error": "Ôi! Có lỗi xảy ra 😅\nVui lòng thử lại sau một chút.", + "link_error": "Hmm, tôi không thể nhận diện liên kết này 🤔\nHãy chắc chắn đây là liên kết video TikTok hợp lệ!", + "video_upload_hint": "Tôi chỉ có thể xóa watermark từ liên kết TikTok, không phải từ video đã tải lên 🎬\n\nVui lòng gửi cho tôi liên kết video TikTok!", + "non_tiktok_link": "Bot này chỉ hoạt động với liên kết TikTok 🔗\n\nVui lòng gửi liên kết từ TikTok (tiktok.com hoặc vm.tiktok.com)", + "send_link_prompt": "Gửi cho tôi liên kết video TikTok để tải xuống không có watermark! 🎥", "lang_start": "Đã đặt ngôn ngữ là Tiếng Việt🇻🇳.\nBạn có thể đổi bằng lệnh /lang", "lang": "Ngôn ngữ hiện tại: Tiếng Việt🇻🇳", "start": "Bạn đã khởi chạy No Watermark TikTok🤖\n\nBot này hỗ trợ tải:\n📹Video, 🖼Hình ảnh và 🔈Âm thanh\ntừ TikTok không có watermark\n\nBạn cũng có thể theo dõi kênh của chúng tôi để nhận tin mới nhất về trạng thái bot, cập nhật và thông báo!\n@ttgrab\n\nGửi liên kết video để bắt đầu", @@ -38,10 +41,11 @@ "error_deleted": "Video này đã bị người tạo xóa.", "error_private": "Video này ở chế độ riêng tư và không thể truy cập.", "error_region": "Video này không khả dụng ở khu vực của bạn.", - "error_network": "Đã xảy ra lỗi mạng.\nVui lòng thử lại.", - "error_rate_limit": "Quá nhiều yêu cầu.\nVui lòng đợi một chút và thử lại.", + "error_network": "Vấn đề kết nối 📡\nVui lòng thử lại sau một chút.", + "error_rate_limit": "Từ từ thôi! 🐢\nVui lòng đợi một chút và thử lại.", "error_too_long": "Video này quá dài.\nThời lượng tối đa cho phép là 30 phút.", "error_queue_full": "Vui lòng đợi!\nBạn đang có {0} video đang xử lý. Hãy thử lại sau một chút.", "try_again_button": "Thử lại", - "inline_retry_attempt": "🔄 Lần thử {0} trên {1}..." + "inline_retry_attempt": "🔄 Lần thử {0} trên {1}...", + "tiktok_links_only": "Bot này chỉ hỗ trợ liên kết TikTok 🔗\n\nVui lòng gửi liên kết video TikTok để tải xuống không có watermark!" } diff --git a/handlers/get_video.py b/handlers/get_video.py index 47cb205..749e6b2 100644 --- a/handlers/get_video.py +++ b/handlers/get_video.py @@ -39,6 +39,51 @@ def try_again_button(lang: str): return keyb.as_markup() +@video_router.message(F.video | F.video_note) +async def handle_video_upload(message: Message): + """Inform users that we need links, not video uploads (private chats only).""" + if message.chat.type != "private": + return + + settings = await get_user_settings(message.chat.id) + if settings: + lang = settings[0] + else: + lang = await lang_func(message.chat.id, message.from_user.language_code, True) + + await message.reply(locale[lang]["video_upload_hint"]) + + +@video_router.message(F.photo) +async def handle_image_upload(message: Message): + """Inform users that we only support TikTok links, not images (private chats only).""" + if message.chat.type != "private": + return + + settings = await get_user_settings(message.chat.id) + if settings: + lang = settings[0] + else: + lang = await lang_func(message.chat.id, message.from_user.language_code, True) + + await message.reply(locale[lang]["tiktok_links_only"]) + + +@video_router.message(F.voice | F.audio) +async def handle_voice_upload(message: Message): + """Inform users that we only support TikTok links, not voice messages (private chats only).""" + if message.chat.type != "private": + return + + settings = await get_user_settings(message.chat.id) + if settings: + lang = settings[0] + else: + lang = await lang_func(message.chat.id, message.from_user.language_code, True) + + await message.reply(locale[lang]["tiktok_links_only"]) + + @video_router.message(F.text) async def send_tiktok_video(message: Message): # Api init with proxy support @@ -70,9 +115,16 @@ async def send_tiktok_video(message: Message): video_link, is_mobile = await api.regex_check(message.text) # If not valid if video_link is None: - # Send error message, if not in group chat + # Send appropriate error message in private chats only if not group_chat: - await message.reply(locale[lang]["link_error"]) + # Check if message contains URL entities (non-TikTok link) + has_url = message.entities and any( + e.type in ("url", "text_link") for e in message.entities + ) + if has_url: + await message.reply(locale[lang]["non_tiktok_link"]) + else: + await message.reply(locale[lang]["send_link_prompt"]) return # Check per-user queue limit before proceeding (0 = no limit) @@ -183,7 +235,7 @@ async def send_tiktok_video(message: Message): else: if not status_message: try: - await message.react([]) + await message.react([ReactionTypeEmoji(emoji="😢")]) except TelegramBadRequest: pass except Exception as e: @@ -198,7 +250,7 @@ async def send_tiktok_video(message: Message): else: if not status_message: try: - await message.react([]) + await message.react([ReactionTypeEmoji(emoji="😢")]) except TelegramBadRequest: pass was_processed = False # Videos are not processed @@ -260,7 +312,7 @@ async def send_tiktok_video(message: Message): await message.react([ReactionTypeEmoji(emoji="😢")]) else: if not status_message: - await message.react([]) + await message.react([ReactionTypeEmoji(emoji="😢")]) except TelegramBadRequest: logging.debug("Failed to update UI during error cleanup") except Exception as cleanup_err: @@ -294,3 +346,18 @@ async def handle_retry_callback(callback: CallbackQuery): # Re-process the original message await send_tiktok_video(original_message) + + +@video_router.message() +async def handle_unsupported_content(message: Message): + """Catch-all for any unsupported content types (private chats only).""" + if message.chat.type != "private": + return + + settings = await get_user_settings(message.chat.id) + if settings: + lang = settings[0] + else: + lang = await lang_func(message.chat.id, message.from_user.language_code, True) + + await message.reply(locale[lang]["tiktok_links_only"])