Files
wiseclaw/backend/app/telegram/bot.py

332 lines
16 KiB
Python

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 <id> (wc)"),
BotCommand("otomasyon_baslat", "Bir otomasyonu yeniden baslat: /otomasyon_baslat <id> (wc)"),
BotCommand("otomasyon_sil", "Bir otomasyonu sil: /otomasyon_sil <id> (wc)"),
BotCommand("notlarima_ekle", "Ikinci beyne yeni not ekle (wc)"),
]