prod
This commit is contained in:
parent
d596ed7e19
commit
aa6626d80d
21 changed files with 1051 additions and 333 deletions
172
telegram_bot.py
172
telegram_bot.py
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue