Files
ai-tg/handlers/chat.py
T

291 lines
10 KiB
Python

import re
import asyncio
import logging
import time
from telegram import Update, Message
from telegram.constants import ChatAction, ChatType
from telegram.ext import ContextTypes
from services import auth, ai_client
from utils import context, chunker, persian, formatter
logger = logging.getLogger(__name__)
_MEDIA_GROUP_WAIT = 1.2
# ── Sending helpers ───────────────────────────────────────────
async def _send(message: Message, text: str):
"""Send text with HTML formatting; fall back to plain on parse error."""
formatted, mode = formatter.safe_send(text)
for chunk in chunker.chunk_text(formatted):
try:
await message.reply_text(chunk, parse_mode=mode)
except Exception:
plain = re.sub(r"<[^>]+>", "", chunk)
await message.reply_text(plain)
async def _send_images(message: Message, urls: list[str]):
"""Upload up to 5 image URLs to Telegram."""
sent = 0
for url in urls[:5]:
try:
await message.reply_photo(url)
sent += 1
except Exception as exc:
logger.warning("Could not send image %s: %s", url[:60], exc)
return sent
# ── Group-mention / reply detection ──────────────────────────
def _is_addressed(message: Message, bot_username: str, bot_id: int) -> bool:
text = message.text or message.caption or ""
# Reply to bot's own message
rt = message.reply_to_message
if rt and rt.from_user:
if rt.from_user.id == bot_id:
return True
if rt.from_user.username and rt.from_user.username.lower() == bot_username.lower():
return True
# @mention in entities
for entity in list(message.entities or []) + list(message.caption_entities or []):
if entity.type == "mention":
mention = text[entity.offset: entity.offset + entity.length]
if mention.lower() == f"@{bot_username.lower()}":
return True
# Name mention (Puppy / پاپی)
return persian.is_puppy_mentioned(text)
def _clean_text(text: str, bot_username: str) -> str:
text = re.sub(rf"@{re.escape(bot_username)}\s*", "", text, flags=re.IGNORECASE)
text = persian.strip_puppy_prefix(text)
return text.strip()
def _get_quoted_context(message: Message, bot_id: int) -> str:
"""If user replied to another user's message, return a quoted prefix for context."""
rt = message.reply_to_message
if not rt or not rt.from_user:
return ""
if rt.from_user.id == bot_id:
return "" # replying to bot is handled naturally via history
quoted = rt.text or rt.caption or ""
if not quoted:
return ""
name = rt.from_user.first_name or "someone"
return f'[Replying to {name}: "{quoted[:300]}"]\n'
def _image_analysis_mode(caption: str) -> str:
text = caption.lower()
if any(word in text for word in ("ocr", "text", "read", "extract", "متن", "بخوان")):
return "ocr"
if any(word in text for word in ("describe", "description", "توصیف", "شرح")):
return "describe"
if any(word in text for word in ("objects", "things", "items", "اشیا", "چیزها")):
return "objects"
if "?" in caption or any(word in text for word in ("what", "why", "how", "where", "who", "چی", "چرا", "کجا")):
return "question"
return "auto"
async def _download_image(message: Message, ctx: ContextTypes.DEFAULT_TYPE) -> tuple[bytes, str] | None:
if message.photo:
image_file = await ctx.bot.get_file(message.photo[-1].file_id)
return bytes(await image_file.download_as_bytearray()), "image/jpeg"
if message.document and (message.document.mime_type or "").startswith("image/"):
image_file = await ctx.bot.get_file(message.document.file_id)
return bytes(await image_file.download_as_bytearray()), message.document.mime_type or "image/jpeg"
return None
def _is_image_message(message: Message) -> bool:
return bool(message.photo or (message.document and (message.document.mime_type or "").startswith("image/")))
# ── Image handler ─────────────────────────────────────────────
async def _handle_photo(update: Update, ctx: ContextTypes.DEFAULT_TYPE, force_mode: str | None = None):
message = update.effective_message
user = update.effective_user
chat = update.effective_chat
caption = _clean_text(message.caption or "", ctx.bot.username or "")
settings = context.get_user_settings(user.id)
history = context.get_history(message.chat_id)
sys_prompt = context.get_system_prompt()
if context.reset_chat_if_input_too_large(message.chat_id, sys_prompt, keep_last=False):
history = []
await ctx.bot.send_chat_action(chat.id, ChatAction.TYPING)
try:
downloaded = await _download_image(message, ctx)
if not downloaded:
return
image_bytes, mime_type = downloaded
analysis_mode = force_mode or _image_analysis_mode(caption)
reply = await ai_client.chat_with_image(
messages=history,
system_prompt=sys_prompt,
max_tokens=settings["max_tokens"],
image_bytes=image_bytes,
mime_type=mime_type,
caption=caption,
analysis_mode=analysis_mode,
)
context.add_message(message.chat_id, "user", f"[Photo]{': ' + caption if caption else ''}")
context.add_message(message.chat_id, "assistant", reply)
context.increment_msg()
context.record_event("images_analyzed")
await _send(message, reply)
except Exception as exc:
context.record_error(f"Image error: {exc}")
await message.reply_text(f"❌ Image error: {exc}")
async def _handle_media_group(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
message = update.effective_message
group_id = message.media_group_id
if not group_id:
await _handle_photo(update, ctx)
return
groups = ctx.application.bot_data.setdefault("media_groups", {})
bucket = groups.setdefault(group_id, {"messages": [], "last_seen": 0.0})
bucket["messages"].append(message)
bucket["last_seen"] = time.time()
if len(bucket["messages"]) > 1:
return
await asyncio.sleep(_MEDIA_GROUP_WAIT)
bucket = groups.pop(group_id, bucket)
messages = bucket["messages"][:10]
first = messages[0]
user = first.from_user
settings = context.get_user_settings(user.id)
history = context.get_history(first.chat_id)
sys_prompt = context.get_system_prompt()
if context.reset_chat_if_input_too_large(first.chat_id, sys_prompt, keep_last=False):
history = []
caption = ""
images: list[tuple[bytes, str]] = []
await ctx.bot.send_chat_action(first.chat_id, ChatAction.TYPING)
try:
for msg in messages:
if not caption and msg.caption:
caption = _clean_text(msg.caption, ctx.bot.username or "")
downloaded = await _download_image(msg, ctx)
if downloaded:
images.append(downloaded)
if not images:
return
reply = await ai_client.chat_with_images(
messages=history,
system_prompt=sys_prompt,
max_tokens=settings["max_tokens"],
images=images,
caption=caption,
analysis_mode=_image_analysis_mode(caption),
)
context.add_message(first.chat_id, "user", f"[{len(images)} photos]{': ' + caption if caption else ''}")
context.add_message(first.chat_id, "assistant", reply)
context.increment_msg()
context.record_event("multi_image_analyses")
await _send(first, reply)
except Exception as exc:
context.record_error(f"Multi-image error: {exc}")
await first.reply_text(f"❌ Multi-image error: {exc}")
# ── Main message handler ──────────────────────────────────────
async def message_handler(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
message = update.effective_message
if not message:
return
user = update.effective_user
chat = update.effective_chat
if not auth.is_authorized(user.id, user.username or ""):
return
auth.register_known(user.id, username=user.username or "")
# Group gate: must be addressed
if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP):
if not _is_addressed(message, ctx.bot.username or "", ctx.bot.id):
return
# Image branch: Telegram photos and image files/documents.
if _is_image_message(message):
if message.media_group_id:
await _handle_media_group(update, ctx)
else:
await _handle_photo(update, ctx)
return
text = message.text or ""
if not text:
return
text = _clean_text(text, ctx.bot.username or "")
if not text:
return
# Include quoted message from another user as context prefix
quoted = _get_quoted_context(message, ctx.bot.id)
full_text = quoted + text
settings = context.get_user_settings(user.id)
sys_prompt = context.get_system_prompt()
# Add to history first so AI sees the current message
context.add_message(message.chat_id, "user", full_text)
context.reset_chat_if_input_too_large(message.chat_id, sys_prompt, keep_last=True)
history = context.get_history(message.chat_id)
await ctx.bot.send_chat_action(chat.id, ChatAction.TYPING)
try:
reply, image_urls = await ai_client.chat(
messages=history,
system_prompt=sys_prompt,
max_tokens=settings["max_tokens"],
search_enabled=settings["search_enabled"],
)
context.add_message(message.chat_id, "assistant", reply)
context.increment_msg()
context.record_event("text_chats")
# Send images first (appear above text)
if image_urls:
await _send_images(message, image_urls)
await _send(message, reply)
except Exception as exc:
context.pop_last_message(message.chat_id)
context.record_error(f"Chat error: {exc}")
logger.error("Chat error: %s", exc)
await message.reply_text(f"❌ Error: {exc}")