Mohammed Foud commited on
Commit
f959aff
·
1 Parent(s): b0eb5bd

Add application file

Browse files
Files changed (12) hide show
  1. .gitignore +53 -0
  2. Dockerfile +30 -0
  3. book.py +97 -0
  4. explore.py +92 -0
  5. login.py +96 -0
  6. login_failed.png +0 -0
  7. main.py +49 -0
  8. monitor.py +101 -0
  9. monitor_handlers.py +65 -0
  10. requirements.txt +4 -0
  11. telegram_bot.py +778 -0
  12. utils.py +57 -0
.gitignore ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Distribution / packaging
7
+ .Python
8
+ build/
9
+ develop-eggs/
10
+ dist/
11
+ downloads/
12
+ eggs/
13
+ .eggs/
14
+ lib/
15
+ lib64/
16
+ parts/
17
+ sdist/
18
+ var/
19
+ wheels/
20
+ *.egg-info/
21
+ .installed.cfg
22
+ *.egg
23
+
24
+ # Installer logs
25
+ pip-log.txt
26
+ pip-delete-this-directory.txt
27
+
28
+ # Unit test / coverage reports
29
+ htmlcov/
30
+ .tox/
31
+ .nox/
32
+ .coverage
33
+ *.cover
34
+ *.py,cover
35
+ .hypothesis/
36
+ .pytest_cache/
37
+
38
+ # IDE
39
+ .vscode/
40
+ .idea/
41
+ *.swp
42
+ *.swo
43
+ d.sh
44
+ exp.py
45
+ ollamafreeapi/tools.py
46
+ trash
47
+ webook_profile
48
+ webook_login.log
49
+ *.log
50
+
51
+ # Environment variables
52
+ .env
53
+ .env.*
Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use Python 3.11 as base image
2
+ FROM python:3.11-slim
3
+
4
+ # Set working directory
5
+ WORKDIR /app
6
+
7
+ # Install system dependencies required for Playwright
8
+ RUN apt-get update && apt-get install -y \
9
+ wget \
10
+ gnupg \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ # Copy requirements first to leverage Docker cache
14
+ COPY requirements.txt .
15
+
16
+ # Install Python dependencies
17
+ RUN pip install --no-cache-dir -r requirements.txt
18
+
19
+ # Install Playwright browsers
20
+ RUN playwright install chromium
21
+ RUN playwright install-deps
22
+
23
+ # Copy the rest of the application
24
+ COPY . .
25
+
26
+ # Expose port 7860
27
+ EXPOSE 7860
28
+
29
+ # Command to run the application
30
+ CMD ["python", "telegram_bot.py"]
book.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time # Import the time module
2
+ from utils import WeBookBase, logger
3
+
4
+ class WeBookBooking(WeBookBase):
5
+ def __init__(self, page, profile_dir=None):
6
+ super().__init__(profile_dir, page=page)
7
+ if not self.page:
8
+ raise ValueError("A Playwright page object must be provided to WeBookBooking")
9
+
10
+ def book_event(self, event_url):
11
+ try:
12
+ logger.info(f"Navigating to event booking page: {event_url}")
13
+ self.page.goto(event_url, wait_until='networkidle')
14
+
15
+ self._handle_cookies()
16
+
17
+ # Locate and click the '+' button for the first ticket offer
18
+ # Locate the button containing the specific '+' SVG icon
19
+ try:
20
+ # We are looking for a button that contains an SVG with the specific path
21
+ plus_button = self.page.locator('button:has(svg path[d="M7 1C7 0.447715 6.55228 0 6 0C5.44772 0 5 0.447715 5 1V5H1C0.447715 5 0 5.44772 0 6C0 6.55228 0.447715 7 1 7H5V11C5 11.5523 5.44772 12 6 12C6.55228 12 7 11.5523 7 11V7H11C11.5523 7 12 6.55228 12 6C12 5.44772 11.5523 5 11 5H7V1Z"])').first
22
+
23
+ if plus_button.is_visible():
24
+ plus_button.click()
25
+ logger.info("Clicked '+' button (SVG) for the first ticket offer")
26
+
27
+
28
+ # Wait for the main summary/payment button to become visible
29
+ summary_button = self.page.locator('[data-testid="ticketing_tickets_go_to_summary_button"]')
30
+ summary_button.wait_for(state='visible', timeout=5000) # Wait up to 5 seconds
31
+
32
+ else:
33
+ logger.warning("Could not find the '+' button (SVG) for ticket selection.")
34
+ return False # Exit if ticket selection failed
35
+
36
+ except Exception as click_e:
37
+ logger.error(f"Failed to click '+' button (SVG) or wait for summary button: {str(click_e)}")
38
+ return False # Exit on error
39
+
40
+ # Locate and click the main summary/payment button using its data-testid
41
+ if summary_button.is_visible():
42
+ summary_button.click()
43
+ logger.info("Clicked main summary/payment button (using data-testid)")
44
+
45
+ # --- New Step: Wait for and click the "Skip to Payment" button ---
46
+ # Use a more specific selector for the Skip to checkout button
47
+ skip_button = self.page.get_by_role("button", name="Skip to checkout")
48
+ try:
49
+ skip_button.wait_for(state='visible', timeout=5000) # Wait up to 5 seconds for the skip button
50
+ if skip_button.is_visible():
51
+ skip_button.click()
52
+ logger.info("Clicked 'Skip to Payment' button")
53
+ else:
54
+ logger.warning("'Skip to Payment' button not visible after waiting.")
55
+ return False
56
+ except Exception as skip_e:
57
+ logger.error(f"Failed to wait for or click 'Skip to Payment' button: {str(skip_e)}")
58
+ return False # Exit on error during skip button click
59
+ # --- End New Step ---
60
+
61
+ else:
62
+ logger.warning("Main summary/payment button not visible after waiting.")
63
+ return False # Exit if summary button is not visible
64
+
65
+ # Wait for and click the terms checkbox
66
+ terms_checkbox = self.page.locator('[data-testid="ticketing_summary_resell_terms_checkbox"]')
67
+ if terms_checkbox.is_visible():
68
+ terms_checkbox.click()
69
+ logger.info("Accepted terms and conditions")
70
+ else:
71
+ logger.warning("Could not find the terms checkbox.") # Might be critical depending on site
72
+ # Consider returning False here if accepting terms is mandatory
73
+ # return False
74
+
75
+
76
+
77
+ # Add final step to click the Confirm & pay button
78
+ confirm_button = self.page.locator('[data-testid="ticketing_summary_proceed_to_payment"]')
79
+ try:
80
+ confirm_button.wait_for(state='visible', timeout=5000)
81
+ if confirm_button.is_visible():
82
+ confirm_button.click()
83
+ logger.info("Clicked 'Confirm & pay' button")
84
+ else:
85
+ logger.warning("'Confirm & pay' button not visible after waiting.")
86
+ return False
87
+ except Exception as confirm_e:
88
+ logger.error(f"Failed to wait for or click 'Confirm & pay' button: {str(confirm_e)}")
89
+ return False
90
+
91
+ time.sleep(330)
92
+
93
+ return True
94
+
95
+ except Exception as e:
96
+ logger.error(f"Booking process failed: {str(e)}")
97
+ return False
explore.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ import json
3
+
4
+ url = "https://cdn.webook.com/"
5
+
6
+ headers = {
7
+ "Accept": "*/*",
8
+ "Accept-Encoding": "gzip, deflate, br, zstd",
9
+ "Accept-Language": "en-US,en;q=0.9,ar;q=0.8",
10
+ "Content-Type": "application/json",
11
+ "Origin": "https://webook.com",
12
+ "Referer": "https://webook.com/",
13
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
14
+ }
15
+
16
+ payload = {
17
+ "query": """query getEventListing($lang:String,$limit:Int,$skip:Int,$where:EventFilter,$order:[EventOrder]){
18
+ eventCollection(locale:$lang,limit:$limit,skip:$skip,where:$where,order:$order){
19
+ total items{
20
+ __typename sys{id}id title subtitle slug ticketingUrlSlug
21
+ image11{title sys{id publishedAt}url width height contentType}
22
+ image31{title sys{id publishedAt}url width height contentType}
23
+ startingPrice currencyCode
24
+ schedule{title openTitle openDateTime closeDateTime openScheduleText}
25
+ isStreamingEvent zoneEntryIncluded streamingUrl buttonLabel cardButtonLabel
26
+ eventType buttonLink
27
+ zone{id slug title zoneLogo{title sys{id publishedAt}url width height contentType}
28
+ sponsorLogo{title sys{id publishedAt}url width height contentType}}
29
+ location{title address city countryCode seactionHeader location{lat lon}
30
+ banner{title sys{id publishedAt}url width height contentType} accessibility}
31
+ category{id title slug} isComingSoon organizationSlug
32
+ carousalCollection(limit:10){
33
+ items{title sys{id publishedAt}url width height contentType}
34
+ }
35
+ }
36
+ }
37
+ }""",
38
+ "variables": {
39
+ "order": ["order_ASC", "sys_publishedAt_DESC"],
40
+ "lang": "ar-SA",
41
+ "limit": 7,
42
+ "skip": 0,
43
+ "where": {
44
+ "visibility_not": "private",
45
+ "OR": [
46
+ {"schedule": {"closeDateTime_exists": False}},
47
+ {"schedule": {"closeDateTime_gte": "2025-05-28T22:00:00.000Z"}}
48
+ ],
49
+ "AND": [
50
+ {},
51
+ {},
52
+ {
53
+ "OR": [
54
+ {"cmsTags": {"slug_in": ["football"]}}
55
+ ]
56
+ },
57
+ {},
58
+ {},
59
+ {
60
+ "OR": [
61
+ {
62
+ "schedule": {
63
+ "openDateTime_lte": "2025-05-29T20:59:59.999Z",
64
+ "closeDateTime_gte": "2025-05-28T21:00:00.000Z"
65
+ }
66
+ },
67
+ {
68
+ "schedule": {
69
+ "openDateTime_gte": "2025-05-28T21:00:00.000Z",
70
+ "openDateTime_lte": "2025-05-29T20:59:59.999Z"
71
+ }
72
+ },
73
+ {
74
+ "schedule": {
75
+ "openDateTime_lte": "2025-05-29T20:59:59.999Z",
76
+ "closeDateTime_exists": False
77
+ }
78
+ }
79
+ ]
80
+ },
81
+ {}
82
+ ]
83
+ }
84
+ },
85
+ "operationName": "getEventListing"
86
+ }
87
+
88
+ response = requests.post(url, headers=headers, json=payload)
89
+
90
+ print(f"Status Code: {response.status_code}")
91
+ print("Response JSON:")
92
+ print(json.dumps(response.json(), indent=2, ensure_ascii=False))
login.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import os
3
+ from pathlib import Path
4
+ from playwright.sync_api import sync_playwright
5
+ from utils import WeBookBase, logger
6
+
7
+ # Configure logging
8
+ logging.basicConfig(
9
+ level=logging.INFO,
10
+ format='%(asctime)s - %(levelname)s - %(message)s',
11
+ handlers=[
12
+ logging.FileHandler('logs/webook_login.log'),
13
+ logging.StreamHandler()
14
+ ]
15
+ )
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ class WeBookLogin(WeBookBase):
20
+ def __init__(self, email, password, profile_dir=None):
21
+ super().__init__(profile_dir)
22
+ self.email = email
23
+ self.password = password
24
+
25
+ def _submit_login_form(self):
26
+ try:
27
+ self.page.locator('[data-testid="auth_login_email_input"]').fill(self.email)
28
+ self.page.locator('#password').fill(self.password)
29
+ login_button = self.page.locator('[data-testid="auth_login_submit_button"]')
30
+ login_button.click()
31
+ logger.info("Login form submitted")
32
+
33
+ loading_spinner = self.page.locator('[data-testid="circle-spinner"]')
34
+ if loading_spinner.is_visible():
35
+ logger.info("Waiting for login to process...")
36
+ loading_spinner.wait_for(state="hidden")
37
+ except Exception as e:
38
+ logger.error(f"Login form submission failed: {str(e)}")
39
+ raise
40
+
41
+ def _check_login_result(self):
42
+ try:
43
+ error_message = self.page.locator('p.text-error')
44
+ if error_message.is_visible():
45
+ error_text = error_message.inner_text()
46
+ logger.error(f"Login failed: {error_text}")
47
+ self.page.screenshot(path="login_failed.png")
48
+ return False
49
+
50
+ self.page.wait_for_url("https://webook.com/en", timeout=10000)
51
+ logger.info("Successfully redirected to dashboard")
52
+ return True
53
+
54
+ except Exception as e:
55
+ logger.error(f"Login verification failed: {str(e)}")
56
+ return False
57
+
58
+ def _check_already_logged_in(self):
59
+ try:
60
+ # Navigate to login page
61
+ self.page.goto("https://webook.com/en/login", wait_until='networkidle')
62
+
63
+ # Check if we're redirected to dashboard
64
+ current_url = self.page.url
65
+ if current_url == "https://webook.com/en":
66
+ logger.info("Already logged in - redirected to dashboard")
67
+ return True
68
+
69
+ return False
70
+
71
+ except Exception as e:
72
+ logger.error(f"Error checking login status: {str(e)}")
73
+ return False
74
+
75
+ def login(self):
76
+ try:
77
+ # Setup browser and get the page/context, even if using an existing one
78
+ self.context, self.page = self._setup_browser()
79
+
80
+ # First check if already logged in
81
+ if self._check_already_logged_in():
82
+ return self.page # Return the page if already logged in
83
+
84
+ logger.info("Not logged in, proceeding with login form")
85
+ self._handle_cookies()
86
+ self._submit_login_form()
87
+
88
+ # Check login result and return page or False
89
+ if self._check_login_result():
90
+ return self.page # Return the page if login was successful
91
+ else:
92
+ return False # Return False if login failed
93
+
94
+ except Exception as e:
95
+ logger.error(f"Login process failed: {str(e)}")
96
+ return False
login_failed.png ADDED
main.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from venv import logger
2
+ from login import WeBookLogin
3
+ from book import WeBookBooking
4
+ from utils import WeBookBase # Import WeBookBase for cleanup
5
+ from dotenv import load_dotenv
6
+ import os
7
+
8
+ # Load environment variables
9
+ load_dotenv()
10
+
11
+ def main():
12
+ profile_dir = "./webook_profile"
13
+
14
+ # Instantiate login and perform login
15
+ login = WeBookLogin(
16
+ email=os.getenv('WEBBOOK_EMAIL'),
17
+ password=os.getenv('WEBBOOK_PASSWORD'),
18
+ profile_dir=profile_dir
19
+ )
20
+
21
+ # login() now returns the page object on success, or False on failure
22
+ page = login.login()
23
+
24
+ if page: # Check if login was successful and returned a page object
25
+ logger.info("Login successful, proceeding with booking.")
26
+ # Instantiate booking with the existing page object
27
+ booking = WeBookBooking(page=page, profile_dir=profile_dir)
28
+ event_url = "https://webook.com/en/events/mdlbeast-beast-house-ec/book"
29
+ booking.book_event(event_url)
30
+
31
+ # Perform final cleanup using the context/playwright from the login object
32
+ # The page object is part of the context, so closing context is enough.
33
+ if login.context: # Check if context was created by login
34
+ login.context.close()
35
+ if login.playwright: # Check if playwright was started by login
36
+ login.playwright.stop()
37
+ logger.info("Browser closed after booking.")
38
+
39
+ else:
40
+ logger.error("Login failed. Cannot proceed with booking.")
41
+ # Cleanup should already be handled within login() on failure, but a final check
42
+ if login.context:
43
+ login.context.close()
44
+ if login.playwright:
45
+ login.playwright.stop()
46
+
47
+
48
+ if __name__ == "__main__":
49
+ main()
monitor.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from datetime import datetime
3
+ import logging
4
+ from typing import Dict, List
5
+ import json
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ class EventMonitor:
10
+ def __init__(self, bot):
11
+ self.bot = bot
12
+ self.monitoring_tasks: Dict[int, asyncio.Task] = {} # user_id -> task
13
+ self.user_filters: Dict[int, dict] = {} # user_id -> filters
14
+ self.last_events: Dict[int, List[str]] = {} # user_id -> list of event IDs
15
+
16
+ async def start_monitoring(self, user_id: int, filters: dict):
17
+ """Start monitoring events for a user with given filters"""
18
+ if user_id in self.monitoring_tasks:
19
+ # Stop existing task if any
20
+ self.monitoring_tasks[user_id].cancel()
21
+
22
+ # Store user filters
23
+ self.user_filters[user_id] = filters
24
+ self.last_events[user_id] = []
25
+
26
+ # Create and start new monitoring task
27
+ task = asyncio.create_task(self._monitor_loop(user_id))
28
+ self.monitoring_tasks[user_id] = task
29
+
30
+ return True
31
+
32
+ async def stop_monitoring(self, user_id: int):
33
+ """Stop monitoring events for a user"""
34
+ if user_id in self.monitoring_tasks:
35
+ self.monitoring_tasks[user_id].cancel()
36
+ del self.monitoring_tasks[user_id]
37
+ del self.user_filters[user_id]
38
+ del self.last_events[user_id]
39
+ return True
40
+ return False
41
+
42
+ async def _monitor_loop(self, user_id: int):
43
+ """Main monitoring loop"""
44
+ while True:
45
+ try:
46
+ filters = self.user_filters[user_id]
47
+ # Get current events using existing payload method
48
+ payload = self.bot._get_events_payload(**filters)
49
+ response = await self.bot._make_request(payload)
50
+ events = response["data"]["eventCollection"]["items"]
51
+
52
+ # Check for new events
53
+ current_event_ids = [event['id'] for event in events]
54
+ new_events = [event for event in events
55
+ if event['id'] not in self.last_events[user_id]]
56
+
57
+ if new_events:
58
+ # Send notification for each new event
59
+ for event in new_events:
60
+ message = self._format_event_message(event)
61
+ await self.bot.send_message(user_id, message)
62
+
63
+ # Update last events list
64
+ self.last_events[user_id] = current_event_ids
65
+
66
+ # Wait for 1 minute before next check
67
+ await asyncio.sleep(60)
68
+
69
+ except asyncio.CancelledError:
70
+ break
71
+ except Exception as e:
72
+ logger.error(f"Error in monitor loop for user {user_id}: {e}")
73
+ await asyncio.sleep(60) # Wait before retrying
74
+
75
+ def _format_event_message(self, event: dict) -> str:
76
+ """Format event details into a message"""
77
+ message_parts = [
78
+ "🎉 *New Event Alert!* 🎉",
79
+ f"🏆 *{event.get('title', 'N/A')}*",
80
+ ]
81
+
82
+ if event.get('subtitle'):
83
+ message_parts.append(f"_{event.get('subtitle')}_")
84
+
85
+ location = event.get('location', {}).get('title', 'N/A')
86
+ message_parts.append(f"📍 Location: {location}")
87
+
88
+ price = event.get('startingPrice', 'N/A')
89
+ currency = event.get('currencyCode', 'N/A')
90
+ message_parts.append(f"💰 Starting Price: {price} {currency}")
91
+
92
+ schedule = event.get('schedule', {})
93
+ if schedule.get('openDateTime'):
94
+ message_parts.append(f"🕒 Starts: {schedule['openDateTime']}")
95
+
96
+ if event.get('ticketingUrlSlug'):
97
+ message_parts.append(
98
+ f"[More Info/Tickets](https://webook.com/ar/events/{event['ticketingUrlSlug']})"
99
+ )
100
+
101
+ return "\n".join(message_parts)
monitor_handlers.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
2
+ from telegram.ext import ContextTypes
3
+ import logging
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ class MonitorHandlers:
8
+ def __init__(self, bot):
9
+ self.bot = bot
10
+
11
+ async def handle_country_selection(self, query, context):
12
+ """Handle country selection for monitoring"""
13
+ keyboard = [
14
+ [InlineKeyboardButton("🇸🇦 Saudi Arabia", callback_data='monitor_country_SA')],
15
+ [InlineKeyboardButton("🇦🇪 UAE", callback_data='monitor_country_AE')],
16
+ [InlineKeyboardButton("🇶🇦 Qatar", callback_data='monitor_country_QA')],
17
+ [InlineKeyboardButton("⬅️ Back", callback_data='start_monitoring')]
18
+ ]
19
+
20
+ reply_markup = InlineKeyboardMarkup(keyboard)
21
+ await query.edit_message_text(
22
+ "🌍 Select a country to monitor events from:",
23
+ reply_markup=reply_markup
24
+ )
25
+
26
+ async def handle_date_selection(self, query, context):
27
+ """Handle date filter selection for monitoring"""
28
+ keyboard = [
29
+ [InlineKeyboardButton("☀️ Today", callback_data='monitor_date_today')],
30
+ [InlineKeyboardButton("🗓️ Tomorrow", callback_data='monitor_date_tomorrow')],
31
+ [InlineKeyboardButton("📅 This Week", callback_data='monitor_date_this_week')],
32
+ [InlineKeyboardButton("⬅️ Back", callback_data='start_monitoring')]
33
+ ]
34
+
35
+ reply_markup = InlineKeyboardMarkup(keyboard)
36
+ await query.edit_message_text(
37
+ "📅 Select a date filter for monitoring:",
38
+ reply_markup=reply_markup
39
+ )
40
+
41
+ async def handle_category_selection(self, query, context):
42
+ """Handle category selection for monitoring"""
43
+ keyboard = [
44
+ [InlineKeyboardButton("🎁 Offers", callback_data='monitor_category_offers')],
45
+ [InlineKeyboardButton("⚽ Sports", callback_data='monitor_category_sports-events')],
46
+ [InlineKeyboardButton("🎭 Theater", callback_data='monitor_category_theater-and-performing-arts')],
47
+ [InlineKeyboardButton("⬅️ Back", callback_data='start_monitoring')]
48
+ ]
49
+
50
+ reply_markup = InlineKeyboardMarkup(keyboard)
51
+ await query.edit_message_text(
52
+ "🎯 Select a category to monitor:",
53
+ reply_markup=reply_markup
54
+ )
55
+
56
+ async def handle_price_range(self, query, context):
57
+ """Handle price range selection for monitoring"""
58
+ # This would typically involve a conversation flow to get min/max prices
59
+ await query.edit_message_text(
60
+ "💰 Please enter the price range in the format:\n"
61
+ "min-max\n"
62
+ "Example: 100-500\n"
63
+ "Or enter 'any' for no price filter"
64
+ )
65
+ context.user_data['awaiting_price_range'] = True
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ python-telegram-bot>=20.0
2
+ requests>=2.31.0
3
+ playwright>=1.40.0
4
+ python-dotenv>=1.0.0
telegram_bot.py ADDED
@@ -0,0 +1,778 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from telegram import CallbackQuery, Update, InlineKeyboardButton, InlineKeyboardMarkup
3
+ from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes, MessageHandler, filters
4
+ import requests
5
+ import json
6
+ from datetime import datetime, timedelta
7
+ from monitor import EventMonitor
8
+ from dotenv import load_dotenv
9
+ import os
10
+
11
+ # Load environment variables
12
+ load_dotenv()
13
+
14
+ # Configure logging
15
+ logging.basicConfig(
16
+ level=logging.INFO,
17
+ format='%(asctime)s - %(levelname)s - %(message)s',
18
+ handlers=[
19
+ logging.FileHandler('logs/telegram_bot.log'),
20
+ logging.StreamHandler()
21
+ ]
22
+ )
23
+ logger = logging.getLogger(__name__)
24
+
25
+ class WeBookBot:
26
+ def __init__(self, token):
27
+ self.token = token
28
+ self.url = "https://cdn.webook.com/"
29
+ self.headers = {
30
+ "Accept": "*/*",
31
+ "Accept-Encoding": "gzip, deflate, br, zstd",
32
+ "Accept-Language": "en-US,en;q=0.9,ar;q=0.8",
33
+ "Content-Type": "application/json",
34
+ "Origin": "https://webook.com",
35
+ "Referer": "https://webook.com/",
36
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
37
+ }
38
+ self.monitor = EventMonitor(self)
39
+
40
+ def _get_events_payload(self, category=None, date_filter=None, price_min=None, price_max=None, zones=None, country_code="SA", limit=20, skip=0):
41
+ """Generate the payload for the events API request with flexible filtering using the getShows query."""
42
+ now = datetime.now()
43
+ tomorrow = now + timedelta(days=1)
44
+ end_of_week = now + timedelta(days=7)
45
+
46
+ # --- Construct Date Filter Clause (based on the structure from the new query) ---
47
+ # The new query uses an OR condition for schedules that includes
48
+ # events without a close date or those ending after a certain time,
49
+ # combined with specific date range checks.
50
+ date_schedule_or_clauses = []
51
+
52
+ # Add the clause for events without a close date or ending from now onwards
53
+ # (Adjusting "2025-05-28T22:00:00.000Z" to a dynamic current time or relevant start point)
54
+ # Let's use the start of today as a general lower bound for events that should be considered "current"
55
+ start_of_today = now.replace(hour=0, minute=0, second=0, microsecond=0)
56
+ date_schedule_or_clauses.append({
57
+ "OR": [
58
+ {"schedule":{"closeDateTime_exists":False}},
59
+ {"schedule":{"closeDateTime_gte": start_of_today.strftime("%Y-%m-%dT%H:%M:%S.000Z")}}
60
+ ]
61
+ })
62
+
63
+
64
+ # Add specific date range clause based on date_filter
65
+ date_range_and_clause = {}
66
+ if date_filter == 'today':
67
+ start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
68
+ end_of_day = now.replace(hour=23, minute=59, second=59, microsecond=999999)
69
+ # Events active at any point today
70
+ date_range_and_clause = {
71
+ "AND": [
72
+ {"schedule": {"openDateTime_lte": end_of_day.strftime("%Y-%m-%dT%H:%M:%S.000Z")}},
73
+ {"schedule": {"closeDateTime_gte": start_of_day.strftime("%Y-%m-%dT%H:%M:%S.000Z")}}
74
+ ]
75
+ }
76
+ elif date_filter == 'tomorrow':
77
+ tomorrow_start = tomorrow.replace(hour=0, minute=0, second=0, microsecond=0)
78
+ tomorrow_end = tomorrow.replace(hour=23, minute=59, second=59, microsecond=999999)
79
+ # Events active at any point tomorrow
80
+ date_range_and_clause = {
81
+ "AND": [
82
+ {"schedule": {"openDateTime_lte": tomorrow_end.strftime("%Y-%m-%dT%H:%M:%S.000Z")}},
83
+ {"schedule": {"closeDateTime_gte": tomorrow_start.strftime("%Y-%m-%dT%H:%M:%S.000Z")}}
84
+ ]
85
+ }
86
+ elif date_filter == 'this_week':
87
+ start_of_week = now.replace(hour=0, minute=0, second=0, microsecond=0)
88
+ end_of_week = end_of_week.replace(hour=23, minute=59, second=59, microsecond=999999)
89
+ # Events active at any point this week
90
+ date_range_and_clause = {
91
+ "AND": [
92
+ {"schedule": {"openDateTime_lte": end_of_week.strftime("%Y-%m-%dT%H:%M:%S.000Z")}},
93
+ {"schedule": {"closeDateTime_gte": start_of_week.strftime("%Y-%m-%dT%H:%M:%S.000Z")}}
94
+ ]
95
+ }
96
+
97
+ if date_range_and_clause:
98
+ date_schedule_or_clauses.append(date_range_and_clause)
99
+
100
+
101
+ # --- Construct Category Filter Clause ---
102
+ category_clause = {}
103
+ if category: # category can be a single slug or a list of slugs
104
+ if isinstance(category, str):
105
+ category = [category]
106
+ # The new query uses 'category' with 'slug_in' directly within an OR in the main AND
107
+ category_clause = {"OR": [{"category": {"slug_in": category}}]} # Changed cmsTags to category
108
+
109
+
110
+ # --- Construct Price Filter Clause ---
111
+ price_clause = {}
112
+ price_and_clauses = []
113
+ if price_min is not None:
114
+ price_and_clauses.append({"startingPrice_gte": price_min})
115
+ if price_max is not None:
116
+ price_and_clauses.append({"startingPrice_lte": price_max})
117
+ if price_and_clauses:
118
+ price_clause = {"AND": price_and_clauses}
119
+
120
+
121
+ # --- Construct Zone Filter Clause ---
122
+ zone_clause = {}
123
+ if zones: # zones should be a list of slugs
124
+ zone_clause = {"OR":[{"zone":{"slug_in": zones}}]}
125
+
126
+ # --- Construct Country Filter Clause ---
127
+ country_clause = {}
128
+ if country_code:
129
+ country_clause = {"OR":[{"location":{"countryCode": country_code}}]}
130
+
131
+
132
+ # --- Build the main 'AND' array for the 'where' clause ---
133
+ # Following the structure of the provided query payload
134
+ and_clauses = []
135
+
136
+ # Add the zone filter if present
137
+ if zone_clause:
138
+ and_clauses.append(zone_clause)
139
+
140
+ # Add the category filter if present
141
+ if category_clause:
142
+ and_clauses.append(category_clause)
143
+
144
+ and_clauses.append({}) # Placeholder like in the example
145
+
146
+ # Add the price filter if present
147
+ if price_clause:
148
+ and_clauses.append(price_clause)
149
+
150
+ and_clauses.append({}) # Placeholder like in the example
151
+
152
+ # Add the combined date schedule OR clause if it has content
153
+ if date_schedule_or_clauses:
154
+ # If only the 'closeDateTime_exists' part is present, don't wrap in an extra OR
155
+ if len(date_schedule_or_clauses) == 1 and "OR" in date_schedule_or_clauses[0]:
156
+ and_clauses.append(date_schedule_or_clauses[0])
157
+ else:
158
+ and_clauses.append({"OR": date_schedule_or_clauses})
159
+
160
+ # Add the country filter if present
161
+ if country_clause:
162
+ and_clauses.append(country_clause)
163
+
164
+
165
+ # Filter out empty placeholder clauses, but be careful if a placeholder is intended
166
+ # Based on the example, there seem to be two empty placeholders that are kept.
167
+ # Let's keep any empty dictionaries explicitly added.
168
+ # and_clauses = [clause for clause in and_clauses if clause] # Removed this line to keep explicit {}
169
+
170
+
171
+ # --- Construct the final 'where' clause ---
172
+ where_clause = {
173
+ "visibility_not": "private",
174
+ # The top-level OR from the example, handling events without end date or ending from now
175
+ # We've integrated this logic into date_schedule_or_clauses now, so this is redundant here
176
+ # "OR": [
177
+ # {"schedule":{"closeDateTime_exists":False}},
178
+ # {"schedule":{"closeDateTime_gte": now.strftime("%Y-%m-%dT%H:%M:%S.000Z")}}
179
+ # ],
180
+ }
181
+
182
+ if and_clauses:
183
+ where_clause["AND"] = and_clauses
184
+
185
+
186
+ return {
187
+ "query": """query getEventListing($lang:String,$limit:Int,$skip:Int,$where:EventFilter,$order:[EventOrder]){
188
+ eventCollection(locale:$lang,limit:$limit,skip:$skip,where:$where,order:$order){
189
+ total items{
190
+ __typename sys{id}id title subtitle slug ticketingUrlSlug
191
+ image11{title sys{id publishedAt}url width height contentType}
192
+ image31{title sys{id publishedAt}url width height contentType}
193
+ startingPrice currencyCode
194
+ schedule{title openTitle openDateTime closeDateTime openScheduleText}
195
+ isStreamingEvent zoneEntryIncluded streamingUrl buttonLabel cardButtonLabel
196
+ eventType buttonLink
197
+ zone{id slug title zoneLogo{title sys{id publishedAt}url width height contentType}
198
+ sponsorLogo{title sys{id publishedAt}url width height contentType}}
199
+ location{title address city countryCode seactionHeader location{lat lon}
200
+ banner{title sys{id publishedAt}url width height contentType} accessibility}
201
+ category{id title slug} isComingSoon organizationSlug
202
+ carousalCollection(limit:10){
203
+ items{title sys{id publishedAt}url width height contentType}
204
+ }
205
+ }
206
+ }
207
+ }""",
208
+ "variables": {
209
+ "order": ["sys_publishedAt_DESC"],
210
+ "lang": "ar-SA",
211
+ "limit": limit,
212
+ "skip": skip,
213
+ "where": where_clause,
214
+ },
215
+ "operationName": "getEventListing"
216
+ }
217
+
218
+ async def start_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
219
+ """Handle the /start command"""
220
+ # Store default filters in context user_data if they don't exist
221
+ if 'category' not in context.user_data:
222
+ context.user_data['category'] = None # Or a default category slug if needed
223
+ if 'date_filter' not in context.user_data:
224
+ context.user_data['date_filter'] = 'today' # Default date filter
225
+ # Add placeholders for other filters
226
+ if 'price_min' not in context.user_data:
227
+ context.user_data['price_min'] = None
228
+ if 'price_max' not in context.user_data:
229
+ context.user_data['price_max'] = None
230
+ if 'zones' not in context.user_data:
231
+ context.user_data['zones'] = None # Or a list of default zone slugs if needed
232
+ if 'country_code' not in context.user_data:
233
+ context.user_data['country_code'] = 'SA' # Default country
234
+
235
+ keyboard = [
236
+ [
237
+ InlineKeyboardButton("☀️ اليوم", callback_data='date_today'),
238
+ InlineKeyboardButton("🗓️ غداً", callback_data='date_tomorrow'),
239
+ InlineKeyboardButton("📅 هذا الأسبوع", callback_data='date_this_week')
240
+ ],
241
+ [
242
+ InlineKeyboardButton("🎁 العروض", callback_data='category_offers'),
243
+ InlineKeyboardButton("⚽ الرياضة", callback_data='category_sports-events')
244
+ ],
245
+ [
246
+ InlineKeyboardButton("🎭 المسرح والفنون الأدائية", callback_data='category_theater-and-performing-arts'),
247
+ InlineKeyboardButton("⛰️ الأنشطة والمغامرات", callback_data='category_activities-adventures')
248
+ ],
249
+ [
250
+ InlineKeyboardButton("🎶 موسیقی", callback_data='category_music-events'),
251
+ InlineKeyboardButton("✨ التجارب", callback_data='category_experience')
252
+ ],
253
+ [
254
+ InlineKeyboardButton("🍽️ المطاعم", callback_data='category_restaurant-and-cafe')
255
+ ],
256
+ # Add buttons for other filters would be complex here.
257
+ # You might consider a "Filter Options" button that leads to a new message
258
+ # with more specific filter buttons or instructions.
259
+ # For now, we just keep the category and date buttons.
260
+ [
261
+ InlineKeyboardButton("✅ Show Events with Current Filters", callback_data='show_events')
262
+ ],
263
+ [
264
+ InlineKeyboardButton("🔄 Reset Filters", callback_data='reset_filters')
265
+ ],
266
+ [
267
+ InlineKeyboardButton("🔍 Start Event Monitoring", callback_data='start_monitoring'),
268
+ InlineKeyboardButton("⏹️ Stop Monitoring", callback_data='stop_monitoring')
269
+ ]
270
+ ]
271
+ reply_markup = InlineKeyboardMarkup(keyboard)
272
+
273
+ # Display current filters in the message
274
+ current_filters_text = self._get_current_filters_text(context.user_data)
275
+
276
+ await update.message.reply_text(
277
+ f'Welcome to WeBook Events Bot! 🎉\n'
278
+ f'Please select filters or "Show Events":\n{current_filters_text}',
279
+ reply_markup=reply_markup
280
+ )
281
+
282
+ async def button_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
283
+ """Handle button callbacks"""
284
+ query = update.callback_query
285
+ await query.answer()
286
+
287
+ try:
288
+ callback_data = query.data
289
+
290
+ # --- Handle Filter Button Presses ---
291
+ if callback_data.startswith('category_'):
292
+ selected_category = callback_data.replace('category_', '')
293
+ # Toggle category selection (assuming single selection for simplicity for now)
294
+ if context.user_data.get('category') == selected_category:
295
+ context.user_data['category'] = None # Deselect
296
+ else:
297
+ context.user_data['category'] = selected_category # Select
298
+ # Update the message to reflect the selected filter
299
+ current_filters_text = self._get_current_filters_text(context.user_data)
300
+ await query.edit_message_text(
301
+ f'Welcome to WeBook Events Bot! 🎉\n'
302
+ f'Please select filters or "Show Events":\n{current_filters_text}',
303
+ reply_markup=query.message.reply_markup # Keep the existing buttons
304
+ )
305
+ return # Don't fetch events yet
306
+
307
+ elif callback_data.startswith('date_'):
308
+ selected_date_filter = callback_data.replace('date_', '')
309
+ # Toggle date filter selection (assuming single selection)
310
+ if context.user_data.get('date_filter') == selected_date_filter:
311
+ # Decide if you want to allow deselecting date, or keep a default
312
+ # For now, let's allow changing but not deselecting to None
313
+ pass # Do nothing if same button clicked again
314
+ else:
315
+ context.user_data['date_filter'] = selected_date_filter # Select
316
+ # Update the message to reflect the selected filter
317
+ current_filters_text = self._get_current_filters_text(context.user_data)
318
+ await query.edit_message_text(
319
+ f'Welcome to WeBook Events Bot! 🎉\n'
320
+ f'Please select filters or "Show Events":\n{current_filters_text}',
321
+ reply_markup=query.message.reply_markup # Keep the existing buttons
322
+ )
323
+ return # Don't fetch events yet
324
+
325
+ elif callback_data == 'reset_filters':
326
+ # Reset all filters to default
327
+ context.user_data['category'] = None
328
+ context.user_data['date_filter'] = 'tomorrow'
329
+ context.user_data['price_min'] = None
330
+ context.user_data['price_max'] = None
331
+ context.user_data['zones'] = None
332
+ context.user_data['country_code'] = 'SA'
333
+ # Update the message to reflect the reset filters
334
+ current_filters_text = self._get_current_filters_text(context.user_data)
335
+ await query.edit_message_text(
336
+ f'Welcome to WeBook Events Bot! 🎉\n'
337
+ f'Filters have been reset. Please select filters or "Show Events":\n{current_filters_text}',
338
+ reply_markup=query.message.reply_markup # Keep the existing buttons
339
+ )
340
+ return # Don't fetch events yet
341
+
342
+ # --- Handle Pagination Button Presses ---
343
+ elif callback_data.startswith('page_'):
344
+ try:
345
+ # callback_data will be 'page_X' where X is the new skip value
346
+ new_skip = int(callback_data.replace('page_', ''))
347
+ # Get current filters and total events from user_data
348
+ current_category = context.user_data.get('category')
349
+ current_date_filter = context.user_data.get('date_filter')
350
+ current_price_min = context.user_data.get('price_min')
351
+ current_price_max = context.user_data.get('price_max')
352
+ current_zones = context.user_data.get('zones')
353
+ current_country_code = context.user_data.get('country_code')
354
+ current_limit = 10 # Assuming limit is always 20 for pagination
355
+
356
+ # Update skip value in user_data
357
+ context.user_data['skip'] = new_skip
358
+
359
+ # Fetch events for the new page
360
+ payload = self._get_events_payload(
361
+ category=current_category,
362
+ date_filter=current_date_filter,
363
+ price_min=current_price_min,
364
+ price_max=current_price_max,
365
+ zones=current_zones,
366
+ country_code=current_country_code,
367
+ limit=current_limit,
368
+ skip=new_skip # Use the new skip value
369
+ )
370
+
371
+ response = requests.post(self.url, headers=self.headers, json=payload)
372
+ response.raise_for_status()
373
+
374
+ events = response.json()["data"]["eventCollection"]["items"]
375
+ total_events = response.json()["data"]["eventCollection"]["total"] # Get total count
376
+
377
+ # Indicate current filters applied in the response message
378
+ filter_info = self._get_current_filters_text(context.user_data)
379
+
380
+ if not events:
381
+ # This shouldn't happen if total_events > 0, but as a fallback
382
+ await query.edit_message_text(f"No events found for this page.\n{filter_info}")
383
+ return
384
+
385
+ # Format events into a readable card list with numbers
386
+ message_parts = [f"🎫 Upcoming Events (Filtered - Page {int(new_skip/current_limit) + 1}/{int(total_events/current_limit) + (1 if total_events % current_limit > 0 else 0)} of {total_events}):\n{filter_info}\n"] # Add page info and total count
387
+
388
+ for i, event in enumerate(events):
389
+ # Display event number based on the current page and index
390
+ event_number = new_skip + i + 1
391
+ message_parts.append(f"--- Event {event_number} ---") # Separator for cards
392
+ message_parts.append(f"🏆 *{event.get('title', 'N/A')}*") # Bold title
393
+ if event.get('subtitle'):
394
+ message_parts.append(f"_{event.get('subtitle')}_") # Italic subtitle
395
+
396
+ location_title = event.get('location', {}).get('title', 'N/A')
397
+ message_parts.append(f"📍 Location: {location_title}")
398
+
399
+ price = event.get('startingPrice', 'N/A')
400
+ currency = event.get('currencyCode', 'N/A')
401
+ message_parts.append(f"💰 Starting Price: {price} {currency}") # Price info
402
+
403
+ # Display schedule dates more clearly
404
+ open_date = event.get('schedule', {}).get('openDateTime')
405
+ close_date = event.get('schedule', {}).get('closeDateTime')
406
+ schedule_text = "🕒 Schedule: "
407
+ if open_date:
408
+ try:
409
+ open_dt = datetime.fromisoformat(open_date.replace('Z', '+00:00'))
410
+ schedule_text += f"Starts: {open_dt.strftime('%Y-%m-%d %H:%M')}"
411
+ except ValueError:
412
+ schedule_text += f"Starts: {open_date}"
413
+ if close_date:
414
+ try:
415
+ close_dt = datetime.fromisoformat(close_date.replace('Z', '+00:00'))
416
+ schedule_text += f", Ends: {close_dt.strftime('%Y-%m-%d %H:%M')}"
417
+ except ValueError:
418
+ schedule_text += f", Ends: {close_date}"
419
+ if not open_date and not close_date:
420
+ schedule_text += "Info Not Available"
421
+ message_parts.append(schedule_text)
422
+
423
+ # Optional: Add a link if available
424
+ ticketing_slug = event.get('ticketingUrlSlug')
425
+ if ticketing_slug and ticketing_slug != 'N/A':
426
+ message_parts.append(f"[More Info/Tickets](https://webook.com/ar/events/{ticketing_slug})")
427
+
428
+
429
+ message_parts.append("") # Add an empty line for spacing between cards
430
+
431
+ # Add pagination and select buttons below the list
432
+ pagination_buttons = []
433
+ # Check if there's a previous page
434
+ if new_skip > 0:
435
+ pagination_buttons.append(InlineKeyboardButton("⬅️ Back", callback_data=f'page_{max(0, new_skip - current_limit)}'))
436
+ # Check if there's a next page
437
+ if new_skip + len(events) < total_events:
438
+ pagination_buttons.append(InlineKeyboardButton("➡️ Next", callback_data=f'page_{new_skip + current_limit}'))
439
+
440
+ # Add the "Select Event" button
441
+ select_button = [InlineKeyboardButton("👆 Select Event by Number", callback_data='prompt_select_event')] # New callback data
442
+
443
+ # Combine pagination and select buttons
444
+ keyboard = [pagination_buttons] if pagination_buttons else []
445
+ keyboard.append(select_button)
446
+ reply_markup = InlineKeyboardMarkup(keyboard)
447
+
448
+
449
+ # Join all parts to form the final message
450
+ message = "\n".join(message_parts)
451
+
452
+ # Send the formatted message and update the buttons
453
+ await query.edit_message_text(text=message, reply_markup=reply_markup, parse_mode='Markdown', disable_web_page_preview=True) # Use Markdown for bold/italic
454
+
455
+ except (ValueError, TypeError) as e:
456
+ logger.error(f"Error parsing page number from callback data: {e}")
457
+ await query.edit_message_text("Sorry, there was an error processing the page request.")
458
+ except Exception as e:
459
+ logger.error(f"Error fetching paginated events: {str(e)}")
460
+ await query.edit_message_text(
461
+ "Sorry, there was an error fetching the next page of events. Please try again later."
462
+ )
463
+ return # Handled pagination, return
464
+
465
+
466
+ # --- Handle "Show Events" Button Press ---
467
+ elif callback_data == 'show_events':
468
+ # Fetch events with the selected filters from user_data
469
+ current_category = context.user_data.get('category')
470
+ current_date_filter = context.user_data.get('date_filter')
471
+ current_price_min = context.user_data.get('price_min')
472
+ current_price_max = context.user_data.get('price_max')
473
+ current_zones = context.user_data.get('zones')
474
+ current_country_code = context.user_data.get('country_code')
475
+
476
+ # Reset skip to 0 for a new search
477
+ context.user_data['skip'] = 0
478
+ current_limit = 20
479
+
480
+ payload = self._get_events_payload(
481
+ category=current_category,
482
+ date_filter=current_date_filter,
483
+ price_min=current_price_min,
484
+ price_max=current_price_max,
485
+ zones=current_zones,
486
+ country_code=current_country_code,
487
+ limit=current_limit, # Pass limit
488
+ skip=0 # Start from the first page
489
+ )
490
+
491
+ response = requests.post(self.url, headers=self.headers, json=payload)
492
+ response.raise_for_status()
493
+
494
+ events = response.json()["data"]["eventCollection"]["items"]
495
+ total_events = response.json()["data"]["eventCollection"]["total"] # Get total count
496
+
497
+ # Indicate current filters applied in the response message
498
+ filter_info = self._get_current_filters_text(context.user_data)
499
+
500
+ if not events:
501
+ await query.edit_message_text(f"No events found for the selected filters.\n{filter_info}")
502
+ return
503
+
504
+ # Store total events for pagination reference
505
+ context.user_data['total_events'] = total_events
506
+ # Store the currently displayed events to easily retrieve details when selecting by number
507
+ context.user_data['current_event_list'] = events
508
+
509
+
510
+ # Format events into a readable card list with numbers
511
+ message_parts = [f"🎫 Upcoming Events (Filtered - Page 1/{int(total_events/current_limit) + (1 if total_events % current_limit > 0 else 0)} of {total_events}):\n{filter_info}\n"] # Add page info and total count
512
+
513
+ for i, event in enumerate(events):
514
+ # Display event number based on the current page (always 1 for initial show) and index
515
+ event_number = context.user_data['skip'] + i + 1
516
+ message_parts.append(f"--- Event {event_number} ---") # Separator for cards
517
+ message_parts.append(f"🏆 *{event.get('title', 'N/A')}*") # Bold title
518
+ if event.get('subtitle'):
519
+ message_parts.append(f"_{event.get('subtitle')}_") # Italic subtitle
520
+
521
+ location_title = event.get('location', {}).get('title', 'N/A')
522
+ message_parts.append(f"📍 Location: {location_title}")
523
+
524
+ price = event.get('startingPrice', 'N/A')
525
+ currency = event.get('currencyCode', 'N/A')
526
+ message_parts.append(f"💰 Starting Price: {price} {currency}") # Price info
527
+
528
+ # Display schedule dates more clearly
529
+ open_date = event.get('schedule', {}).get('openDateTime')
530
+ close_date = event.get('schedule', {}).get('closeDateTime')
531
+ schedule_text = "🕒 Schedule: "
532
+ if open_date:
533
+ try:
534
+ open_dt = datetime.fromisoformat(open_date.replace('Z', '+00:00'))
535
+ schedule_text += f"Starts: {open_dt.strftime('%Y-%m-%d %H:%M')}"
536
+ except ValueError:
537
+ schedule_text += f"Starts: {open_date}"
538
+ if close_date:
539
+ try:
540
+ close_dt = datetime.fromisoformat(close_date.replace('Z', '+00:00'))
541
+ schedule_text += f", Ends: {close_dt.strftime('%Y-%m-%d %H:%M')}"
542
+ except ValueError:
543
+ schedule_text += f", Ends: {close_date}"
544
+ if not open_date and not close_date:
545
+ schedule_text += "Info Not Available"
546
+ message_parts.append(schedule_text)
547
+
548
+ # Optional: Add a link if available
549
+ ticketing_slug = event.get('ticketingUrlSlug')
550
+ if ticketing_slug and ticketing_slug != 'N/A':
551
+ message_parts.append(f"[More Info/Tickets](https://webook.com/ar/events/{ticketing_slug})")
552
+
553
+
554
+ message_parts.append("") # Add an empty line for spacing between cards
555
+
556
+ # Add pagination and select buttons below the list
557
+ pagination_buttons = []
558
+ # Check if there's a next page on the initial load
559
+ if len(events) < total_events:
560
+ pagination_buttons.append(InlineKeyboardButton("➡️ Next", callback_data=f'page_{current_limit}')) # Next page starts after the first limit
561
+
562
+ # Add the "Select Event" button
563
+ select_button = [InlineKeyboardButton("👆 Select Event by Number", callback_data='prompt_select_event')] # New callback data
564
+
565
+ # Combine pagination and select buttons
566
+ keyboard = [pagination_buttons] if pagination_buttons else []
567
+ keyboard.append(select_button)
568
+ reply_markup = InlineKeyboardMarkup(keyboard)
569
+
570
+
571
+ # Join all parts to form the final message
572
+ message = "\n".join(message_parts)
573
+
574
+ # Send the formatted message and update the buttons
575
+ await query.edit_message_text(text=message, reply_markup=reply_markup, parse_mode='Markdown', disable_web_page_preview=True) # Use Markdown for bold/italic
576
+
577
+
578
+ # --- Handle Prompt Select Event Button Press ---
579
+ elif callback_data == 'prompt_select_event':
580
+ await query.edit_message_text(
581
+ f"{query.message.text_markdown_v2}\n\n" # Keep the existing event list
582
+ f"Please reply with the number of the event you want to select (e.g., `1`, `5`, `12`)." # Prompt the user
583
+ # Note: We cannot keep inline buttons here and also wait for a text reply easily.
584
+ # A full ConversationHandler is needed for that. For simplicity,
585
+ # we remove the inline buttons and expect a text reply.
586
+ # You might want to add a "Cancel" button if you implement ConversationHandler.
587
+ )
588
+ # You might set a state here in user_data if you implement a MessageHandler
589
+ context.user_data['awaiting_event_number'] = True
590
+
591
+ elif callback_data == 'start_monitoring':
592
+ # Start monitoring setup flow
593
+ await self._start_monitoring_setup(query, context)
594
+ elif callback_data == 'stop_monitoring':
595
+ # Stop monitoring
596
+ user_id = query.from_user.id
597
+ if await self.monitor.stop_monitoring(user_id):
598
+ await query.edit_message_text(
599
+ "✅ Event monitoring stopped successfully."
600
+ )
601
+ else:
602
+ await query.edit_message_text(
603
+ "❌ No active monitoring found."
604
+ )
605
+ else:
606
+ # Handle other potential buttons here if needed
607
+ await query.edit_message_text("Unknown button pressed.")
608
+ return
609
+
610
+
611
+ except Exception as e:
612
+ logger.error(f"Error processing callback: {str(e)}")
613
+ logger.error(f"Response content: {response.text if 'response' in locals() else 'No response'}")
614
+ await query.edit_message_text(
615
+ "Sorry, there was an error fetching the events. Please try again later."
616
+ )
617
+
618
+ async def _start_monitoring_setup(self, query: CallbackQuery, context: ContextTypes.DEFAULT_TYPE):
619
+ """Start the monitoring setup process"""
620
+ keyboard = [
621
+ [InlineKeyboardButton("🌍 Select Country", callback_data='monitor_country')],
622
+ [InlineKeyboardButton("📅 Select Date Filter", callback_data='monitor_date')],
623
+ [InlineKeyboardButton("🎯 Select Category", callback_data='monitor_category')],
624
+ [InlineKeyboardButton("💰 Set Price Range", callback_data='monitor_price')],
625
+ [InlineKeyboardButton("✅ Start Monitoring", callback_data='confirm_monitoring')]
626
+ ]
627
+
628
+ reply_markup = InlineKeyboardMarkup(keyboard)
629
+ await query.edit_message_text(
630
+ "🔍 *Event Monitoring Setup*\n\n"
631
+ "Please configure your monitoring preferences:",
632
+ reply_markup=reply_markup,
633
+ parse_mode='Markdown'
634
+ )
635
+
636
+ # Initialize monitoring filters in user_data
637
+ context.user_data['monitoring_filters'] = {
638
+ 'country_code': 'SA',
639
+ 'date_filter': 'today',
640
+ 'category': None,
641
+ 'price_min': None,
642
+ 'price_max': None
643
+ }
644
+
645
+ def _get_current_filters_text(self, user_data):
646
+ """Helper function to generate text summarizing current filters."""
647
+ category = user_data.get('category')
648
+ date_filter = user_data.get('date_filter')
649
+ price_min = user_data.get('price_min')
650
+ price_max = user_data.get('price_max')
651
+ zones = user_data.get('zones')
652
+ country_code = user_data.get('country_code')
653
+
654
+ filter_parts = []
655
+ filter_parts.append(f"Date: {date_filter.replace('_', ' ').title() if date_filter else 'Any'}")
656
+ filter_parts.append(f"Category: {category if category else 'All'}")
657
+
658
+ if price_min is not None or price_max is not None:
659
+ price_text = "Price: "
660
+ if price_min is not None:
661
+ price_text += f"From {price_min} "
662
+ if price_max is not None:
663
+ price_text += f"To {price_max}"
664
+ filter_parts.append(price_text)
665
+
666
+ if zones:
667
+ filter_parts.append(f"Zones: {', '.join(zones)}")
668
+
669
+ if country_code:
670
+ filter_parts.append(f"Country: {country_code}")
671
+
672
+ return "Current Filters:\n" + "\n".join(filter_parts)
673
+
674
+ async def handle_event_number_input(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
675
+ """Handle the user's reply with an event number."""
676
+ # Check if the bot is expecting an event number from this user
677
+ if context.user_data.get('awaiting_event_number'):
678
+ try:
679
+ event_number_str = update.message.text.strip()
680
+ event_number = int(event_number_str)
681
+
682
+ # Get the current list of events displayed to the user (this is just the current page)
683
+ # Note: This logic is simplified. A robust solution should fetch the event
684
+ # details based on the global index (event_number) and the full search results.
685
+ # For now, let's try to find the event in the *currently displayed* list.
686
+ current_events_on_page = context.user_data.get('current_event_list', [])
687
+ current_skip = context.user_data.get('skip', 0)
688
+
689
+ # Find the event corresponding to the number on the current page
690
+ # event_number is 1-based global index. We need the 0-based index on the current page.
691
+ event_index_on_page = event_number - 1 - current_skip
692
+
693
+ if 0 <= event_index_on_page < len(current_events_on_page):
694
+ selected_event = current_events_on_page[event_index_on_page]
695
+ # Found the event. Now you can ask "what offer want" or proceed to booking.
696
+ # For now, just confirm the selection.
697
+ await update.message.reply_text(
698
+ f"You selected event number {event_number}:\n"
699
+ f"🏆 *{selected_event.get('title', 'N/A')}*\n"
700
+ f"📍 Location: {selected_event.get('location', {}).get('title', 'N/A')}\n"
701
+ f"What offer would you like to select for this event?" # Placeholder for next step
702
+ , parse_mode='Markdown'
703
+ )
704
+ # Reset the state
705
+ context.user_data['awaiting_event_number'] = False
706
+ context.user_data.pop('current_event_list', None) # Clear temporary data
707
+ context.user_data.pop('skip', None)
708
+ context.user_data.pop('total_events', None)
709
+
710
+
711
+ else:
712
+ # Number is out of range for the current page or invalid based on global index
713
+ await update.message.reply_text(
714
+ f"Invalid event number '{event_number_str}'. Please reply with a number from the list above."
715
+ # You might want to redisplay the list or provide a "Cancel" option.
716
+ )
717
+ # Keep state active to allow another attempt
718
+
719
+ except ValueError:
720
+ # User did not send a valid number
721
+ await update.message.reply_text(
722
+ f"That doesn't look like a number. Please reply with the number of the event you want to select."
723
+ # Keep state active
724
+ )
725
+ except Exception as e:
726
+ logger.error(f"Error handling event number input: {e}")
727
+ await update.message.reply_text("Sorry, there was an error processing your selection.")
728
+ # Reset state on error
729
+ context.user_data['awaiting_event_number'] = False
730
+ context.user_data.pop('current_event_list', None)
731
+ context.user_data.pop('skip', None)
732
+ context.user_data.pop('total_events', None)
733
+
734
+ async def send_message(self, user_id: int, message: str):
735
+ """Helper method to send messages to users"""
736
+ try:
737
+ await self.application.bot.send_message(
738
+ chat_id=user_id,
739
+ text=message,
740
+ parse_mode='Markdown',
741
+ disable_web_page_preview=True
742
+ )
743
+ except Exception as e:
744
+ logger.error(f"Error sending message to user {user_id}: {e}")
745
+
746
+ async def _make_request(self, payload: dict):
747
+ """Make API request to WeBook"""
748
+ response = requests.post(self.url, headers=self.headers, json=payload)
749
+ response.raise_for_status()
750
+ return response.json()
751
+
752
+ def run(self):
753
+ """Run the bot"""
754
+ try:
755
+ application = Application.builder().token(self.token).build()
756
+
757
+ # Add handlers
758
+ application.add_handler(CommandHandler("start", self.start_command))
759
+ application.add_handler(CallbackQueryHandler(self.button_callback))
760
+ # Add a MessageHandler for text input to handle event number selection
761
+ application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_event_number_input)) # Listen for non-command text
762
+
763
+ # Start the bot
764
+ logger.info("Starting bot...")
765
+ application.run_polling()
766
+
767
+ except Exception as e:
768
+ logger.error(f"Error running bot: {str(e)}")
769
+ raise
770
+
771
+ if __name__ == "__main__":
772
+ # Get bot token from environment variable
773
+ BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN')
774
+ if not BOT_TOKEN:
775
+ raise ValueError("TELEGRAM_BOT_TOKEN not found in environment variables")
776
+
777
+ bot = WeBookBot(BOT_TOKEN)
778
+ bot.run()
utils.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from playwright.sync_api import sync_playwright
3
+ import os
4
+ from pathlib import Path
5
+
6
+ # Configure logging
7
+ logging.basicConfig(
8
+ level=logging.INFO,
9
+ format='%(asctime)s - %(levelname)s - %(message)s',
10
+ handlers=[
11
+ logging.FileHandler('logs/webook_actions.log'),
12
+ logging.StreamHandler()
13
+ ]
14
+ )
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ class WeBookBase:
19
+ def __init__(self, profile_dir=None, page=None):
20
+ self.profile_dir = profile_dir or os.path.join(Path.home(), '.webook_profile')
21
+ self.playwright = None
22
+ self.browser = None
23
+ self.page = page # Accept existing page
24
+ self.context = None # Keep track of context if created here
25
+
26
+ def _handle_cookies(self):
27
+ try:
28
+ cookie_banner = self.page.locator('#cookie_consent')
29
+ if cookie_banner.is_visible():
30
+ accept_button = self.page.get_by_role('button', name='Accept all')
31
+ if accept_button.is_visible():
32
+ accept_button.click()
33
+ logger.info("Cookie consent accepted")
34
+ except Exception as e:
35
+ logger.warning(f"Cookie handling issue: {str(e)}")
36
+
37
+ def _setup_browser(self):
38
+ if not self.page: # Only set up if page is not provided
39
+ self.playwright = sync_playwright().start()
40
+ self.context = self.playwright.chromium.launch_persistent_context(
41
+ user_data_dir=self.profile_dir,
42
+ headless=False,
43
+ args=[]
44
+ )
45
+ self.page = self.context.pages[0] if self.context.pages else self.context.new_page()
46
+ logger.info(f"Using profile directory: {self.profile_dir}")
47
+ # Return the page and context for external management
48
+ return self.context, self.page
49
+
50
+ # Removed the cleanup method
51
+ # def cleanup(self):
52
+ # if hasattr(self, 'page') and self.page:
53
+ # self.page.close()
54
+ # if hasattr(self, 'browser') and self.browser:
55
+ # self.browser.close()
56
+ # if hasattr(self, 'playwright') and self.playwright:
57
+ # self.playwright.stop()