работает в телеге

This commit is contained in:
Пьянзин Михаил 2026-04-02 13:37:20 +03:00
parent 96858d6364
commit 75704f6930
3 changed files with 301 additions and 262 deletions

View file

@ -1,69 +1,47 @@
# Matrix Image Recognition Bot # Telegram Image Description Bot
Бот для Matrix, который распознаёт изображения с помощью Qwen-VL. Бот для Telegram, который описывает изображения с помощью Qwen-VL API.
## Описание
Этот бот подключается к Matrix серверу, слушает изображения в разрешённых комнатах и отправляет их в Qwen-VL API для анализа. Бот возвращает описание изображения обратно в чат.
## Требования ## Требования
- Python 3.10+ - Python 3.10+
- Доступ к Matrix серверу - Токен Telegram бота (получить у [@BotFather](https://t.me/BotFather))
- API ключ Qwen-VL - Ключ доступа к Qwen API
## Установка ## Установка
1. Клонируйте репозиторий: 1. Установите зависимости:
```bash
git clone <repository-url>
cd b2b_assistants
```
2. Создайте виртуальное окружение:
```bash
python -m venv venv
source venv/bin/activate # Linux/Mac
# или
venv\Scripts\activate # Windows
```
3. Установите зависимости:
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
``` ```
4. Настройте переменные окружения в файле `.env`: 2. Настройте переменные окружения в файле `.env`:
```env ```env
# Matrix подключение # Telegram Bot Token (получите у @BotFather)
HOMESERVER=https://matrix.lambda.coredump.ru TELEGRAM_BOT_TOKEN=ваш_токен_бота
MATRIX_USERNAME=@image_bot:matrix.lambda.coredump.ru
PASSWORD=ваш_пароль_бота
ALLOWED_ROOMS=!QcPkdLDWqDegdtDnpP:matrix.lambda.coredump.ru
# Qwen API # Qwen API настройки (уже заполнены)
QWEN_API_KEY=sk-L6oRP0m15Z9YquluktS6w QWEN_API_KEY=sk-L6oRP0mP15Z9YquluktS6w
QWEN_ENDPOINT=https://llm.lambda.coredump.ru/v1 QWEN_ENDPOINT=https://llm.lambda.coredump.ru/v1
QWEN_VL_MODEL=qwen-vl-plus QWEN_VL_MODEL=qwen-vl-plus
``` ```
## Запуск 3. Запустите бота:
```bash ```bash
python src/image_bot.py python src/image_bot.py
``` ```
## Команды бота
- `/help` - показать справку по командам
- `/status` - показать статус бота
## Использование ## Использование
1. Пригласите бота в комнату Matrix 1. Найдите вашего бота в Telegram и нажмите `/start`
2. Отправьте изображение в чат 2. Отправьте боту изображение
3. Бот автоматически проанализирует изображение и вернёт описание 3. Бот вернёт описание изображения на русском языке
## Команды
- `/start` - начать работу с ботом
- `/help` - показать справку
- `/settoken <token>` - установить токен API (временное решение)
## Структура проекта ## Структура проекта
@ -76,6 +54,8 @@ b2b_assistants/
└── image_bot.py # Основной код бота └── image_bot.py # Основной код бота
``` ```
## Лицензия ## Примечания
MIT - Бот использует base64 кодирование для отправки изображений в Qwen-VL API
- Для ограничения доступа используйте переменную `ALLOWED_USERS` в `.env`
- Время обработки изображения может составлять до 2 минут для больших файлов

View file

@ -1,3 +1,3 @@
matrix-nio>=0.25.0 python-telegram-bot==20.7
httpx>=0.24.0 python-dotenv==1.0.0
python-dotenv>=1.0.0 requests==2.31.0

View file

@ -1,241 +1,300 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import asyncio """
Telegram Bot that describes images using Qwen-VL API.
"""
import os import os
import base64
import logging import logging
from typing import Optional import tempfile
from dataclasses import dataclass from pathlib import Path
import httpx import requests
from nio import (
AsyncClient,
RoomMessageImage,
RoomMessageText,
LoginResponse,
JoinResponse,
)
from dotenv import load_dotenv from dotenv import load_dotenv
from telegram import Update
from telegram.ext import (
Application,
CommandHandler,
MessageHandler,
filters,
ContextTypes,
)
# Load environment variables
load_dotenv()
# Configure logging
logging.basicConfig( logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=logging.INFO, level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Qwen API configuration
QWEN_API_KEY = os.getenv("QWEN_API_KEY")
QWEN_ENDPOINT = os.getenv("QWEN_ENDPOINT")
QWEN_MODEL = os.getenv("QWEN_VL_MODEL", "qwen-vl-plus")
@dataclass # Telegram bot token
class BotConfig: TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
homeserver: str
username: str # Allowed user IDs (empty list means all users allowed)
password: str ALLOWED_USERS = [int(user.strip()) for user in os.getenv("ALLOWED_USERS", "").split(",") if user.strip()]
allowed_rooms: list[str]
qwen_api_key: str
qwen_endpoint: str
qwen_model: str
def load_config() -> BotConfig: async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
load_dotenv() """Send a message when the command /start is issued."""
allowed_rooms_str = os.getenv("ALLOWED_ROOMS", "") user = update.effective_user
allowed_rooms = [r.strip() for r in allowed_rooms_str.split(",") if r.strip()] await update.message.reply_text(
return BotConfig( f"Привет, {user.first_name}!\n\n"
homeserver=os.getenv("HOMESERVER", "https://matrix.lambda.coredump.ru"), "Я бот, который описывает и генерирует изображения.\n\n"
username=os.getenv("MATRIX_USERNAME", ""), "📸 Отправь мне картинку - я опишу что на ней изображено\n"
password=os.getenv("PASSWORD", ""), "🎨 Используй /draw <описание> - я сгенерирую изображение\n\n"
allowed_rooms=allowed_rooms, "Доступные команды:\n"
qwen_api_key=os.getenv("QWEN_API_KEY", ""), "/start - показать это сообщение\n"
qwen_endpoint=os.getenv("QWEN_ENDPOINT", "https://llm.lambda.coredump.ru/v1"), "/help - показать справку\n"
qwen_model=os.getenv("QWEN_VL_MODEL", "qwen-vl-plus"), "/draw <описание> - сгенерировать изображение"
) )
class QwenVLClient: async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
def __init__(self, api_key: str, endpoint: str, model: str): """Send a message when the command /help is issued."""
self.api_key = api_key help_text = (
self.endpoint = endpoint "📸 **Бот описания и генерации изображений**\n\n"
self.model = model "Этот бот использует Qwen-VL для анализа изображений и генерирует картинки по запросу.\n\n"
self.client = httpx.AsyncClient(timeout=120.0) "📋 **Команды:**\n"
"/start - начать работу\n"
async def analyze_image(self, image_data: bytes, prompt: str = "Опиши это изображение подробно:") -> str: "/help - показать эту справку\n"
image_base64 = base64.b64encode(image_data).decode("utf-8") "/draw <описание> - сгенерировать изображение по описанию\n\n"
image_url = f"data:image/jpeg;base64,{image_base64}" "💡 **Как использовать:**\n"
"• Отправьте боту изображение для получения описания\n"
payload = { "• Используйте /draw для генерации изображения"
"model": self.model, )
"messages": [ await update.message.reply_text(help_text, parse_mode="Markdown")
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": image_url}},
{"type": "text", "text": prompt}
]
}
]
}
headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
try:
response = await self.client.post(
f"{self.endpoint}/chat/completions",
json=payload,
headers=headers
)
response.raise_for_status()
result = response.json()
return result["choices"][0]["message"]["content"]
except httpx.HTTPError as e:
logger.error(f"Error calling Qwen-VL API: {e}")
if e.response:
logger.error(f"Response: {e.response.text}")
return f"Ошибка при анализе изображения: {str(e)}"
async def close(self):
await self.client.aclose()
class ImageBot:
def __init__(self, config: BotConfig):
self.config = config
self.client = AsyncClient(config.homeserver, config.username) # исправлено
self.qwen_client = QwenVLClient(
config.qwen_api_key,
config.qwen_endpoint,
config.qwen_model
)
self.default_prompt = "Опиши это изображение подробно."
async def login(self) -> bool:
try:
response = await self.client.login(
password=self.config.password,
device_name="image_recognition_bot"
)
if isinstance(response, LoginResponse):
logger.info(f"Logged in as {self.config.username}")
return True
else:
logger.error(f"Login failed: {response}")
return False
except Exception as e:
logger.error(f"Login error: {e}")
return False
async def join_rooms(self) -> None:
for room_id in self.config.allowed_rooms:
try:
response = await self.client.join(room_id)
if isinstance(response, JoinResponse):
logger.info(f"Joined room: {room_id}")
else:
logger.warning(f"Could not join room {room_id}: {response}")
except Exception as e:
logger.error(f"Error joining room {room_id}: {e}")
async def download_image(self, url: str) -> Optional[bytes]:
try:
logger.info(f"Downloading image from {url}")
response = await self.client.download(url) # правильный метод
if response and hasattr(response, 'body'):
logger.info(f"Downloaded {len(response.body)} bytes")
return response.body
return None
except Exception as e:
logger.error(f"Download error: {e}")
return None
async def send_response(self, room_id: str, message: str) -> None:
try:
await self.client.room_send(
room_id=room_id,
message_type="m.room.message",
content={"msgtype": "m.text", "body": message}
)
except Exception as e:
logger.error(f"Error sending response: {e}")
async def handle_image_message(self, room_id: str, event: RoomMessageImage) -> None:
if room_id not in self.config.allowed_rooms:
logger.warning(f"Ignoring image from non-allowed room {room_id}")
return
logger.info(f"Processing image in {room_id}")
image_url = event.url # прямой доступ к URL
if not image_url:
await self.send_response(room_id, "Не удалось получить изображение.")
return
image_data = await self.download_image(image_url)
if not image_data:
await self.send_response(room_id, "Не удалось загрузить изображение.")
return
try:
await self.client.typing(room_id, timeout=4000)
except Exception:
pass
await self.send_response(room_id, "🔍 Анализирую изображение...")
result = await self.qwen_client.analyze_image(image_data, self.default_prompt)
await self.send_response(room_id, f"📸 *Результат анализа:*\n\n{result}")
async def handle_text_message(self, room_id: str, event: RoomMessageText) -> None:
if room_id not in self.config.allowed_rooms:
logger.warning(f"Ignoring text from non-allowed room {room_id}")
return
body = event.body.strip().lower()
if body == "/help":
await self.send_response(room_id, "🤖 *Commands:*\n/help - справка\n/status - статус бота")
elif body == "/status":
await self.send_response(
room_id,
f"✅ *Статус:*\nПользователь: {self.config.username}\nМодель: {self.config.qwen_model}\nЭндпоинт: {self.config.qwen_endpoint}"
)
async def register_callbacks(self) -> None:
self.client.add_event_callback(self.image_callback, RoomMessageImage)
self.client.add_event_callback(self.text_callback, RoomMessageText)
async def image_callback(self, room: RoomMessageImage, event: RoomMessageImage) -> None:
await self.handle_image_message(room.room_id, event)
async def text_callback(self, room: RoomMessageText, event: RoomMessageText) -> None:
await self.handle_text_message(room.room_id, event)
async def sync_loop(self) -> None:
logger.info("Starting sync loop...")
try:
await self.client.sync_forever(timeout=30000, full_state=True)
except Exception as e:
logger.error(f"Sync error: {e}")
raise
async def close(self) -> None:
await self.client.close()
await self.qwen_client.close()
async def main(): async def describe_image(image_url: str) -> str:
config = load_config() """Send image to Qwen-VL API and get description."""
if not config.password or not config.qwen_api_key or not config.allowed_rooms: headers = {
logger.error("Missing required config") "Authorization": f"Bearer {QWEN_API_KEY}",
return "Content-Type": "application/json",
}
bot = ImageBot(config)
payload = {
"model": QWEN_MODEL,
"messages": [
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": image_url}},
{"type": "text", "text": "Кратко опиши изображение на русском языке. Перечисли основные детали, каждая в 1-2 предложениях. Будь лаконичен."},
],
}
],
}
api_url = f"{QWEN_ENDPOINT}/chat/completions"
try: try:
if not await bot.login(): response = requests.post(api_url, headers=headers, json=payload, timeout=60)
return
await bot.join_rooms() if response.status_code != 200:
await bot.register_callbacks() logger.error(f"API error: {response.status_code} - {response.text}")
await bot.sync_loop() return f"Ошибка API: {response.status_code}. {response.text[:200]}"
except KeyboardInterrupt:
logger.info("Bot stopped") result = response.json()
description = result["choices"][0]["message"]["content"]
return description
except requests.exceptions.RequestException as e:
logger.error(f"API request failed: {e}")
return f"Ошибка при запросе к API: {str(e)}"
except (KeyError, IndexError) as e:
logger.error(f"Unexpected response format: {e}")
return "Ошибка при обработке ответа от API"
async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle photo messages."""
# Check if user is allowed
if ALLOWED_USERS and update.effective_user.id not in ALLOWED_USERS:
await update.message.reply_text("У вас нет доступа к этому боту.")
return
photo = update.message.photo[-1] # Get the highest resolution photo
user = update.effective_user
await update.message.reply_text("🔍 Анализирую изображение...")
try:
# Download the photo
file = await context.bot.get_file(photo.file_id)
# Create a temporary file
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp_file:
tmp_path = tmp_file.name
await file.download_to_drive(tmp_path)
# For Qwen API, we need a public URL
# Since we can't easily host the image, we'll use a different approach
# We'll read the image and send it as base64 if the API supports it
try:
description = await describe_image_with_base64(tmp_path)
except Exception as e:
logger.warning(f"Base64 approach failed: {e}, trying URL approach")
# Fallback: try with a placeholder URL approach
description = await describe_image("placeholder")
# Clean up temp file
Path(tmp_path).unlink(missing_ok=True)
await update.message.reply_text(f"📝 Описание:\n\n{description}")
except Exception as e: except Exception as e:
logger.error(f"Bot error: {e}") logger.error(f"Error processing photo: {e}")
finally: await update.message.reply_text(f"❌ Произошла ошибка при обработке изображения: {str(e)}")
await bot.close()
async def describe_image_with_base64(image_path: str) -> str:
"""Send image to Qwen-VL API using base64 encoding."""
import base64
headers = {
"Authorization": f"Bearer {QWEN_API_KEY}",
"Content-Type": "application/json",
}
# Read and encode image
with open(image_path, "rb") as f:
image_data = base64.b64encode(f.read()).decode("utf-8")
# Create data URI
image_uri = f"data:image/jpeg;base64,{image_data}"
payload = {
"model": QWEN_MODEL,
"messages": [
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": image_uri}},
{"type": "text", "text": "Кратко опиши изображение на русском языке. Перечисли основные детали, каждая в 1-2 предложениях. Будь лаконичен."},
],
}
],
}
api_url = f"{QWEN_ENDPOINT}/chat/completions"
response = requests.post(api_url, headers=headers, json=payload, timeout=120)
if response.status_code != 200:
logger.error(f"API error: {response.status_code} - {response.text}")
raise Exception(f"API error: {response.status_code} - {response.text}")
result = response.json()
return result["choices"][0]["message"]["content"]
async def draw_image(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /draw command to generate images."""
if not context.args:
await update.message.reply_text(
"Пожалуйста, укажите описание изображения:\n/draw <описание того что хотите увидеть>"
)
return
prompt = " ".join(context.args)
await update.message.reply_text("🎨 Генерирую изображение... Это может занять до 2 минут.")
try:
image_url = await generate_image(prompt)
if image_url:
await context.bot.send_photo(chat_id=update.effective_chat.id, photo=image_url, caption=f"{prompt}")
else:
await update.message.reply_text("Не удалось сгенерировать изображение. API не поддерживает генерацию картинок.")
except Exception as e:
logger.error(f"Image generation error: {e}")
await update.message.reply_text(f"❌ Ошибка при генерации: {str(e)}")
async def generate_image(prompt: str) -> str | None:
"""Generate image using Qwen API or alternative services."""
headers = {
"Authorization": f"Bearer {QWEN_API_KEY}",
"Content-Type": "application/json",
}
# Try Qwen image generation endpoint first
# API expects prompt as a direct parameter
payload = {
"model": "mai",
"prompt": prompt,
"n": 1,
"size": "1024x1024"
}
api_url = f"{QWEN_ENDPOINT}/images/generations"
try:
response = requests.post(api_url, headers=headers, json=payload, timeout=120)
logger.info(f"Image API response: {response.status_code}")
if response.status_code == 200:
result = response.json()
logger.info(f"Image result: {result}")
# Try different response formats
if "data" in result and len(result["data"]) > 0:
return result["data"][0].get("url") or result["data"][0].get("image_url")
elif "image_url" in result:
return result["image_url"]
elif "output" in result and "url" in result["output"]:
return result["output"]["url"]
else:
logger.warning(f"Image API failed: {response.status_code} - {response.text}")
except Exception as e:
logger.warning(f"Image generation failed: {e}")
# Fallback: return None and inform user
return None
def main() -> None:
"""Start the bot."""
if not TELEGRAM_BOT_TOKEN:
logger.error("TELEGRAM_BOT_TOKEN not set. Please set it in .env file.")
print("❌ Ошибка: TELEGRAM_BOT_TOKEN не установлен.")
print("\nПолучите токен у @BotFather в Telegram:")
print("1. Напишите @BotFather")
print("2. Отправьте команду /newbot")
print("3. Следуйте инструкциям и скопируйте токен")
print("\nДобавьте токен в файл .env:")
print("TELEGRAM_BOT_TOKEN=ваш_токен_от_BotFather")
return
# Create application with increased timeout configuration
application = Application.builder() \
.token(TELEGRAM_BOT_TOKEN) \
.connect_timeout(60) \
.read_timeout(60) \
.write_timeout(60) \
.pool_timeout(60) \
.build()
# Add handlers
application.add_handler(CommandHandler("start", start))
application.add_handler(CommandHandler("help", help_command))
application.add_handler(CommandHandler("draw", draw_image))
application.add_handler(
MessageHandler(filters.PHOTO, handle_photo),
)
# Start the bot
logger.info("Бот запущен...")
print("🤖 Бот запущен! Ожидание сообщений...")
application.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) main()