2025-12-27 11:59:04 +01:00
|
|
|
|
import os
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import threading
|
|
|
|
|
|
import time
|
|
|
|
|
|
import requests
|
2025-12-28 19:59:31 +01:00
|
|
|
|
import asyncio
|
2026-01-01 15:27:25 +01:00
|
|
|
|
import httpx
|
2025-12-31 16:06:42 +01:00
|
|
|
|
|
2025-12-27 11:59:04 +01:00
|
|
|
|
# Configuration from environment
|
|
|
|
|
|
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
|
|
|
|
|
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
|
2025-12-28 19:59:31 +01:00
|
|
|
|
TELEGRAM_MAX_RETRIES = int(os.environ.get("TELEGRAM_MAX_RETRIES", 3))
|
2025-12-27 11:59:04 +01:00
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
2025-12-29 22:46:10 +01:00
|
|
|
|
|
2025-12-27 11:59:04 +01:00
|
|
|
|
class TelegramBot:
|
|
|
|
|
|
|
2026-01-01 15:27:25 +01:00
|
|
|
|
async def _handle_help_command(self) -> None:
|
2025-12-31 16:06:42 +01:00
|
|
|
|
"""Send a help message with available commands."""
|
|
|
|
|
|
help_text = (
|
|
|
|
|
|
"<b>Available commands:</b>\n"
|
2026-01-02 13:41:21 +01:00
|
|
|
|
"/start - Resume monitoring\n"
|
|
|
|
|
|
"/stop - Pause monitoring\n"
|
2025-12-31 16:06:42 +01:00
|
|
|
|
"/autopilot on|off - Enable/disable autopilot\n"
|
|
|
|
|
|
"/status - Show current status\n"
|
|
|
|
|
|
"/plot - Show weekly listing pattern plot\n"
|
|
|
|
|
|
"/errorrate - Show autopilot error rate plot\n"
|
|
|
|
|
|
"/retryfailed - Retry failed applications\n"
|
|
|
|
|
|
"/resetlistings - Reset listings file\n"
|
2026-01-04 18:29:56 +01:00
|
|
|
|
"/logs [n] - Show last n log lines (default 50)\n"
|
2025-12-31 16:06:42 +01:00
|
|
|
|
"/help - Show this help message"
|
|
|
|
|
|
)
|
|
|
|
|
|
await self._send_message(help_text)
|
|
|
|
|
|
|
2026-01-01 15:27:25 +01:00
|
|
|
|
async def _handle_unknown_command(self, text: str) -> None:
|
2025-12-31 16:06:42 +01:00
|
|
|
|
"""Handle unknown commands and notify the user."""
|
|
|
|
|
|
cmd = text.split()[0] if text else text
|
|
|
|
|
|
msg = (
|
|
|
|
|
|
f"❓ Unknown command: <code>{cmd}</code>\n\nUse /help to see available commands."
|
|
|
|
|
|
)
|
|
|
|
|
|
await self._send_message(msg)
|
|
|
|
|
|
|
2026-01-02 13:41:21 +01:00
|
|
|
|
async def _handle_start_command(self) -> None:
|
|
|
|
|
|
"""Resume monitoring for new listings."""
|
|
|
|
|
|
self.monitor.state_manager.set_monitoring_enabled(True)
|
|
|
|
|
|
await self._send_message(
|
|
|
|
|
|
"▶️ <b>Monitoring RESUMED</b>\n\n"
|
|
|
|
|
|
"Bot will now check for new listings and notify you."
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info("Monitoring resumed via /start command")
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_stop_command(self) -> None:
|
|
|
|
|
|
"""Pause monitoring without stopping the bot."""
|
|
|
|
|
|
self.monitor.state_manager.set_monitoring_enabled(False)
|
|
|
|
|
|
await self._send_message(
|
|
|
|
|
|
"⏸️ <b>Monitoring PAUSED</b>\n\n"
|
|
|
|
|
|
"Bot will not check for new listings until you send /start.\n"
|
|
|
|
|
|
"Commands like /status, /plot, and /retryfailed still work."
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info("Monitoring paused via /stop command")
|
|
|
|
|
|
|
2026-01-04 18:29:56 +01:00
|
|
|
|
async def _handle_logs_command(self, num_lines: int = 50) -> None:
|
|
|
|
|
|
"""Send the last n lines of the log file."""
|
2026-01-02 23:29:17 +01:00
|
|
|
|
log_file = "data/monitor.log"
|
|
|
|
|
|
try:
|
|
|
|
|
|
if not os.path.exists(log_file):
|
|
|
|
|
|
await self._send_message("📋 No log file found.")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
with open(log_file, "r", encoding="utf-8") as f:
|
|
|
|
|
|
lines = f.readlines()
|
|
|
|
|
|
|
2026-01-04 18:29:56 +01:00
|
|
|
|
last_lines = lines[-num_lines:] if len(lines) > num_lines else lines
|
2026-01-02 23:29:17 +01:00
|
|
|
|
log_text = "".join(last_lines)
|
|
|
|
|
|
|
|
|
|
|
|
# Truncate if too long for Telegram (4096 char limit)
|
|
|
|
|
|
if len(log_text) > 4000:
|
|
|
|
|
|
log_text = log_text[-4000:]
|
|
|
|
|
|
log_text = "..." + log_text[log_text.find("\n") + 1:] # Start from next newline
|
|
|
|
|
|
|
|
|
|
|
|
message = f"📋 <b>Last {len(last_lines)} log lines:</b>\n\n<pre>{log_text}</pre>"
|
|
|
|
|
|
await self._send_message(message)
|
2026-01-04 18:29:56 +01:00
|
|
|
|
logger.info(f"Sent last {len(last_lines)} log lines via /logs command")
|
2026-01-02 23:29:17 +01:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Failed to read log file: {e}")
|
|
|
|
|
|
await self._send_message(f"❌ Error reading logs: {e}")
|
|
|
|
|
|
|
2026-01-01 15:27:25 +01:00
|
|
|
|
async def _handle_reset_listings_command(self) -> None:
|
2025-12-29 22:46:10 +01:00
|
|
|
|
"""Move listings.json to data/old/ with a timestamp, preserving statistics and application history."""
|
|
|
|
|
|
import shutil
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
try:
|
|
|
|
|
|
listings_path = os.path.join("data", "listings.json")
|
|
|
|
|
|
old_dir = os.path.join("data", "old")
|
|
|
|
|
|
if os.path.exists(listings_path):
|
|
|
|
|
|
# Ensure old_dir exists
|
|
|
|
|
|
os.makedirs(old_dir, exist_ok=True)
|
|
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
2025-12-31 16:06:42 +01:00
|
|
|
|
dest_path = os.path.join(
|
|
|
|
|
|
old_dir, f"listings_{timestamp}.json"
|
|
|
|
|
|
)
|
2025-12-29 22:46:10 +01:00
|
|
|
|
shutil.move(listings_path, dest_path)
|
2025-12-31 16:06:42 +01:00
|
|
|
|
msg = (
|
|
|
|
|
|
f"🗑️ <b>Listings reset:</b>\n<code>listings.json</code> moved to "
|
|
|
|
|
|
f"<code>old/listings_{timestamp}.json</code>."
|
|
|
|
|
|
)
|
2025-12-29 22:46:10 +01:00
|
|
|
|
else:
|
|
|
|
|
|
msg = "ℹ️ No listings file found to move."
|
2025-12-31 16:06:42 +01:00
|
|
|
|
await self._send_message(msg)
|
2025-12-29 22:46:10 +01:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Error resetting listings: {e}")
|
2026-01-02 11:23:35 +01:00
|
|
|
|
await self._send_message(f"[ERROR] Error resetting listings: {str(e)}")
|
2025-12-29 22:46:10 +01:00
|
|
|
|
|
2026-01-01 15:27:25 +01:00
|
|
|
|
def __init__(self, monitor, bot_token: str | None = None, chat_id: str | None = None, event_loop=None) -> None:
|
2025-12-27 11:59:04 +01:00
|
|
|
|
self.monitor = monitor
|
|
|
|
|
|
self.bot_token = bot_token or TELEGRAM_BOT_TOKEN
|
|
|
|
|
|
self.chat_id = chat_id or TELEGRAM_CHAT_ID
|
2026-01-01 15:27:25 +01:00
|
|
|
|
self.last_update_id: int = 0
|
|
|
|
|
|
self.running: bool = False
|
2025-12-27 11:59:04 +01:00
|
|
|
|
|
2025-12-28 19:59:31 +01:00
|
|
|
|
# Add reference to application handler
|
|
|
|
|
|
self.app_handler = monitor
|
|
|
|
|
|
# Store the main event loop for thread-safe async calls
|
|
|
|
|
|
self.event_loop = event_loop or asyncio.get_event_loop()
|
|
|
|
|
|
|
2026-01-01 15:27:25 +01:00
|
|
|
|
# Initialize persistent httpx client with connection pooling
|
|
|
|
|
|
self._http_client: httpx.AsyncClient | None = None
|
|
|
|
|
|
|
|
|
|
|
|
async def _get_http_client(self) -> httpx.AsyncClient:
|
|
|
|
|
|
"""Get or create the persistent httpx client with connection pooling."""
|
|
|
|
|
|
if self._http_client is None:
|
|
|
|
|
|
self._http_client = httpx.AsyncClient(
|
|
|
|
|
|
timeout=30,
|
|
|
|
|
|
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10)
|
|
|
|
|
|
)
|
|
|
|
|
|
return self._http_client
|
|
|
|
|
|
|
|
|
|
|
|
async def close(self) -> None:
|
|
|
|
|
|
"""Close the httpx client gracefully."""
|
|
|
|
|
|
if self._http_client:
|
|
|
|
|
|
await self._http_client.aclose()
|
|
|
|
|
|
self._http_client = None
|
|
|
|
|
|
|
|
|
|
|
|
def start(self) -> None:
|
2025-12-27 11:59:04 +01:00
|
|
|
|
if not self.bot_token:
|
|
|
|
|
|
logger.warning("Telegram bot token not configured, commands disabled")
|
|
|
|
|
|
return
|
|
|
|
|
|
self.running = True
|
|
|
|
|
|
thread = threading.Thread(target=self._poll_updates, daemon=True)
|
|
|
|
|
|
thread.start()
|
|
|
|
|
|
logger.info("Telegram command listener started")
|
|
|
|
|
|
|
2026-01-01 15:27:25 +01:00
|
|
|
|
def stop(self) -> None:
|
2025-12-27 11:59:04 +01:00
|
|
|
|
self.running = False
|
|
|
|
|
|
|
2026-01-01 15:27:25 +01:00
|
|
|
|
def _poll_updates(self) -> None:
|
2025-12-27 11:59:04 +01:00
|
|
|
|
while self.running:
|
|
|
|
|
|
try:
|
|
|
|
|
|
url = f"https://api.telegram.org/bot{self.bot_token}/getUpdates"
|
|
|
|
|
|
params = {"offset": self.last_update_id + 1, "timeout": 30}
|
|
|
|
|
|
response = requests.get(url, params=params, timeout=35)
|
|
|
|
|
|
if response.ok:
|
|
|
|
|
|
data = response.json()
|
|
|
|
|
|
if data.get("ok") and data.get("result"):
|
|
|
|
|
|
for update in data["result"]:
|
|
|
|
|
|
self.last_update_id = update["update_id"]
|
|
|
|
|
|
self._handle_update(update)
|
|
|
|
|
|
except requests.exceptions.Timeout:
|
|
|
|
|
|
continue
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Telegram polling error: {e}")
|
|
|
|
|
|
time.sleep(5)
|
|
|
|
|
|
|
2026-01-01 15:27:25 +01:00
|
|
|
|
def _handle_update(self, update: dict) -> None:
|
2025-12-27 11:59:04 +01:00
|
|
|
|
message = update.get("message", {})
|
|
|
|
|
|
text = message.get("text", "")
|
|
|
|
|
|
chat_id = str(message.get("chat", {}).get("id", ""))
|
|
|
|
|
|
if chat_id != self.chat_id:
|
|
|
|
|
|
logger.debug(f"Ignoring message from unknown chat: {chat_id}")
|
|
|
|
|
|
return
|
|
|
|
|
|
logger.info(f"Received Telegram command: {text}")
|
2025-12-31 16:06:42 +01:00
|
|
|
|
loop = self.event_loop
|
2026-01-02 13:41:21 +01:00
|
|
|
|
if text == "/start":
|
|
|
|
|
|
asyncio.run_coroutine_threadsafe(self._handle_start_command(), loop)
|
|
|
|
|
|
elif text == "/stop":
|
|
|
|
|
|
asyncio.run_coroutine_threadsafe(self._handle_stop_command(), loop)
|
|
|
|
|
|
elif text.startswith("/autopilot"):
|
2025-12-31 16:06:42 +01:00
|
|
|
|
asyncio.run_coroutine_threadsafe(self._handle_autopilot_command(text), loop)
|
2025-12-27 11:59:04 +01:00
|
|
|
|
elif text == "/status":
|
2025-12-31 16:06:42 +01:00
|
|
|
|
asyncio.run_coroutine_threadsafe(self._handle_status_command(), loop)
|
2025-12-27 11:59:04 +01:00
|
|
|
|
elif text == "/help":
|
2025-12-31 16:06:42 +01:00
|
|
|
|
asyncio.run_coroutine_threadsafe(self._handle_help_command(), loop)
|
2025-12-27 11:59:04 +01:00
|
|
|
|
elif text == "/plot":
|
2025-12-31 16:06:42 +01:00
|
|
|
|
asyncio.run_coroutine_threadsafe(self._handle_plot_command(), loop)
|
2025-12-27 11:59:04 +01:00
|
|
|
|
elif text == "/errorrate":
|
2025-12-31 16:06:42 +01:00
|
|
|
|
asyncio.run_coroutine_threadsafe(self._handle_error_rate_command(), loop)
|
2025-12-28 19:59:31 +01:00
|
|
|
|
elif text == "/retryfailed":
|
|
|
|
|
|
fut = asyncio.run_coroutine_threadsafe(
|
|
|
|
|
|
self._handle_retry_failed_command(max_retries=TELEGRAM_MAX_RETRIES),
|
2025-12-31 16:06:42 +01:00
|
|
|
|
loop
|
2025-12-28 19:59:31 +01:00
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
fut.result()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"/retryfailed command failed: {e}")
|
2025-12-29 22:46:10 +01:00
|
|
|
|
elif text == "/resetlistings":
|
2025-12-31 16:06:42 +01:00
|
|
|
|
asyncio.run_coroutine_threadsafe(self._handle_reset_listings_command(), loop)
|
2026-01-04 18:29:56 +01:00
|
|
|
|
elif text.startswith("/logs"):
|
|
|
|
|
|
# Parse optional number parameter
|
|
|
|
|
|
parts = text.split()
|
|
|
|
|
|
num_lines = 50 # default
|
|
|
|
|
|
if len(parts) > 1:
|
|
|
|
|
|
try:
|
|
|
|
|
|
num_lines = int(parts[1])
|
|
|
|
|
|
num_lines = max(1, min(num_lines, 500)) # clamp between 1-500
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass # use default if invalid
|
|
|
|
|
|
asyncio.run_coroutine_threadsafe(self._handle_logs_command(num_lines), loop)
|
2025-12-27 11:59:04 +01:00
|
|
|
|
elif text.startswith("/"):
|
2025-12-31 16:06:42 +01:00
|
|
|
|
asyncio.run_coroutine_threadsafe(self._handle_unknown_command(text), loop)
|
|
|
|
|
|
|
2026-01-01 15:27:25 +01:00
|
|
|
|
async def _handle_retry_failed_command(self, max_retries: int = 3) -> None:
|
2025-12-28 19:59:31 +01:00
|
|
|
|
"""Retry all failed applications up to max_retries."""
|
|
|
|
|
|
# Ensure browser context is initialized
|
|
|
|
|
|
if not hasattr(self.app_handler, 'context') or self.app_handler.context is None:
|
|
|
|
|
|
if hasattr(self.app_handler, 'init_browser'):
|
|
|
|
|
|
await self.app_handler.init_browser()
|
|
|
|
|
|
# After (re-)init, propagate context to all sub-handlers (defensive)
|
|
|
|
|
|
if hasattr(self.app_handler, 'context') and hasattr(self.app_handler, 'handlers'):
|
|
|
|
|
|
for handler in self.app_handler.handlers.values():
|
|
|
|
|
|
handler.context = self.app_handler.context
|
|
|
|
|
|
applications = self.app_handler.load_applications()
|
2026-01-01 22:14:55 +01:00
|
|
|
|
failed = [
|
|
|
|
|
|
app for app in applications.values()
|
|
|
|
|
|
if not app.get("success")
|
|
|
|
|
|
and app.get("retries", 0) < max_retries
|
|
|
|
|
|
and not app.get("deactivated", False)
|
|
|
|
|
|
]
|
2026-01-02 11:23:35 +01:00
|
|
|
|
await self._send_message(f"[RETRY] Retrying {len(failed)} failed applications (max retries: {max_retries})...")
|
2025-12-28 19:59:31 +01:00
|
|
|
|
if not failed:
|
2026-01-02 11:23:35 +01:00
|
|
|
|
await self._send_message("[INFO] No failed applications to retry (or all reached max retries).")
|
2025-12-28 19:59:31 +01:00
|
|
|
|
return
|
|
|
|
|
|
results = {}
|
|
|
|
|
|
details = []
|
|
|
|
|
|
for app in failed:
|
|
|
|
|
|
listing = {
|
|
|
|
|
|
"id": app["listing_id"],
|
|
|
|
|
|
"rooms": app.get("rooms", ""),
|
|
|
|
|
|
"size": app.get("size", ""),
|
|
|
|
|
|
"price": app.get("price", ""),
|
|
|
|
|
|
"address": app.get("address", ""),
|
|
|
|
|
|
"link": app.get("link", "")
|
|
|
|
|
|
}
|
|
|
|
|
|
retries = app.get("retries", 0) + 1
|
|
|
|
|
|
result = await self.app_handler.apply(listing)
|
|
|
|
|
|
result["retries"] = retries
|
2026-01-01 21:28:43 +01:00
|
|
|
|
# Preserve original timestamp only if still failing; update on success
|
|
|
|
|
|
if not result["success"]:
|
|
|
|
|
|
result["timestamp"] = app.get("timestamp", result["timestamp"])
|
2025-12-28 19:59:31 +01:00
|
|
|
|
self.app_handler.save_application(result)
|
|
|
|
|
|
results[listing["id"]] = result
|
2026-01-02 11:23:35 +01:00
|
|
|
|
status_emoji = "[SUCCESS]" if result["success"] else "[FAILED]"
|
2025-12-28 19:59:31 +01:00
|
|
|
|
details.append(
|
|
|
|
|
|
f"{status_emoji} <b>{result.get('address', '')}</b> ({result.get('company', '')})\n"
|
|
|
|
|
|
f"<code>{result.get('link', '')}</code>\n"
|
|
|
|
|
|
f"<i>{result.get('message', '')}</i>\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
n_success = sum(1 for r in results.values() if r["success"])
|
|
|
|
|
|
n_fail = sum(1 for r in results.values() if not r["success"])
|
2026-01-02 11:23:35 +01:00
|
|
|
|
summary = f"[RETRY] Retried {len(results)} failed applications.\n[SUCCESS]: {n_success}\n[FAILED]: {n_fail}"
|
2025-12-28 19:59:31 +01:00
|
|
|
|
if details:
|
|
|
|
|
|
summary += "\n\n<b>Details:</b>\n" + "\n".join(details)
|
2025-12-31 16:06:42 +01:00
|
|
|
|
await self._send_message(summary)
|
2025-12-27 11:59:04 +01:00
|
|
|
|
|
2026-01-01 15:27:25 +01:00
|
|
|
|
async def _handle_autopilot_command(self, text: str) -> None:
|
2025-12-27 11:59:04 +01:00
|
|
|
|
logger.info(f"Processing autopilot command: {text}")
|
|
|
|
|
|
parts = text.split()
|
|
|
|
|
|
if len(parts) < 2:
|
2025-12-31 16:06:42 +01:00
|
|
|
|
await self._send_message("Usage: /autopilot on|off")
|
2025-12-27 11:59:04 +01:00
|
|
|
|
return
|
|
|
|
|
|
action = parts[1].lower()
|
|
|
|
|
|
if action == "on":
|
|
|
|
|
|
logger.info("Enabling autopilot mode")
|
|
|
|
|
|
self.monitor.set_autopilot(True)
|
2026-01-02 11:23:35 +01:00
|
|
|
|
await self._send_message("<b>Autopilot ENABLED</b>\n\nI will automatically apply to new listings!")
|
2025-12-27 11:59:04 +01:00
|
|
|
|
elif action == "off":
|
|
|
|
|
|
self.monitor.set_autopilot(False)
|
2025-12-31 16:06:42 +01:00
|
|
|
|
await self._send_message("🛑 <b>Autopilot DISABLED</b>\n\nI will only notify you of new listings.")
|
2025-12-27 11:59:04 +01:00
|
|
|
|
else:
|
2025-12-31 16:06:42 +01:00
|
|
|
|
await self._send_message("Usage: /autopilot on|off")
|
2025-12-27 11:59:04 +01:00
|
|
|
|
|
2026-01-01 15:27:25 +01:00
|
|
|
|
async def _handle_status_command(self) -> None:
|
2025-12-28 19:59:31 +01:00
|
|
|
|
state = self.app_handler.load_state()
|
2025-12-27 11:59:04 +01:00
|
|
|
|
autopilot = state.get("autopilot", False)
|
2026-01-02 13:41:21 +01:00
|
|
|
|
monitoring = state.get("monitoring_enabled", True)
|
2025-12-28 19:59:31 +01:00
|
|
|
|
applications = self.app_handler.load_applications()
|
2026-01-02 13:41:21 +01:00
|
|
|
|
|
|
|
|
|
|
status = "<b>Monitoring:</b> " + ("▶️ RUNNING" if monitoring else "⏸️ PAUSED")
|
|
|
|
|
|
status += "\n<b>Autopilot:</b> " + ("✅ ON" if autopilot else "🛑 OFF")
|
2025-12-27 11:59:04 +01:00
|
|
|
|
status += f"\n📝 <b>Applications sent:</b> {len(applications)}"
|
2026-01-02 13:41:21 +01:00
|
|
|
|
|
2026-01-01 15:27:25 +01:00
|
|
|
|
by_company: dict[str, int] = {}
|
2025-12-27 11:59:04 +01:00
|
|
|
|
for app in applications.values():
|
|
|
|
|
|
company = app.get("company", "unknown")
|
|
|
|
|
|
by_company[company] = by_company.get(company, 0) + 1
|
|
|
|
|
|
if by_company:
|
|
|
|
|
|
status += "\n\n<b>By company:</b>"
|
|
|
|
|
|
for company, count in sorted(by_company.items()):
|
|
|
|
|
|
status += f"\n • {company}: {count}"
|
2025-12-31 16:06:42 +01:00
|
|
|
|
await self._send_message(status)
|
2025-12-27 11:59:04 +01:00
|
|
|
|
|
2026-01-01 15:27:25 +01:00
|
|
|
|
async def _handle_plot_command(self) -> None:
|
2025-12-29 22:46:10 +01:00
|
|
|
|
logger.info("Generating listing times plot...")
|
|
|
|
|
|
try:
|
|
|
|
|
|
plot_path = self.app_handler._generate_weekly_plot()
|
2025-12-31 16:06:42 +01:00
|
|
|
|
await self._send_photo(plot_path, "\U0001f4ca <b>Weekly Listing Patterns</b>\n\nThis shows when new listings typically appear throughout the week.")
|
2025-12-29 22:46:10 +01:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Error generating plot: {e}")
|
|
|
|
|
|
import traceback
|
|
|
|
|
|
logger.error(traceback.format_exc())
|
2025-12-31 16:06:42 +01:00
|
|
|
|
await self._send_message(f"\u274c Error generating plot: {str(e)}")
|
2025-12-27 11:59:04 +01:00
|
|
|
|
|
2026-01-01 15:27:25 +01:00
|
|
|
|
async def _handle_error_rate_command(self) -> None:
|
2025-12-27 11:59:04 +01:00
|
|
|
|
logger.info("Generating autopilot errorrate plot...")
|
|
|
|
|
|
try:
|
2025-12-28 19:59:31 +01:00
|
|
|
|
plot_path, summary = self.app_handler._generate_error_rate_plot()
|
2025-12-29 22:46:10 +01:00
|
|
|
|
caption = "📉 <b>Autopilot Success vs Failure</b>\n\n" + summary
|
2025-12-31 16:06:42 +01:00
|
|
|
|
await self._send_photo(plot_path, caption)
|
2025-12-27 11:59:04 +01:00
|
|
|
|
except Exception as e:
|
2025-12-28 19:59:31 +01:00
|
|
|
|
logger.error(f"Error generating errorrate plot: {e}")
|
|
|
|
|
|
import traceback
|
|
|
|
|
|
logger.error(traceback.format_exc())
|
2026-01-02 11:23:35 +01:00
|
|
|
|
await self._send_message(f"[ERROR] Error generating errorrate plot: {str(e)}")
|
2025-12-28 19:59:31 +01:00
|
|
|
|
|
2025-12-27 11:59:04 +01:00
|
|
|
|
|
2026-01-01 15:27:25 +01:00
|
|
|
|
async def _send_message(self, text: str) -> None:
|
|
|
|
|
|
"""Send a text message to the configured Telegram chat, with retry logic and detailed error logging (async)."""
|
2025-12-31 16:06:42 +01:00
|
|
|
|
MAX_LENGTH = 4096 # Telegram message character limit
|
2025-12-27 11:59:04 +01:00
|
|
|
|
if not self.bot_token or not self.chat_id:
|
|
|
|
|
|
logger.warning("Telegram bot token or chat ID not configured, cannot send message")
|
|
|
|
|
|
return
|
2026-01-01 15:27:25 +01:00
|
|
|
|
|
2026-01-02 11:23:35 +01:00
|
|
|
|
# Clean text: remove invalid unicode surrogates
|
|
|
|
|
|
text = text.encode('utf-8', errors='ignore').decode('utf-8')
|
|
|
|
|
|
|
2025-12-27 11:59:04 +01:00
|
|
|
|
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
|
2026-01-01 15:27:25 +01:00
|
|
|
|
|
2025-12-31 16:06:42 +01:00
|
|
|
|
# Split message into chunks if too long
|
2026-01-01 15:27:25 +01:00
|
|
|
|
messages: list[str] = []
|
2025-12-31 16:06:42 +01:00
|
|
|
|
if isinstance(text, str) and len(text) > MAX_LENGTH:
|
|
|
|
|
|
# Try to split on line breaks for readability
|
|
|
|
|
|
lines = text.split('\n')
|
|
|
|
|
|
chunk = ""
|
|
|
|
|
|
for line in lines:
|
|
|
|
|
|
if len(chunk) + len(line) + 1 > MAX_LENGTH:
|
|
|
|
|
|
messages.append(chunk)
|
|
|
|
|
|
chunk = line
|
|
|
|
|
|
else:
|
|
|
|
|
|
if chunk:
|
|
|
|
|
|
chunk += "\n"
|
|
|
|
|
|
chunk += line
|
|
|
|
|
|
if chunk:
|
|
|
|
|
|
messages.append(chunk)
|
|
|
|
|
|
else:
|
|
|
|
|
|
messages = [text]
|
2026-01-01 15:27:25 +01:00
|
|
|
|
|
|
|
|
|
|
max_retries = 3
|
|
|
|
|
|
retry_delay = 1 # Initial delay in seconds
|
|
|
|
|
|
|
2025-12-27 11:59:04 +01:00
|
|
|
|
try:
|
2026-01-01 15:27:25 +01:00
|
|
|
|
client = await self._get_http_client()
|
|
|
|
|
|
for idx, msg in enumerate(messages):
|
|
|
|
|
|
payload = {"chat_id": self.chat_id, "text": msg, "parse_mode": "HTML"}
|
|
|
|
|
|
|
|
|
|
|
|
# Retry logic for each message chunk
|
|
|
|
|
|
for attempt in range(max_retries):
|
|
|
|
|
|
try:
|
|
|
|
|
|
response = await client.post(url, json=payload)
|
|
|
|
|
|
if response.is_success:
|
|
|
|
|
|
logger.info(f"[TELEGRAM] Sent message part {idx+1}/{len(messages)}: status={response.status_code}")
|
|
|
|
|
|
break
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.warning(f"[TELEGRAM] Failed attempt {attempt+1}/{max_retries}: {response.status_code}")
|
|
|
|
|
|
if attempt < max_retries - 1:
|
|
|
|
|
|
wait_time = retry_delay * (2 ** attempt)
|
|
|
|
|
|
await asyncio.sleep(wait_time)
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.error(f"Failed to send Telegram message after {max_retries} attempts: {response.text}")
|
|
|
|
|
|
except httpx.TimeoutException as e:
|
|
|
|
|
|
logger.warning(f"[TELEGRAM] Timeout on attempt {attempt+1}/{max_retries}")
|
|
|
|
|
|
if attempt < max_retries - 1:
|
|
|
|
|
|
wait_time = retry_delay * (2 ** attempt)
|
|
|
|
|
|
await asyncio.sleep(wait_time)
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.error(f"Telegram message timed out after {max_retries} attempts")
|
|
|
|
|
|
except httpx.RequestError as e:
|
|
|
|
|
|
logger.warning(f"[TELEGRAM] Network error on attempt {attempt+1}/{max_retries}: {e}")
|
|
|
|
|
|
if attempt < max_retries - 1:
|
|
|
|
|
|
wait_time = retry_delay * (2 ** attempt)
|
|
|
|
|
|
await asyncio.sleep(wait_time)
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.error(f"Telegram message failed after {max_retries} attempts: {e}")
|
2025-12-27 11:59:04 +01:00
|
|
|
|
except Exception as e:
|
2026-01-01 15:27:25 +01:00
|
|
|
|
logger.error(f"Unexpected error while sending Telegram message: {e}")
|
2025-12-27 11:59:04 +01:00
|
|
|
|
|
2026-01-01 15:27:25 +01:00
|
|
|
|
async def _send_photo(self, photo_path: str, caption: str) -> None:
|
|
|
|
|
|
"""Send a photo to the configured Telegram chat with retry logic (async)."""
|
2025-12-27 11:59:04 +01:00
|
|
|
|
if not self.bot_token or not self.chat_id:
|
|
|
|
|
|
logger.warning("Telegram bot token or chat ID not configured, cannot send photo")
|
|
|
|
|
|
return
|
2026-01-01 15:27:25 +01:00
|
|
|
|
|
2026-01-02 11:23:35 +01:00
|
|
|
|
# Clean caption: remove invalid unicode surrogates
|
|
|
|
|
|
caption = caption.encode('utf-8', errors='ignore').decode('utf-8')
|
|
|
|
|
|
|
2025-12-27 11:59:04 +01:00
|
|
|
|
url = f"https://api.telegram.org/bot{self.bot_token}/sendPhoto"
|
2026-01-01 15:27:25 +01:00
|
|
|
|
max_retries = 3
|
|
|
|
|
|
retry_delay = 1 # Initial delay in seconds
|
|
|
|
|
|
|
|
|
|
|
|
for attempt in range(max_retries):
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(photo_path, "rb") as photo:
|
|
|
|
|
|
files = {"photo": (photo_path, photo, "image/jpeg")}
|
|
|
|
|
|
data = {"chat_id": self.chat_id, "caption": caption, "parse_mode": "HTML"}
|
|
|
|
|
|
client = await self._get_http_client()
|
2025-12-31 16:06:42 +01:00
|
|
|
|
response = await client.post(url, data=data, files=files)
|
2026-01-01 15:27:25 +01:00
|
|
|
|
|
|
|
|
|
|
if response.is_success:
|
|
|
|
|
|
logger.info(f"[TELEGRAM] Sent photo: {photo_path}")
|
|
|
|
|
|
return
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.warning(f"[TELEGRAM] Photo send attempt {attempt+1}/{max_retries} failed: {response.status_code}")
|
|
|
|
|
|
if attempt < max_retries - 1:
|
|
|
|
|
|
wait_time = retry_delay * (2 ** attempt)
|
|
|
|
|
|
await asyncio.sleep(wait_time)
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.error(f"Failed to send Telegram photo after {max_retries} attempts: {response.text}")
|
|
|
|
|
|
except httpx.TimeoutException:
|
|
|
|
|
|
logger.warning(f"[TELEGRAM] Photo timeout on attempt {attempt+1}/{max_retries}")
|
|
|
|
|
|
if attempt < max_retries - 1:
|
|
|
|
|
|
wait_time = retry_delay * (2 ** attempt)
|
|
|
|
|
|
await asyncio.sleep(wait_time)
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.error(f"Telegram photo timed out after {max_retries} attempts")
|
|
|
|
|
|
except httpx.RequestError as e:
|
|
|
|
|
|
logger.warning(f"[TELEGRAM] Network error on attempt {attempt+1}/{max_retries}: {e}")
|
|
|
|
|
|
if attempt < max_retries - 1:
|
|
|
|
|
|
wait_time = retry_delay * (2 ** attempt)
|
|
|
|
|
|
await asyncio.sleep(wait_time)
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.error(f"Telegram photo failed after {max_retries} attempts: {e}")
|
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
|
logger.error(f"Photo file not found: {photo_path}")
|
|
|
|
|
|
return # No point retrying if file doesn't exist
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Unexpected error while sending Telegram photo: {e}")
|
|
|
|
|
|
if attempt < max_retries - 1:
|
|
|
|
|
|
wait_time = retry_delay * (2 ** attempt)
|
|
|
|
|
|
await asyncio.sleep(wait_time)
|
|
|
|
|
|
else:
|
|
|
|
|
|
return
|