import asyncio import json from contextlib import suppress from pathlib import Path from typing import Any from telegram import BotCommand, InputMediaPhoto, Update from telegram.constants import ChatAction from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters from app.db import AuditLogORM from app.orchestrator import WiseClawOrchestrator from app.telegram.auth import is_authorized from app.tools.registry import build_tools class TelegramBotService: MAX_MESSAGE_LEN = 3500 def __init__(self, token: str, orchestrator_factory: Any) -> None: self.token = token self.orchestrator_factory = orchestrator_factory self.application: Application | None = None async def process_message(self, telegram_user_id: int, text: str) -> str: with self.orchestrator_factory() as session: orchestrator = WiseClawOrchestrator(session) return await orchestrator.handle_text_message(telegram_user_id=telegram_user_id, text=text) async def process_message_payload(self, telegram_user_id: int, text: str) -> dict[str, object]: with self.orchestrator_factory() as session: orchestrator = WiseClawOrchestrator(session) payload = await orchestrator.handle_message_payload(telegram_user_id=telegram_user_id, text=text) text_value = str(payload.get("text", "")) if text_value.startswith("__WC_MEDIA__"): try: decoded = json.loads(text_value[len("__WC_MEDIA__") :]) except json.JSONDecodeError: return {"text": text_value, "media": []} return { "text": str(decoded.get("text", "")), "media": decoded.get("media", []) if isinstance(decoded.get("media"), list) else [], } return payload async def send_message(self, chat_id: int, text: str) -> None: if self.application is None: return for chunk in self._chunk_message(text): await self.application.bot.send_message(chat_id=chat_id, text=chunk) async def send_media(self, chat_id: int, media: list[dict[str, str]]) -> None: if self.application is None: return clean_media = [item for item in media[:3] if item.get("url")] if not clean_media: return if len(clean_media) == 1: item = clean_media[0] try: await self.application.bot.send_photo(chat_id=chat_id, photo=item["url"], caption=item.get("caption", "")[:1024]) except Exception: return return media_group = [] for item in clean_media: media_group.append(InputMediaPhoto(media=item["url"], caption=item.get("caption", "")[:1024])) try: await self.application.bot.send_media_group(chat_id=chat_id, media=media_group) except Exception: for item in clean_media: try: await self.application.bot.send_photo(chat_id=chat_id, photo=item["url"], caption=item.get("caption", "")[:1024]) except Exception: continue async def start(self) -> None: if not self.token: return self.application = Application.builder().token(self.token).build() self.application.add_handler(CommandHandler("start", self._on_start)) self.application.add_handler(CommandHandler("clean_chat", self._on_clean_chat)) self.application.add_handler(CommandHandler("tanisalim", self._on_command_passthrough)) self.application.add_handler(CommandHandler("profilim", self._on_command_passthrough)) self.application.add_handler(CommandHandler("tercihlerim", self._on_command_passthrough)) self.application.add_handler(CommandHandler("tanisalim_sifirla", self._on_command_passthrough)) self.application.add_handler(CommandHandler("otomasyon_ekle", self._on_command_passthrough)) self.application.add_handler(CommandHandler("otomasyonlar", self._on_command_passthrough)) self.application.add_handler(CommandHandler("otomasyon_durdur", self._on_command_passthrough)) self.application.add_handler(CommandHandler("otomasyon_baslat", self._on_command_passthrough)) self.application.add_handler(CommandHandler("otomasyon_sil", self._on_command_passthrough)) self.application.add_handler(CommandHandler("notlarima_ekle", self._on_command_passthrough)) self.application.add_handler(MessageHandler(filters.Document.ALL | filters.PHOTO, self._on_attachment)) self.application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._on_text)) await self.application.initialize() await self.application.bot.set_my_commands(self._telegram_commands()) await self.application.start() await self.application.updater.start_polling(drop_pending_updates=True) async def stop(self) -> None: if self.application is None: return await self.application.updater.stop() await self.application.stop() await self.application.shutdown() self.application = None async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: del context if update.message is None or update.effective_user is None: return await update.message.reply_text( "WiseClaw is online. If your Telegram user is whitelisted, send a message to start." ) async def _on_text(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.message is None or update.effective_user is None or update.message.text is None: return if await self._maybe_handle_drive_upload_from_reply(update, context): return typing_task = asyncio.create_task(self._send_typing(update.effective_chat.id, context)) try: reply = await self.process_message_payload(update.effective_user.id, update.message.text) finally: typing_task.cancel() with suppress(asyncio.CancelledError): await typing_task media = reply.get("media", []) if isinstance(reply, dict) else [] if isinstance(media, list) and media: await self.send_media( update.effective_chat.id, [item for item in media if isinstance(item, dict)], ) text_reply = str(reply.get("text", "")) if isinstance(reply, dict) else str(reply) for chunk in self._chunk_message(text_reply): await update.message.reply_text(chunk) async def _on_attachment(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.message is None or update.effective_user is None: return if not self._message_has_supported_attachment(update.message): return if self._looks_like_drive_upload_request(update.message.caption or ""): await self._handle_drive_upload(update, context, update.message) return await update.message.reply_text( "Dosyayi aldim. Google Drive'a yuklemek icin bu mesaja reply yapip `Bunu Google Drive'a yukle` yazabilirsin.", ) async def _on_clean_chat(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.message is None or update.effective_chat is None: return chat_id = update.effective_chat.id latest_message_id = update.message.message_id consecutive_failures = 0 for message_id in range(latest_message_id, 0, -1): try: await context.bot.delete_message(chat_id=chat_id, message_id=message_id) consecutive_failures = 0 except Exception: consecutive_failures += 1 if consecutive_failures >= 50: break continue async def _on_command_passthrough(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: del context if update.message is None or update.effective_user is None or update.message.text is None: return reply = await self.process_message_payload(update.effective_user.id, update.message.text) media = reply.get("media", []) if isinstance(reply, dict) else [] if isinstance(media, list) and media: await self.send_media( update.effective_chat.id, [item for item in media if isinstance(item, dict)], ) text_reply = str(reply.get("text", "")) if isinstance(reply, dict) else str(reply) for chunk in self._chunk_message(text_reply): await update.message.reply_text(chunk) async def _send_typing(self, chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> None: while True: await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING) await asyncio.sleep(4) async def _maybe_handle_drive_upload_from_reply(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool: if update.message is None or update.effective_user is None or update.message.text is None: return False if not self._looks_like_drive_upload_request(update.message.text): return False reply_target = update.message.reply_to_message if reply_target is None or not self._message_has_supported_attachment(reply_target): await update.message.reply_text( "Google Drive'a yuklemek icin once dosya veya fotograf gonder, sonra o mesaja reply yapip `Bunu Google Drive'a yukle` yaz.", ) return True await self._handle_drive_upload(update, context, reply_target) return True async def _handle_drive_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE, source_message: Any) -> None: if update.message is None or update.effective_user is None or update.effective_chat is None: return with self.orchestrator_factory() as session: if not is_authorized(session, update.effective_user.id): await update.message.reply_text("This Telegram user is not authorized for WiseClaw.") return orchestrator = WiseClawOrchestrator(session) runtime = orchestrator.get_runtime_settings() tools = build_tools(runtime, Path(__file__).resolve().parents[2], session) drive_tool = tools.get("google_drive") if drive_tool is None: await update.message.reply_text("Google Drive araci etkin degil.") return temp_file = None try: attachment = await self._download_attachment(context, update.effective_chat.id, source_message) if attachment is None: await update.message.reply_text("Bu mesajdan yuklenebilir bir dosya cikarilamadi.") return temp_file = attachment["local_path"] result = await drive_tool.run( { "action": "upload", "local_path": attachment["local_path"], "filename": attachment["filename"], "mime_type": attachment["mime_type"], } ) session.add( AuditLogORM( category="tool", message=f"tool:google_drive:{json.dumps({'action': 'upload', 'filename': attachment['filename']}, ensure_ascii=False)}", ) ) if result.get("status") != "ok": message = str(result.get("message", "Google Drive upload failed.")) await update.message.reply_text(f"Dosyayi Google Drive'a yukleyemedim: {message}") return file_info = result.get("file", {}) if not isinstance(file_info, dict): file_info = {} link = str(file_info.get("web_view_link") or file_info.get("web_content_link") or "").strip() file_id = str(file_info.get("id", "")).strip() name = str(file_info.get("name", attachment["filename"])).strip() response_lines = [f"Dosya Google Drive'a yuklendi: {name}"] if link: response_lines.append(f"Link: {link}") if file_id: response_lines.append(f"Dosya ID: {file_id}") await update.message.reply_text("\n".join(response_lines)) finally: if temp_file: with suppress(OSError): Path(temp_file).unlink() session.commit() async def _download_attachment(self, context: ContextTypes.DEFAULT_TYPE, chat_id: int, message: Any) -> dict[str, str] | None: if getattr(message, "document", None) is not None: document = message.document tg_file = await context.bot.get_file(document.file_id) filename = document.file_name or f"telegram_document_{message.message_id}" mime_type = document.mime_type or "application/octet-stream" elif getattr(message, "photo", None): photo = message.photo[-1] tg_file = await context.bot.get_file(photo.file_id) filename = f"telegram_photo_{message.message_id}.jpg" mime_type = "image/jpeg" else: return None temp_dir = Path(__file__).resolve().parents[2] / "tmp" / "telegram_uploads" temp_dir.mkdir(parents=True, exist_ok=True) safe_name = self._sanitize_filename(filename) local_path = temp_dir / f"{chat_id}_{message.message_id}_{safe_name}" await tg_file.download_to_drive(custom_path=str(local_path)) return { "local_path": str(local_path), "filename": filename, "mime_type": mime_type, } def _looks_like_drive_upload_request(self, text: str) -> bool: normalized = text.casefold() references_drive = "drive" in normalized or "google drive" in normalized upload_intent = any(term in normalized for term in ("yukle", "yükle", "gonder", "gönder", "upload")) return references_drive and upload_intent def _message_has_supported_attachment(self, message: Any) -> bool: return bool(getattr(message, "document", None) is not None or getattr(message, "photo", None)) def _sanitize_filename(self, filename: str) -> str: cleaned = "".join(char if char.isalnum() or char in {"-", "_", "."} else "_" for char in filename.strip()) return cleaned or "attachment.bin" def _chunk_message(self, text: str) -> list[str]: if len(text) <= self.MAX_MESSAGE_LEN: return [text] chunks: list[str] = [] remaining = text while len(remaining) > self.MAX_MESSAGE_LEN: split_at = remaining.rfind("\n", 0, self.MAX_MESSAGE_LEN) if split_at <= 0: split_at = self.MAX_MESSAGE_LEN chunks.append(remaining[:split_at].strip()) remaining = remaining[split_at:].strip() if remaining: chunks.append(remaining) return chunks def _telegram_commands(self) -> list[BotCommand]: return [ BotCommand("start", "WiseClaw'i baslat (wc)"), BotCommand("tanisalim", "12 soruluk tanisma akisini baslat (wc)"), BotCommand("profilim", "Kayitli profil ozetimi goster (wc)"), BotCommand("tercihlerim", "Kayitli iletisim tercihlerini goster (wc)"), BotCommand("tanisalim_sifirla", "Tanisma profilini sifirla (wc)"), BotCommand("clean_chat", "Telegram ekranindaki mesajlari temizle (wc)"), BotCommand("otomasyon_ekle", "Yeni otomasyon wizard'ini baslat (wc)"), BotCommand("otomasyonlar", "Otomasyon listesini goster (wc)"), BotCommand("otomasyon_durdur", "Bir otomasyonu durdur: /otomasyon_durdur (wc)"), BotCommand("otomasyon_baslat", "Bir otomasyonu yeniden baslat: /otomasyon_baslat (wc)"), BotCommand("otomasyon_sil", "Bir otomasyonu sil: /otomasyon_sil (wc)"), BotCommand("notlarima_ekle", "Ikinci beyne yeni not ekle (wc)"), ]