diff --git a/README.md b/README.md index d24a529..d605bc6 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,9 @@ Bu yaklaşım belge tabanlı RAG akışına daha uygun olduğu için doğrudan D - `/otomasyon_baslat ` - `/otomasyon_sil ` - `/notlarima_ekle` +- `/clean_chat` + +`/clean_chat` yalnızca Telegram konuşma ekranını temizlemeye çalışır; veritabanındaki memory, audit log, profil veya second brain kayıtlarını silmez. ## ⏱️ Otomasyonlar diff --git a/backend/app/telegram/bot.py b/backend/app/telegram/bot.py index ec322bf..d1df28c 100644 --- a/backend/app/telegram/bot.py +++ b/backend/app/telegram/bot.py @@ -1,13 +1,17 @@ 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: @@ -75,6 +79,7 @@ class TelegramBotService: 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)) @@ -85,6 +90,7 @@ class TelegramBotService: 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()) @@ -110,6 +116,8 @@ class TelegramBotService: 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) @@ -127,6 +135,36 @@ class TelegramBotService: 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: @@ -147,6 +185,119 @@ class TelegramBotService: 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] @@ -170,6 +321,7 @@ class TelegramBotService: 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)"),