Initial commit: Telegram Sticker Bot Manager

Features:
- Create and manage sticker packs
- Add existing sticker sets to database
- Automatic image optimization (512px, WebP conversion)
- GIF to animated video sticker conversion (WebM, VP9)
- Background removal (local rembg + optional Remove.bg API)
- Hybrid API with retry logic and usage tracking
- Premium status detection
- SQLite database for persistence
- Docker containerization
- Complete OPENCODE.md documentation

Technical Stack:
- python-telegram-bot 22.7
- Pillow for image processing
- FFmpeg for video conversion
- rembg for local background removal
- aiosqlite for async database operations
This commit is contained in:
coding
2026-03-25 08:59:38 +00:00
commit f634b884ec
12 changed files with 2708 additions and 0 deletions

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
# Telegram Sticker Bot Configuration
BOT_TOKEN=your_bot_token_here
OWNER_ID=your_telegram_user_id_here
# Optional: Remove.bg API Key for background removal
# If not set, only local processing is available (50 free calls/month)
REMOVE_BG_API_KEY=your_remove_bg_api_key_here

31
Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
FROM python:3.11-alpine
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Install system dependencies
RUN apk add --no-cache gcc musl-dev ffmpeg
# Set work directory
WORKDIR /app
# Copy requirements first for better caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy project
COPY . .
# Create non-root user
RUN adduser -D -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import sys; sys.exit(0)"
# Run the bot
CMD ["python", "bot.py"]

346
OPENCODE.md Normal file
View File

@@ -0,0 +1,346 @@
# OPENCODE.md - Telegram Sticker Bot
## Overview
This is a Telegram bot for managing sticker packs. Built with python-telegram-bot v22.7, it allows the bot owner to create, manage, and organize sticker sets through an interactive conversation interface.
## Architecture
### Technology Stack
- **Python**: 3.11+
- **Library**: python-telegram-bot v22.7 (supports Bot API 9.5)
- **Database**: SQLite with aiosqlite for async operations
- **Image Processing**: Pillow (PIL) for automatic image conversion
- **Video Processing**: FFmpeg for GIF to WebM conversion
- **Background Removal**: rembg (local) + remove.bg API (optional)
- **Container**: Docker + Docker Compose
- **Environment**: python-dotenv for configuration
### Project Structure
```
stickerbot/
├── bot.py # Main bot with ConversationHandler
├── config.py # Configuration and environment loading
├── database.py # SQLite database operations
├── sticker_manager.py # Sticker management utilities
├── image_processor.py # Automatic image conversion
├── gif_processor.py # GIF to WebM conversion
├── background_remover.py # Background removal (local + API)
├── requirements.txt # Python dependencies
├── Dockerfile # Docker image configuration
├── docker-compose.yml # Docker Compose configuration
├── .env.example # Environment template
└── data/ # Persistent data directory
```
### Database Schema
- **sticker_sets**: Tracks created sticker sets (user_id, set_name, set_title, timestamps)
- **user_premium**: Caches user premium status with last check timestamp
- **stickers**: Tracks individual stickers in sets (file_id, emoji, set_name)
- **api_usage**: Tracks API usage for external services (remove.bg)
## Commands
### Available Commands
- `/start` - Start the bot and show main menu
- `/hilfe` - Display detailed help message
- `/cancel` - Cancel current conversation
## Build/Lint/Test Commands
### Docker
```bash
# Build and run
sudo docker-compose up -d --build
# View logs
sudo docker-compose logs -f
# Stop
sudo docker-compose down
# Rebuild after changes
sudo docker-compose up -d --build --force-recreate
```
### Local Development (without Docker)
```bash
# Install dependencies
pip install -r requirements.txt
# Run bot
python bot.py
# Run with environment file
python -c "from bot import main; main()"
```
### Testing
- No test suite implemented yet
- Manual testing via Telegram bot interface
- Use `/cancel` to abort any conversation
### Linting
```bash
# Optional: Add ruff or flake8
pip install ruff
ruff check .
ruff format .
```
## Code Style Guidelines
### Imports
- Group imports: stdlib, third-party, local
- Use absolute imports
- Example:
```python
import logging
from telegram import Update
from telegram.ext import Application
from config import BOT_TOKEN
```
### Async/Await
- All handlers are async (python-telegram-bot v22.x requirement)
- Database operations use aiosqlite (async)
- Always await coroutines
- Example:
```python
async def handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text("Hello")
```
### Types
- Use type hints where possible
- Follow telegram library types: Update, ContextTypes, InlineKeyboardMarkup
- Database functions return Optional[bool], List[Dict[str, Any]]
### Naming Conventions
- Functions: snake_case
- Constants: UPPER_CASE
- Classes: PascalCase
- Private: _prefix
### Error Handling
- Use try-except blocks for API calls
- Log errors with logger.error()
- Return success/failure tuples or booleans
- Always clean up user_data on errors
### Conversation States
- Defined as constants at module level
- Use range() for sequential states
- Clear user_data when conversation ends
- State order: MENU → CREATE_SET_* → SELECT_SET → SET_ACTION → specific actions
### Security
- OWNER_ID check on every /start
- No group chat support (private only)
- SQLite for local persistence
- No secrets in code (use .env)
## Key Features
### Premium Detection
- Check via `chat.is_premium` from Telegram API
- Cached in database with 24h refresh interval
- Scheduled job using JobQueue
- Displayed in main menu
### Sticker Operations
#### 1. Create New Sticker Set
**Flow:** Name → Title → Emoji → Image
- 4-step wizard with input validation
- Automatic image optimization
- Optional background removal
#### 2. Add Existing Sticker Set
**Flow:** Select "📥 Existierendes hinzufügen" → Enter set name
- User provides existing sticker set name
- Bot verifies via `getStickerSet()` API
- Auto-appends `_by_<bot_username>` if not present
- Duplicate check prevents double entries
#### 3. Add Sticker to Set
**Supported formats:**
- **Static images**: PNG, JPG, WebP (automatic conversion to WebP)
- **GIFs**: Converted to animated video stickers (WebM, max 3 seconds)
**Features:**
- Automatic resizing to 512px
- Background removal option (if REMOVE_BG_API_KEY configured)
- GIF trimming for animations > 3 seconds
#### 4. Delete Sticker
- Forward sticker to bot
- Confirmation before deletion
#### 5. Change Emoji
- Forward sticker → Enter new emoji
#### 6. Rename Set
- Enter new title (max 64 characters)
#### 7. Delete Set
- Confirmation required
- Cannot be undone
### Automatic Image Processing
All uploaded images are automatically converted to Telegram sticker format:
- **Resize**: One dimension set to 512px (other ≤512px), maintaining aspect ratio
- **Format conversion**: Converted to WebP with transparency (RGBA)
- **Compression**: Quality adjusted automatically (60-95) to stay under 3MB
- **Supported formats**: PNG, JPG, JPEG, WebP, GIF, BMP, TIFF
- **Quality**: Uses LANCZOS resampling for best results
### Background Removal (Hybrid)
#### Local Processing (rembg)
- **Cost**: Free, runs on server
- **Method**: U²Net deep learning model
- **First run**: Downloads model (~180MB)
- **Speed**: Depends on image size
#### Remove.bg API (Optional)
- **Cost**: 50 free calls/month, then paid
- **Configuration**: Set REMOVE_BG_API_KEY in .env
- **Retry logic**: 3 attempts with delays (2s, 5s, 10s)
- **Limit tracking**: Stored in database, resets monthly
- **Fallback**: Automatically falls back to local on API errors
#### User Interface
```
Without API key:
Image → [Auto: Local processing] → Emoji → Done
With API key:
Image → Selection:
├─ [🤖 Local (free)]
├─ [☁️ API (45/50)] or [☁️ API (50/50) ⚠️ Limit]
└─ [⏭️ Skip removal]
If API fails/limit reached:
❌ Remove.bg API limit reached (50/50)
[🤖 Process locally instead]
[⏭️ Without background removal]
[❌ Cancel]
```
### GIF to Video Sticker Conversion
- **Format**: WebM with VP9 codec
- **Resolution**: 512px (one side), other ≤512px
- **FPS**: 30 frames per second
- **Duration**: Maximum 3 seconds
- **File size**: Maximum 256KB
- **Compression**: Automatic quality adjustment
- **Trimming**: User confirmation for GIFs > 3s
## Environment Variables
```bash
# Required
BOT_TOKEN=your_telegram_bot_token
OWNER_ID=your_telegram_user_id
# Optional
DATABASE_PATH=/app/data/sticker_bot.db
PREMIUM_CHECK_INTERVAL=24 # Hours, default: 24
# Optional: Remove.bg API for background removal
# 50 free calls/month
REMOVE_BG_API_KEY=your_remove_bg_api_key
```
## Useful Commands
```bash
# Check bot is running
sudo docker ps | grep stickerbot
# View logs
sudo docker-compose logs -f
# Database inspection
sqlite3 data/sticker_bot.db ".tables"
sqlite3 data/sticker_bot.db "SELECT * FROM sticker_sets;"
sqlite3 data/sticker_bot.db "SELECT * FROM api_usage;"
# Reset database
rm data/sticker_bot.db
# Rebuild container
sudo docker-compose up -d --build --force-recreate
```
## Important Notes
### Sticker Requirements
- **Static stickers**: 512x512px, WebP format, max 3MB
- **Animated stickers**: WebM (VP9), 512px one side, max 3s, max 256KB
- **Emoji**: One per sticker (can be changed later)
### Set Names
- Must end with `_by_<bot_username>` (handled automatically)
- 1-64 characters
- Only alphanumeric and underscores for short name
### Limits
- **Regular sets**: Max 120 stickers
- **Emoji sets**: Max 200 stickers
- **API calls**: 50/month for remove.bg (free tier)
### Best Practices
- Send images as file (not compressed photo) for best quality
- Use /start to return to main menu anytime
- Use /cancel to abort current operation
- Database persists in `./data/` volume
## API Methods Used
### Telegram Bot API
- `create_new_sticker_set()` - Create sticker sets
- `add_sticker_to_set()` - Add stickers
- `delete_sticker_from_set()` - Remove stickers
- `set_sticker_emoji_list()` - Change emoji
- `set_sticker_set_title()` - Rename set
- `delete_sticker_set()` - Delete entire set
- `get_sticker_set()` - Retrieve set info (for existing sets)
- `get_chat()` - Check premium status
### External APIs
- **Remove.bg API**: `POST https://api.remove.bg/v1.0/removebg` - Background removal
## Common Issues
1. **Import errors in IDE**: Install requirements.txt (`pip install -r requirements.txt`)
2. **Database locked**: Only one process should access SQLite at a time
3. **Sticker creation fails**: Check image format (must be valid image file)
4. **Premium check fails**: User must have started bot first
5. **API limit reached**: Check current usage with `/hilfe` or in database
6. **Background removal slow**: First run downloads ML model (~180MB)
7. **GIF conversion fails**: Ensure GIF is under 10MB before upload
## Troubleshooting
### Bot not responding
```bash
# Check if container is running
sudo docker ps | grep stickerbot
# Check logs for errors
sudo docker-compose logs -f
```
### Database issues
```bash
# Reset database (removes all data)
rm data/sticker_bot.db
sudo docker-compose restart
```
### API issues
- Verify REMOVE_BG_API_KEY is correct
- Check API usage in database: `sqlite3 data/sticker_bot.db "SELECT * FROM api_usage;"`
- API automatically falls back to local processing on errors

183
background_remover.py Normal file
View File

@@ -0,0 +1,183 @@
"""Background removal module supporting local (rembg) and API (remove.bg) methods."""
import io
import logging
import time
from typing import Optional, Tuple, Union
import requests
from PIL import Image
logger = logging.getLogger(__name__)
# Remove.bg API endpoint
REMOVE_BG_API_URL = "https://api.remove.bg/v1.0/removebg"
MAX_API_CALLS_PER_MONTH = 50
# Retry configuration
MAX_RETRIES = 3
RETRY_DELAYS = [2, 5, 10] # seconds
async def remove_background_local(image_bytes: bytes) -> Optional[bytes]:
"""
Remove background using local rembg library (U²Net model).
Args:
image_bytes: Raw image bytes
Returns:
PNG image bytes with transparent background, or None if failed
"""
try:
from rembg import remove
# Open image
input_image = Image.open(io.BytesIO(image_bytes))
# Remove background
output_image = remove(input_image)
# Save to bytes
output_buffer = io.BytesIO()
output_image.save(output_buffer, format='PNG')
output_buffer.seek(0)
logger.info("Background removed successfully (local)")
return output_buffer.getvalue()
except Exception as e:
logger.error(f"Error removing background locally: {e}")
return None
async def remove_background_api(
image_bytes: bytes,
api_key: str
) -> Tuple[Union[bytes, str], str]:
"""
Remove background using remove.bg API with retry logic.
Args:
image_bytes: Raw image bytes
api_key: Remove.bg API key
Returns:
Tuple of (result, message)
- Success: (image_bytes, "success")
- Rate limit: ("LIMIT_REACHED", error_message)
- Other error: ("API_ERROR", error_message)
"""
headers = {
"X-Api-Key": api_key
}
last_error = None
# Retry loop
for attempt in range(MAX_RETRIES):
try:
# Prepare multipart form data
files = {
'image_file': ('image.png', io.BytesIO(image_bytes), 'image/png')
}
response = requests.post(
REMOVE_BG_API_URL,
headers=headers,
files=files,
timeout=30
)
# Check response
if response.status_code == 200:
# Success - return image
logger.info("Background removed successfully (API)")
return response.content, "success"
elif response.status_code in (402, 429):
# Rate limit / Payment required (limit reached)
error_data = response.json()
error_msg = error_data.get('errors', [{}])[0].get('title', 'Rate limit exceeded')
logger.warning(f"Remove.bg API limit reached: {error_msg}")
return "LIMIT_REACHED", error_msg
else:
# Other API error
error_data = response.json() if response.text else {}
error_msg = error_data.get('errors', [{}])[0].get('title', f'HTTP {response.status_code}')
logger.error(f"Remove.bg API error: {error_msg}")
last_error = error_msg
except requests.exceptions.Timeout:
last_error = "Timeout - API antwortet nicht"
logger.warning(f"API timeout (attempt {attempt + 1}/{MAX_RETRIES})")
except requests.exceptions.ConnectionError:
last_error = "Verbindungsfehler - API nicht erreichbar"
logger.warning(f"API connection error (attempt {attempt + 1}/{MAX_RETRIES})")
except Exception as e:
last_error = str(e)
logger.error(f"API call error (attempt {attempt + 1}/{MAX_RETRIES}): {e}")
# Wait before retry (except for last attempt)
if attempt < MAX_RETRIES - 1:
delay = RETRY_DELAYS[attempt]
logger.info(f"Retrying in {delay} seconds...")
time.sleep(delay)
# All retries failed
logger.error(f"All API retries failed: {last_error}")
return "API_ERROR", f"API nicht erreichbar nach {MAX_RETRIES} Versuchen: {last_error}"
async def should_show_api_limit_warning(api_key: str, current_usage: int) -> bool:
"""
Check if we should show warning on the API button.
Args:
api_key: API key (if None, no warning)
current_usage: Current month usage count
Returns:
True if limit reached or very close
"""
if not api_key:
return False
# Show warning when at or above limit
return current_usage >= MAX_API_CALLS_PER_MONTH
async def validate_image_for_bg_removal(image_bytes: bytes) -> Tuple[bool, str]:
"""
Validate if image is suitable for background removal.
Args:
image_bytes: Raw image bytes
Returns:
Tuple of (is_valid, error_message)
"""
try:
image = Image.open(io.BytesIO(image_bytes))
# Check format
if image.format not in ('PNG', 'JPEG', 'JPG', 'WEBP', 'BMP'):
return False, f"Format '{image.format}' nicht unterstützt. Nutze PNG, JPG oder WebP."
# Check size
width, height = image.size
if width < 64 or height < 64:
return False, "Bild zu klein (min. 64x64 Pixel)"
if width > 4096 or height > 4096:
return False, "Bild zu groß (max. 4096x4096 Pixel)"
# Check file size
if len(image_bytes) > 10 * 1024 * 1024: # 10MB
return False, "Datei zu groß (max. 10MB)"
return True, ""
except Exception as e:
return False, f"Ungültiges Bild: {str(e)}"

1158
bot.py Normal file

File diff suppressed because it is too large Load Diff

38
config.py Normal file
View File

@@ -0,0 +1,38 @@
"""Configuration module for sticker bot."""
import os
import logging
from dotenv import load_dotenv
load_dotenv()
# Logging setup
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=logging.INFO
)
logger = logging.getLogger(__name__)
# Bot Configuration
BOT_TOKEN = os.getenv("BOT_TOKEN")
OWNER_ID = os.getenv("OWNER_ID")
if not BOT_TOKEN:
raise ValueError("BOT_TOKEN must be set in .env file")
if not OWNER_ID:
raise ValueError("OWNER_ID must be set in .env file")
try:
OWNER_ID = int(OWNER_ID)
except ValueError:
raise ValueError("OWNER_ID must be a valid integer")
# Database Configuration
DATABASE_PATH = os.getenv("DATABASE_PATH", "sticker_bot.db")
# Premium Check Interval (in hours)
PREMIUM_CHECK_INTERVAL = int(os.getenv("PREMIUM_CHECK_INTERVAL", "24"))
# Optional: Remove.bg API Key for background removal
# If not set, only local processing is available
REMOVE_BG_API_KEY = os.getenv("REMOVE_BG_API_KEY")

174
database.py Normal file
View File

@@ -0,0 +1,174 @@
"""Database module for sticker bot."""
import aiosqlite
from datetime import datetime
from typing import List, Optional, Dict, Any
from config import DATABASE_PATH, logger
class Database:
"""SQLite database handler for sticker bot."""
def __init__(self, db_path: str = DATABASE_PATH):
self.db_path = db_path
async def init(self):
"""Initialize database tables."""
async with aiosqlite.connect(self.db_path) as db:
# Sticker sets table
await db.execute("""
CREATE TABLE IF NOT EXISTS sticker_sets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
set_name TEXT NOT NULL UNIQUE,
set_title TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# User premium status table
await db.execute("""
CREATE TABLE IF NOT EXISTS user_premium (
user_id INTEGER PRIMARY KEY,
is_premium BOOLEAN DEFAULT FALSE,
last_checked TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Track stickers for each set
await db.execute("""
CREATE TABLE IF NOT EXISTS stickers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
set_name TEXT NOT NULL,
file_id TEXT NOT NULL,
emoji TEXT,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (set_name) REFERENCES sticker_sets(set_name) ON DELETE CASCADE
)
""")
# API usage tracking (for remove.bg and other external services)
await db.execute("""
CREATE TABLE IF NOT EXISTS api_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service TEXT NOT NULL,
year_month TEXT NOT NULL,
usage_count INTEGER DEFAULT 0,
last_used TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(service, year_month)
)
""")
await db.commit()
logger.info("Database initialized successfully")
async def add_sticker_set(self, user_id: int, set_name: str, set_title: str):
"""Add a new sticker set to tracking."""
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""INSERT OR REPLACE INTO sticker_sets (user_id, set_name, set_title, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)""",
(user_id, set_name, set_title)
)
await db.commit()
async def get_user_sticker_sets(self, user_id: int) -> List[Dict[str, Any]]:
"""Get all sticker sets for a user."""
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM sticker_sets WHERE user_id = ? ORDER BY created_at DESC",
(user_id,)
) as cursor:
rows = await cursor.fetchall()
return [dict(row) for row in rows]
async def delete_sticker_set(self, set_name: str):
"""Delete a sticker set from tracking."""
async with aiosqlite.connect(self.db_path) as db:
await db.execute("DELETE FROM sticker_sets WHERE set_name = ?", (set_name,))
await db.commit()
async def rename_sticker_set(self, set_name: str, new_title: str):
"""Update sticker set title."""
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""UPDATE sticker_sets SET set_title = ?, updated_at = CURRENT_TIMESTAMP
WHERE set_name = ?""",
(new_title, set_name)
)
await db.commit()
async def update_premium_status(self, user_id: int, is_premium: bool):
"""Update user's premium status."""
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""INSERT OR REPLACE INTO user_premium (user_id, is_premium, last_checked)
VALUES (?, ?, CURRENT_TIMESTAMP)""",
(user_id, is_premium)
)
await db.commit()
async def get_premium_status(self, user_id: int) -> Optional[bool]:
"""Get user's premium status."""
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT is_premium FROM user_premium WHERE user_id = ?",
(user_id,)
) as cursor:
row = await cursor.fetchone()
return row[0] if row else None
async def add_sticker(self, set_name: str, file_id: str, emoji: Optional[str] = None):
"""Track a sticker in the database."""
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"INSERT INTO stickers (set_name, file_id, emoji) VALUES (?, ?, ?)",
(set_name, file_id, emoji)
)
await db.commit()
async def delete_sticker(self, file_id: str):
"""Remove a sticker from tracking."""
async with aiosqlite.connect(self.db_path) as db:
await db.execute("DELETE FROM stickers WHERE file_id = ?", (file_id,))
await db.commit()
async def get_stickers_in_set(self, set_name: str) -> List[Dict[str, Any]]:
"""Get all stickers in a set."""
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM stickers WHERE set_name = ?",
(set_name,)
) as cursor:
rows = await cursor.fetchall()
return [dict(row) for row in rows]
async def track_api_call(self, service: str):
"""Track an API call for usage statistics."""
year_month = datetime.now().strftime("%Y-%m")
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""INSERT INTO api_usage (service, year_month, usage_count, last_used)
VALUES (?, ?, 1, CURRENT_TIMESTAMP)
ON CONFLICT(service, year_month)
DO UPDATE SET usage_count = usage_count + 1, last_used = CURRENT_TIMESTAMP""",
(service, year_month)
)
await db.commit()
async def get_monthly_api_usage(self, service: str) -> int:
"""Get current month's API usage count."""
year_month = datetime.now().strftime("%Y-%m")
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT usage_count FROM api_usage WHERE service = ? AND year_month = ?",
(service, year_month)
) as cursor:
row = await cursor.fetchone()
return row[0] if row else 0
# Global database instance
db = Database()

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
version: '3.8'
services:
stickerbot:
build: .
container_name: telegram-sticker-bot
restart: unless-stopped
env_file:
- .env
volumes:
- ./data:/app/data
- ./sticker_bot.db:/app/sticker_bot.db
environment:
- DATABASE_PATH=/app/data/sticker_bot.db
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"

267
gif_processor.py Normal file
View File

@@ -0,0 +1,267 @@
"""GIF to WebM Video Sticker converter using ffmpeg."""
import io
import logging
import subprocess
import tempfile
from typing import Optional, Tuple, Dict, Any
from PIL import Image
logger = logging.getLogger(__name__)
# Telegram Video Sticker requirements
MAX_DURATION_SECONDS = 3
MAX_FILE_SIZE_KB = 256
MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_KB * 1024
STICKER_SIZE = 512
TARGET_FPS = 30
async def get_gif_info(gif_bytes: bytes) -> Dict[str, Any]:
"""
Get GIF metadata using ffprobe.
Returns dict with duration, width, height, fps
"""
try:
with tempfile.NamedTemporaryFile(suffix='.gif', delete=False) as tmp_in:
tmp_in.write(gif_bytes)
tmp_in.flush()
# Use ffprobe to get duration
result = subprocess.run(
[
'ffprobe', '-v', 'error', '-show_entries',
'format=duration', '-show_entries',
'stream=width,height,r_frame_rate',
'-of', 'default=noprint_wrappers=1',
tmp_in.name
],
capture_output=True,
text=True
)
info = {'duration': 0, 'width': 0, 'height': 0, 'fps': 0}
for line in result.stdout.strip().split('\n'):
if '=' in line:
key, value = line.split('=', 1)
if key == 'duration':
info['duration'] = float(value)
elif key == 'width':
info['width'] = int(value)
elif key == 'height':
info['height'] = int(value)
elif key == 'r_frame_rate':
# Parse fraction like "30/1"
if '/' in value:
num, den = value.split('/')
info['fps'] = int(num) / int(den) if int(den) != 0 else 0
return info
except Exception as e:
logger.error(f"Error getting GIF info: {e}")
return {'duration': 0, 'width': 0, 'height': 0, 'fps': 0}
async def validate_gif_duration(duration: float) -> Tuple[bool, str]:
"""
Check if GIF duration is within limits.
Returns:
Tuple of (is_valid, message)
"""
if duration > MAX_DURATION_SECONDS:
return False, f"GIF ist {duration:.1f}s lang. Maximal erlaubt: {MAX_DURATION_SECONDS}s"
return True, ""
async def convert_gif_to_webm(
gif_bytes: bytes,
max_duration: Optional[float] = None
) -> Optional[bytes]:
"""
Convert GIF to WebM Video Sticker format using ffmpeg.
Args:
gif_bytes: Raw GIF file bytes
max_duration: Maximum duration in seconds (None = no limit, will be truncated)
Returns:
WebM video bytes or None if conversion fails
"""
try:
# Get GIF info
gif_info = await get_gif_info(gif_bytes)
duration = gif_info.get('duration', 0)
width = gif_info.get('width', 512)
height = gif_info.get('height', 512)
logger.info(f"Converting GIF: {width}x{height}, {duration}s, "
f"{gif_info.get('fps', 0)}fps")
# Calculate dimensions (one side must be exactly 512px)
if width >= height:
new_width = STICKER_SIZE
new_height = int((height / width) * STICKER_SIZE)
else:
new_height = STICKER_SIZE
new_width = int((width / height) * STICKER_SIZE)
# Ensure neither exceeds 512
new_width = min(new_width, STICKER_SIZE)
new_height = min(new_height, STICKER_SIZE)
# Determine duration limit
duration_limit = min(duration, max_duration or duration)
if duration > MAX_DURATION_SECONDS:
duration_limit = MAX_DURATION_SECONDS
logger.info(f"GIF too long ({duration}s), truncating to {duration_limit}s")
# Create temporary files
with tempfile.NamedTemporaryFile(suffix='.gif', delete=False) as tmp_in:
tmp_in.write(gif_bytes)
tmp_in_path = tmp_in.name
with tempfile.NamedTemporaryFile(suffix='.webm', delete=False) as tmp_out:
tmp_out_path = tmp_out.name
# Build ffmpeg command
# Video sticker requirements: WebM, VP9, 512px, 30fps, no audio, max 3s, max 256KB
cmd = [
'ffmpeg',
'-i', tmp_in_path,
'-t', str(duration_limit), # Duration limit
'-vf', f'scale={new_width}:{new_height}:flags=lanczos,fps={TARGET_FPS}', # Resize + FPS
'-c:v', 'libvpx-vp9', # VP9 codec
'-pix_fmt', 'yuva420p', # With alpha channel (transparency)
'-b:v', '0', # Use constant quality
'-crf', '40', # Quality (lower = better, 0-63)
'-deadline', 'good', # Encoding speed/quality tradeoff
'-cpu-used', '2', # Encoding speed
'-row-mt', '1', # Multi-threading
'-an', # No audio
'-f', 'webm', # Output format
'-y', # Overwrite output
tmp_out_path
]
# Run ffmpeg
result = subprocess.run(
cmd,
capture_output=True,
text=True
)
if result.returncode != 0:
logger.error(f"ffmpeg failed: {result.stderr}")
return None
# Read output file
with open(tmp_out_path, 'rb') as f:
webm_bytes = f.read()
file_size_kb = len(webm_bytes) / 1024
logger.info(f"Initial conversion: {file_size_kb:.1f}KB")
# If too large, try with lower quality
if len(webm_bytes) > MAX_FILE_SIZE_BYTES:
webm_bytes = await _compress_webm(tmp_in_path, tmp_out_path, duration_limit, new_width, new_height)
if webm_bytes and len(webm_bytes) <= MAX_FILE_SIZE_BYTES:
final_size_kb = len(webm_bytes) / 1024
logger.info(f"Final WebM: {final_size_kb:.1f}KB, "
f"{new_width}x{new_height}")
return webm_bytes
else:
logger.error("Could not compress to required size")
return None
except Exception as e:
logger.error(f"Error converting GIF to WebM: {e}")
return None
async def _compress_webm(
input_path: str,
output_path: str,
duration: float,
width: int,
height: int
) -> Optional[bytes]:
"""
Try to compress WebM to meet size requirements.
Tries progressively lower quality settings.
"""
# Try different CRF values (lower = better quality, higher = smaller file)
crf_values = [45, 50, 55, 60]
for crf in crf_values:
try:
logger.info(f"Trying compression with CRF={crf}")
cmd = [
'ffmpeg',
'-i', input_path,
'-t', str(duration),
'-vf', f'scale={width}:{height}:flags=lanczos,fps={TARGET_FPS}',
'-c:v', 'libvpx-vp9',
'-pix_fmt', 'yuva420p',
'-b:v', '0',
'-crf', str(crf),
'-deadline', 'good',
'-cpu-used', '3', # Faster encoding for smaller files
'-row-mt', '1',
'-an',
'-f', 'webm',
'-y',
output_path
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
with open(output_path, 'rb') as f:
webm_bytes = f.read()
file_size_kb = len(webm_bytes) / 1024
logger.info(f"Compression CRF={crf}: {file_size_kb:.1f}KB")
if len(webm_bytes) <= MAX_FILE_SIZE_BYTES:
return webm_bytes
except Exception as e:
logger.error(f"Compression attempt failed: {e}")
continue
return None
async def is_animated_image(file_bytes: bytes) -> bool:
"""
Check if image file is animated (GIF with multiple frames).
Args:
file_bytes: Raw file bytes
Returns:
True if animated, False otherwise
"""
try:
image = Image.open(io.BytesIO(file_bytes))
# Check if it's a GIF
if image.format != 'GIF':
return False
# Check if it has multiple frames
try:
image.seek(1) # Try to go to second frame
return True
except EOFError:
return False
except Exception as e:
logger.error(f"Error checking if image is animated: {e}")
return False

169
image_processor.py Normal file
View File

@@ -0,0 +1,169 @@
"""Image processing module for converting images to Telegram sticker format."""
import io
import logging
from typing import Optional, Tuple
from PIL import Image
logger = logging.getLogger(__name__)
# Telegram sticker requirements
STICKER_SIZE = 512 # One side must be exactly 512px
MAX_FILE_SIZE_MB = 3 # Maximum file size in MB
MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
async def process_image_for_sticker(file_data: bytes, filename: Optional[str] = None) -> Optional[bytes]:
"""
Process an image file to meet Telegram sticker requirements.
Args:
file_data: Raw image bytes from Telegram
filename: Original filename (optional, for format detection)
Returns:
Processed image bytes in WebP format, or None if processing fails
"""
try:
# Open image from bytes
image = Image.open(io.BytesIO(file_data))
# Convert to RGBA if necessary (Telegram requires transparency support)
if image.mode != 'RGBA':
if image.mode == 'RGB':
# Add alpha channel
image = image.convert('RGBA')
elif image.mode in ('L', 'LA', 'P'):
# Grayscale or palette - convert to RGBA
image = image.convert('RGBA')
else:
# Any other mode - convert to RGBA
image = image.convert('RGBA')
# Resize to Telegram sticker format (512px on one side)
image = resize_for_sticker(image)
# Convert to WebP format with optimization
output = io.BytesIO()
# Try different quality levels to meet file size requirements
quality = 95
while quality >= 60:
output.seek(0)
output.truncate(0)
image.save(
output,
format='WEBP',
quality=quality,
method=6, # Best compression method
exact=True # Preserve transparency
)
file_size = output.tell()
if file_size <= MAX_FILE_SIZE_BYTES:
logger.info(f"Image processed: {image.size}, quality={quality}, size={file_size/1024:.1f}KB")
output.seek(0)
return output.getvalue()
# Reduce quality and try again
quality -= 5
logger.debug(f"File too large ({file_size/1024:.1f}KB), reducing quality to {quality}")
# If we get here, even lowest quality is too large
logger.error(f"Could not compress image below {MAX_FILE_SIZE_MB}MB")
return None
except Exception as e:
logger.error(f"Error processing image: {e}")
return None
def resize_for_sticker(image: Image.Image) -> Image.Image:
"""
Resize image to meet Telegram sticker requirements.
One side must be exactly 512px, other side <= 512px
Args:
image: PIL Image object
Returns:
Resized image
"""
width, height = image.size
# Determine which side is longer
if width >= height:
# Landscape or square - set width to 512
new_width = STICKER_SIZE
new_height = int((height / width) * STICKER_SIZE)
else:
# Portrait - set height to 512
new_height = STICKER_SIZE
new_width = int((width / height) * STICKER_SIZE)
# Ensure neither dimension exceeds 512
new_width = min(new_width, STICKER_SIZE)
new_height = min(new_height, STICKER_SIZE)
# Resize using LANCZOS for best quality
resized = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
logger.debug(f"Resized image from {width}x{height} to {new_width}x{new_height}")
return resized
async def validate_image(file_data: bytes) -> Tuple[bool, str]:
"""
Validate if file data is a valid image.
Args:
file_data: Raw file bytes
Returns:
Tuple of (is_valid, error_message)
"""
try:
image = Image.open(io.BytesIO(file_data))
# Check format
if image.format not in ('PNG', 'JPEG', 'JPG', 'WEBP', 'GIF', 'BMP', 'TIFF'):
return False, f"Unsupported image format: {image.format}"
# Check if image can be processed
width, height = image.size
if width < 64 or height < 64:
return False, "Image too small (minimum 64x64 pixels)"
# Check file size
if len(file_data) > 10 * 1024 * 1024: # 10MB limit for input
return False, "Image file too large (max 10MB for upload)"
return True, ""
except Exception as e:
return False, f"Invalid image file: {str(e)}"
async def get_image_info(file_data: bytes) -> dict:
"""
Get information about an image.
Args:
file_data: Raw image bytes
Returns:
Dictionary with image info
"""
try:
image = Image.open(io.BytesIO(file_data))
return {
"format": image.format,
"mode": image.mode,
"size": image.size,
"width": image.width,
"height": image.height,
}
except Exception as e:
logger.error(f"Error getting image info: {e}")
return {}

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
python-telegram-bot==22.7
python-dotenv==1.0.1
aiosqlite==0.20.0
Pillow==11.1.0
rembg==2.0.60
requests==2.32.3

310
sticker_manager.py Normal file
View File

@@ -0,0 +1,310 @@
"""Sticker management utilities for Telegram bot."""
import logging
from typing import List, Optional, Tuple, Dict, Any
from telegram import InlineKeyboardMarkup, InlineKeyboardButton, Update
from telegram.ext import ContextTypes
from telegram.constants import StickerFormat
from database import db
from image_processor import process_image_for_sticker, validate_image
logger = logging.getLogger(__name__)
async def check_owner(update: Update) -> bool:
"""Check if user is the bot owner."""
from config import OWNER_ID
user_id = update.effective_user.id
return user_id == OWNER_ID
async def check_premium_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool:
"""Check if user has Telegram Premium."""
user_id = update.effective_user.id
# Get current user info from Telegram
try:
chat = await context.bot.get_chat(user_id)
is_premium = bool(getattr(chat, 'is_premium', False))
# Update database
await db.update_premium_status(user_id, is_premium)
return is_premium
except Exception as e:
logger.error(f"Error checking premium status: {e}")
# Fallback to cached status
cached = await db.get_premium_status(user_id)
return cached if cached is not None else False
def get_main_menu_keyboard() -> InlineKeyboardMarkup:
"""Generate main menu keyboard."""
keyboard = [
[InlineKeyboardButton(" Neues Stickerpaket", callback_data="create_set")],
[InlineKeyboardButton("📥 Existierendes hinzufügen", callback_data="add_existing")],
[InlineKeyboardButton("📦 Meine Stickerpakete", callback_data="list_sets")],
[InlineKeyboardButton("❓ Hilfe", callback_data="help")],
]
return InlineKeyboardMarkup(keyboard)
def get_set_action_keyboard(set_name: str) -> InlineKeyboardMarkup:
"""Generate keyboard for set actions."""
keyboard = [
[InlineKeyboardButton(" Sticker hinzufügen", callback_data=f"add_sticker:{set_name}")],
[InlineKeyboardButton("🗑 Sticker löschen", callback_data=f"delete_sticker:{set_name}")],
[InlineKeyboardButton("🏷 Emoji ändern", callback_data=f"change_emoji:{set_name}")],
[InlineKeyboardButton("✏️ Paket umbenennen", callback_data=f"rename_set:{set_name}")],
[InlineKeyboardButton("🗑 Paket löschen", callback_data=f"delete_set:{set_name}")],
[InlineKeyboardButton("⬅️ Zurück", callback_data="back_to_menu")],
]
return InlineKeyboardMarkup(keyboard)
async def get_user_sets_keyboard(user_id: int) -> Tuple[Optional[InlineKeyboardMarkup], str]:
"""Generate keyboard with user's sticker sets."""
sets = await db.get_user_sticker_sets(user_id)
if not sets:
return None, "📭 Du hast noch keine Stickerpakete."
keyboard = []
for s in sets:
display_name = s['set_title'][:30] if s['set_title'] else s['set_name']
keyboard.append([InlineKeyboardButton(
f"📦 {display_name}",
callback_data=f"select_set:{s['set_name']}"
)])
keyboard.append([InlineKeyboardButton("⬅️ Zurück", callback_data="back_to_menu")])
text = f"📦 **Deine Stickerpakete** ({len(sets)} total)\\n\\nWähle ein Paket:"
return InlineKeyboardMarkup(keyboard), text
async def create_sticker_set(
update: Update,
context: ContextTypes.DEFAULT_TYPE,
name: str,
title: str,
sticker_file,
emoji: str = "🤖"
) -> Tuple[bool, str]:
"""Create a new sticker set with automatic image processing."""
try:
user_id = update.effective_user.id
bot_username = context.bot.username
set_name = f"{name}_by_{bot_username}"
# Download the file
file_obj = await sticker_file.download_as_bytearray()
file_bytes = bytes(file_obj)
# Validate image
is_valid, error_msg = await validate_image(file_bytes)
if not is_valid:
return False, f"Invalid image: {error_msg}"
# Process image for sticker format
processed_image = await process_image_for_sticker(file_bytes)
if not processed_image:
return False, "Failed to process image. Please ensure it's a valid image file (PNG, JPG, WebP) and under 10MB."
# Create sticker input with processed image
from telegram import InputSticker
sticker = InputSticker(
sticker=processed_image,
format=StickerFormat.STATIC,
emoji_list=[emoji]
)
# Create sticker set
await context.bot.create_new_sticker_set(
user_id=user_id,
name=set_name,
title=title,
stickers=[sticker]
)
# Track in database
await db.add_sticker_set(user_id, set_name, title)
return True, set_name
except Exception as e:
logger.error(f"Error creating sticker set: {e}")
return False, str(e)
async def add_sticker_to_set(
update: Update,
context: ContextTypes.DEFAULT_TYPE,
set_name: str,
sticker_file,
emoji: str = "🤖"
) -> bool:
"""Add a sticker to existing set with automatic image processing."""
try:
# Download and process the file
file_obj = await sticker_file.download_as_bytearray()
file_bytes = bytes(file_obj)
# Validate image
is_valid, error_msg = await validate_image(file_bytes)
if not is_valid:
logger.error(f"Invalid image for sticker: {error_msg}")
return False
# Process image for sticker format
processed_image = await process_image_for_sticker(file_bytes)
if not processed_image:
logger.error("Failed to process image for sticker")
return False
from telegram import InputSticker
sticker = InputSticker(
sticker=processed_image,
format=StickerFormat.STATIC,
emoji_list=[emoji]
)
await context.bot.add_sticker_to_set(
user_id=update.effective_user.id,
name=set_name,
sticker=sticker
)
return True
except Exception as e:
logger.error(f"Error adding sticker: {e}")
return False
async def add_video_sticker_to_set(
update: Update,
context: ContextTypes.DEFAULT_TYPE,
set_name: str,
webm_bytes: bytes,
emoji: str = "🤖"
) -> bool:
"""Add a video sticker (WebM) to existing set."""
try:
from telegram import InputSticker
sticker = InputSticker(
sticker=webm_bytes,
format=StickerFormat.VIDEO,
emoji_list=[emoji]
)
await context.bot.add_sticker_to_set(
user_id=update.effective_user.id,
name=set_name,
sticker=sticker
)
return True
except Exception as e:
logger.error(f"Error adding video sticker: {e}")
return False
async def delete_sticker_from_set(update: Update, context: ContextTypes.DEFAULT_TYPE,
sticker_file_id: str) -> bool:
"""Delete a sticker from a set."""
try:
await context.bot.delete_sticker_from_set(sticker_file_id)
await db.delete_sticker(sticker_file_id)
return True
except Exception as e:
logger.error(f"Error deleting sticker: {e}")
return False
async def delete_sticker_set(update: Update, context: ContextTypes.DEFAULT_TYPE,
set_name: str) -> bool:
"""Delete a sticker set."""
try:
await context.bot.delete_sticker_set(set_name)
await db.delete_sticker_set(set_name)
return True
except Exception as e:
logger.error(f"Error deleting sticker set: {e}")
return False
async def rename_sticker_set(update: Update, context: ContextTypes.DEFAULT_TYPE,
set_name: str, new_title: str) -> bool:
"""Rename a sticker set title."""
try:
await context.bot.set_sticker_set_title(set_name, new_title)
await db.rename_sticker_set(set_name, new_title)
return True
except Exception as e:
logger.error(f"Error renaming sticker set: {e}")
return False
async def change_sticker_emoji(update: Update, context: ContextTypes.DEFAULT_TYPE,
sticker_file_id: str, new_emoji: str) -> bool:
"""Change emoji of a sticker."""
try:
await context.bot.set_sticker_emoji_list(sticker_file_id, [new_emoji])
return True
except Exception as e:
logger.error(f"Error changing sticker emoji: {e}")
return False
async def add_existing_sticker_set(
update: Update,
context: ContextTypes.DEFAULT_TYPE,
set_name: str
) -> Tuple[bool, str]:
"""
Add an existing sticker set to the database.
Args:
set_name: Name of the sticker set (e.g., "my_stickers_by_mybot")
Returns:
Tuple of (success, message)
"""
try:
# Try to get sticker set from Telegram
sticker_set = await context.bot.get_sticker_set(set_name)
if not sticker_set:
return False, "Stickerpaket nicht gefunden."
# Get set info
title = sticker_set.title
user_id = update.effective_user.id
# Check if already in database
existing_sets = await db.get_user_sticker_sets(user_id)
if any(s['set_name'] == set_name for s in existing_sets):
return False, "Dieses Stickerpaket ist bereits in deiner Liste."
# Add to database
await db.add_sticker_set(user_id, set_name, title)
logger.info(f"Added existing sticker set '{set_name}' to database for user {user_id}")
return True, f"Stickerpaket '{title}' wurde hinzugefügt!"
except Exception as e:
logger.error(f"Error adding existing sticker set: {e}")
return False, f"Fehler: {str(e)}\n\nPrüfe, ob der Name korrekt ist und du Zugriff auf das Paket hast."
async def schedule_premium_check(context: ContextTypes.DEFAULT_TYPE):
"""Scheduled job to check premium status periodically."""
from config import OWNER_ID, logger
try:
# Check owner's premium status
chat = await context.bot.get_chat(OWNER_ID)
is_premium = bool(getattr(chat, 'is_premium', False))
await db.update_premium_status(OWNER_ID, is_premium)
logger.info(f"Premium check completed. Owner premium status: {is_premium}")
except Exception as e:
logger.error(f"Error in scheduled premium check: {e}")