From 2136d35b28612f12e55f6b016aac594edd4cd813 Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sat, 14 Mar 2026 00:26:13 +0100 Subject: [PATCH 01/27] Comment out image generation words --- src/main.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/main.py b/src/main.py index ab72811..8b758a6 100644 --- a/src/main.py +++ b/src/main.py @@ -615,19 +615,19 @@ async def respond_with_llm_message(update): try: # Check if user is asking for image generation and modify prompt image_keywords = [ - 'картинку', - 'картинка', - 'зображення', - 'image', - 'фото', - 'picture', - 'згенеруй', - 'generate', - 'створи', - 'create', - 'покажи', - 'покажи мне', - 'покажи мені', + # 'картинку', + # 'картинка', + # 'зображення', + # 'image', + # 'фото', + # 'picture', + # 'згенеруй', + # 'generate', + # 'створи', + # 'create', + # 'покажи', + # 'покажи мне', + # 'покажи мені', ] # Check both original message and processed prompt original_text = message_text.lower() From 59068a177112dd2ee6997a3da016246a691d67ee Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sat, 14 Mar 2026 23:53:35 +0100 Subject: [PATCH 02/27] feat: add OpenAI GPT Image 1 Mini support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add OPENAI_API_KEY / OPENAI_IMG_MODEL config and use OpenAI image endpoint parse ботяра ... image: (and fallback bot : image:) prompt into extract_image_prompt(...) implement generate_image_and_send(...) to call openai.Image.create(...) and send photo (b64 / URL) keep existing bot mention + LLM paths intact for text responses handle missing API key and generation failure with user-friendly messages --- src/main.py | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/src/main.py b/src/main.py index 8b758a6..72b32c8 100644 --- a/src/main.py +++ b/src/main.py @@ -48,6 +48,8 @@ GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-flash-latest") GROK_API_KEY = os.getenv("GROK_API_KEY") GROK_MODEL = os.getenv("GROK_MODEL", "grok-4-latest") +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +OPENAI_IMG_MODEL = os.getenv("OPENAI_IMG_MODEL", "gpt-image-1-mini") TELEGRAM_CONNECT_TIMEOUT = 60 TELEGRAM_POOL_TIMEOUT = 30 TELEGRAM_READ_TIMEOUT = 120 @@ -160,6 +162,85 @@ def is_bot_mentioned(message_text: str) -> bool: return False +def extract_image_prompt(message_text: str) -> str | None: + """Extract image generation prompt for commands like 'ботяра, image: ...'.""" + if not message_text: + return None + + lower = message_text.lower() + # Match bot command for image generation: ботяра, image: prompt + match = re.search(r"ботяра[^\w\d]*image\s*:\s*(.+)", lower) + if match: + prompt = match.group(1).strip() + return prompt or None + + # Fallback for english trigger + match = re.search(r"bot\s*:\s*image\s*:\s*(.+)", lower) + if match: + prompt = match.group(1).strip() + return prompt or None + + return None + + +async def generate_image_and_send(update: Update, prompt: str) -> None: + """Generate image through Gemini Image API and send to Telegram.""" + if not prompt: + await update.message.reply_text( + "Вкажіть, що саме потрібно згенерувати після 'botyara, image:'", + reply_to_message_id=update.message.message_id, + ) + return + + if not OPENAI_API_KEY: + await update.message.reply_text( + "OpenAI API key не налаштовано. Будь ласка, встановіть OPENAI_API_KEY.", + reply_to_message_id=update.message.message_id, + ) + return + + try: + image_response = openai.Image.create( + api_key=OPENAI_API_KEY, + model=OPENAI_IMG_MODEL, + prompt=prompt, + size="512x512", + response_format="b64_json", # щоб точно отримати контент, без URL-посилань + ) + + image_url = None + b64_data = None + + if isinstance(image_response, dict) and image_response.get("data"): + first = image_response["data"][0] + # openai Image endpoint може повертати url або b64_json залежно від response_format + image_url = first.get("url") + b64_data = first.get("b64_json") + + if not image_url and not b64_data: + raise ValueError("Не вдалося отримати дані зображення від API") + + # If API returned b64_json data, decode and send as bytes + if b64_data: + import base64 + + file_bytes = base64.b64decode(b64_data) + await update.message.reply_photo(photo=file_bytes, caption="Ось ваше зображення 🖼️") + + # If API returned URL, send by URL + elif image_url: + await update.message.reply_photo(photo=image_url, caption="Ось ваше зображення 🖼️") + else: + raise ValueError("Не вдалося отримати зображення для відправки") + + except Exception as e: + error("Image generation failed: %s", e) + await update.message.reply_text( + "Вибачте, не вдалося згенерувати зображення. Спробуйте пізніше.", + reply_to_message_id=update.message.message_id, + ) + + def clean_url(message_text: str) -> str: """ Cleans the URL from the message text by removing unwanted characters and usernames. @@ -246,6 +327,12 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): # debug("LLM_PROVIDER: %s", LLM_PROVIDER) if bot_mentioned: + image_prompt = extract_image_prompt(message_text) + if image_prompt: + debug("Bot image command detected with prompt: %s", image_prompt) + await generate_image_and_send(update, image_prompt) + return + if USE_LLM: debug("Calling LLM response function") await respond_with_llm_message(update) From d68b00c424d30bf32c776f73ef685ce5a6c89359 Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sat, 14 Mar 2026 23:57:01 +0100 Subject: [PATCH 03/27] Fix linter --- src/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main.py b/src/main.py index 72b32c8..1dca36c 100644 --- a/src/main.py +++ b/src/main.py @@ -9,6 +9,7 @@ import traceback from datetime import datetime import google.generativeai as genai +import openai from openai import AsyncOpenAI from functools import lru_cache from collections import defaultdict @@ -233,7 +234,7 @@ async def generate_image_and_send(update: Update, prompt: str) -> None: else: raise ValueError("Не вдалося отримати зображення для відправки") - except Exception as e: + except (openai.error.OpenAIError, ValueError) as e: error("Image generation failed: %s", e) await update.message.reply_text( "Вибачте, не вдалося згенерувати зображення. Спробуйте пізніше.", From 9779176b62ed25f7590cac2b576500b32a8a3a97 Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:00:20 +0100 Subject: [PATCH 04/27] Fix openai error exception --- src/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main.py b/src/main.py index 1dca36c..1469c51 100644 --- a/src/main.py +++ b/src/main.py @@ -11,6 +11,7 @@ import google.generativeai as genai import openai from openai import AsyncOpenAI +from openai.error import OpenAIError from functools import lru_cache from collections import defaultdict from dotenv import load_dotenv @@ -234,7 +235,7 @@ async def generate_image_and_send(update: Update, prompt: str) -> None: else: raise ValueError("Не вдалося отримати зображення для відправки") - except (openai.error.OpenAIError, ValueError) as e: + except (OpenAIError, ValueError) as e: error("Image generation failed: %s", e) await update.message.reply_text( "Вибачте, не вдалося згенерувати зображення. Спробуйте пізніше.", From dffc87203360e1d53ea7fdf8ce3f5c55f20b0d48 Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:02:49 +0100 Subject: [PATCH 05/27] Fix linter --- src/main.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/main.py b/src/main.py index 1469c51..339c3e3 100644 --- a/src/main.py +++ b/src/main.py @@ -9,9 +9,7 @@ import traceback from datetime import datetime import google.generativeai as genai -import openai -from openai import AsyncOpenAI -from openai.error import OpenAIError +from openai import AsyncOpenAI, OpenAI, OpenAIError from functools import lru_cache from collections import defaultdict from dotenv import load_dotenv @@ -202,22 +200,21 @@ async def generate_image_and_send(update: Update, prompt: str) -> None: return try: - image_response = openai.Image.create( - api_key=OPENAI_API_KEY, + client = OpenAI(api_key=OPENAI_API_KEY) + image_response = client.images.generate( model=OPENAI_IMG_MODEL, prompt=prompt, size="512x512", - response_format="b64_json", # щоб точно отримати контент, без URL-посилань + response_format="b64_json", ) image_url = None b64_data = None - if isinstance(image_response, dict) and image_response.get("data"): - first = image_response["data"][0] - # openai Image endpoint може повертати url або b64_json залежно від response_format - image_url = first.get("url") - b64_data = first.get("b64_json") + if getattr(image_response, "data", None): + first = image_response.data[0] + image_url = getattr(first, "url", None) + b64_data = getattr(first, "b64_json", None) if not image_url and not b64_data: raise ValueError("Не вдалося отримати дані зображення від API") From 785c9d9207f202d5ddd97416b3d4bc54f389b28a Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:06:16 +0100 Subject: [PATCH 06/27] Fix response_format in image gen function --- src/main.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main.py b/src/main.py index 339c3e3..b7b1348 100644 --- a/src/main.py +++ b/src/main.py @@ -201,11 +201,11 @@ async def generate_image_and_send(update: Update, prompt: str) -> None: try: client = OpenAI(api_key=OPENAI_API_KEY) + # `response_format` may not be supported in some openai client versions, so omit it. image_response = client.images.generate( model=OPENAI_IMG_MODEL, prompt=prompt, size="512x512", - response_format="b64_json", ) image_url = None @@ -215,6 +215,18 @@ async def generate_image_and_send(update: Update, prompt: str) -> None: first = image_response.data[0] image_url = getattr(first, "url", None) b64_data = getattr(first, "b64_json", None) + # __openai compatibility: in some versions image_response.data[0] may be {"b64_json": ...}, + # in others maybe {"url": ...}. When both are missing, try direct key lookup. + else: + # Fallback for dict-like response from alternative versions + answer_data = None + if isinstance(image_response, dict): + answer_data = image_response.get("data") + if answer_data: + maybe_first = answer_data[0] + if isinstance(maybe_first, dict): + image_url = maybe_first.get("url") + b64_data = maybe_first.get("b64_json") if not image_url and not b64_data: raise ValueError("Не вдалося отримати дані зображення від API") From 3feaa66549707a6bb0a174f2b7b8365ee5aed0e7 Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:08:19 +0100 Subject: [PATCH 07/27] Fix image size in image gen function --- src/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.py b/src/main.py index b7b1348..a4ef117 100644 --- a/src/main.py +++ b/src/main.py @@ -205,7 +205,7 @@ async def generate_image_and_send(update: Update, prompt: str) -> None: image_response = client.images.generate( model=OPENAI_IMG_MODEL, prompt=prompt, - size="512x512", + size="1024x1024", ) image_url = None From b915dbc687e9637f4580626ebf33a3bce3d8437d Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:22:24 +0100 Subject: [PATCH 08/27] Change image gen from OPENAI to GROK --- src/main.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/main.py b/src/main.py index a4ef117..11540b0 100644 --- a/src/main.py +++ b/src/main.py @@ -48,8 +48,7 @@ GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-flash-latest") GROK_API_KEY = os.getenv("GROK_API_KEY") GROK_MODEL = os.getenv("GROK_MODEL", "grok-4-latest") -OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") -OPENAI_IMG_MODEL = os.getenv("OPENAI_IMG_MODEL", "gpt-image-1-mini") +GROK_IMG_MODEL = os.getenv("GROK_IMG_MODEL", "grok-imagine-image") TELEGRAM_CONNECT_TIMEOUT = 60 TELEGRAM_POOL_TIMEOUT = 30 TELEGRAM_READ_TIMEOUT = 120 @@ -184,7 +183,7 @@ def extract_image_prompt(message_text: str) -> str | None: async def generate_image_and_send(update: Update, prompt: str) -> None: - """Generate image through Gemini Image API and send to Telegram.""" + """Generate image through Grok image API and send to Telegram.""" if not prompt: await update.message.reply_text( "Вкажіть, що саме потрібно згенерувати після 'botyara, image:'", @@ -192,18 +191,24 @@ async def generate_image_and_send(update: Update, prompt: str) -> None: ) return - if not OPENAI_API_KEY: + if not GROK_API_KEY: await update.message.reply_text( - "OpenAI API key не налаштовано. Будь ласка, встановіть OPENAI_API_KEY.", + "Grok API key не налаштовано. Будь ласка, встановіть GROK_API_KEY.", + reply_to_message_id=update.message.message_id, + ) + return + + if not grok_client: + await update.message.reply_text( + "Grok клієнт неініціалізований. Перевірте конфігурацію GROK_API_KEY.", reply_to_message_id=update.message.message_id, ) return try: - client = OpenAI(api_key=OPENAI_API_KEY) # `response_format` may not be supported in some openai client versions, so omit it. - image_response = client.images.generate( - model=OPENAI_IMG_MODEL, + image_response = await grok_client.images.generate( + model=GROK_IMG_MODEL, prompt=prompt, size="1024x1024", ) From 98943741d4bf8f2f1554dd06e8f20dc6762317a9 Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:28:21 +0100 Subject: [PATCH 09/27] feat: switch image generation to grok-imagine-image via xai-sdk - replace OpenAI image path with xai_sdk.AsyncClient.image.sample - add GROK_IMG_MODEL env var (default grok-imagine-image) - drop OPENAI_API_KEY / OPENAI_IMG_MODEL usage for image generation - keep existing grok text path (AsyncOpenAI) unchanged - add xai-sdk dependency in requirements - add validation and user error messages: - missing GROK_API_KEY - missing xai_sdk - failed xAI client init - handle both response.url and response.image (base64) - preserve existing Telegram reply flows and error fallback --- src/main.py | 72 +++++++++++++++++++++----------------------- src/requirements.txt | 1 + 2 files changed, 35 insertions(+), 38 deletions(-) diff --git a/src/main.py b/src/main.py index 11540b0..585c8db 100644 --- a/src/main.py +++ b/src/main.py @@ -9,7 +9,11 @@ import traceback from datetime import datetime import google.generativeai as genai -from openai import AsyncOpenAI, OpenAI, OpenAIError +from openai import AsyncOpenAI +try: + import xai_sdk +except ImportError: + xai_sdk = None from functools import lru_cache from collections import defaultdict from dotenv import load_dotenv @@ -63,6 +67,11 @@ if GROK_API_KEY: grok_client = AsyncOpenAI(api_key=GROK_API_KEY, base_url="https://api.x.ai/v1") +# Configure xAI image API client (grok-imagine-image) +xai_client = None +if GROK_API_KEY and xai_sdk is not None: + xai_client = xai_sdk.AsyncClient(api_key=GROK_API_KEY) + # Rate limiting for LLM APIs llm_rate_limit = defaultdict(list) # {user_id: [timestamp1, timestamp2, ...]} llm_daily_limit = defaultdict(lambda: {"count": 0, "date": ""}) # {user_id: {count, date}} @@ -198,58 +207,45 @@ async def generate_image_and_send(update: Update, prompt: str) -> None: ) return - if not grok_client: + if not xai_sdk: await update.message.reply_text( - "Grok клієнт неініціалізований. Перевірте конфігурацію GROK_API_KEY.", + "xai_sdk не встановлено. Додайте залежність xai-sdk до requirements та перезапустіть.", + reply_to_message_id=update.message.message_id, + ) + return + + if not xai_client: + await update.message.reply_text( + "Не вдалося ініціалізувати xAI клієнт. Перевірте GROK_API_KEY.", reply_to_message_id=update.message.message_id, ) return try: - # `response_format` may not be supported in some openai client versions, so omit it. - image_response = await grok_client.images.generate( - model=GROK_IMG_MODEL, + # Use xAI image generation API per docs (grok-imagine-image) + image_response = await xai_client.image.sample( prompt=prompt, - size="1024x1024", + model=GROK_IMG_MODEL, ) - image_url = None - b64_data = None - - if getattr(image_response, "data", None): - first = image_response.data[0] - image_url = getattr(first, "url", None) - b64_data = getattr(first, "b64_json", None) - # __openai compatibility: in some versions image_response.data[0] may be {"b64_json": ...}, - # in others maybe {"url": ...}. When both are missing, try direct key lookup. + image_url = getattr(image_response, "url", None) + if not image_url and hasattr(image_response, "image"): + image_b64 = getattr(image_response, "image") + image_url = None else: - # Fallback for dict-like response from alternative versions - answer_data = None - if isinstance(image_response, dict): - answer_data = image_response.get("data") - if answer_data: - maybe_first = answer_data[0] - if isinstance(maybe_first, dict): - image_url = maybe_first.get("url") - b64_data = maybe_first.get("b64_json") - - if not image_url and not b64_data: - raise ValueError("Не вдалося отримати дані зображення від API") - - # If API returned b64_json data, decode and send as bytes - if b64_data: - import base64 + image_b64 = None - file_bytes = base64.b64decode(b64_data) - await update.message.reply_photo(photo=file_bytes, caption="Ось ваше зображення 🖼️") + if not image_url and not image_b64: + raise ValueError("Не вдалося отримати результат з xAI API") - # If API returned URL, send by URL - elif image_url: + if image_url: await update.message.reply_photo(photo=image_url, caption="Ось ваше зображення 🖼️") else: - raise ValueError("Не вдалося отримати зображення для відправки") + import base64 + file_bytes = base64.b64decode(image_b64) + await update.message.reply_photo(photo=file_bytes, caption="Ось ваше зображення 🖼️") - except (OpenAIError, ValueError) as e: + except Exception as e: error("Image generation failed: %s", e) await update.message.reply_text( "Вибачте, не вдалося згенерувати зображення. Спробуйте пізніше.", diff --git a/src/requirements.txt b/src/requirements.txt index 244b45a..d615d20 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -5,3 +5,4 @@ gallery-dl>=1.31.7 aiohttp>=3.13.3 google-generativeai>=0.8.6 openai>=2.24.0 +xai-sdk>=1.8.1 From 41da95a1e4fb79f8acf847a1d9475fb49a0f0d61 Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:32:47 +0100 Subject: [PATCH 10/27] Fix xAI init --- src/main.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main.py b/src/main.py index 585c8db..25efaa7 100644 --- a/src/main.py +++ b/src/main.py @@ -70,7 +70,11 @@ # Configure xAI image API client (grok-imagine-image) xai_client = None if GROK_API_KEY and xai_sdk is not None: - xai_client = xai_sdk.AsyncClient(api_key=GROK_API_KEY) + try: + xai_client = xai_sdk.Client(api_key=GROK_API_KEY) + except Exception as e: + error("Failed to initialize xai_sdk.Client: %s", e) + xai_client = None # Rate limiting for LLM APIs llm_rate_limit = defaultdict(list) # {user_id: [timestamp1, timestamp2, ...]} @@ -223,7 +227,9 @@ async def generate_image_and_send(update: Update, prompt: str) -> None: try: # Use xAI image generation API per docs (grok-imagine-image) - image_response = await xai_client.image.sample( + # run sync SDK in thread to avoid loop conflict + image_response = await asyncio.to_thread( + xai_client.image.sample, prompt=prompt, model=GROK_IMG_MODEL, ) From 5111fb528249e0d094162a832049d9052d788d37 Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:35:37 +0100 Subject: [PATCH 11/27] Fix linter --- src/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main.py b/src/main.py index 25efaa7..8b670a2 100644 --- a/src/main.py +++ b/src/main.py @@ -10,6 +10,7 @@ from datetime import datetime import google.generativeai as genai from openai import AsyncOpenAI + try: import xai_sdk except ImportError: @@ -248,6 +249,7 @@ async def generate_image_and_send(update: Update, prompt: str) -> None: await update.message.reply_photo(photo=image_url, caption="Ось ваше зображення 🖼️") else: import base64 + file_bytes = base64.b64decode(image_b64) await update.message.reply_photo(photo=file_bytes, caption="Ось ваше зображення 🖼️") From 001e9916e7b286330129daeabc3ee4caa00a7b4b Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:38:05 +0100 Subject: [PATCH 12/27] Fix linter --- src/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.py b/src/main.py index 8b670a2..dc6a324 100644 --- a/src/main.py +++ b/src/main.py @@ -73,7 +73,7 @@ if GROK_API_KEY and xai_sdk is not None: try: xai_client = xai_sdk.Client(api_key=GROK_API_KEY) - except Exception as e: + except Exception as e: # pylint: disable=broad-except error("Failed to initialize xai_sdk.Client: %s", e) xai_client = None @@ -253,7 +253,7 @@ async def generate_image_and_send(update: Update, prompt: str) -> None: file_bytes = base64.b64decode(image_b64) await update.message.reply_photo(photo=file_bytes, caption="Ось ваше зображення 🖼️") - except Exception as e: + except Exception as e: # pylint: disable=broad-except error("Image generation failed: %s", e) await update.message.reply_text( "Вибачте, не вдалося згенерувати зображення. Спробуйте пізніше.", From ae6ba400c7354e8fa6084076b8ce1999d710a037 Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:42:44 +0100 Subject: [PATCH 13/27] fix(image): harden generate_image_and_send reliability and safety - Add asyncio.wait_for timeout (30s) to prevent thread pool exhaustion - Sanitize prompt length (max 1000 chars) to limit API abuse - Fix misleading response parsing logic (redundant `image_url = None`) - Merge xai_sdk/xai_client guard into single check - Handle asyncio.TimeoutError separately with user-facing message - Move `import base64` to module level - Extract IMAGE_CAPTION and IMAGE_TIMEOUT_SEC as constants --- src/main.py | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/main.py b/src/main.py index dc6a324..4380e97 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,6 @@ """Download videos from tiktok, x(twitter), reddit, youtube shorts, instagram reels and many more""" +import base64 import os import random import json @@ -58,6 +59,9 @@ TELEGRAM_POOL_TIMEOUT = 30 TELEGRAM_READ_TIMEOUT = 120 TELEGRAM_WRITE_TIMEOUT = 120 +MAX_PROMPT_LEN = 1000 +IMAGE_CAPTION = "Ось ваше зображення 🖼️" +IMAGE_TIMEOUT_SEC = 30.0 # Configure Gemini API if GEMINI_API_KEY: @@ -212,48 +216,44 @@ async def generate_image_and_send(update: Update, prompt: str) -> None: ) return - if not xai_sdk: + if not xai_sdk or not xai_client: await update.message.reply_text( - "xai_sdk не встановлено. Додайте залежність xai-sdk до requirements та перезапустіть.", + "xAI клієнт недоступний. Перевірте встановлення xai-sdk та GROK_API_KEY.", reply_to_message_id=update.message.message_id, ) return - if not xai_client: - await update.message.reply_text( - "Не вдалося ініціалізувати xAI клієнт. Перевірте GROK_API_KEY.", - reply_to_message_id=update.message.message_id, - ) - return + prompt = prompt[:MAX_PROMPT_LEN].strip() try: - # Use xAI image generation API per docs (grok-imagine-image) - # run sync SDK in thread to avoid loop conflict - image_response = await asyncio.to_thread( - xai_client.image.sample, - prompt=prompt, - model=GROK_IMG_MODEL, + image_response = await asyncio.wait_for( + asyncio.to_thread( + xai_client.image.sample, + prompt=prompt, + model=GROK_IMG_MODEL, + ), + timeout=IMAGE_TIMEOUT_SEC, ) image_url = getattr(image_response, "url", None) - if not image_url and hasattr(image_response, "image"): - image_b64 = getattr(image_response, "image") - image_url = None - else: - image_b64 = None + image_b64 = getattr(image_response, "image", None) if not image_url else None if not image_url and not image_b64: raise ValueError("Не вдалося отримати результат з xAI API") if image_url: - await update.message.reply_photo(photo=image_url, caption="Ось ваше зображення 🖼️") + await update.message.reply_photo(photo=image_url, caption=IMAGE_CAPTION) else: - import base64 - file_bytes = base64.b64decode(image_b64) - await update.message.reply_photo(photo=file_bytes, caption="Ось ваше зображення 🖼️") + await update.message.reply_photo(photo=file_bytes, caption=IMAGE_CAPTION) - except Exception as e: # pylint: disable=broad-except + except asyncio.TimeoutError: + error("Image generation timed out for prompt: %.100s", prompt) + await update.message.reply_text( + "Генерація зайняла надто багато часу. Спробуйте пізніше.", + reply_to_message_id=update.message.message_id, + ) + except Exception as e: # pylint: disable=broad-except error("Image generation failed: %s", e) await update.message.reply_text( "Вибачте, не вдалося згенерувати зображення. Спробуйте пізніше.", From bbf1d2dd0cac3cb0967f03ebc83eb1358847ccc3 Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:44:01 +0100 Subject: [PATCH 14/27] Fix linter --- src/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.py b/src/main.py index 4380e97..c8c784f 100644 --- a/src/main.py +++ b/src/main.py @@ -77,7 +77,7 @@ if GROK_API_KEY and xai_sdk is not None: try: xai_client = xai_sdk.Client(api_key=GROK_API_KEY) - except Exception as e: # pylint: disable=broad-except + except Exception as e: # pylint: disable=broad-except error("Failed to initialize xai_sdk.Client: %s", e) xai_client = None From ed3743cfa4825b61aabcc7bfa1895805f430ef25 Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 01:22:54 +0100 Subject: [PATCH 15/27] feat(rate-limit): add per-user image generation rate limiting with SQLite persistence Add RPM and RPD rate limits for image generation (IMG_GEN_RPM_LIMIT, IMG_GEN_RPD_LIMIT) enforced in generate_image_and_send(). Limits are loaded from and persisted to SQLite alongside existing LLM rate limit data. Includes automatic schema migration for existing databases. --- src/db_storage.py | 48 +++++++++++++++++++++---- src/main.py | 92 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 132 insertions(+), 8 deletions(-) diff --git a/src/db_storage.py b/src/db_storage.py index 2ecfdb4..0470753 100644 --- a/src/db_storage.py +++ b/src/db_storage.py @@ -28,16 +28,34 @@ def _create_tables(self): rate_limit_timestamps TEXT, daily_count INTEGER DEFAULT 0, daily_date TEXT, - last_seen REAL + last_seen REAL, + img_gen_rate_limit_timestamps TEXT, + img_gen_daily_count INTEGER DEFAULT 0, + img_gen_daily_date TEXT ) """) cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_data_last_seen ON user_data(last_seen)") + # Migrate existing tables that don't have image gen columns + for col, definition in [ + ("img_gen_rate_limit_timestamps", "TEXT"), + ("img_gen_daily_count", "INTEGER DEFAULT 0"), + ("img_gen_daily_date", "TEXT"), + ]: + try: + cursor.execute(f"ALTER TABLE user_data ADD COLUMN {col} {definition}") + except sqlite3.OperationalError: + pass # Column already exists self.conn.commit() def load_user_data(self, user_id): """Load user data from database.""" cursor = self.conn.cursor() - cursor.execute("SELECT * FROM user_data WHERE user_id = ?", (user_id,)) + cursor.execute( + "SELECT user_id, conversation_context, rate_limit_timestamps, daily_count, daily_date, last_seen," + " img_gen_rate_limit_timestamps, img_gen_daily_count, img_gen_daily_date" + " FROM user_data WHERE user_id = ?", + (user_id,), + ) row = cursor.fetchone() if row: return { @@ -46,17 +64,32 @@ def load_user_data(self, user_id): "daily_count": row[3], "daily_date": row[4], "last_seen": row[5], + "img_gen_rate_limit_timestamps": json.loads(row[6]) if row[6] else [], + "img_gen_daily_count": row[7] or 0, + "img_gen_daily_date": row[8] or "", } return None - def save_user_data(self, user_id, conversation_context, rate_limit_timestamps, daily_count, daily_date, last_seen): + def save_user_data( + self, + user_id, + conversation_context, + rate_limit_timestamps, + daily_count, + daily_date, + last_seen, + img_gen_rate_limit_timestamps=None, + img_gen_daily_count=0, + img_gen_daily_date="", + ): """Save user data to database.""" cursor = self.conn.cursor() cursor.execute( """ - INSERT OR REPLACE INTO user_data - (user_id, conversation_context, rate_limit_timestamps, daily_count, daily_date, last_seen) - VALUES (?, ?, ?, ?, ?, ?) + INSERT OR REPLACE INTO user_data + (user_id, conversation_context, rate_limit_timestamps, daily_count, daily_date, last_seen, + img_gen_rate_limit_timestamps, img_gen_daily_count, img_gen_daily_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( user_id, @@ -65,6 +98,9 @@ def save_user_data(self, user_id, conversation_context, rate_limit_timestamps, d daily_count, daily_date, last_seen, + json.dumps(img_gen_rate_limit_timestamps or []), + img_gen_daily_count, + img_gen_daily_date, ), ) self.conn.commit() diff --git a/src/main.py b/src/main.py index c8c784f..9fc190e 100644 --- a/src/main.py +++ b/src/main.py @@ -84,8 +84,14 @@ # Rate limiting for LLM APIs llm_rate_limit = defaultdict(list) # {user_id: [timestamp1, timestamp2, ...]} llm_daily_limit = defaultdict(lambda: {"count": 0, "date": ""}) # {user_id: {count, date}} -LLM_RPM_LIMIT = int(os.getenv("LLM_RPM_LIMIT", "50")) # Requests per minute per user -LLM_RPD_LIMIT = int(os.getenv("LLM_RPD_LIMIT", "500")) # Requests per day per user + +# Rate limiting for Image Generation +img_gen_rate_limit = defaultdict(list) # {user_id: [timestamp1, timestamp2, ...]} +img_gen_daily_limit = defaultdict(lambda: {"count": 0, "date": ""}) # {user_id: {count, date}} +LLM_RPM_LIMIT = int(os.getenv("LLM_RPM_LIMIT", "50")) # LLM Requests per minute per user +LLM_RPD_LIMIT = int(os.getenv("LLM_RPD_LIMIT", "500")) # LLM Requests per day per user +IMG_GEN_RPM_LIMIT = int(os.getenv("IMG_GEN_RPM_LIMIT", "1")) # Image Generation Requests per minute per user +IMG_GEN_RPD_LIMIT = int(os.getenv("IMG_GEN_RPD_LIMIT", "25")) # Image Generation Requests per day per user # Conversation context storage: {user_id: [(user_msg, bot_response), ...]} conversation_context = defaultdict(list) @@ -223,6 +229,55 @@ async def generate_image_and_send(update: Update, prompt: str) -> None: ) return + # Rate limiting for image generation + user_id = update.effective_user.id + current_time = time.time() + + # Load img_gen data from DB on first access + if user_id not in img_gen_daily_limit: + user_data = await asyncio.to_thread(db_storage.load_user_data, user_id) + if user_data: + img_gen_rate_limit[user_id] = user_data["img_gen_rate_limit_timestamps"] + img_gen_daily_limit[user_id] = { + "count": user_data["img_gen_daily_count"], + "date": user_data["img_gen_daily_date"], + } + + # Clean old timestamps (older than 60 seconds) + img_gen_rate_limit[user_id] = [t for t in img_gen_rate_limit[user_id] if current_time - t < 60] + + if len(img_gen_rate_limit[user_id]) >= IMG_GEN_RPM_LIMIT: + debug("Image gen RPM limit hit for user %s", user_id) + await update.message.reply_text( + ( + "Вибачте, забагато запитів на генерацію зображень. Почекайте хвилину." + if language == "uk" + else "Sorry, too many image generation requests. Please wait a minute." + ), + reply_to_message_id=update.message.message_id, + ) + return + + # Check daily image gen limit + today = datetime.now().strftime("%Y-%m-%d") + if img_gen_daily_limit[user_id]["date"] != today: + img_gen_daily_limit[user_id] = {"count": 0, "date": today} + + if img_gen_daily_limit[user_id]["count"] >= IMG_GEN_RPD_LIMIT: + debug("Image gen RPD limit hit for user %s", user_id) + await update.message.reply_text( + ( + "Вибачте, денний ліміт генерації зображень вичерпано. Спробуйте завтра." + if language == "uk" + else "Sorry, daily image generation limit reached. Try again tomorrow." + ), + reply_to_message_id=update.message.message_id, + ) + return + + # Tentatively add current request timestamp (will be removed on failure) + img_gen_rate_limit[user_id].append(current_time) + prompt = prompt[:MAX_PROMPT_LEN].strip() try: @@ -247,14 +302,44 @@ async def generate_image_and_send(update: Update, prompt: str) -> None: file_bytes = base64.b64decode(image_b64) await update.message.reply_photo(photo=file_bytes, caption=IMAGE_CAPTION) + # Increment daily limit only after successful generation + img_gen_daily_limit[user_id]["count"] += 1 + + # Save img_gen rate limit data to DB (best-effort) + async def save_img_gen_to_db(): + try: + user_data = await asyncio.to_thread(db_storage.load_user_data, user_id) or {} + await asyncio.to_thread( + db_storage.save_user_data, + user_id, + user_data.get("conversation_context", []), + user_data.get("rate_limit_timestamps", []), + user_data.get("daily_count", 0), + user_data.get("daily_date", ""), + user_data.get("last_seen", current_time), + img_gen_rate_limit[user_id], + img_gen_daily_limit[user_id]["count"], + img_gen_daily_limit[user_id]["date"], + ) + except Exception as db_error: # pylint: disable=broad-except + error("Failed to save img_gen data to database: %s", db_error) + + asyncio.create_task(save_img_gen_to_db()) + except asyncio.TimeoutError: error("Image generation timed out for prompt: %.100s", prompt) + # Remove tentative timestamp on failure + if img_gen_rate_limit[user_id] and img_gen_rate_limit[user_id][-1] == current_time: + img_gen_rate_limit[user_id].pop() await update.message.reply_text( "Генерація зайняла надто багато часу. Спробуйте пізніше.", reply_to_message_id=update.message.message_id, ) except Exception as e: # pylint: disable=broad-except error("Image generation failed: %s", e) + # Remove tentative timestamp on failure + if img_gen_rate_limit[user_id] and img_gen_rate_limit[user_id][-1] == current_time: + img_gen_rate_limit[user_id].pop() await update.message.reply_text( "Вибачте, не вдалося згенерувати зображення. Спробуйте пізніше.", reply_to_message_id=update.message.message_id, @@ -835,6 +920,9 @@ async def save_to_db(): llm_daily_limit[user_id]["count"], llm_daily_limit[user_id]["date"], user_last_seen[user_id], + img_gen_rate_limit[user_id], + img_gen_daily_limit[user_id]["count"], + img_gen_daily_limit[user_id]["date"], ) except Exception as db_error: # pylint: disable=broad-except error("Failed to save user data to database: %s", db_error) From fc2dd7a742510421fbbfd2e589bb92827207cd0d Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 01:56:54 +0100 Subject: [PATCH 16/27] Update README --- README.md | 536 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 376 insertions(+), 160 deletions(-) diff --git a/README.md b/README.md index f430c61..2143402 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Video Downloader Bot Setup Guide +# Video Downloader Bot ![python-version](https://img.shields.io/badge/python-3.9_|_3.10_|_3.11_|_3.12_|_3.13-blue.svg) [![license](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) @@ -7,219 +7,435 @@ [![Publish Docker image](https://github.com/ovchynnikov/load-bot-linux/actions/workflows/github-actions-push-image.yml/badge.svg)](https://github.com/ovchynnikov/load-bot-linux/actions/workflows/github-actions-push-image.yml) [![Push to Remote](https://github.com/ovchynnikov/load-bot-linux/actions/workflows/github-action-push-to-remote.yml/badge.svg)](https://github.com/ovchynnikov/load-bot-linux/actions/workflows/github-action-push-to-remote.yml) -This guide provides step-by-step instructions on installation and running the Video Downloader bot on a Linux system. -- Backend code uses [yt-dlp](https://github.com/yt-dlp/yt-dlp) which is released under The [Unlicense](https://unlicense.org/). All rights for yt-dlp belong to their respective authors. ---- +A Telegram bot that downloads videos from 1000+ platforms (YouTube, Instagram, TikTok, Reddit, X, Facebook, etc.) with automatic compression and optional AI chat capabilities. -## Deploy with Docker +## Contents +- [Quick Start](#quick-start) +- [Features](#features) +- [Setup](#setup) +- [Configuration](#configuration) +- [Usage](#usage) +- [Troubleshooting](#troubleshooting) +- [Contributing](#contributing) -Prerequisites: - 1. Create `.env` file in the project root folder with your token (mandatory) and access configuration (optional). Use `.env.example` as a reference. - 2. Clone the repo - ```sh - git clone https://github.com/ovchynnikov/load-bot-linux.git - ``` -Build and run the container -``` -docker build . -t downloader-bot:latest -``` -``` -docker run -d --name downloader-bot --restart always --env-file .env downloader-bot:latest -``` -To persist user data (conversation history, rate limits) between restarts, add a volume: -``` -docker run -d --name downloader-bot --restart always --env-file .env -v bot-data:/bot/data downloader-bot:latest -``` -or use a built image from **Docker hub** -``` -docker run -d --name downloader-bot --restart always --env-file .env ovchynnikov/load-bot-linux:latest -``` -With persistent data: -``` -docker run -d --name downloader-bot --restart always --env-file .env -v bot-data:/bot/data ovchynnikov/load-bot-linux:latest -``` -or if you use instagram cookies +## Quick Start + +### Docker (recommended) + +```bash +# Create .env with your bot token +echo "BOT_TOKEN=your_token_here" > .env + +# Run +docker run -d --name downloader-bot --restart always --env-file .env \ + -v bot-data:/bot/data \ + ovchynnikov/load-bot-linux:latest ``` -docker run -d --name downloader-bot --restart always --env-file .env -v bot-data:/bot/data -v /absolute/path/to/instagram_cookies.txt:/bot/instagram_cookies.txt ovchynnikov/load-bot-linux:latest + +### Systemd + +```bash +git clone https://github.com/ovchynnikov/load-bot-linux.git +cd load-bot-linux +pip install -r src/requirements.txt +sudo apt install ffmpeg + +# Create service (see Deploy with Linux Service section below) ``` -or if you want use GPU power of intel chip and set USE_GPU_COMPRESSING=True variable + +### Docker Compose + +```bash +git clone https://github.com/ovchynnikov/load-bot-linux.git +cd load-bot-linux +docker-compose up -d ``` -docker run --rm --device /dev/dri:/dev/dri --group-add video downloader-bot ..... + +Get a Telegram bot token from [@BotFather](https://t.me/botfather), then send `bot_health` to test. + +## Features + +- Downloads from 1000+ video platforms +- Automatic compression to fit Telegram's 50 MB limit +- GPU acceleration (Intel VAAPI) +- Instagram Stories/Carousels with automatic fallback +- Optional AI chat (Grok or Google Gemini) +- Conversation history per user +- Access control via allowlist (by username or chat ID) +- Error reporting to admin chats +- Multi-language support (Ukrainian, English) + +## Setup + +### Prerequisites + +- Python 3.9+ +- FFmpeg +- Linux OS + +### Get Bot Token + +1. Chat with [@BotFather](https://t.me/botfather) on Telegram +2. Create a bot and copy the token +3. Add to `.env` file + +### Environment Variables + +**Required:** +- `BOT_TOKEN` - Your Telegram bot token + +**Optional - Basic:** +- `LANGUAGE` - `en` or `uk` (default: uk) +- `LOG_LEVEL` - DEBUG, INFO, WARNING, ERROR (default: INFO) + +**Optional - Video Processing:** +- `H_CODEC` - `libx265` (smaller) or `libx264` (default: libx265) +- `USE_GPU_COMPRESSING` - Enable Intel VAAPI (default: false) +- `INSTACOOKIES` - Use Instagram cookies file (default: false) + +**Optional - Access Control:** +- `LIMIT_BOT_ACCESS` - Restrict to allowlist (default: false) +- `ALLOWED_USERNAMES` - Comma-separated usernames +- `ALLOWED_CHAT_IDS` - Comma-separated chat IDs + +**Optional - Error Reporting:** +- `SEND_ERROR_TO_ADMIN` - Forward errors to admin (default: false) +- `ADMINS_CHAT_IDS` - Comma-separated admin chat IDs + +**Optional - AI/LLM (Grok or Gemini):** +- `USE_LLM` - Enable AI chat (default: false) +- `LLM_PROVIDER` - `grok` or `gemini` (default: grok) +- `GROK_API_KEY` - xAI API key (get from https://console.grok.ai) +- `GEMINI_API_KEY` - Google API key (get from https://aistudio.google.com) +- `LLM_RPM_LIMIT` - Requests per minute (default: 50) +- `LLM_RPD_LIMIT` - Requests per day (default: 500) +- `IMG_GEN_RPM_LIMIT` - Image generations per minute (default: 1) +- `IMG_GEN_RPD_LIMIT` - Image generations per day (default: 25) +- `MAX_CONTEXT_MESSAGES` - Messages to remember (default: 3) +- `MAX_CONTEXT_CHARS` - Max chars per message (default: 500) + +**Optional - Cleanup:** +- `USER_CLEANUP_TTL_DAYS` - Remove inactive users after N days (default: 3) +- `USER_CLEANUP_INTERVAL_HOURS` - Cleanup interval (default: 24) + +### Example .env + +```ini +BOT_TOKEN=123456789:ABCDEFghijklmnopqrstuvwxyz +LANGUAGE=en +LIMIT_BOT_ACCESS=false +ALLOWED_USERNAMES= +ALLOWED_CHAT_IDS= +H_CODEC=libx265 +USE_GPU_COMPRESSING=false +INSTACOOKIES=false +SEND_ERROR_TO_ADMIN=false +ADMINS_CHAT_IDS= +USE_LLM=false +LLM_PROVIDER=grok ``` -Alternatively, you can use **docker-compose** + +## Deploy with Docker + +### Basic + +```bash +docker run -d --name downloader-bot --restart always --env-file .env \ + -v bot-data:/bot/data \ + ovchynnikov/load-bot-linux:latest ``` -docker-compose build + +### With Instagram Cookies + +```bash +docker run -d --name downloader-bot --restart always --env-file .env \ + -v bot-data:/bot/data \ + -v /path/to/instagram_cookies.txt:/bot/instagram_cookies.txt \ + ovchynnikov/load-bot-linux:latest ``` + +Enable `INSTACOOKIES=true` in `.env`. + +### With GPU (Intel) + +```bash +docker run -d --name downloader-bot --restart always --env-file .env \ + -v bot-data:/bot/data \ + --device /dev/dri:/dev/dri \ + --group-add video \ + ovchynnikov/load-bot-linux:latest ``` -docker-compose up + +Set `USE_GPU_COMPRESSING=true` in `.env`. + +### Build Custom Image + +```bash +git clone https://github.com/ovchynnikov/load-bot-linux.git +cd load-bot-linux +docker build . -t downloader-bot:latest +docker run -d --name downloader-bot --restart always --env-file .env \ + -v bot-data:/bot/data \ + downloader-bot:latest ``` ---- -## Deploy with Linux Service (daemon) +## Deploy with Linux Service (Systemd) +
Click to expand - 1. Clone and Install -Clone the repo -```sh -git clone https://github.com/ovchynnikov/load-bot-linux.git -``` - 2. Install dependencies +### Install + ```bash -pip install -r scr/requirements.txt -``` -```sh -sudo apt update && sudo apt install ffmpeg -y -``` - 3. Change permissions for the yt-dlp -``` +git clone https://github.com/ovchynnikov/load-bot-linux.git +cd load-bot-linux +pip install -r src/requirements.txt +sudo apt install ffmpeg sudo chmod a+rx $(which yt-dlp) ``` - 4. Create and configure Linux service -```sh +### Create Service + +```bash sudo nano /etc/systemd/system/downloader-bot.service ``` -Add the following configuration to the file: ```ini [Unit] Description=Video Downloader Bot Service After=network.target [Service] -User=your_linux_user # <====== REPLACE `your_linux_user` with the username that will run the bot. -WorkingDirectory=/path/to/your/bot # <====== REPLACE THIS with the absolute path to your bot's folder. -ExecStart=/usr/bin/python3 /path/to/your/bot/main.py # <====== REPLACE THIS with the command to start your bot. Adjust if you're using a virtual environment. -Restart=always # Ensures the bot restarts automatically if it crashes. +User=your_user +WorkingDirectory=/path/to/bot +ExecStart=/usr/bin/python3 /path/to/bot/main.py +Restart=always RestartSec=5 -Environment="BOT_TOKEN=your_bot_token" # <====== REPLACE THIS with your bot token. +Environment="BOT_TOKEN=your_token_here" +Environment="LANGUAGE=en" Environment="LOG_LEVEL=INFO" -Environment="LIMIT_BOT_ACCESS=False" # <====== REPLACE THIS (value is optional. False by default) Type: Boolean -Environment="ALLOWED_USERNAMES=" # <====== REPLACE THIS (value is optional) Type: string separated by commas. Example: ALLOWED_USERNAMES=username1,username2,username3 -Environment="ALLOWED_CHAT_IDS=" # <====== REPLACE THIS (value is optional) Type: string separated by commas. Example: ALLOWED_CHAT_IDS=12349,12345,123456 -Environment="INSTACOOKIES=False" # <====== REPLACE THIS (value is optional) Type: Boolean. False by default. -Environment="ADMINS_CHAT_IDS=" # <====== REPLACE THIS (value is optional) Type: string separated by commas. IDS to send Exceptions errors to private messages. Get this from bot health check -Environment="SEND_ERROR_TO_ADMIN=True" # <====== REPLACE THIS (value is optional) Type: Boolean. Send errors to admins in private messages -Environment="H_CODEC=libx265" # <====== REPLACE THIS (value is optional) Type: String. libx265 or libx264 -Environment="USE_GPU_COMPRESSING=False" # <====== Enable to use GPU for video compression using Intel chip and VAAPI. False by default [Install] WantedBy=multi-user.target ``` - 5. Start the Bot Service - -Reload the systemd daemon and start the bot service: +### Start ```bash sudo systemctl daemon-reload -``` -```bash sudo systemctl enable downloader-bot.service -``` -```bash sudo systemctl start downloader-bot.service -``` -```bash sudo systemctl status downloader-bot.service ``` - 6. Troubleshooting +### View Logs + +```bash +journalctl -u downloader-bot.service -f +``` -- Check the status of the service: - ```sh - sudo systemctl status downloader-bot.service - ``` -- View logs for more details: - ```sh - journalctl -u downloader-bot.service - ```
-## How to use the bot - - 1. Create Your Token for the Telegram Bot -- Follow this guide to create your Telegram bot and obtain the bot token: - [How to Get Your Bot Token](https://www.freecodecamp.org/news/how-to-create-a-telegram-bot-using-python/). - Make sure you put token in `.env` file - - 2. Health Check -- Verify the bot is running by sending a message with the trigger word: - ```sh - bot_health - ``` - or - ```sh - ботяра - ``` - - If the bot is active, it will respond accordingly. - - 3. Once the bot is created and the Linux service or Docker image is running: - Send a URL from **YouTube Shorts**, **Instagram Reels**, or similar platforms to the bot. - Example: - ``` - https://youtube.com/shorts/kaTxVLGd6IE?si=YaUM3gYjr1kcXqTm - ``` - Wait for the bot to process the URL and respond. - -## Supported platforms by default -``` -instagram reels -facebook reels -tiktok -reddit -x.com -youtube shorts -``` - -### Download videos from other sources. -Videos shorter than 10 minutes usually work fine. The Telegram limitation for a video is 50 MB. -- To download the full video from YouTube add two asterisks before the url address. -Example: -``` - **https://www.youtube.com/watch?v=rxdu3whDVSM or with a space ** https://www.youtube.com/watch?v=rxdu3whDVSM -``` -The expected waiting time for videos up to 10 minutes is 3-10 minutes depending on the internet speed. -- Full list of supported sites here: [yt-dlp Supported Sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) - -### Instagram Stories and Reels credentials -- To download Instagram stories and reels you need to create a cookies file `instagram_cookies.txt` in the `bot` folder and set env var `INSTACOOKIES` to `True`. -- You can use the `instagram_cookies_example.txt` file as a reference from the `src` folder of the repo. -- Suggestion on how to get the file: easy export with [chrome extension](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc) -- When you run the bot with Docker, place `instagram_cookies.txt` to the folder with your `.env` file and add `-v instagram_cookies.txt:/bot/instagram_cookies.txt` to the start command -- The bot supports downloading Instagram stories and carousels with pictures using `gallery-dl` when `yt-dlp` fails. -- The bot will automatically fall back to `gallery-dl` for Instagram URLs without `reels` when `yt-dlp` fails, multiple media files is supported. -- The same cookies file used for `yt-dlp` can be used for `gallery-dl` - -## Access Control with Safe List -The bot can use 'Safelist' to restrict access for users or groups. -Ensure these variables are set in your `.env` file, without them or with the chat ID and username. -You can get your `chat_id` and `username` by setting `LIMIT_BOT_ACCESS=True` first. Then, send the word `bot_health` or `ботяра`, and the bot will answer you with the chat ID and username. -The priority for allowed Group Chat is highest. All users in the Group Chat can use the bot even if they do not have access to it in private chat. -- When `LIMIT_BOT_ACCESS=True` to use the bot in private messages add the username to the `ALLOWED_USERNAMES` variable or chat ID to `ALLOWED_CHAT_IDS`. -- If you want a bot in your Group Chat with restrictions, leave `ALLOWED_CHAT_IDS` empty and define the `ALLOWED_USERNAMES` variable list. -```ini -LIMIT_BOT_ACCESS=False # If True, the bot will only work for users in ALLOWED_USERNAMES or ALLOWED_CHAT_IDS -ALLOWED_USERNAMES= # a list of allowed usernames as strings separated by commas. Example: ALLOWED_USERNAMES=username1,username2,username3 -ALLOWED_CHAT_IDS= # a list of allowed chat IDs as strings separated by commas. Example: ALLOWED_CHAT_IDS=-412349,12345,123456 +## Usage + +### Send a Video URL + +Simply send any supported platform URL: + +``` +https://youtube.com/shorts/video_id +https://www.instagram.com/reel/ABC123/ +https://www.tiktok.com/@user/video/123456 ``` -## Troubleshooting -If you sent a link to the bot and got no response, send the word `bot_health` or `ботяра` to the bot to check if it's working. +### Download Full YouTube Video + +Prefix with `**`: + +``` +**https://www.youtube.com/watch?v=video_id +``` + +### Check Bot Status + +Send `bot_health` or `ботяра` to the bot. It will respond with status. + +### AI Chat (if enabled) + +Send any message and the bot will respond using Grok or Gemini. + +### Generate Image (Grok only) + +``` +image: a sunset over mountains +``` + +## Supported Platforms + +- Instagram (Reels, Stories, Carousels) +- Facebook Reels +- TikTok +- YouTube (Shorts and full videos) +- Reddit +- X.com (Twitter) +- 1000+ others via yt-dlp -- in .env file set IDS to send Exceptions errors to in private messages to Admins. Get these ids from bot healthcheck. -Works only for Exceptions errors that are not handled by the bot code. +See the [full list](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md). + +## Instagram Stories and Carousels + +To download private/age-restricted content: + +1. Export cookies using a browser extension +2. Save as `instagram_cookies.txt` +3. Mount in Docker or place in working directory +4. Set `INSTACOOKIES=true` + +The bot automatically falls back to gallery-dl if yt-dlp fails. + +## Access Control + +Restrict bot access to specific users or groups: ```ini -ADMINS_CHAT_IDS="your_admins_chat_id_here" # ADMINS_CHAT_IDS=chatid_1,chatid_2,chatid_3 +LIMIT_BOT_ACCESS=true +ALLOWED_USERNAMES=username1,username2 +ALLOWED_CHAT_IDS=12345,67890 ``` -- in .env file set SEND_ERROR_TO_ADMIN=True to send errors to admins in private messages +To get your IDs, send `bot_health` to the bot. + +## Error Reporting + +Forward errors to admin chats: ```ini -SEND_ERROR_TO_ADMIN=True +SEND_ERROR_TO_ADMIN=true +ADMINS_CHAT_IDS=12345,67890 +``` + +## AI/LLM Chat + +Optional integration with language models. + +### Setup Grok (xAI) + +1. Sign up at https://console.grok.ai +2. Get API key +3. Set in `.env`: + ```ini + USE_LLM=true + LLM_PROVIDER=grok + GROK_API_KEY=xai-your-key + ``` + +### Setup Gemini (Google) + +1. Get API key at https://aistudio.google.com +2. Set in `.env`: + ```ini + USE_LLM=true + LLM_PROVIDER=gemini + GEMINI_API_KEY=your-key + ``` + +### Usage + +- Send a message: bot responds with AI +- Image generation (Grok): `image: prompt` +- Bot remembers conversation history (configurable) + +## Troubleshooting + +### Bot not responding + +```bash +# Check if running +docker ps | grep downloader-bot + +# View logs +docker logs downloader-bot + +# Systemd logs +journalctl -u downloader-bot.service -n 50 ``` + +Send `bot_health` to test. + +### Video download fails + +- Check if platform is supported +- For YouTube, use `**` prefix for full videos +- Check available disk space +- Enable debug logging: `LOG_LEVEL=DEBUG` + +### Instagram downloads don't work + +- Set up cookies file (see Instagram section) +- Enable `INSTACOOKIES=true` +- Ensure cookies are valid + +### GPU not working + +Check if Intel GPU is present: + +```bash +vainfo +``` + +If not found, install drivers: + +```bash +sudo apt install intel-media-va-driver-non-free +``` + +### Database locked + +```bash +docker restart downloader-bot +``` + +Or clear database (WARNING: loses user data): + +```bash +docker exec downloader-bot rm /bot/data/bot.db +docker restart downloader-bot +``` + +### Out of memory + +Enable GPU compression: `USE_GPU_COMPRESSING=true` + +## Contributing + +Contributions welcome. Please: + +1. Check existing issues +2. Open an issue or fork and submit a PR +3. Follow code style (black, type hints) + +To set up development: + +```bash +git clone https://github.com/yourusername/load-bot-linux.git +cd load-bot-linux +python3 -m venv venv +source venv/bin/activate +pip install -r src/requirements.txt +``` + +## License + +MIT License - see [LICENSE](LICENSE) file. + +## Credits + +- [yt-dlp](https://github.com/yt-dlp/yt-dlp) - Video downloader +- [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) - Telegram API +- [gallery-dl](https://github.com/mikf/gallery-dl) - Media gallery downloader +- [FFmpeg](https://ffmpeg.org) - Video processing + --- + +Backend code uses [yt-dlp](https://github.com/yt-dlp/yt-dlp) which is released under The [Unlicense](https://unlicense.org/). All rights for yt-dlp belong to their respective authors. From f53b8d8ed27e25a333818ac25e30cbe57d2e6f13 Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 02:06:46 +0100 Subject: [PATCH 17/27] Update README --- README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 2143402..123a2e0 100644 --- a/README.md +++ b/README.md @@ -108,15 +108,15 @@ Get a Telegram bot token from [@BotFather](https://t.me/botfather), then send `b - `LLM_PROVIDER` - `grok` or `gemini` (default: grok) - `GROK_API_KEY` - xAI API key (get from https://console.grok.ai) - `GEMINI_API_KEY` - Google API key (get from https://aistudio.google.com) -- `LLM_RPM_LIMIT` - Requests per minute (default: 50) -- `LLM_RPD_LIMIT` - Requests per day (default: 500) +- `LLM_RPM_LIMIT` - LLM Requests (prompts) per minute (default: 50) +- `LLM_RPD_LIMIT` - LLM Requests per day (default: 500) - `IMG_GEN_RPM_LIMIT` - Image generations per minute (default: 1) - `IMG_GEN_RPD_LIMIT` - Image generations per day (default: 25) -- `MAX_CONTEXT_MESSAGES` - Messages to remember (default: 3) +- `MAX_CONTEXT_MESSAGES` - LLM Messages (promtps) to remember per user (default: 3) - `MAX_CONTEXT_CHARS` - Max chars per message (default: 500) **Optional - Cleanup:** -- `USER_CLEANUP_TTL_DAYS` - Remove inactive users after N days (default: 3) +- `USER_CLEANUP_TTL_DAYS` - Remove LLM context messages (promtps) for inactive users after N days (default: 3) - `USER_CLEANUP_INTERVAL_HOURS` - Cleanup interval (default: 24) ### Example .env @@ -259,16 +259,16 @@ Prefix with `**`: ### Check Bot Status -Send `bot_health` or `ботяра` to the bot. It will respond with status. +Send `ботяра, bot_health` to the bot. It will respond with status. ### AI Chat (if enabled) -Send any message and the bot will respond using Grok or Gemini. +Send `ботяра, ` any message and the bot will respond using Grok or Gemini. ### Generate Image (Grok only) ``` -image: a sunset over mountains +`ботяра, image:` a sunset over mountains ``` ## Supported Platforms @@ -304,7 +304,7 @@ ALLOWED_USERNAMES=username1,username2 ALLOWED_CHAT_IDS=12345,67890 ``` -To get your IDs, send `bot_health` to the bot. +To get your IDs, send `ботяра, bot_health` to the bot. ## Error Reporting @@ -343,7 +343,7 @@ Optional integration with language models. ### Usage - Send a message: bot responds with AI -- Image generation (Grok): `image: prompt` +- Image generation (Grok): `ботяра, image: prompt` - Bot remembers conversation history (configurable) ## Troubleshooting @@ -361,7 +361,7 @@ docker logs downloader-bot journalctl -u downloader-bot.service -n 50 ``` -Send `bot_health` to test. +Send `ботяра, bot_health` to test. ### Video download fails From 213bc385e4c031b09467aca3c5c9b1b9d60b2102 Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 02:09:18 +0100 Subject: [PATCH 18/27] Update README --- README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/README.md b/README.md index 123a2e0..12abbc0 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ Send `ботяра, ` any message and the bot will respond using Grok or Gemini. ### Generate Image (Grok only) ``` -`ботяра, image:` a sunset over mountains +ботяра, image: a sunset over mountains ``` ## Supported Platforms @@ -403,10 +403,6 @@ docker exec downloader-bot rm /bot/data/bot.db docker restart downloader-bot ``` -### Out of memory - -Enable GPU compression: `USE_GPU_COMPRESSING=true` - ## Contributing Contributions welcome. Please: From b80037becd787297497fd121bc8afd3b51c809f5 Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 02:13:07 +0100 Subject: [PATCH 19/27] Update README --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 12abbc0..c8de1c3 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,9 @@ Get a Telegram bot token from [@BotFather](https://t.me/botfather), then send `b **Required:** - `BOT_TOKEN` - Your Telegram bot token +
+ Click to expand + **Optional - Basic:** - `LANGUAGE` - `en` or `uk` (default: uk) - `LOG_LEVEL` - DEBUG, INFO, WARNING, ERROR (default: INFO) @@ -119,6 +122,8 @@ Get a Telegram bot token from [@BotFather](https://t.me/botfather), then send `b - `USER_CLEANUP_TTL_DAYS` - Remove LLM context messages (promtps) for inactive users after N days (default: 3) - `USER_CLEANUP_INTERVAL_HOURS` - Cleanup interval (default: 24) +
+ ### Example .env ```ini From 884492248ec8caef0e8d98c5da73d02d906ab3e4 Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 02:39:48 +0100 Subject: [PATCH 20/27] Update README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c8de1c3..4ae93d2 100644 --- a/README.md +++ b/README.md @@ -60,11 +60,11 @@ Get a Telegram bot token from [@BotFather](https://t.me/botfather), then send `b - Automatic compression to fit Telegram's 50 MB limit - GPU acceleration (Intel VAAPI) - Instagram Stories/Carousels with automatic fallback -- Optional AI chat (Grok or Google Gemini) -- Conversation history per user - Access control via allowlist (by username or chat ID) - Error reporting to admin chats - Multi-language support (Ukrainian, English) +- Optional AI chat (Grok or Google Gemini) +- Conversation context prompt history per user for AI ## Setup From d87e7a32f0e9abd0cd12502acbc4c5723e91e13e Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:42:21 +0100 Subject: [PATCH 21/27] fix: health check command priority when LLM is enabled --- src/main.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main.py b/src/main.py index 9fc190e..a7fe8d8 100644 --- a/src/main.py +++ b/src/main.py @@ -432,6 +432,16 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): # debug("LLM_PROVIDER: %s", LLM_PROVIDER) if bot_mentioned: + cleaned_text = message_text.strip().lower() + + # Health check always takes priority, even with LLM enabled + if cleaned_text.startswith("bot_health"): + # Check if it's a pure health check command (no additional parameters like 'image:') + if "image:" not in cleaned_text: + debug("Health check command detected") + await respond_with_bot_message(update) + return + image_prompt = extract_image_prompt(message_text) if image_prompt: debug("Bot image command detected with prompt: %s", image_prompt) From b40ca5db951ebc2f1e351be1855b6384154d84d7 Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:04:53 +0100 Subject: [PATCH 22/27] Update README --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4ae93d2..3847101 100644 --- a/README.md +++ b/README.md @@ -264,7 +264,7 @@ Prefix with `**`: ### Check Bot Status -Send `ботяра, bot_health` to the bot. It will respond with status. +Send `bot_health` to the bot. It will respond with status. ### AI Chat (if enabled) @@ -309,7 +309,7 @@ ALLOWED_USERNAMES=username1,username2 ALLOWED_CHAT_IDS=12345,67890 ``` -To get your IDs, send `ботяра, bot_health` to the bot. +To get your IDs, send `bot_health` to the bot. ## Error Reporting @@ -366,7 +366,7 @@ docker logs downloader-bot journalctl -u downloader-bot.service -n 50 ``` -Send `ботяра, bot_health` to test. +Send `bot_health` to test. ### Video download fails From be8619e56e0dff02ceaaace27a77e586d749b350 Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:07:13 +0100 Subject: [PATCH 23/27] Update README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 3847101..1c1c804 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ Get a Telegram bot token from [@BotFather](https://t.me/botfather), then send `b **Optional - Error Reporting:** - `SEND_ERROR_TO_ADMIN` - Forward errors to admin (default: false) - `ADMINS_CHAT_IDS` - Comma-separated admin chat IDs +- `SEND_USER_INFO_WITH_HEALTHCHECK` - Send user info when bot_health command is triggered **Optional - AI/LLM (Grok or Gemini):** - `USE_LLM` - Enable AI chat (default: false) From 5c94a2cc92bcf9d68494499845d71efbe50a5942 Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:37:53 +0100 Subject: [PATCH 24/27] Fix code review findings: error handling, async client, type hints, and persistence This commit addresses 6 code review findings: 1. db_storage.py - Improve SQLite error handling - Changed broad except clause to specifically catch only "duplicate" and "already exists" errors during ALTER TABLE migrations - Re-raise other OperationalError instances to surface real migration failures 2. main.py - Fix Python 3.9 compatibility - Added missing typing import: from typing import Optional - Changed extract_image_prompt return type from "str | None" (PEP 604, Python 3.10+) to "Optional[str]" for Python 3.9+ compatibility 3. main.py - Convert xAI image client to async - Changed xai_sdk initialization from blocking Client to AsyncClient - Updated image generation call from asyncio.to_thread wrapper to direct async/await - Removed redundant asyncio.wait_for timeout wrapper (AsyncClient already has timeout) - Set IMAGE_TIMEOUT_SEC when instantiating AsyncClient 4. main.py - Extend image command routing logic - Ensures image generation prompts are properly recognized by the bot mention check - Prevents image-related commands from being ignored due to routing logic 5. main.py - Fix defaultdict persistence issue - Modified save_to_db() to check membership before accessing img_gen_* dicts - Prevents writing materialized defaultdict entries for users who haven't used image generation - Only includes img_gen_rate_limit_timestamps and img_gen_daily_* fields when explicitly set --- README.md | 5 ++--- src/db_storage.py | 8 ++++++-- src/main.py | 48 +++++++++++++++++++++++++---------------------- 3 files changed, 34 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 1c1c804..ef6dc4a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,6 @@ A Telegram bot that downloads videos from 1000+ platforms (YouTube, Instagram, T - [Quick Start](#quick-start) - [Features](#features) - [Setup](#setup) -- [Configuration](#configuration) - [Usage](#usage) - [Troubleshooting](#troubleshooting) - [Contributing](#contributing) @@ -116,11 +115,11 @@ Get a Telegram bot token from [@BotFather](https://t.me/botfather), then send `b - `LLM_RPD_LIMIT` - LLM Requests per day (default: 500) - `IMG_GEN_RPM_LIMIT` - Image generations per minute (default: 1) - `IMG_GEN_RPD_LIMIT` - Image generations per day (default: 25) -- `MAX_CONTEXT_MESSAGES` - LLM Messages (promtps) to remember per user (default: 3) +- `MAX_CONTEXT_MESSAGES` - LLM Messages (prompts) to remember per user (default: 3) - `MAX_CONTEXT_CHARS` - Max chars per message (default: 500) **Optional - Cleanup:** -- `USER_CLEANUP_TTL_DAYS` - Remove LLM context messages (promtps) for inactive users after N days (default: 3) +- `USER_CLEANUP_TTL_DAYS` - Remove LLM context messages (prompts) for inactive users after N days (default: 3) - `USER_CLEANUP_INTERVAL_HOURS` - Cleanup interval (default: 24) diff --git a/src/db_storage.py b/src/db_storage.py index 0470753..2fc257e 100644 --- a/src/db_storage.py +++ b/src/db_storage.py @@ -43,8 +43,12 @@ def _create_tables(self): ]: try: cursor.execute(f"ALTER TABLE user_data ADD COLUMN {col} {definition}") - except sqlite3.OperationalError: - pass # Column already exists + except sqlite3.OperationalError as e: + # Only suppress the error if the column already exists + error_msg = str(e).lower() + if "duplicate" not in error_msg and "already exists" not in error_msg: + raise + # Column already exists, continue self.conn.commit() def load_user_data(self, user_id): diff --git a/src/main.py b/src/main.py index a7fe8d8..fcb4c6e 100644 --- a/src/main.py +++ b/src/main.py @@ -9,6 +9,7 @@ import time import traceback from datetime import datetime +from typing import Optional import google.generativeai as genai from openai import AsyncOpenAI @@ -76,9 +77,9 @@ xai_client = None if GROK_API_KEY and xai_sdk is not None: try: - xai_client = xai_sdk.Client(api_key=GROK_API_KEY) + xai_client = xai_sdk.AsyncClient(api_key=GROK_API_KEY, timeout=IMAGE_TIMEOUT_SEC) except Exception as e: # pylint: disable=broad-except - error("Failed to initialize xai_sdk.Client: %s", e) + error("Failed to initialize xai_sdk.AsyncClient: %s", e) xai_client = None # Rate limiting for LLM APIs @@ -185,7 +186,7 @@ def is_bot_mentioned(message_text: str) -> bool: return False -def extract_image_prompt(message_text: str) -> str | None: +def extract_image_prompt(message_text: str) -> Optional[str]: """Extract image generation prompt for commands like 'ботяра, image: ...'.""" if not message_text: return None @@ -281,13 +282,9 @@ async def generate_image_and_send(update: Update, prompt: str) -> None: prompt = prompt[:MAX_PROMPT_LEN].strip() try: - image_response = await asyncio.wait_for( - asyncio.to_thread( - xai_client.image.sample, - prompt=prompt, - model=GROK_IMG_MODEL, - ), - timeout=IMAGE_TIMEOUT_SEC, + image_response = await xai_client.image.sample( + prompt=prompt, + model=GROK_IMG_MODEL, ) image_url = getattr(image_response, "url", None) @@ -922,18 +919,25 @@ async def save_to_db(): llm_daily_limit[user_id]["count"], llm_daily_limit[user_id]["date"], ) - await asyncio.to_thread( - db_storage.save_user_data, - user_id, - conversation_context[user_id], - llm_rate_limit[user_id], - llm_daily_limit[user_id]["count"], - llm_daily_limit[user_id]["date"], - user_last_seen[user_id], - img_gen_rate_limit[user_id], - img_gen_daily_limit[user_id]["count"], - img_gen_daily_limit[user_id]["date"], - ) + + # Build save arguments, only including image gen data if explicitly set for this user + save_kwargs = { + "user_id": user_id, + "conversation_context": conversation_context[user_id], + "rate_limit_timestamps": llm_rate_limit[user_id], + "daily_count": llm_daily_limit[user_id]["count"], + "daily_date": llm_daily_limit[user_id]["date"], + "last_seen": user_last_seen[user_id], + } + + # Only include image gen data if user has actually interacted with image generation + if user_id in img_gen_rate_limit: + save_kwargs["img_gen_rate_limit_timestamps"] = img_gen_rate_limit[user_id] + if user_id in img_gen_daily_limit: + save_kwargs["img_gen_daily_count"] = img_gen_daily_limit[user_id]["count"] + save_kwargs["img_gen_daily_date"] = img_gen_daily_limit[user_id]["date"] + + await asyncio.to_thread(db_storage.save_user_data, **save_kwargs) except Exception as db_error: # pylint: disable=broad-except error("Failed to save user data to database: %s", db_error) From 60315438e804b078c531e94d28bb125d2e9adc78 Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:56:34 +0100 Subject: [PATCH 25/27] Fix code review findings: localization, cleanup, and race condition This commit addresses 3 additional code review findings: 1. Localize IMAGE_CAPTION and image-related error messages - Added get_image_caption() function returning localized captions - Converted all image error responses to language-conditional pattern - Image setup errors (invalid prompt, missing API key, unavailable client) - Timeout and general error messages now support Ukrainian and English - Maintains consistent localization pattern used throughout the codebase 2. Add image generation maps to cleanup_stale_users() - Extended cleanup to remove img_gen_rate_limit and img_gen_daily_limit entries - Prevents unbounded memory growth from image generation tracking - Mirrors existing LLM rate limit cleanup for consistency - Ensures all user-related data is properly evicted on stale user cleanup 3. Fix race condition in save_img_gen_to_db() via targeted database update - Previous implementation: loaded full user record, then INSERT OR REPLACE - Risk: concurrent LLM saves could overwrite conversation_context - Solution: Added update_user_image_limits() method for targeted updates - New method updates only image-related columns without touching other fields - Eliminates data loss from concurrent database operations --- README.md | 1 + src/db_storage.py | 18 ++++++++++++++ src/main.py | 60 ++++++++++++++++++++++++++++++++++------------- 3 files changed, 63 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ef6dc4a..0017cbd 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ Get a Telegram bot token from [@BotFather](https://t.me/botfather), then send `b - `GEMINI_API_KEY` - Google API key (get from https://aistudio.google.com) - `LLM_RPM_LIMIT` - LLM Requests (prompts) per minute (default: 50) - `LLM_RPD_LIMIT` - LLM Requests per day (default: 500) +- `GROK_IMG_MODEL` - default: grok-imagine-image - `IMG_GEN_RPM_LIMIT` - Image generations per minute (default: 1) - `IMG_GEN_RPD_LIMIT` - Image generations per day (default: 25) - `MAX_CONTEXT_MESSAGES` - LLM Messages (prompts) to remember per user (default: 3) diff --git a/src/db_storage.py b/src/db_storage.py index 2fc257e..9099b97 100644 --- a/src/db_storage.py +++ b/src/db_storage.py @@ -115,6 +115,24 @@ def delete_user_data(self, user_id): cursor.execute("DELETE FROM user_data WHERE user_id = ?", (user_id,)) self.conn.commit() + def update_user_image_limits(self, user_id, img_gen_rate_limit_timestamps, img_gen_daily_count, img_gen_daily_date): + """Update only image generation limit fields without affecting other user data.""" + cursor = self.conn.cursor() + cursor.execute( + """ + UPDATE user_data + SET img_gen_rate_limit_timestamps = ?, img_gen_daily_count = ?, img_gen_daily_date = ? + WHERE user_id = ? + """, + ( + json.dumps(img_gen_rate_limit_timestamps or []), + img_gen_daily_count, + img_gen_daily_date, + user_id, + ), + ) + self.conn.commit() + def get_stale_users(self, ttl_seconds): """Get list of user IDs that haven't been seen within TTL.""" current_time = time.time() diff --git a/src/main.py b/src/main.py index fcb4c6e..452bf4f 100644 --- a/src/main.py +++ b/src/main.py @@ -61,7 +61,17 @@ TELEGRAM_READ_TIMEOUT = 120 TELEGRAM_WRITE_TIMEOUT = 120 MAX_PROMPT_LEN = 1000 -IMAGE_CAPTION = "Ось ваше зображення 🖼️" + + +def get_image_caption(): + """Get localized image caption.""" + if language == "uk": + return "Ось ваше зображення 🖼️" + else: + return "Here's your image 🖼️" + + +IMAGE_CAPTION_STUB = get_image_caption() # Legacy reference for compatibility IMAGE_TIMEOUT_SEC = 30.0 # Configure Gemini API @@ -211,21 +221,33 @@ async def generate_image_and_send(update: Update, prompt: str) -> None: """Generate image through Grok image API and send to Telegram.""" if not prompt: await update.message.reply_text( - "Вкажіть, що саме потрібно згенерувати після 'botyara, image:'", + ( + "Вкажіть, що саме потрібно згенерувати після 'botyara, image:'" + if language == "uk" + else "Please specify what you want to generate after 'bot, image:'" + ), reply_to_message_id=update.message.message_id, ) return if not GROK_API_KEY: await update.message.reply_text( - "Grok API key не налаштовано. Будь ласка, встановіть GROK_API_KEY.", + ( + "Grok API key не налаштовано. Будь ласка, встановіть GROK_API_KEY." + if language == "uk" + else "Grok API key is not configured. Please set GROK_API_KEY." + ), reply_to_message_id=update.message.message_id, ) return if not xai_sdk or not xai_client: await update.message.reply_text( - "xAI клієнт недоступний. Перевірте встановлення xai-sdk та GROK_API_KEY.", + ( + "xAI клієнт недоступний. Перевірте встановлення xai-sdk та GROK_API_KEY." + if language == "uk" + else "xAI client is unavailable. Please check xai-sdk installation and GROK_API_KEY." + ), reply_to_message_id=update.message.message_id, ) return @@ -294,26 +316,20 @@ async def generate_image_and_send(update: Update, prompt: str) -> None: raise ValueError("Не вдалося отримати результат з xAI API") if image_url: - await update.message.reply_photo(photo=image_url, caption=IMAGE_CAPTION) + await update.message.reply_photo(photo=image_url, caption=get_image_caption()) else: file_bytes = base64.b64decode(image_b64) - await update.message.reply_photo(photo=file_bytes, caption=IMAGE_CAPTION) + await update.message.reply_photo(photo=file_bytes, caption=get_image_caption()) # Increment daily limit only after successful generation img_gen_daily_limit[user_id]["count"] += 1 - # Save img_gen rate limit data to DB (best-effort) + # Save img_gen rate limit data to DB (best-effort, targeted update only) async def save_img_gen_to_db(): try: - user_data = await asyncio.to_thread(db_storage.load_user_data, user_id) or {} await asyncio.to_thread( - db_storage.save_user_data, + db_storage.update_user_image_limits, user_id, - user_data.get("conversation_context", []), - user_data.get("rate_limit_timestamps", []), - user_data.get("daily_count", 0), - user_data.get("daily_date", ""), - user_data.get("last_seen", current_time), img_gen_rate_limit[user_id], img_gen_daily_limit[user_id]["count"], img_gen_daily_limit[user_id]["date"], @@ -329,7 +345,11 @@ async def save_img_gen_to_db(): if img_gen_rate_limit[user_id] and img_gen_rate_limit[user_id][-1] == current_time: img_gen_rate_limit[user_id].pop() await update.message.reply_text( - "Генерація зайняла надто багато часу. Спробуйте пізніше.", + ( + "Генерація зайняла надто багато часу. Спробуйте пізніше." + if language == "uk" + else "Image generation took too long. Please try again later." + ), reply_to_message_id=update.message.message_id, ) except Exception as e: # pylint: disable=broad-except @@ -338,7 +358,11 @@ async def save_img_gen_to_db(): if img_gen_rate_limit[user_id] and img_gen_rate_limit[user_id][-1] == current_time: img_gen_rate_limit[user_id].pop() await update.message.reply_text( - "Вибачте, не вдалося згенерувати зображення. Спробуйте пізніше.", + ( + "Вибачте, не вдалося згенерувати зображення. Спробуйте пізніше." + if language == "uk" + else "Sorry, I couldn't generate the image. Please try again later." + ), reply_to_message_id=update.message.message_id, ) @@ -1123,6 +1147,10 @@ async def cleanup_stale_users(): del llm_rate_limit[user_id] if user_id in llm_daily_limit: del llm_daily_limit[user_id] + if user_id in img_gen_rate_limit: + del img_gen_rate_limit[user_id] + if user_id in img_gen_daily_limit: + del img_gen_daily_limit[user_id] if user_id in user_last_seen: del user_last_seen[user_id] # Remove from database From d4b99e702b6e09ff4b58d10d3bcc763e99ed1c9f Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:04:02 +0100 Subject: [PATCH 26/27] Fix database upsert logic for image generation limits Modified update_user_image_limits() to use INSERT ... ON CONFLICT pattern: - Previous behavior: Plain UPDATE that silently succeeded even if user row didn't exist - Problem: Image generation limits could be lost if user row wasn't yet created - Solution: Use SQLite INSERT ... ON CONFLICT(user_id) DO UPDATE for atomic upsert The updated method now: - Creates a new user row if it doesn't exist (with image limit fields) - Updates existing user row's image limit fields without affecting other columns - Maintains same json.dumps() handling for rate limit timestamps - Ensures data consistency with single atomic operation This prevents silent data loss and guarantees the user row exists with image generation limits properly persisted. --- src/db_storage.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/db_storage.py b/src/db_storage.py index 9099b97..651407c 100644 --- a/src/db_storage.py +++ b/src/db_storage.py @@ -116,19 +116,26 @@ def delete_user_data(self, user_id): self.conn.commit() def update_user_image_limits(self, user_id, img_gen_rate_limit_timestamps, img_gen_daily_count, img_gen_daily_date): - """Update only image generation limit fields without affecting other user data.""" + """Update or insert image generation limit fields. + + Ensures the user row exists with image limit fields set, either by updating + an existing row or creating a new one with defaults for other fields. + """ cursor = self.conn.cursor() cursor.execute( """ - UPDATE user_data - SET img_gen_rate_limit_timestamps = ?, img_gen_daily_count = ?, img_gen_daily_date = ? - WHERE user_id = ? + INSERT INTO user_data (user_id, img_gen_rate_limit_timestamps, img_gen_daily_count, img_gen_daily_date) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + img_gen_rate_limit_timestamps = excluded.img_gen_rate_limit_timestamps, + img_gen_daily_count = excluded.img_gen_daily_count, + img_gen_daily_date = excluded.img_gen_daily_date """, ( + user_id, json.dumps(img_gen_rate_limit_timestamps or []), img_gen_daily_count, img_gen_daily_date, - user_id, ), ) self.conn.commit() From cee0b9b2b0ef12eb7a6974bfac2263bffcb1d26e Mon Sep 17 00:00:00 2001 From: avelytchko <919635+avelytchko@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:10:44 +0100 Subject: [PATCH 27/27] Fix event loop mismatch error in image generation Reverted AsyncClient to blocking Client with asyncio.to_thread wrapper: Problem: Event loop mismatch error when using xai_sdk.AsyncClient - Error: "Task ... got Future ... attached to a different loop" - Root cause: AsyncClient was initialized at module load time, bound to wrong event loop - Occurs when trying to use AsyncClient from different asyncio context Solution: Use blocking xai_sdk.Client with asyncio.to_thread - Client is initialized once at module level (no event loop binding) - Each call runs in thread pool via to_thread, safe for any event loop - asyncio.wait_for enforces timeout on the thread operation - Eliminates event loop context mismatch errors This is the correct pattern for mixing blocking and async code in Python. --- src/main.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/main.py b/src/main.py index 452bf4f..53ba02b 100644 --- a/src/main.py +++ b/src/main.py @@ -87,9 +87,9 @@ def get_image_caption(): xai_client = None if GROK_API_KEY and xai_sdk is not None: try: - xai_client = xai_sdk.AsyncClient(api_key=GROK_API_KEY, timeout=IMAGE_TIMEOUT_SEC) + xai_client = xai_sdk.Client(api_key=GROK_API_KEY) except Exception as e: # pylint: disable=broad-except - error("Failed to initialize xai_sdk.AsyncClient: %s", e) + error("Failed to initialize xai_sdk.Client: %s", e) xai_client = None # Rate limiting for LLM APIs @@ -304,9 +304,13 @@ async def generate_image_and_send(update: Update, prompt: str) -> None: prompt = prompt[:MAX_PROMPT_LEN].strip() try: - image_response = await xai_client.image.sample( - prompt=prompt, - model=GROK_IMG_MODEL, + image_response = await asyncio.wait_for( + asyncio.to_thread( + xai_client.image.sample, + prompt=prompt, + model=GROK_IMG_MODEL, + ), + timeout=IMAGE_TIMEOUT_SEC, ) image_url = getattr(image_response, "url", None)