332 lines
16 KiB
Python
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)"),
|
|
]
|