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

[](LICENSE)
@@ -7,219 +7,435 @@
[](https://github.com/ovchynnikov/load-bot-linux/actions/workflows/github-actions-push-image.yml)
[](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)