This commit is contained in:
Aron Petau 2026-01-01 15:27:25 +01:00
parent d596ed7e19
commit aa6626d80d
21 changed files with 1051 additions and 333 deletions

View file

@ -1,13 +1,10 @@
import os
import logging
import threading
import time
import requests
import asyncio
import httpx
# Configuration from environment
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
@ -19,7 +16,7 @@ logger = logging.getLogger(__name__)
class TelegramBot:
async def _handle_help_command(self):
async def _handle_help_command(self) -> None:
"""Send a help message with available commands."""
help_text = (
"<b>Available commands:</b>\n"
@ -33,7 +30,7 @@ class TelegramBot:
)
await self._send_message(help_text)
async def _handle_unknown_command(self, text):
async def _handle_unknown_command(self, text: str) -> None:
"""Handle unknown commands and notify the user."""
cmd = text.split()[0] if text else text
msg = (
@ -41,7 +38,7 @@ class TelegramBot:
)
await self._send_message(msg)
async def _handle_reset_listings_command(self):
async def _handle_reset_listings_command(self) -> None:
"""Move listings.json to data/old/ with a timestamp, preserving statistics and application history."""
import shutil
from datetime import datetime
@ -67,19 +64,37 @@ class TelegramBot:
logger.error(f"Error resetting listings: {e}")
await self._send_message(f"❌ Error resetting listings: {str(e)}")
def __init__(self, monitor, bot_token=None, chat_id=None, event_loop=None):
def __init__(self, monitor, bot_token: str | None = None, chat_id: str | None = None, event_loop=None) -> None:
self.monitor = monitor
self.bot_token = bot_token or TELEGRAM_BOT_TOKEN
self.chat_id = chat_id or TELEGRAM_CHAT_ID
self.last_update_id = 0
self.running = False
self.last_update_id: int = 0
self.running: bool = False
# 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()
def start(self):
# 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:
if not self.bot_token:
logger.warning("Telegram bot token not configured, commands disabled")
return
@ -88,10 +103,10 @@ class TelegramBot:
thread.start()
logger.info("Telegram command listener started")
def stop(self):
def stop(self) -> None:
self.running = False
def _poll_updates(self):
def _poll_updates(self) -> None:
while self.running:
try:
url = f"https://api.telegram.org/bot{self.bot_token}/getUpdates"
@ -109,7 +124,7 @@ class TelegramBot:
logger.error(f"Telegram polling error: {e}")
time.sleep(5)
def _handle_update(self, update):
def _handle_update(self, update: dict) -> None:
message = update.get("message", {})
text = message.get("text", "")
chat_id = str(message.get("chat", {}).get("id", ""))
@ -142,7 +157,7 @@ class TelegramBot:
elif text.startswith("/"):
asyncio.run_coroutine_threadsafe(self._handle_unknown_command(text), loop)
async def _handle_retry_failed_command(self, max_retries: int = 3):
async def _handle_retry_failed_command(self, max_retries: int = 3) -> None:
"""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:
@ -187,7 +202,7 @@ class TelegramBot:
summary += "\n\n<b>Details:</b>\n" + "\n".join(details)
await self._send_message(summary)
async def _handle_autopilot_command(self, text):
async def _handle_autopilot_command(self, text: str) -> None:
logger.info(f"Processing autopilot command: {text}")
parts = text.split()
if len(parts) < 2:
@ -204,13 +219,13 @@ class TelegramBot:
else:
await self._send_message("Usage: /autopilot on|off")
async def _handle_status_command(self):
async def _handle_status_command(self) -> None:
state = self.app_handler.load_state()
autopilot = state.get("autopilot", False)
applications = self.app_handler.load_applications()
status = "🤖 <b>Autopilot:</b> " + ("ON ✅" if autopilot else "OFF ❌")
status += f"\n📝 <b>Applications sent:</b> {len(applications)}"
by_company = {}
by_company: dict[str, int] = {}
for app in applications.values():
company = app.get("company", "unknown")
by_company[company] = by_company.get(company, 0) + 1
@ -220,7 +235,7 @@ class TelegramBot:
status += f"\n{company}: {count}"
await self._send_message(status)
async def _handle_plot_command(self):
async def _handle_plot_command(self) -> None:
logger.info("Generating listing times plot...")
try:
plot_path = self.app_handler._generate_weekly_plot()
@ -231,7 +246,7 @@ class TelegramBot:
logger.error(traceback.format_exc())
await self._send_message(f"\u274c Error generating plot: {str(e)}")
async def _handle_error_rate_command(self):
async def _handle_error_rate_command(self) -> None:
logger.info("Generating autopilot errorrate plot...")
try:
plot_path, summary = self.app_handler._generate_error_rate_plot()
@ -244,16 +259,17 @@ class TelegramBot:
await self._send_message(f"❌ Error generating errorrate plot: {str(e)}")
async def _send_message(self, text):
"""Send a text message to the configured Telegram chat, with detailed error logging (async)."""
import httpx
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)."""
MAX_LENGTH = 4096 # Telegram message character limit
if not self.bot_token or not self.chat_id:
logger.warning("Telegram bot token or chat ID not configured, cannot send message")
return
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
# Split message into chunks if too long
messages = []
messages: list[str] = []
if isinstance(text, str) and len(text) > MAX_LENGTH:
# Try to split on line breaks for readability
lines = text.split('\n')
@ -270,31 +286,95 @@ class TelegramBot:
messages.append(chunk)
else:
messages = [text]
try:
async with httpx.AsyncClient(timeout=10) as client:
for idx, msg in enumerate(messages):
payload = {"chat_id": self.chat_id, "text": msg, "parse_mode": "HTML"}
response = await client.post(url, json=payload)
logger.info(f"[TELEGRAM] Sent message part {idx+1}/{len(messages)}: status={response.status_code}, ok={response.is_success}")
if not response.is_success:
logger.error(f"Failed to send Telegram message: {response.text}")
except Exception as e:
logger.error(f"Error while sending Telegram message: {e}")
async def _send_photo(self, photo_path, caption):
"""Send a photo to the configured Telegram chat (async)."""
import httpx
max_retries = 3
retry_delay = 1 # Initial delay in seconds
try:
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}")
except Exception as e:
logger.error(f"Unexpected error while sending Telegram message: {e}")
async def _send_photo(self, photo_path: str, caption: str) -> None:
"""Send a photo to the configured Telegram chat with retry logic (async)."""
if not self.bot_token or not self.chat_id:
logger.warning("Telegram bot token or chat ID not configured, cannot send photo")
return
url = f"https://api.telegram.org/bot{self.bot_token}/sendPhoto"
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"}
async with httpx.AsyncClient(timeout=10) as client:
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()
response = await client.post(url, data=data, files=files)
if not response.is_success:
logger.error(f"Failed to send Telegram photo: {response.text}")
except Exception as e:
logger.error(f"Error while sending Telegram photo: {e}")
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