Add chat and image message handlers
This commit is contained in:
@@ -0,0 +1,290 @@
|
|||||||
|
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}")
|
||||||
Reference in New Issue
Block a user