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:
7
.env.example
Normal file
7
.env.example
Normal 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
31
Dockerfile
Normal 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
346
OPENCODE.md
Normal 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
183
background_remover.py
Normal 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)}"
|
||||
38
config.py
Normal file
38
config.py
Normal 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
174
database.py
Normal 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
19
docker-compose.yml
Normal 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
267
gif_processor.py
Normal 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
169
image_processor.py
Normal 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
6
requirements.txt
Normal 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
310
sticker_manager.py
Normal 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}")
|
||||
Reference in New Issue
Block a user