ea286e117d
Deploy to Server / deploy (push) Has been cancelled
- Parse school diary calendar HTML table for daily/weekly/monthly views - Cache diary data with 5-minute TTL - Inline keyboard for today/tomorrow/week/month selection - Ukrainian month/weekday names and formatting
938 lines
42 KiB
Python
938 lines
42 KiB
Python
import os
|
||
import re
|
||
import logging
|
||
import redis
|
||
import json
|
||
import time
|
||
from datetime import datetime, timedelta
|
||
|
||
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, ChatMember
|
||
from telegram.constants import ChatType
|
||
from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes
|
||
from playwright.async_api import async_playwright
|
||
|
||
# Logger
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format='%(asctime)s - %(levelname)s - %(message)s'
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Suppress HTTP request logs
|
||
logging.getLogger('urllib3').setLevel(logging.WARNING)
|
||
logging.getLogger('httpx').setLevel(logging.WARNING)
|
||
logging.getLogger('telegram.ext._application').setLevel(logging.WARNING)
|
||
|
||
# Load environment variables
|
||
def _env(key, default=None):
|
||
v = os.getenv(key, default)
|
||
if isinstance(v, str) and len(v) >= 2 and ((v[0] == '"' and v[-1] == '"') or (v[0] == "'" and v[-1] == "'")):
|
||
return v[1:-1]
|
||
return v
|
||
|
||
EDU_BASE = _env('EDU_URL_BASE', 'https://edu.edu.vn.ua')
|
||
EDU_WEBINAR_PATH = _env('EDU_URL_WEBINAR', '/webinar/useractive')
|
||
WEBINAR_URL = f"{EDU_BASE.rstrip('/')}/{EDU_WEBINAR_PATH.lstrip('/')}"
|
||
DIARY_URL = f"{EDU_BASE.rstrip('/')}/user/diary"
|
||
|
||
WEBINAR_CHECK_INTERVAL = int(_env('WEBINAR_CHECK_INTERVAL', 60))
|
||
REDIS_HOST = _env('REDIS_HOST', 'redis')
|
||
REDIS_PORT = int(_env('REDIS_PORT', 6379))
|
||
PLAYWRIGHT_WS = _env('PLAYWRIGHT_WS', 'ws://playwright-service:3000/ws')
|
||
USER_AGENT = _env('USER_AGENT', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36')
|
||
WEBINAR_TELEGRAM_TOKEN = _env('WEBINAR_TELEGRAM_TOKEN')
|
||
ADMIN_ID = int(_env('WEBINAR_ADMIN_ID', '0'))
|
||
|
||
# Redis Keys
|
||
KEY_WHITELIST = "bot:whitelist"
|
||
KEY_WHITELIST_ENABLED = "bot:whitelist_enabled"
|
||
KEY_SUBSCRIBERS = "bot:subscribers"
|
||
KEY_PHPSESSID = "EDU_PHPSESSID"
|
||
KEY_WEBINAR_HISTORY = "bot:webinar_history" # Stores last 3 webinars
|
||
|
||
# Initialize Redis
|
||
try:
|
||
redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)
|
||
redis_client.ping()
|
||
logger.info(f"Connected to Redis at {REDIS_HOST}:{REDIS_PORT}")
|
||
except Exception as e:
|
||
logger.error(f"Failed to connect to Redis: {e}")
|
||
exit(1)
|
||
|
||
# --- Translations ---
|
||
|
||
TRANSLATIONS = {
|
||
'ru': {
|
||
'welcome': "👋 Привет, {name}!\n\nЯ бот-уведомитель о вебинарах. Я буду сообщать вам, когда появится новый вебинар.\nВы подписаны на уведомления.",
|
||
'welcome_admin': "\n\n👑 <b>Режим администратора активен</b>",
|
||
'access_denied': "⛔ Доступ запрещен. Вас нет в белом списке.",
|
||
'help_title': "🤖 <b>Помощь по боту</b>\n\n",
|
||
'help_commands': "/start - Подписаться на уведомления\n/stop - Отписаться от уведомлений\n/help - Показать это сообщение\n/language - Сменить язык",
|
||
'help_admin': "\n<b>Команды администратора:</b>\n/adduser [user_id] - Добавить пользователя в белый список\n/removeuser [user_id] - Удалить пользователя из белого списка\nИли используйте панель ниже для управления настройками.",
|
||
'admin_only': "⛔ Только для администратора!",
|
||
'user_added': "✅ Пользователь {user_id} добавлен в белый список",
|
||
'user_removed': "✅ Пользователь {user_id} удален из белого списка",
|
||
'user_not_in_whitelist': "⚠️ Пользователь {user_id} не был в белом списке",
|
||
'cannot_remove_admin': "❌ Невозможно удалить администратора из белого списка",
|
||
'invalid_user_id': "❌ Неверный ID пользователя. Должно быть число.",
|
||
'usage_adduser': "Использование: /adduser [user_id]",
|
||
'usage_removeuser': "Использование: /removeuser [user_id]",
|
||
'whitelist_enabled': "✅ Белый список включен",
|
||
'whitelist_disabled': "✅ Белый список отключен",
|
||
'whitelist_title': "📋 <b>Белый список:</b>\n",
|
||
'subscribers_title': "👥 <b>Подписчики:</b>\n",
|
||
'empty': "Пусто",
|
||
'force_check_running': "🔄 Запускаю проверку...",
|
||
'check_failed': "❌ Проверка не удалась. Смотрите логи.",
|
||
'check_completed_none': "✅ Проверка завершена. Вебинаров не найдено.",
|
||
'check_completed': "✅ Проверка завершена. Найдено {count} вебинар(ов)!",
|
||
'toggle_whitelist_disable': "🔒 Отключить белый список",
|
||
'toggle_whitelist_enable': "🔓 Включить белый список",
|
||
'view_whitelist': "📋 Посмотреть белый список",
|
||
'view_subscribers': "👥 Посмотреть подписчиков",
|
||
'force_check': "🔄 Принудительная проверка",
|
||
'webinar_found': "🎓 <b>Новый вебинар!</b>\n\n",
|
||
'webinar_item': "📌 <b>{name}</b>\n🔗 https://edu.edu.vn.ua{url}",
|
||
'select_language': "🌐 <b>Выберите язык / Оберіть мову / Select language:</b>",
|
||
'language_changed': "✅ Язык изменен на русский",
|
||
'flag_ru': "🇷🇺 Русский",
|
||
'flag_uk': "🇺🇦 Українська",
|
||
'flag_en': "🇬🇧 English",
|
||
'history_cleared': "✅ История вебинаров очищена",
|
||
'history_clear_failed': "❌ Ошибка при очистке истории",
|
||
},
|
||
'uk': {
|
||
'welcome': "👋 Привіт, {name}!\n\nЯ бот-сповіщувач про вебінари. Я повідомлятиму вас, коли з'явиться новий вебінар.\nВи підписані на сповіщення.",
|
||
'welcome_admin': "\n\n👑 <b>Режим адміністратора активний</b>",
|
||
'access_denied': "⛔ Доступ заборонено. Вас немає в білому списку.",
|
||
'help_title': "🤖 <b>Довідка по боту</b>\n\n",
|
||
'help_commands': "/start - Підписатися на сповіщення\n/stop - Відписатися від сповіщень\n/help - Показати це повідомлення\n/language - Змінити мову",
|
||
'help_admin': "\n<b>Команди адміністратора:</b>\n/adduser [user_id] - Додати користувача до білого списку\n/removeuser [user_id] - Видалити користувача з білого списку\nАбо використовуйте панель нижче для керування налаштуваннями.",
|
||
'admin_only': "⛔ Тільки для адміністратора!",
|
||
'user_added': "✅ Користувач {user_id} доданий до білого списку",
|
||
'user_removed': "✅ Користувач {user_id} видалений з білого списку",
|
||
'user_not_in_whitelist': "⚠️ Користувач {user_id} не був у білому списку",
|
||
'cannot_remove_admin': "❌ Неможливо видалити адміністратора з білого списку",
|
||
'invalid_user_id': "❌ Невірний ID користувача. Має бути число.",
|
||
'usage_adduser': "Використання: /adduser [user_id]",
|
||
'usage_removeuser': "Використання: /removeuser [user_id]",
|
||
'whitelist_enabled': "✅ Білий список увімкнено",
|
||
'whitelist_disabled': "✅ Білий список вимкнено",
|
||
'whitelist_title': "📋 <b>Білий список:</b>\n",
|
||
'subscribers_title': "👥 <b>Підписники:</b>\n",
|
||
'empty': "Порожньо",
|
||
'force_check_running': "🔄 Запускаю перевірку...",
|
||
'check_failed': "❌ Перевірка не вдалася. Дивіться логи.",
|
||
'check_completed_none': "✅ Перевірка завершена. Вебінарів не знайдено.",
|
||
'check_completed': "✅ Перевірка завершена. Знайдено {count} вебінар(ів)!",
|
||
'toggle_whitelist_disable': "🔒 Вимкнути білий список",
|
||
'toggle_whitelist_enable': "🔓 Увімкнути білий список",
|
||
'view_whitelist': "📋 Переглянути білий список",
|
||
'view_subscribers': "👥 Переглянути підписників",
|
||
'force_check': "🔄 Примусова перевірка",
|
||
'webinar_found': "🎓 <b>Новий вебінар!</b>\n\n",
|
||
'webinar_item': "📌 <b>{name}</b>\n🔗 https://edu.edu.vn.ua{url}",
|
||
'select_language': "🌐 <b>Виберіть мову / Выберите язык / Select language:</b>",
|
||
'language_changed': "✅ Мову змінено на українську",
|
||
'flag_ru': "🇷🇺 Русский",
|
||
'flag_uk': "🇺🇦 Українська",
|
||
'flag_en': "🇬🇧 English",
|
||
'history_cleared': "✅ Історія вебінарів очищена",
|
||
'history_clear_failed': "❌ Помилка при очищенні історії",
|
||
},
|
||
'en': {
|
||
'welcome': "👋 Hello, {name}!\n\nI am the Webinar Checker Bot. I will notify you when a new webinar appears.\nYou have been subscribed to notifications.",
|
||
'welcome_admin': "\n\n👑 <b>Admin Mode Active</b>",
|
||
'access_denied': "⛔ Access denied. You are not on the whitelist.",
|
||
'help_title': "🤖 <b>Bot Help</b>\n\n",
|
||
'help_commands': "/start - Subscribe to notifications\n/stop - Unsubscribe from notifications\n/help - Show this message\n/language - Change language",
|
||
'help_admin': "\n<b>Admin Commands:</b>\n/adduser [user_id] - Add user to whitelist\n/removeuser [user_id] - Remove user from whitelist\nOr use the panel below to manage settings.",
|
||
'admin_only': "⛔ Admin only!",
|
||
'user_added': "✅ User {user_id} added to whitelist",
|
||
'user_removed': "✅ User {user_id} removed from whitelist",
|
||
'user_not_in_whitelist': "⚠️ User {user_id} was not in whitelist",
|
||
'cannot_remove_admin': "❌ Cannot remove admin from whitelist",
|
||
'invalid_user_id': "❌ Invalid user ID. Must be a number.",
|
||
'usage_adduser': "Usage: /adduser [user_id]",
|
||
'usage_removeuser': "Usage: /removeuser [user_id]",
|
||
'whitelist_enabled': "✅ Whitelist Enabled",
|
||
'whitelist_disabled': "✅ Whitelist Disabled",
|
||
'whitelist_title': "📋 <b>Whitelist:</b>\n",
|
||
'subscribers_title': "👥 <b>Subscribers:</b>\n",
|
||
'empty': "Empty",
|
||
'force_check_running': "🔄 Running immediate check...",
|
||
'check_failed': "❌ Check failed. See logs for details.",
|
||
'check_completed_none': "✅ Check completed. No webinars found.",
|
||
'check_completed': "✅ Check completed. Found {count} webinar(s)!",
|
||
'toggle_whitelist_disable': "🔒 Disable Whitelist",
|
||
'toggle_whitelist_enable': "🔓 Enable Whitelist",
|
||
'view_whitelist': "📋 View Whitelist",
|
||
'view_subscribers': "👥 View Subscribers",
|
||
'force_check': "🔄 Force Check",
|
||
'webinar_found': "🎓 <b>New webinar found!</b>\n\n",
|
||
'webinar_item': "📌 <b>{name}</b>\n🔗 https://edu.edu.vn.ua{url}",
|
||
'select_language': "🌐 <b>Select language / Виберіть мову / Выберите язык:</b>",
|
||
'language_changed': "✅ Language changed to English",
|
||
'flag_ru': "🇷🇺 Русский",
|
||
'flag_uk': "🇺🇦 Українська",
|
||
'flag_en': "🇬🇧 English",
|
||
'history_cleared': "✅ Webinar history cleared",
|
||
'history_clear_failed': "❌ Error clearing history",
|
||
}
|
||
}
|
||
|
||
# --- Language Helper Functions ---
|
||
|
||
def get_user_language(user_id: int) -> str:
|
||
"""Get user's preferred language from Redis. Default: Ukrainian."""
|
||
lang = redis_client.get(f"user:{user_id}:language")
|
||
return lang if lang in ['ru', 'uk', 'en'] else 'uk'
|
||
|
||
def set_user_language(user_id: int, lang: str):
|
||
"""Save user's language preference to Redis."""
|
||
if lang in ['ru', 'uk', 'en']:
|
||
redis_client.set(f"user:{user_id}:language", lang)
|
||
logger.info(f"User {user_id} language set to {lang}")
|
||
|
||
def t(user_id: int, key: str, **kwargs) -> str:
|
||
"""Translate message for user with optional formatting."""
|
||
lang = get_user_language(user_id)
|
||
message = TRANSLATIONS.get(lang, TRANSLATIONS['uk']).get(key, key)
|
||
if kwargs:
|
||
return message.format(**kwargs)
|
||
return message
|
||
|
||
def get_language_keyboard():
|
||
"""Generate language selection keyboard."""
|
||
keyboard = [
|
||
[
|
||
InlineKeyboardButton("🇷🇺 Русский", callback_data="lang_ru"),
|
||
InlineKeyboardButton("🇺🇦 Українська", callback_data="lang_uk"),
|
||
],
|
||
[
|
||
InlineKeyboardButton("🇬🇧 English", callback_data="lang_en"),
|
||
]
|
||
]
|
||
return InlineKeyboardMarkup(keyboard)
|
||
|
||
# --- Helper Functions ---
|
||
|
||
def is_whitelisted(user_id: int) -> bool:
|
||
"""Check if user is allowed to use the bot."""
|
||
if user_id == ADMIN_ID:
|
||
return True
|
||
|
||
enabled = redis_client.get(KEY_WHITELIST_ENABLED)
|
||
if enabled == "0": # Whitelist disabled
|
||
return True
|
||
|
||
return redis_client.sismember(KEY_WHITELIST, str(user_id))
|
||
|
||
async def is_group_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool:
|
||
"""Check if the user is an administrator in the group."""
|
||
user = update.effective_user
|
||
chat = update.effective_chat
|
||
|
||
if chat.type in [ChatType.PRIVATE, "private"]:
|
||
return True
|
||
|
||
try:
|
||
member = await context.bot.get_chat_member(chat.id, user.id)
|
||
return member.status in [ChatMember.OWNER, ChatMember.ADMINISTRATOR]
|
||
except Exception as e:
|
||
logger.error(f"Failed to check admin status: {e}")
|
||
return False
|
||
|
||
def get_admin_keyboard(user_id: int):
|
||
"""Generate admin panel keyboard."""
|
||
whitelist_enabled = redis_client.get(KEY_WHITELIST_ENABLED) != "0"
|
||
toggle_text = t(user_id, 'toggle_whitelist_disable') if whitelist_enabled else t(user_id, 'toggle_whitelist_enable')
|
||
|
||
keyboard = [
|
||
[InlineKeyboardButton(toggle_text, callback_data="toggle_whitelist")],
|
||
[InlineKeyboardButton(t(user_id, 'view_whitelist'), callback_data="view_whitelist")],
|
||
[InlineKeyboardButton(t(user_id, 'view_subscribers'), callback_data="view_subscribers")],
|
||
[InlineKeyboardButton(t(user_id, 'force_check'), callback_data="force_check")]
|
||
]
|
||
return InlineKeyboardMarkup(keyboard)
|
||
|
||
# --- Diary Functions ---
|
||
|
||
DIARY_MONTH_NAMES = ['', 'Січня', 'Лютого', 'Березня', 'Квітня', 'Травня', 'Червня',
|
||
'Липня', 'Серпня', 'Вересня', 'Жовтня', 'Листопада', 'Грудня']
|
||
|
||
DIARY_WEEKDAYS_SHORT = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Нд']
|
||
|
||
def get_diary_keyboard():
|
||
today = datetime.now()
|
||
keyboard = [
|
||
[
|
||
InlineKeyboardButton(f"📌 Сьогодні ({today.day}.{today.month:02d})", callback_data="diary_today"),
|
||
InlineKeyboardButton("📌 Завтра", callback_data="diary_tomorrow"),
|
||
],
|
||
[
|
||
InlineKeyboardButton("📅 Цей тиждень", callback_data="diary_week"),
|
||
InlineKeyboardButton("📅 Весь місяць", callback_data="diary_month"),
|
||
],
|
||
]
|
||
return InlineKeyboardMarkup(keyboard)
|
||
|
||
def _parse_calendar_html(table_html: str) -> tuple:
|
||
"""Parse calendar HTML table into (month_text, {day_num: {weekday, events}})."""
|
||
days = {}
|
||
weekdays = []
|
||
rows = re.findall(r'<tr[^>]*>(.*?)</tr>', table_html, re.DOTALL)
|
||
|
||
month_text = ''
|
||
for r_idx, row in enumerate(rows):
|
||
cells = re.findall(r'<t[dh][^>]*>(.*?)</t[dh]>', row, re.DOTALL)
|
||
|
||
if r_idx == 0:
|
||
# Month navigation row: extract "Травень 2026" from nav text
|
||
raw = re.sub(r'<[^>]+>', ' ', row).strip()
|
||
raw = re.sub(r'\s+', ' ', raw)
|
||
m = re.search(r'([А-Яа-яіїєґ\']+\s*:?\s*\d{4})', raw)
|
||
if m:
|
||
month_text = m.group(1).replace(' : ', ' ').strip()
|
||
else:
|
||
month_text = raw
|
||
elif r_idx == 1:
|
||
# Day names row
|
||
for cell in cells:
|
||
name = re.sub(r'<[^>]+>', '', cell).strip()
|
||
if name:
|
||
weekdays.append(name)
|
||
else:
|
||
# Data rows: each cell = a day
|
||
for col_idx, cell in enumerate(cells):
|
||
# Extract day number — first number in the cell text
|
||
text = re.sub(r'<[^>]+>', ' ', cell).strip()
|
||
text = re.sub(r'\s+', ' ', text)
|
||
dm = re.match(r'(\d+)', text)
|
||
if not dm:
|
||
continue
|
||
day_num = dm.group(1)
|
||
|
||
# Extract events: title attribute (full name) of ALL <a> tags inside the cell
|
||
events = []
|
||
for a_match in re.finditer(r'<a[^>]*>(.*?)</a>', cell, re.DOTALL):
|
||
a_tag = a_match.group(0)
|
||
# Prefer the title attribute (contains full name, not truncated)
|
||
title_m = re.search(r'title\s*=\s*"([^"]*)"', a_tag)
|
||
if title_m:
|
||
et = title_m.group(1).strip()
|
||
else:
|
||
et = re.sub(r'<[^>]+>', '', a_match.group(1)).strip()
|
||
if et:
|
||
events.append(et)
|
||
|
||
weekday = weekdays[col_idx] if col_idx < len(weekdays) else ''
|
||
days[day_num] = {'weekday': weekday, 'events': events}
|
||
|
||
return month_text, days
|
||
|
||
|
||
async def fetch_diary_data(phpsessid: str) -> dict | None:
|
||
logger.info("Fetching diary data via Playwright...")
|
||
try:
|
||
async with async_playwright() as p:
|
||
browser = await p.chromium.connect(PLAYWRIGHT_WS)
|
||
try:
|
||
context_browser = await browser.new_context(user_agent=USER_AGENT)
|
||
await context_browser.add_cookies([{
|
||
'name': 'PHPSESSID',
|
||
'value': phpsessid,
|
||
'domain': 'edu.edu.vn.ua',
|
||
'path': '/'
|
||
}])
|
||
page = await context_browser.new_page()
|
||
|
||
try:
|
||
await page.goto(DIARY_URL, wait_until='domcontentloaded')
|
||
await page.wait_for_selector('table.calendar', timeout=10000)
|
||
await page.wait_for_timeout(1500)
|
||
|
||
table_html = await page.evaluate("""
|
||
() => {
|
||
const t = document.querySelector('table.calendar');
|
||
return t ? t.outerHTML : null;
|
||
}
|
||
""")
|
||
if not table_html:
|
||
logger.error("table.calendar not found in DOM")
|
||
return None
|
||
|
||
# Debug: save HTML for troubleshooting
|
||
try:
|
||
with open('/tmp/diary_debug.html', 'w', encoding='utf-8') as f:
|
||
f.write(table_html)
|
||
except Exception:
|
||
pass
|
||
|
||
month_text, days = _parse_calendar_html(table_html)
|
||
logger.info(f"Diary parsed: month={month_text!r}, days_with_events={sum(1 for d in days.values() if d['events'])}/{len(days)}")
|
||
|
||
return {'monthFullText': month_text, 'days': days}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error parsing diary: {e}")
|
||
return None
|
||
finally:
|
||
await page.close()
|
||
await context_browser.close()
|
||
finally:
|
||
await browser.close()
|
||
except Exception as e:
|
||
logger.error(f"Playwright error in diary fetch: {e}")
|
||
return None
|
||
|
||
def _parse_diary_month(text: str) -> str:
|
||
match = re.search(r'([А-Яа-яіїєґ\']+\s*:\s*\d{4})', text)
|
||
if match:
|
||
return match.group(1).replace(' : ', ' ').strip()
|
||
return text.strip()
|
||
|
||
def format_diary_day(data: dict, day_num: int) -> str:
|
||
days = data.get('days', {})
|
||
month_str = _parse_diary_month(data.get('monthFullText', ''))
|
||
day_data = days.get(str(day_num))
|
||
lines = [f"📅 <b>{day_num} {month_str}</b>", "─" * 18]
|
||
if not day_data or not day_data.get('events'):
|
||
lines.append("Немає подій")
|
||
else:
|
||
for e in day_data['events']:
|
||
lines.append(f"📌 {e}")
|
||
lines.append(f"\n🔗 {DIARY_URL}")
|
||
return "\n".join(lines)
|
||
|
||
def format_diary_week(data: dict, today: datetime) -> str:
|
||
days = data.get('days', {})
|
||
month_str = _parse_diary_month(data.get('monthFullText', ''))
|
||
monday = today - timedelta(days=today.weekday())
|
||
sunday = monday + timedelta(days=6)
|
||
lines = [f"📅 <b>Тиждень {monday.day}.{monday.month} – {sunday.day}.{sunday.month}</b>\n"]
|
||
for i in range(7):
|
||
d = monday + timedelta(days=i)
|
||
day_data = days.get(str(d.day))
|
||
lines.append(f"─ <b>{DIARY_WEEKDAYS_SHORT[i]} {d.day}.{d.month}</b> ─")
|
||
if not day_data or not day_data.get('events'):
|
||
lines.append("Немає подій\n")
|
||
else:
|
||
for e in day_data['events']:
|
||
lines.append(f"📌 {e}")
|
||
lines.append("")
|
||
lines.append(f"🔗 {DIARY_URL}")
|
||
return "\n".join(lines)
|
||
|
||
def format_diary_month(data: dict) -> str:
|
||
days = data.get('days', {})
|
||
month_str = _parse_diary_month(data.get('monthFullText', ''))
|
||
lines = [f"📅 <b>{month_str}</b>\n"]
|
||
for day_num in sorted(days.keys(), key=int):
|
||
day_data = days[day_num]
|
||
events = day_data.get('events', [])
|
||
weekday = day_data.get('weekday', '')
|
||
lines.append(f"─ <b>{weekday} {day_num}</b> ─")
|
||
if not events:
|
||
lines.append("Немає подій\n")
|
||
else:
|
||
for e in events:
|
||
lines.append(f"📌 {e}")
|
||
lines.append("")
|
||
lines.append(f"🔗 {DIARY_URL}")
|
||
return "\n".join(lines)
|
||
|
||
async def _get_diary_data(context: ContextTypes.DEFAULT_TYPE) -> dict | None:
|
||
cached = context.user_data.get('diary_cache')
|
||
now_ts = time.time()
|
||
if cached and (now_ts - cached.get('timestamp', 0)) < 300:
|
||
return cached['data']
|
||
phpsessid = redis_client.get(KEY_PHPSESSID)
|
||
if not phpsessid:
|
||
return None
|
||
data = await fetch_diary_data(phpsessid)
|
||
if data:
|
||
context.user_data['diary_cache'] = {'data': data, 'timestamp': now_ts}
|
||
return data
|
||
|
||
# --- Command Handlers ---
|
||
|
||
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""Handle /start command."""
|
||
user = update.effective_user
|
||
chat = update.effective_chat
|
||
logger.info(f"User {user.id} ({user.username}) started the bot in chat {chat.id} ({chat.type}).")
|
||
|
||
# Check whitelist - MUST be the user executing the command
|
||
if not is_whitelisted(user.id):
|
||
await update.message.reply_text(t(user.id, 'access_denied'))
|
||
return
|
||
|
||
# Add to subscribers (Chat ID!)
|
||
redis_client.sadd(KEY_SUBSCRIBERS, chat.id)
|
||
|
||
msg = t(chat.id, 'welcome', name=user.first_name)
|
||
|
||
if user.id == ADMIN_ID and chat.type == "private":
|
||
msg += t(chat.id, 'welcome_admin')
|
||
await update.message.reply_text(msg, parse_mode='HTML', reply_markup=get_admin_keyboard(user.id))
|
||
else:
|
||
await update.message.reply_text(msg, parse_mode='HTML')
|
||
|
||
async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""Handle /help command."""
|
||
user_id = update.effective_user.id
|
||
chat_id = update.effective_chat.id
|
||
msg = t(chat_id, 'help_title') + t(chat_id, 'help_commands')
|
||
|
||
if user_id == ADMIN_ID and update.effective_chat.type == "private":
|
||
msg += t(chat_id, 'help_admin')
|
||
await update.message.reply_text(msg, parse_mode='HTML', reply_markup=get_admin_keyboard(user_id))
|
||
else:
|
||
await update.message.reply_text(msg, parse_mode='HTML')
|
||
|
||
async def stop_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""Handle /stop command (unsubscribe)."""
|
||
user = update.effective_user
|
||
chat = update.effective_chat
|
||
|
||
# Permission check: Whitelisted user OR Group Admin
|
||
if not (is_whitelisted(user.id) or await is_group_admin(update, context)):
|
||
await update.message.reply_text(t(chat.id, 'access_denied')) # Or specific "admin only" message
|
||
return
|
||
|
||
redis_client.srem(KEY_SUBSCRIBERS, chat.id)
|
||
await update.message.reply_text(t(chat.id, 'whitelist_disabled').replace(" whitelist", " notifications").replace("Білий список", "Сповіщення").replace("Белый список", "Уведомления") if chat.id else "Unsubscribed")
|
||
|
||
async def language_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""Handle /language command."""
|
||
user = update.effective_user
|
||
chat = update.effective_chat
|
||
|
||
# Permission check for groups
|
||
if not (is_whitelisted(user.id) or await is_group_admin(update, context)):
|
||
return
|
||
|
||
await update.message.reply_text(
|
||
t(chat.id, 'select_language'),
|
||
parse_mode='HTML',
|
||
reply_markup=get_language_keyboard()
|
||
)
|
||
|
||
async def add_user(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""Add user to whitelist (admin only)."""
|
||
admin_id = update.effective_user.id
|
||
if admin_id != ADMIN_ID:
|
||
await update.message.reply_text(t(admin_id, 'admin_only'))
|
||
return
|
||
|
||
if not context.args:
|
||
await update.message.reply_text(t(admin_id, 'usage_adduser'))
|
||
return
|
||
|
||
try:
|
||
user_id = int(context.args[0])
|
||
redis_client.sadd(KEY_WHITELIST, str(user_id))
|
||
await update.message.reply_text(t(admin_id, 'user_added', user_id=user_id))
|
||
logger.info(f"Admin added user {user_id} to whitelist")
|
||
except ValueError:
|
||
await update.message.reply_text(t(admin_id, 'invalid_user_id'))
|
||
|
||
async def remove_user(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""Remove user from whitelist (admin only)."""
|
||
admin_id = update.effective_user.id
|
||
if admin_id != ADMIN_ID:
|
||
await update.message.reply_text(t(admin_id, 'admin_only'))
|
||
return
|
||
|
||
if not context.args:
|
||
await update.message.reply_text(t(admin_id, 'usage_removeuser'))
|
||
return
|
||
|
||
try:
|
||
user_id = int(context.args[0])
|
||
if str(user_id) == str(ADMIN_ID):
|
||
await update.message.reply_text(t(admin_id, 'cannot_remove_admin'))
|
||
return
|
||
|
||
removed = redis_client.srem(KEY_WHITELIST, str(user_id))
|
||
if removed:
|
||
await update.message.reply_text(t(admin_id, 'user_removed', user_id=user_id))
|
||
logger.info(f"Admin removed user {user_id} from whitelist")
|
||
else:
|
||
await update.message.reply_text(t(admin_id, 'user_not_in_whitelist', user_id=user_id))
|
||
except ValueError:
|
||
await update.message.reply_text(t(admin_id, 'invalid_user_id'))
|
||
|
||
async def clear_history(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""Clear webinar history (admin only)."""
|
||
admin_id = update.effective_user.id
|
||
if admin_id != ADMIN_ID:
|
||
await update.message.reply_text(t(admin_id, 'admin_only'))
|
||
return
|
||
|
||
try:
|
||
redis_client.delete(KEY_WEBINAR_HISTORY)
|
||
await update.message.reply_text(t(admin_id, 'history_cleared'))
|
||
logger.info("Admin cleared webinar history")
|
||
except Exception as e:
|
||
logger.error(f"Failed to clear history: {e}")
|
||
await update.message.reply_text(t(admin_id, 'history_clear_failed'))
|
||
|
||
async def diary_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
user = update.effective_user
|
||
chat = update.effective_chat
|
||
if not is_whitelisted(user.id):
|
||
await update.message.reply_text(t(user.id, 'access_denied'))
|
||
return
|
||
await update.message.reply_text(
|
||
"📅 <b>Щоденник</b> — виберіть період:",
|
||
parse_mode='HTML',
|
||
reply_markup=get_diary_keyboard()
|
||
)
|
||
|
||
async def diary_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
query = update.callback_query
|
||
user_id = query.from_user.id
|
||
await query.answer()
|
||
|
||
if user_id != ADMIN_ID:
|
||
if not is_whitelisted(user_id):
|
||
await query.edit_message_text("⛔ Доступ заборонено.")
|
||
return
|
||
|
||
data = query.data
|
||
if data == "diary_refresh":
|
||
context.user_data.pop('diary_cache', None)
|
||
await query.edit_message_text("🔄 Завантажую щоденник...")
|
||
diary_data = await _get_diary_data(context)
|
||
if not diary_data:
|
||
await query.edit_message_text("❌ Не вдалося завантажити щоденник. Немає сесії або помилка.")
|
||
return
|
||
await query.edit_message_text(
|
||
"📅 <b>Щоденник</b> — виберіть період:",
|
||
parse_mode='HTML',
|
||
reply_markup=get_diary_keyboard()
|
||
)
|
||
return
|
||
|
||
await query.edit_message_text("🔄 Завантажую щоденник...")
|
||
diary_data = await _get_diary_data(context)
|
||
if not diary_data:
|
||
await query.edit_message_text("❌ Не вдалося завантажити щоденник.")
|
||
return
|
||
|
||
today = datetime.now()
|
||
if data == "diary_today":
|
||
text = format_diary_day(diary_data, today.day)
|
||
elif data == "diary_tomorrow":
|
||
tomorrow = today + timedelta(days=1)
|
||
if tomorrow.day < today.day:
|
||
text = "❌ Дані за наступний місяць недоступні. Перейдіть на сайт."
|
||
else:
|
||
text = format_diary_day(diary_data, tomorrow.day)
|
||
elif data == "diary_week":
|
||
text = format_diary_week(diary_data, today)
|
||
elif data == "diary_month":
|
||
text = format_diary_month(diary_data)
|
||
else:
|
||
return
|
||
|
||
if len(text) > 4096:
|
||
text = text[:4090] + "\n\n✂️ ...(обрізано)"
|
||
|
||
await query.edit_message_text(
|
||
text,
|
||
parse_mode='HTML',
|
||
reply_markup=get_diary_keyboard()
|
||
)
|
||
|
||
# --- Admin Callbacks ---
|
||
|
||
async def admin_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""Handle admin panel button clicks."""
|
||
query = update.callback_query
|
||
user_id = query.from_user.id
|
||
|
||
if user_id != ADMIN_ID:
|
||
await query.answer(t(user_id, 'admin_only'), show_alert=True)
|
||
return
|
||
|
||
await query.answer()
|
||
data = query.data
|
||
|
||
if data == "toggle_whitelist":
|
||
current = redis_client.get(KEY_WHITELIST_ENABLED)
|
||
new_state = "0" if current != "0" else "1"
|
||
redis_client.set(KEY_WHITELIST_ENABLED, new_state)
|
||
state_text = t(user_id, 'whitelist_disabled') if new_state == "0" else t(user_id, 'whitelist_enabled')
|
||
await query.edit_message_reply_markup(reply_markup=get_admin_keyboard(user_id))
|
||
await query.message.reply_text(state_text)
|
||
|
||
elif data == "view_whitelist":
|
||
members = redis_client.smembers(KEY_WHITELIST)
|
||
msg = t(user_id, 'whitelist_title') + ("\n".join(members) if members else t(user_id, 'empty'))
|
||
await query.message.reply_text(msg, parse_mode='HTML')
|
||
|
||
elif data == "view_subscribers":
|
||
subs = redis_client.smembers(KEY_SUBSCRIBERS)
|
||
msg = t(user_id, 'subscribers_title') + ("\n".join(subs) if subs else t(user_id, 'empty'))
|
||
await query.message.reply_text(msg, parse_mode='HTML')
|
||
|
||
elif data == "force_check":
|
||
await query.message.reply_text(t(user_id, 'force_check_running'))
|
||
result = await check_webinars_job(context)
|
||
|
||
if result is None:
|
||
await query.message.reply_text(t(user_id, 'check_failed'))
|
||
elif result == 0:
|
||
await query.message.reply_text(t(user_id, 'check_completed_none'))
|
||
else:
|
||
await query.message.reply_text(t(user_id, 'check_completed', count=result))
|
||
|
||
async def language_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""Handle language selection button clicks."""
|
||
query = update.callback_query
|
||
user_id = query.from_user.id
|
||
data = query.data
|
||
|
||
if data.startswith("lang_"):
|
||
lang = data.split("_")[1]
|
||
set_user_language(user_id, lang)
|
||
await query.answer()
|
||
await query.edit_message_text(
|
||
t(user_id, 'language_changed'),
|
||
parse_mode='HTML'
|
||
)
|
||
|
||
# --- Webinar Checking Job ---
|
||
|
||
def get_webinar_key(url: str) -> str:
|
||
"""Generate unique key for a webinar based on URL."""
|
||
return url
|
||
|
||
def get_stored_webinars() -> list:
|
||
"""Get list of stored webinar keys from Redis."""
|
||
data = redis_client.get(KEY_WEBINAR_HISTORY)
|
||
if data:
|
||
try:
|
||
return json.loads(data)
|
||
except Exception as e:
|
||
logger.error(f"Failed to parse webinar history: {e}")
|
||
return []
|
||
|
||
def store_webinars(webinar_keys: list):
|
||
"""Store up to 3 most recent webinar keys in Redis."""
|
||
# Keep only last 3
|
||
webinar_keys = webinar_keys[-3:]
|
||
try:
|
||
redis_client.set(KEY_WEBINAR_HISTORY, json.dumps(webinar_keys))
|
||
logger.info(f"Stored {len(webinar_keys)} webinar(s) in history")
|
||
except Exception as e:
|
||
logger.error(f"Failed to store webinar history: {e}")
|
||
|
||
async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE):
|
||
"""Background job to check for webinars using Async Playwright.
|
||
|
||
Returns:
|
||
int: Number of webinars found, or None if check failed
|
||
"""
|
||
logger.info("Running webinar check...")
|
||
|
||
phpsessid = redis_client.get(KEY_PHPSESSID)
|
||
if not phpsessid:
|
||
logger.warning("PHPSESSID missing. Skipping check.")
|
||
# --- DEBUG LOGGING ---
|
||
try:
|
||
with open('phpsessid_missing.log', 'a') as f:
|
||
f.write(f"[{os.getcwd()}] PHPSESSID missing at {context.job.last_run: %Y-%m-%d %H:%M:%S}\n")
|
||
except Exception as e:
|
||
logger.error(f"Failed to write PHPSESSID debug log: {e}")
|
||
# ---------------------
|
||
return None
|
||
|
||
current_webinars = [] # List of dicts with name, url, and formatted text
|
||
content = ""
|
||
|
||
try:
|
||
async with async_playwright() as p:
|
||
# Connect to remote Playwright service
|
||
browser = await p.chromium.connect(PLAYWRIGHT_WS)
|
||
|
||
try:
|
||
# Create browser context with user agent
|
||
context_browser = await browser.new_context(user_agent=USER_AGENT)
|
||
|
||
# Add PHPSESSID cookie
|
||
await context_browser.add_cookies([{
|
||
'name': 'PHPSESSID',
|
||
'value': phpsessid,
|
||
'domain': 'edu.edu.vn.ua',
|
||
'path': '/'
|
||
}])
|
||
|
||
# Create new page
|
||
page = await context_browser.new_page()
|
||
|
||
try:
|
||
# Navigate to webinar page
|
||
await page.goto(WEBINAR_URL, wait_until='domcontentloaded')
|
||
|
||
# Wait for the table to load
|
||
await page.wait_for_selector('#meetings table', timeout=10000)
|
||
await page.wait_for_timeout(2000)
|
||
|
||
# Get page content
|
||
content = await page.content()
|
||
|
||
# Check if "no webinar" message is present
|
||
if "Жодного онлайн уроку зараз" not in content:
|
||
logger.info("!!! WEBINAR FOUND !!!")
|
||
|
||
# Extract webinar details from table rows
|
||
rows = page.locator('#meetings table tbody tr')
|
||
count = await rows.count()
|
||
|
||
for i in range(count):
|
||
row = rows.nth(i)
|
||
text = await row.inner_text()
|
||
|
||
if "Жодного онлайн уроку зараз" not in text:
|
||
# Extract name (topic) from first column
|
||
name_elem = row.locator('td').nth(0)
|
||
name = await name_elem.inner_text()
|
||
name = name.strip()
|
||
|
||
# Extract join URL from fourth column
|
||
url_elem = row.locator('td').nth(3).locator('a[href*="/webinar/join/"]').first
|
||
url = await url_elem.get_attribute('href')
|
||
|
||
if name and url:
|
||
current_webinars.append({
|
||
'name': name,
|
||
'url': url,
|
||
'text': text.strip()
|
||
})
|
||
logger.info(f"Found webinar: {name} -> {url}")
|
||
else:
|
||
logger.info("No webinars found (expected message present)")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error checking page: {e}. Saving content for debug.")
|
||
# If page content is available, save it on error
|
||
try:
|
||
if page and not content:
|
||
content = await page.content()
|
||
except Exception:
|
||
pass # Ignore error during content retrieval on check error
|
||
|
||
return None
|
||
finally:
|
||
await page.close()
|
||
await context_browser.close()
|
||
|
||
finally:
|
||
await browser.close()
|
||
|
||
except Exception as e:
|
||
logger.error(f"Playwright error: {e}")
|
||
return None
|
||
|
||
# --- DEBUG LOGGING (Saving last response content) ---
|
||
if not current_webinars and content: #if no webinars found, save the page content
|
||
try:
|
||
with open('response.html', 'w', encoding='utf-8') as f:
|
||
f.write(content)
|
||
logger.info("Saved page content to response.html for debug.")
|
||
except Exception as e:
|
||
logger.error(f"Failed to write debug HTML: {e}")
|
||
# -----------------------------------------------------
|
||
|
||
# Check for NEW webinars and notify
|
||
if current_webinars:
|
||
# Get stored webinar history
|
||
stored_keys = get_stored_webinars()
|
||
logger.info(f"Stored webinar keys: {stored_keys}")
|
||
|
||
# Find new webinars (not in history)
|
||
new_webinars = []
|
||
current_keys = []
|
||
|
||
for webinar in current_webinars:
|
||
key = get_webinar_key(webinar['url'])
|
||
current_keys.append(key)
|
||
|
||
if key not in stored_keys:
|
||
new_webinars.append(webinar)
|
||
logger.info(f"NEW webinar detected: {webinar['name']}")
|
||
|
||
# Update stored history with current webinars
|
||
# Merge old and new, keeping only last 5
|
||
updated_keys = stored_keys + [k for k in current_keys if k not in stored_keys]
|
||
store_webinars(updated_keys)
|
||
|
||
# Notify subscribers ONLY about NEW webinars
|
||
if new_webinars:
|
||
subscribers = redis_client.smembers(KEY_SUBSCRIBERS)
|
||
logger.info(f"Sending notification about {len(new_webinars)} new webinar(s) to {len(subscribers)} subscriber(s)")
|
||
|
||
for sub_id in subscribers:
|
||
try:
|
||
# Build message in user's language
|
||
# sub_id comes from redis set as string, convert to int for translation lookup
|
||
webinar_items = "\n\n".join([
|
||
t(int(sub_id), 'webinar_item', name=w['name'], url=w['url'])
|
||
for w in new_webinars
|
||
])
|
||
message = t(int(sub_id), 'webinar_found') + webinar_items
|
||
|
||
await context.bot.send_message(chat_id=sub_id, text=message, parse_mode='HTML')
|
||
logger.info(f"Notification sent to {sub_id}")
|
||
except Exception as e:
|
||
logger.error(f"Failed to send to {sub_id}: {e}")
|
||
else:
|
||
logger.info(f"Found {len(current_webinars)} webinar(s), but all are already known")
|
||
|
||
return len(current_webinars)
|
||
|
||
# --- Main ---
|
||
|
||
def main():
|
||
if not WEBINAR_TELEGRAM_TOKEN:
|
||
logger.error("WEBINAR_TELEGRAM_TOKEN is missing!")
|
||
return
|
||
|
||
# Set default whitelist state if not set
|
||
if not redis_client.exists(KEY_WHITELIST_ENABLED):
|
||
redis_client.set(KEY_WHITELIST_ENABLED, "1") # Enabled by default
|
||
|
||
# Add admin to whitelist
|
||
if ADMIN_ID:
|
||
redis_client.sadd(KEY_WHITELIST, str(ADMIN_ID))
|
||
|
||
app = Application.builder().token(WEBINAR_TELEGRAM_TOKEN).build()
|
||
|
||
# Handlers
|
||
app.add_handler(CommandHandler("start", start))
|
||
app.add_handler(CommandHandler("stop", stop_command))
|
||
app.add_handler(CommandHandler("help", help_command))
|
||
app.add_handler(CommandHandler("language", language_command))
|
||
app.add_handler(CommandHandler("adduser", add_user))
|
||
app.add_handler(CommandHandler("removeuser", remove_user))
|
||
app.add_handler(CommandHandler("clearhistory", clear_history))
|
||
app.add_handler(CommandHandler("diary", diary_command))
|
||
|
||
# Callback handlers - diary first, then language selection, then admin panel
|
||
app.add_handler(CallbackQueryHandler(diary_callback, pattern="^diary_"))
|
||
app.add_handler(CallbackQueryHandler(language_callback, pattern="^lang_"))
|
||
app.add_handler(CallbackQueryHandler(admin_callback))
|
||
|
||
# Job Queue
|
||
job_queue = app.job_queue
|
||
job_queue.run_repeating(check_webinars_job, interval=WEBINAR_CHECK_INTERVAL, first=10)
|
||
|
||
logger.info("Bot started polling...")
|
||
app.run_polling()
|
||
|
||
if __name__ == "__main__":
|
||
main() |