diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4816a31 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Python bytecode +__pycache__/ +*.pyc + +# Environment variables & secrets +.env +service_account.json +hotel_os.db + +# IDE and editor directories +.vscode/ +.idea/ diff --git a/README.md b/README.md index c983c6a..f36d531 100644 --- a/README.md +++ b/README.md @@ -1,219 +1,75 @@ -

- Jules Awesome List -

- -
-

Awesome Jules Prompts 🌟

-

Curated prompts for Jules, an async coding agent from Google Labs.

-
- Visit Jules • - Contribute -
- ---- - -## Table of Contents - -- [Table of Contents](#table-of-contents) -- [Everyday Dev Tasks](#everyday-dev-tasks) -- [Debugging](#debugging) -- [Documentation](#documentation) -- [Testing](#testing) -- [Package Management](#package-management) -- [AI-Native Tasks](#ai-native-tasks) -- [Context](#context) -- [Fun \& Experimental](#fun--experimental) -- [Start from Scratch](#start-from-scratch) -- [Contributing](#contributing) - ---- - -## Everyday Dev Tasks - -- `// Refactor {a specific} file from {x} to {y}...` - General-purpose, applies to any language or repo. - -- `// Add a test suite...` - Useful for repos lacking test coverage. - -- `// Add type hints to {a specific} Python function...` - Python codebases transitioning to typed code. - -- `// Generate mock data for {a specific} schema...` - APIs, frontends, or test-heavy environments. - -- `// Convert these commonJS modules to ES modules...` - JS/TS projects modernizing legacy code. - -- `// Turn this callback-based code into async/await...` - JavaScript or Python codebases improving async logic. - -- `// Implement a data class for this dictionary structure...` - Useful for Python projects moving towards more structured data handling with `dataclasses` or Pydantic. - - - -## Debugging - -- `// Help me fix {a specific} error...` - For any repo where you're stuck on a runtime or build error. - -- `// Why is {this specific snippet of code} slow?` - Performance profiling for loops, functions, or queries. - -- `// Trace why this value is undefined...` - Frontend and backend JS/TS bugs. - -- `// Diagnose this memory leak...` - Server-side apps or long-running processes. - -- `// Add logging to help debug this issue...` - Useful when troubleshooting silent failures. - -- `// Find race conditions in this async code` - Concurrent systems in JS, Python, Go, etc. - -- `// Add print statements to trace the execution flow of this Python script...` - For debugging complex Python scripts or understanding unexpected behavior. - - -## Documentation - -- `// Write a README for this project` - Any repo lacking a basic project overview. - -- `// Add comments to this code` - Improves maintainability of complex logic. - -- `// Write API docs for this endpoint` - REST or GraphQL backends. - -- `// Generate Sphinx-style docstrings for this Python module/class/function...` - Ideal for Python projects using Sphinx for documentation generation. - - - -## Testing - -- `// Add integration tests for this API endpoint` - Express, FastAPI, Django, Flask apps. - -- `// Write a test that mocks fetch` - Browser-side fetch or axios logic. - -- `// Convert this test from Mocha to Jest` - JS test suite migrations. - -- `// Generate property-based tests for this function` - Functional or logic-heavy code. - -- `// Simulate slow network conditions in this test suite` - Web and mobile apps. - -- `// Write a test to ensure backward compatibility for this function` - Library or SDK maintainers. - -- `// Write a Pytest fixture to mock this external API call...` - For Python projects using Pytest and needing robust mocking for testing. - - - -## Package Management - -- `// Upgrade my linter and autofix breaking config changes` - JS/TS repos using ESLint or Prettier. - -- `// Show me the changelog for React 19` - Web frontend apps using React. - -- `// Which dependencies can I safely remove?` - Bloated or legacy codebases. - -- `// Check if these packages are still maintained` - Security-conscious or long-term projects. - -- `// Set up Renovate or Dependabot for auto-updates` - Best for active projects with CI/CD. - - - -## AI-Native Tasks - -- `// Analyze this repo and generate 3 feature ideas` - Vision-stage or greenfield products. - -- `// Identify tech debt in this file` - Codebases with messy or fragile logic. - -- `// Find duplicate logic across files` - Sprawling repos lacking DRY practices. - -- `// Cluster related functions and suggest refactors` - Projects with lots of utils or helpers. - -- `// Help me scope this issue so Jules can solve it` - For working with Jules on real issues. - -- `// Convert this function into a reusable plugin/module` - Componentizing logic-heavy code. - -- `// Refactor this Python function to be more amenable to parallel processing (e.g., using multiprocessing or threading)...` - For optimizing performance in computationally intensive Python applications. - - - -## Context - -- `// Write a status update based on recent commits` - Managerial and async communication. - -- `// Summarize all changes in the last 7 days` - Catching up after time off. - - - -## Fun & Experimental - -- `// Add a confetti animation when {a specific} action succeeds` - Frontend web apps with user delight moments. - -- `// Inject a developer joke when {a specific} build finishes` - Personal projects or team tools. - -- `// Build a mini CLI game that runs in the terminal` - For learning or community fun. - -- `// Add a dark mode Easter egg to this UI` - Design-heavy frontend projects. - -- `// Turn this tool into a GitHub App` - Reusable, platform-integrated tools. - -## Start from Scratch - -- `// What's going on in this repo?` - Great for legacy repos or onboarding onto unfamiliar code. - -- `// Initialize a new Express app with CORS enabled` - Web backend projects using Node.js and Express. - -- `// Set up a monorepo using Turborepo and PNPM` - Multi-package JS/TS projects with shared dependencies. - -- `// Bootstrap a Python project with Poetry and Pytest` - Python repos aiming for clean dependency and test setup. - -- `// Create a starter template for a Chrome extension` - Browser extension development. - -- `// I want to build a web scraper—start me off` - Data scraping or automation tools using Python/Node. - - - -## Contributing - -Your contributions are welcome! Add new prompts, fix formatting, or suggest categories. - -- 📄 [Contributing Guide](contributing.md) -- 🪄 Open a [Pull Request](https://github.com/YOUR_REPO/pulls) +# Hotel OS Bot (Python / Gemini Version) + +This is a powerful, context-aware Telegram bot for hotel management, built with Python. It leverages the Google Gemini API for natural language understanding, allowing users to interact with it through normal conversation instead of rigid commands. + +## Core Architecture (The System Blueprint) + +The bot operates on an intelligent loop: +1. **Input Reception:** Receives any user input (text, images). +2. **Contextual Memory:** Manages conversation history for each user. +3. **Dynamic Prompt Engineering:** Combines new input with past conversation to create a rich prompt for the AI. +4. **Gemini API Call:** Sends the engineered prompt to the Gemini API for intent detection and entity extraction. +5. **Response Parsing:** Interprets Gemini's response to decide whether it's an actionable command (JSON) or a conversational reply. +6. **Logic & Action:** Executes internal functions (e.g., database queries, sheet updates) based on the AI's structured output. +7. **Memory Update:** Saves the latest interaction to the user's conversation history. + +## Features + +- **Natural Language Interaction:** Talk to the bot like you would a human assistant. +- **SQLite Database:** Uses a local SQLite database (`hotel_os.db`) for robust data persistence. +- **Google Sheets Integration:** Connects to Google Sheets for specific tasks like repair ticket management. +- **OCR Slip Processing:** Can read text from uploaded payment slips. +- **Modular & Scalable:** Code is organized into logical modules (`database`, `sheets`, `processors`, `handlers`) for easy maintenance. + +## Project Structure + +``` +/ +├── .gitignore +├── config.py +├── requirements.txt +├── main.py +└── bot/ + ├── __init__.py + ├── database.py # Handles all SQLite database operations + ├── sheets.py # Handles all Google Sheets API operations + ├── processors.py # Handles OCR and Gemini API calls + └── handlers.py # Contains all Telegram command and conversation logic +``` + +## Setup & Installation + +This project is designed to be run in an environment like Google Colab where `ngrok` can expose the webhook. + +### 1. Prerequisites +- A Telegram Bot Token from BotFather. +- A Google Cloud Project with a **Service Account JSON file** (for Google Sheets). +- A **Google Gemini API Key**. +- An `ngrok` authentication token. + +### 2. Configuration +- **Colab Secrets:** The most secure way to run this is by using Google Colab's "Secrets" (🔑 icon on the left). Add the following secrets: + - `BOT_TOKEN`: Your Telegram Bot Token. + - `GEMINI_API_KEY`: Your Google Gemini API Key. + - `SERVICE_ACCOUNT_JSON_PATH`: The *full path* to your uploaded `service_account.json` file in your Colab environment (e.g., `/content/service_account.json`). + - `NGROK_AUTHTOKEN`: Your authentication token from the ngrok dashboard. + - `ADMIN_CHAT_ID`: The numeric chat ID for receiving admin notifications. +- **Upload Service Account File:** Upload your `service_account.json` file to your Colab instance. Make sure the path matches what you put in the `SERVICE_ACCOUNT_JSON_PATH` secret. + +### 3. Installation +Run this command in a cell to install all necessary Python libraries: +```bash +!pip install -r requirements.txt +``` + +### 4. Running the Bot +Execute the main script in a Colab cell: +```bash +!python main.py +``` +The script will: +1. Set up the `hotel_os.db` SQLite database file. +2. Register all Telegram handlers. +3. Start an `ngrok` tunnel to create a public URL. +4. Set the Telegram webhook to that public URL. +5. Start listening for incoming messages. You will see a `Bot is running!` message with the public URL. diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..d474e1b --- /dev/null +++ b/bot/__init__.py @@ -0,0 +1 @@ +# This file makes the 'bot' directory a Python package. diff --git a/bot/database.py b/bot/database.py new file mode 100644 index 0000000..223818c --- /dev/null +++ b/bot/database.py @@ -0,0 +1,186 @@ +# bot/database.py +""" +Module for all SQLite database interactions for the Hotel OS Bot. +""" + +import sqlite3 +import logging +from datetime import datetime + +import config + +logger = logging.getLogger(__name__) + +def _get_db_connection(): + """Establishes a connection to the SQLite database.""" + try: + conn = sqlite3.connect(config.DATABASE_FILE) + conn.row_factory = sqlite3.Row # Allows accessing columns by name + return conn + except sqlite3.Error as e: + logger.critical(f"Database connection failed: {e}", exc_info=True) + raise + +async def setup_database(): + """ + Connects to the SQLite database and sets up all necessary tables if they don't exist. + This is one of the most critical functions to ensure the bot can operate. + """ + logger.info(f"Running database setup for {config.DATABASE_FILE}...") + conn = _get_db_connection() + cursor = conn.cursor() + try: + # Create booking_counter table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS booking_counter (last_id INTEGER PRIMARY KEY DEFAULT 0) + """) + cursor.execute("SELECT COUNT(*) FROM booking_counter") + if cursor.fetchone()[0] == 0: + cursor.execute("INSERT INTO booking_counter (last_id) VALUES (0)") + logger.info("Initialized booking_counter table.") + + # Create repair_ticket_counter table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS repair_ticket_counter (last_id INTEGER PRIMARY KEY DEFAULT 0) + """) + cursor.execute("SELECT COUNT(*) FROM repair_ticket_counter") + if cursor.fetchone()[0] == 0: + cursor.execute("INSERT INTO repair_ticket_counter (last_id) VALUES (0)") + logger.info("Initialized repair_ticket_counter table.") + + # Create reservations table with all required columns + cursor.execute(""" + CREATE TABLE IF NOT EXISTS reservations ( + booking_id TEXT PRIMARY KEY, + customer_name TEXT, + checkin_date TEXT, + checkout_date TEXT, + num_nights INTEGER, + full_price REAL, + deposit_amount REAL, + bank_account TEXT, + storage_timestamp TEXT, + payment_status TEXT DEFAULT 'Pending', + extracted_amount REAL, + extracted_bank TEXT, + extracted_timestamp TEXT, + slip_file_path TEXT + ) + """) + logger.info("Reservations table checked/created.") + + # Create repair_tasks table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS repair_tasks ( + ticket_id TEXT PRIMARY KEY, + room_number TEXT, + issue_detail TEXT, + status TEXT, + reported_timestamp TEXT, + closed_timestamp TEXT + ) + """) + logger.info("Repair tasks table checked/created.") + + conn.commit() + logger.info("Database setup completed and committed successfully.") + + except sqlite3.Error as e: + logger.error(f"Database error during setup: {e}", exc_info=True) + conn.rollback() + raise + finally: + conn.close() + +# --- Generic and Reusable Database Functions --- + +async def get_next_id(counter_table_name: str) -> int: + """Generic function to get the next ID from a counter table.""" + conn = _get_db_connection() + cursor = conn.cursor() + try: + cursor.execute(f"SELECT last_id FROM {counter_table_name} LIMIT 1") + row = cursor.fetchone() + last_id = row[0] if row else 0 + new_id = last_id + 1 + cursor.execute(f"UPDATE {counter_table_name} SET last_id = ?", (new_id,)) + conn.commit() + logger.info(f"Incremented {counter_table_name} to {new_id}.") + return new_id + except sqlite3.Error as e: + logger.error(f"Error getting next ID from {counter_table_name}: {e}", exc_info=True) + conn.rollback() + raise + finally: + conn.close() + +# --- Reservation Specific Functions --- + +async def add_reservation(details: dict): + """Adds a new reservation record to the database.""" + conn = _get_db_connection() + cursor = conn.cursor() + try: + sql = """ + INSERT INTO reservations ( + booking_id, customer_name, checkin_date, checkout_date, num_nights, + full_price, deposit_amount, bank_account, storage_timestamp, payment_status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + cursor.execute(sql, ( + details['booking_id'], details['customer_name'], details['checkin_date'], + details['checkout_date'], details['num_nights'], details['full_price'], + details['deposit_amount'], details['bank_account'], timestamp, 'Pending' + )) + conn.commit() + logger.info(f"Successfully added reservation {details['booking_id']}.") + except sqlite3.Error as e: + logger.error(f"Error adding reservation {details.get('booking_id')}: {e}", exc_info=True) + conn.rollback() + raise + finally: + conn.close() + +async def get_reservation(booking_id: str) -> dict | None: + """Fetches a single reservation by its booking ID.""" + conn = _get_db_connection() + cursor = conn.cursor() + try: + cursor.execute("SELECT * FROM reservations WHERE booking_id = ?", (booking_id,)) + row = cursor.fetchone() + return dict(row) if row else None + except sqlite3.Error as e: + logger.error(f"Error fetching reservation {booking_id}: {e}", exc_info=True) + raise + finally: + conn.close() + +async def update_reservation_payment(booking_id: str, status: str, details: dict): + """Updates payment-related info for a reservation.""" + conn = _get_db_connection() + cursor = conn.cursor() + try: + sql = """ + UPDATE reservations + SET payment_status = ?, extracted_amount = ?, extracted_bank = ?, + extracted_timestamp = ?, slip_file_path = ?, storage_timestamp = ? + WHERE booking_id = ? + """ + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + cursor.execute(sql, ( + status, details.get('amount'), details.get('bank'), + details.get('timestamp'), details.get('slip_path'), + timestamp, booking_id + )) + conn.commit() + logger.info(f"Updated payment status for {booking_id} to '{status}'.") + return cursor.rowcount > 0 + except sqlite3.Error as e: + logger.error(f"Error updating payment for {booking_id}: {e}", exc_info=True) + conn.rollback() + raise + finally: + conn.close() + +# ... Other functions like delete_reservation, get_all_reservations, add_repair_ticket, etc. would go here ... diff --git a/bot/handlers.py b/bot/handlers.py new file mode 100644 index 0000000..521348d --- /dev/null +++ b/bot/handlers.py @@ -0,0 +1,408 @@ +# bot/handlers.py +""" +This module contains all the Telegram handler functions and conversation handlers. +It orchestrates the bot's logic by calling processors and data modules. +""" + +import logging +import re +from datetime import datetime +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.ext import ( + Application, + ContextTypes, + ConversationHandler, + CommandHandler, + MessageHandler, + CallbackQueryHandler, + filters, +) + +import config +from . import database as db +from . import sheets +from . import processors + +logger = logging.getLogger(__name__) + +# --- Conversation Handler States --- +( + # Booking States + WAITING_FOR_CUSTOMER_NAME, + WAITING_FOR_PHONE, + WAITING_FOR_EMAIL, + WAITING_FOR_CHECKIN_DATE, + WAITING_FOR_CHECKOUT_DATE, + WAITING_FOR_GUESTS, + CONFIRM_BOOKING, + # Check-in States + REQUEST_BOOKING_ID_CHECKIN, + UPLOAD_SLIP_STATE, + # Checkout States + REQUEST_BOOKING_ID_CHECKOUT, + CONFIRM_CHECKOUT, + # Repair States + REPAIR_ROOM, + REPAIR_DETAIL, +) = range(13) + + +# --- Main Menu and Basic Command Handlers --- + +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handles the /start command and sends the main menu.""" + keyboard = [ + [InlineKeyboardButton("🔑 เช็คอิน", callback_data='menu_checkin')], + [InlineKeyboardButton("🚪 เช็คเอาท์", callback_data='menu_checkout')], + [InlineKeyboardButton("📝 จองห้องพัก", callback_data='menu_reserve')], + [InlineKeyboardButton("🛠️ แจ้งซ่อม", callback_data='menu_repair')], + [InlineKeyboardButton("📄 รายงาน", callback_data='menu_report')], + ] + reply_markup = InlineKeyboardMarkup(keyboard) + # If the command is triggered via a message, reply to it. + # If it's from a callback (like after /cancel), edit the original message. + if update.callback_query: + await update.callback_query.answer() + await update.callback_query.edit_message_text( + "👋 ยินดีต้อนรับสู่ Hotel OS Bot! โปรดเลือกเมนู:", + reply_markup=reply_markup + ) + else: + await update.message.reply_text( + "👋 ยินดีต้อนรับสู่ Hotel OS Bot! โปรดเลือกเมนู:", + reply_markup=reply_markup + ) + + +# --- Booking Reservation Conversation (Now Complete) --- + +async def reserve_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Entry point for the booking reservation conversation.""" + query = update.callback_query + await query.answer() + await query.edit_message_text("📝 **กระบวนการจองห้องพัก**\nกรุณาพิมพ์ชื่อ-นามสกุลลูกค้าครับ:") + return WAITING_FOR_CUSTOMER_NAME + +async def get_customer_name(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + context.user_data['customer_name'] = update.message.text + await update.message.reply_text("📞 กรุณาพิมพ์เบอร์โทรศัพท์ติดต่อ:") + return WAITING_FOR_PHONE + +async def get_phone(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + context.user_data['phone'] = update.message.text + await update.message.reply_text("📧 กรุณาพิมพ์อีเมล:") + return WAITING_FOR_EMAIL + +async def get_email(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + context.user_data['email'] = update.message.text + await update.message.reply_text("🗓️ กรุณาพิมพ์วันที่เช็คอิน (YYYY-MM-DD):") + return WAITING_FOR_CHECKIN_DATE + +async def get_checkin_date(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + # Basic validation could be added here + context.user_data['checkin_date'] = update.message.text + await update.message.reply_text("🗓️ กรุณาพิมพ์วันที่เช็คเอาท์ (YYYY-MM-DD):") + return WAITING_FOR_CHECKOUT_DATE + +async def get_checkout_date(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + context.user_data['checkout_date'] = update.message.text + await update.message.reply_text("👥 กรุณาพิมพ์จำนวนผู้เข้าพัก:") + return WAITING_FOR_GUESTS + +async def get_guests_and_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Gets the final piece of info and shows confirmation.""" + context.user_data['num_guests'] = update.message.text + + details = context.user_data + summary = ( + f"**กรุณายืนยันข้อมูลการจอง:**\n" + f"------------------------------\n" + f"👤 **ชื่อ:** {details['customer_name']}\n" + f"📞 **โทรศัพท์:** {details['phone']}\n" + f"📧 **อีเมล:** {details['email']}\n" + f"➡️ **เช็คอิน:** {details['checkin_date']}\n" + f"⬅️ **เช็คเอาท์:** {details['checkout_date']}\n" + f"👥 **จำนวนผู้เข้าพัก:** {details['num_guests']}\n" + f"------------------------------\n" + f"ข้อมูลถูกต้องหรือไม่?" + ) + keyboard = [ + [InlineKeyboardButton("✅ ยืนยันและบันทึก", callback_data="book_confirm_yes")], + [InlineKeyboardButton("❌ ยกเลิก", callback_data="book_confirm_no")], + ] + reply_markup = InlineKeyboardMarkup(keyboard) + await update.message.reply_text(summary, reply_markup=reply_markup) + return CONFIRM_BOOKING + +async def save_booking(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Saves the booking to the database and ends the conversation.""" + query = update.callback_query + await query.answer() + + if query.data == 'book_confirm_yes': + await query.edit_message_text("⏳ กำลังบันทึกการจอง...") + try: + booking_id_num = await db.get_next_id('booking_counter') + booking_id = f"BOOKING_{booking_id_num}" + + details = context.user_data + # Prepare data for database insertion + reservation_data = { + 'booking_id': booking_id, + 'customer_name': details['customer_name'], + 'check_in_date': details['checkin_date'], + 'check_out_date': details['checkout_date'], + 'num_guests': int(details['num_guests']), + 'contact_info': f"Phone: {details['phone']}, Email: {details['email']}", + 'payment_status': 'Pending', + 'payment_details': {}, + 'total_price': 0.0 # Placeholder + } + + await db.add_reservation(reservation_data) + await query.edit_message_text(f"✅ บันทึกการจองสำเร็จ! ID การจองคือ `{booking_id}`") + except Exception as e: + logger.error(f"Error saving booking: {e}", exc_info=True) + await query.edit_message_text(f"เกิดข้อผิดพลาด: {e}") + else: + await query.edit_message_text("การจองถูกยกเลิก") + + context.user_data.clear() + return ConversationHandler.END + + +# --- Repair Conversation --- + +async def repair_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Entry point for the repair conversation.""" + query = update.callback_query + await query.answer() + await query.edit_message_text(text="🛠️ **กระบวนการแจ้งซ่อม**\nกรุณาพิมพ์หมายเลขห้องครับ") + return REPAIR_ROOM + +async def repair_get_room(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Gets the room number and asks for issue details.""" + context.user_data['room_number'] = update.message.text.strip().upper() + await update.message.reply_text("กรุณาอธิบายรายละเอียดปัญหาที่พบครับ") + return REPAIR_DETAIL + +async def repair_process(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Processes the repair details, calls Gemini, saves data, and confirms.""" + issue_detail = update.message.text + room_number = context.user_data.get('room_number') + + await update.message.reply_text("กำลังวิเคราะห์และบันทึกข้อมูลแจ้งซ่อม...") + + try: + analysis = await processors.analyze_repair_issue_with_gemini(issue_detail) + category = analysis.get('category', 'Other') + summary = analysis.get('summary', issue_detail) + + ticket_id_num = await db.get_next_id('repair_ticket_counter') + ticket_id = f"REPAIR_{ticket_id_num}" + + gsheet = await sheets.get_sheet(config.GSHEET_SPREADSHEET_NAME, "งานซ่อม") + ticket_data = { + "ticket_id": ticket_id, "room_number": room_number, + "issue_detail": issue_detail, "status": "เปิดงาน", + "reported_timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "category": category, + } + await sheets.add_repair_ticket(gsheet, ticket_data) + + confirmation_text = ( + f"✅ บันทึกการแจ้งซ่อมสำเร็จ\n" + f"- ID: `{ticket_id}`\n" + f"- ห้อง: {room_number}\n" + f"- สรุป: {summary}\n" + f"- หมวดหมู่: {category}" + ) + await update.message.reply_text(confirmation_text) + + except Exception as e: + logger.error(f"Error in repair_process for room {room_number}: {e}", exc_info=True) + await update.message.reply_text(f"เกิดข้อผิดพลาดร้ายแรงขณะบันทึกการแจ้งซ่อม: {e}") + + context.user_data.clear() + return ConversationHandler.END + + +# --- Checkout Conversation --- + +async def checkout_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + query = update.callback_query + await query.answer() + await query.edit_message_text(text="🚪 **กระบวนการเช็คเอาท์**\nกรุณาพิมพ์รหัสการจอง (Booking ID) ที่ต้องการเช็คเอาท์ครับ") + return REQUEST_BOOKING_ID_CHECKOUT + +async def checkout_get_booking_id(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + booking_id = update.message.text.strip().upper() + try: + reservation = await db.get_reservation(booking_id) + if reservation: + context.user_data['booking_id'] = booking_id + summary = f"**ยืนยันการเช็คเอาท์?**\n- ID: `{reservation['booking_id']}`\n- ชื่อ: {reservation['customer_name']}" + keyboard = [ + [InlineKeyboardButton("✅ ยืนยัน", callback_data="checkout_confirm_yes")], + [InlineKeyboardButton("❌ ยกเลิก", callback_data="checkout_confirm_no")], + ] + reply_markup = InlineKeyboardMarkup(keyboard) + await update.message.reply_text(summary, reply_markup=reply_markup) + return CONFIRM_CHECKOUT + else: + await update.message.reply_text(f"❌ ไม่พบรหัสการจอง `{booking_id}` ครับ") + return ConversationHandler.END + except Exception as e: + await update.message.reply_text(f"เกิดข้อผิดพลาด: {e}") + return ConversationHandler.END + +async def checkout_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + query = update.callback_query + await query.answer() + booking_id = context.user_data.get('booking_id') + if query.data == 'checkout_confirm_yes': + await query.edit_message_text(f"กำลังดำเนินการเช็คเอาท์สำหรับ `{booking_id}`...") + try: + await db.update_reservation_payment(booking_id, "Checked-Out", {}) + await query.edit_message_text(f"✅ เช็คเอาท์ `{booking_id}` สำเร็จ!") + except Exception as e: + await query.edit_message_text(f"เกิดข้อผิดพลาดขณะอัปเดตข้อมูล: {e}") + else: + await query.edit_message_text("การเช็คเอาท์ถูกยกเลิก") + context.user_data.clear() + return ConversationHandler.END + + +# --- Check-in Conversation --- + +async def checkin_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + query = update.callback_query + await query.answer() + await query.edit_message_text(text="🔑 **กระบวนการเช็คอิน**\nกรุณาพิมพ์รหัสการจอง (Booking ID) ที่ต้องการเช็คอินครับ") + return REQUEST_BOOKING_ID_CHECKIN + +async def checkin_get_booking_id(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + booking_id = update.message.text.strip().upper() + try: + reservation = await db.get_reservation(booking_id) + if reservation and reservation['payment_status'] == 'Pending': + context.user_data['booking_id'] = booking_id + await update.message.reply_text(f"✅ พบการจอง `{booking_id}`\nกรุณาอัปโหลดรูปภาพสลิปการโอนเงินมัดจำครับ") + return UPLOAD_SLIP_STATE + elif reservation: + await update.message.reply_text(f"⚠️ สถานะการจอง `{booking_id}` คือ '{reservation['payment_status']}' ไม่สามารถเช็คอินได้ครับ") + return ConversationHandler.END + else: + await update.message.reply_text(f"❌ ไม่พบรหัสการจอง `{booking_id}`") + return ConversationHandler.END + except Exception as e: + await update.message.reply_text(f"เกิดข้อผิดพลาดในการตรวจสอบข้อมูล: {e}") + return ConversationHandler.END + +async def upload_slip_process(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + booking_id = context.user_data.get('booking_id') + if not update.message.photo: + await update.message.reply_text("กรุณาอัปโหลดเป็นรูปภาพครับ") + return UPLOAD_SLIP_STATE + + await update.message.reply_text("ได้รับสลิปแล้ว กำลังประมวลผลด้วย OCR...") + try: + photo_file = await context.bot.get_file(update.message.photo[-1].file_id) + photo_bytes = await photo_file.download_as_bytearray() + ocr_text = processors.process_image_for_ocr(bytes(photo_bytes)) + + amount_match = re.search(r'(\d{1,3}(?:,\d{3})*\.\d{2})', ocr_text) + extracted_amount = float(amount_match.group(1).replace(',', '')) if amount_match else None + + payment_details = { + 'amount': extracted_amount, 'bank': 'OCR Scan', + 'timestamp': datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + } + success = await db.update_reservation_payment(booking_id, 'Paid', payment_details) + if success: + await update.message.reply_text(f"✅ เช็คอินสำหรับ `{booking_id}` สำเร็จ!\nยอดเงินที่ตรวจพบ: {extracted_amount or 'N/A'} บาท") + else: + await update.message.reply_text("เกิดข้อผิดพลาดในการอัปเดตสถานะการชำระเงิน") + except Exception as e: + logger.error(f"Error during slip processing for {booking_id}: {e}", exc_info=True) + await update.message.reply_text(f"เกิดข้อผิดพลาดร้ายแรงระหว่างประมวลผลสลิป: {e}") + + context.user_data.clear() + return ConversationHandler.END + +async def report_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Placeholder for the report feature.""" + query = update.callback_query + await query.answer() + await query.edit_message_text(text="ฟังก์ชันรายงานยังไม่เปิดใช้งานครับ") + # Bring back the main menu after the message + await start(update, context) + +async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Cancels and ends the conversation, bringing back the main menu.""" + await update.message.reply_text("การดำเนินการถูกยกเลิก") + context.user_data.clear() + await start(update, context) # Show main menu again + return ConversationHandler.END + +# --- Registration Function --- + +def register_handlers(application: Application): + """Registers all command, message, and conversation handlers.""" + + conv_handler_booking = ConversationHandler( + entry_points=[CallbackQueryHandler(reserve_start, pattern='^menu_reserve$')], + states={ + WAITING_FOR_CUSTOMER_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, get_customer_name)], + WAITING_FOR_PHONE: [MessageHandler(filters.TEXT & ~filters.COMMAND, get_phone)], + WAITING_FOR_EMAIL: [MessageHandler(filters.TEXT & ~filters.COMMAND, get_email)], + WAITING_FOR_CHECKIN_DATE: [MessageHandler(filters.TEXT & ~filters.COMMAND, get_checkin_date)], + WAITING_FOR_CHECKOUT_DATE: [MessageHandler(filters.TEXT & ~filters.COMMAND, get_checkout_date)], + WAITING_FOR_GUESTS: [MessageHandler(filters.TEXT & ~filters.COMMAND, get_guests_and_confirm)], + CONFIRM_BOOKING: [CallbackQueryHandler(save_booking, pattern='^book_confirm_')] + }, + fallbacks=[CommandHandler('cancel', cancel)], + per_user=True, allow_reentry=True + ) + + conv_handler_checkin = ConversationHandler( + entry_points=[CallbackQueryHandler(checkin_start, pattern='^menu_checkin$')], + states={ + REQUEST_BOOKING_ID_CHECKIN: [MessageHandler(filters.TEXT & ~filters.COMMAND, checkin_get_booking_id)], + UPLOAD_SLIP_STATE: [MessageHandler(filters.PHOTO, upload_slip_process)], + }, + fallbacks=[CommandHandler('cancel', cancel)], + per_user=True, allow_reentry=True + ) + + conv_handler_checkout = ConversationHandler( + entry_points=[CallbackQueryHandler(checkout_start, pattern='^menu_checkout$')], + states={ + REQUEST_BOOKING_ID_CHECKOUT: [MessageHandler(filters.TEXT & ~filters.COMMAND, checkout_get_booking_id)], + CONFIRM_CHECKOUT: [CallbackQueryHandler(checkout_confirm, pattern='^checkout_confirm_')], + }, + fallbacks=[CommandHandler('cancel', cancel)], + per_user=True, allow_reentry=True + ) + + conv_handler_repair = ConversationHandler( + entry_points=[CallbackQueryHandler(repair_start, pattern='^menu_repair$')], + states={ + REPAIR_ROOM: [MessageHandler(filters.TEXT & ~filters.COMMAND, repair_get_room)], + REPAIR_DETAIL: [MessageHandler(filters.TEXT & ~filters.COMMAND, repair_process)], + }, + fallbacks=[CommandHandler('cancel', cancel)], + per_user=True, allow_reentry=True + ) + + # Add conversation handlers + application.add_handler(conv_handler_booking) + application.add_handler(conv_handler_checkin) + application.add_handler(conv_handler_checkout) + application.add_handler(conv_handler_repair) + + # Add other handlers + application.add_handler(CommandHandler("start", start)) + application.add_handler(CallbackQueryHandler(report_menu, pattern='^menu_report$')) + + logger.info("All handlers have been registered.") diff --git a/bot/processors.py b/bot/processors.py new file mode 100644 index 0000000..d8500e3 --- /dev/null +++ b/bot/processors.py @@ -0,0 +1,87 @@ +# bot/processors.py +""" +Module for processing raw data, such as OCR and AI analysis. +""" + +import logging +import re +import io +from PIL import Image +import pytesseract +import google.generativeai as genai + +import config + +logger = logging.getLogger(__name__) + +# --- OCR Processor --- + +def process_image_for_ocr(image_bytes: bytes) -> str: + """ + Processes an image from a byte stream for OCR using Pytesseract. + """ + try: + logger.info("Processing image for OCR with Pytesseract...") + image = Image.open(io.BytesIO(image_bytes)) + + # 'tha+eng' allows it to recognize both Thai and English characters + extracted_text = pytesseract.image_to_string(image, lang='tha+eng') + + if not extracted_text.strip(): + logger.warning("Pytesseract ran successfully, but no text was detected.") + return "OCR Result: No text could be detected in the image." + + logger.info("Successfully extracted text from image via OCR.") + return extracted_text + + except pytesseract.TesseractNotFoundError: + logger.critical("Tesseract executable not found. Please ensure Tesseract is installed and in your system's PATH.") + # This is a critical setup error. + raise + except Exception as e: + logger.error(f"An error occurred during OCR processing: {e}", exc_info=True) + raise + +# --- Gemini AI Processor --- + +async def analyze_repair_issue_with_gemini(issue_detail: str) -> dict: + """ + Analyzes repair issue details using Gemini API to categorize and summarize. + """ + logger.info(f"Analyzing repair issue with Gemini: '{issue_detail[:100]}...'") + + if not config.GEMINI_API_KEY or config.GEMINI_API_KEY == "YOUR_API_KEY": + logger.error("Gemini API key is not configured.") + return {"error": "Gemini API is not configured."} + + try: + genai.configure(api_key=config.GEMINI_API_KEY) + model = genai.GenerativeModel('gemini-1.5-flash') + + prompt = f""" + Analyze the following repair issue description from a hotel room. + Categorize the issue into one of these types: Plumbing, Electrical, HVAC, Furniture, Appliance, Other. + Provide a concise summary in Thai. + + Issue Description: "{issue_detail}" + + Output format: + Category: [Your Category] + Summary: [Your Summary in Thai] + """ + + response = await model.generate_content_async(prompt) + response_text = response.text.strip() + logger.info(f"Gemini raw response: '{response_text}'") + + category_match = re.search(r'Category: (.+)', response_text) + summary_match = re.search(r'Summary: (.+)', response_text) + + category = category_match.group(1).strip() if category_match else "Other" + summary = summary_match.group(1).strip() if summary_match else issue_detail + + return {"category": category, "summary": summary} + + except Exception as e: + logger.error(f"Error calling Gemini API: {e}", exc_info=True) + return {"error": str(e)} diff --git a/bot/sheets.py b/bot/sheets.py new file mode 100644 index 0000000..6f257d5 --- /dev/null +++ b/bot/sheets.py @@ -0,0 +1,113 @@ +# bot/sheets.py +""" +Module for all Google Sheets interactions using gspread. +""" + +import gspread +from oauth2client.service_account import ServiceAccountCredentials +import logging +import re + +import config + +logger = logging.getLogger(__name__) + +_client = None + +def _get_client(): + """Authenticates with Google Sheets API and returns a client. Caches the client.""" + global _client + if _client: + return _client + + try: + scope = ['https://spreadsheets.google.com/feeds', 'https://www.googleapis.com/auth/drive'] + creds = ServiceAccountCredentials.from_json_keyfile_name(config.SERVICE_ACCOUNT_JSON_PATH, scope) + _client = gspread.authorize(creds) + logger.info("Successfully authorized Google Sheets client.") + return _client + except FileNotFoundError: + logger.error(f"Service account file not found at: {config.SERVICE_ACCOUNT_JSON_PATH}") + raise + except Exception as e: + logger.critical(f"Failed to authorize Google Sheets client: {e}", exc_info=True) + raise + +async def get_sheet(spreadsheet_name: str, worksheet_name: str): + """Gets a specific worksheet from a spreadsheet.""" + try: + client = _get_client() + spreadsheet = client.open(spreadsheet_name) + worksheet = spreadsheet.worksheet(worksheet_name) + logger.info(f"Successfully accessed worksheet '{worksheet_name}'.") + return worksheet + except gspread.exceptions.SpreadsheetNotFound: + logger.error(f"Spreadsheet '{spreadsheet_name}' not found.") + raise + except gspread.exceptions.WorksheetNotFound: + logger.error(f"Worksheet '{worksheet_name}' not found in '{spreadsheet_name}'.") + raise + except Exception as e: + logger.error(f"Error getting worksheet '{worksheet_name}': {e}", exc_info=True) + raise + +async def add_repair_ticket(sheet, ticket_data: dict) -> bool: + """Appends a new repair ticket to the specified sheet.""" + try: + # The order must match the columns in the Google Sheet + row_data = [ + ticket_data.get('ticket_id'), + ticket_data.get('room_number'), + ticket_data.get('issue_detail'), + ticket_data.get('status'), + ticket_data.get('reported_timestamp'), + "", # Placeholder for closed_timestamp + ticket_data.get('category', 'N/A') + ] + sheet.append_row(row_data) + logger.info(f"Appended repair ticket {ticket_data.get('ticket_id')} to Google Sheet.") + return True + except Exception as e: + logger.error(f"Failed to append repair ticket to Google Sheet: {e}", exc_info=True) + return False + +async def update_repair_status(sheet, ticket_id: str, new_status: str, closed_timestamp: str | None) -> bool: + """Finds a repair ticket by ID and updates its status.""" + try: + cell = sheet.find(ticket_id) + if not cell: + logger.warning(f"Ticket ID '{ticket_id}' not found in sheet for status update.") + return False + + # Assuming column order: Ticket ID, Room, Issue, Status, Reported, Closed + sheet.update_cell(cell.row, 4, new_status) # Update Status (Column D) + if new_status == 'ปิดงาน' and closed_timestamp: + sheet.update_cell(cell.row, 6, closed_timestamp) # Update Closed Timestamp (Column F) + + logger.info(f"Updated status for ticket '{ticket_id}' to '{new_status}'.") + return True + except Exception as e: + logger.error(f"Failed to update status for ticket '{ticket_id}': {e}", exc_info=True) + return False + +async def get_next_repair_ticket_id(sheet) -> str: + """Generates the next repair ticket ID based on the last entry in the sheet.""" + try: + ticket_ids_column = sheet.col_values(1) # Assuming Ticket ID is in the first column (A) + last_id_number = 0 + if ticket_ids_column: + for cell_value in reversed(ticket_ids_column): + if cell_value and cell_value.strip(): + match = re.search(r'^REPAIR_(\d+)$', cell_value.strip(), re.IGNORECASE) + if match: + last_id_number = int(match.group(1)) + break # Found the last valid ID + + new_id_number = last_id_number + 1 + ticket_id = f'REPAIR_{new_id_number}' + logger.info(f"Generated new repair ticket ID: {ticket_id}") + return ticket_id + except Exception as e: + logger.error(f"Error generating next repair ticket ID: {e}", exc_info=True) + # Fallback ID in case of error + return f"REPAIR_ERR_{datetime.now().strftime('%H%M%S')}" diff --git a/config.py b/config.py new file mode 100644 index 0000000..e26ad44 --- /dev/null +++ b/config.py @@ -0,0 +1,60 @@ +# config.py +""" +Configuration file for the Hotel OS Bot. +Loads sensitive data from Colab Secrets or environment variables. +""" + +import os +from google.colab import userdata +import logging + +logger = logging.getLogger(__name__) + +def get_secret(secret_name: str, default: str = None) -> str | None: + """Safely retrieves a secret from Colab userdata or environment variables.""" + try: + # Prioritize Colab userdata + value = userdata.get(secret_name) + if value and value != default: + logger.info(f"Successfully loaded '{secret_name}' from Colab Secrets.") + return value + except userdata.SecretNotFoundError: + pass # Fallback to environment variable + except Exception as e: + logger.warning(f"Error loading '{secret_name}' from Colab Secrets: {e}") + + # Fallback to environment variable + value = os.environ.get(secret_name) + if value and value != default: + logger.info(f"Successfully loaded '{secret_name}' from environment variables.") + return value + + logger.warning(f"'{secret_name}' not found in Colab Secrets or environment variables. Using default/placeholder value.") + return default + +# --- Telegram Configuration --- +BOT_TOKEN = get_secret("BOT_TOKEN", "YOUR_BOT_TOKEN_HERE") + +# --- Google Gemini AI Configuration --- +GEMINI_API_KEY = get_secret("GEMINI_API_KEY", "YOUR_API_KEY") + +# --- Google Sheets & Drive Configuration --- +SERVICE_ACCOUNT_JSON_PATH = get_secret("SERVICE_ACCOUNT_JSON_PATH", "hotel-service-account.json") +GSHEET_SPREADSHEET_NAME = "ระบบจัดการโรงแรม_DB" + +# --- Bot Admin/Notification Configuration --- +try: + ADMIN_CHAT_ID = int(get_secret("ADMIN_CHAT_ID", "123456789")) +except (ValueError, TypeError): + logger.warning("ADMIN_CHAT_ID is not a valid integer. Admin notifications will be disabled.") + ADMIN_CHAT_ID = None + +# --- Webhook Configuration (for Colab/ngrok) --- +PORT = 8000 +NGROK_AUTHTOKEN = get_secret("NGROK_AUTHTOKEN", "YOUR_AUTHTOKEN") + +# --- Database Configuration --- +DATABASE_FILE = "hotel_os.db" + +# --- Temporary File Directory --- +TEMP_SLIP_DIR = 'temp_slips' diff --git a/main.py b/main.py new file mode 100644 index 0000000..7398ab2 --- /dev/null +++ b/main.py @@ -0,0 +1,96 @@ +# main.py +""" +Main entry point for the Hotel OS Bot. +This script sets up logging, initializes the database, configures the bot application, +adds all handlers, and starts the bot in webhook mode using ngrok. +""" + +import asyncio +import logging +import nest_asyncio +from pyngrok import ngrok +from telegram.ext import Application + +import config +from bot.database import setup_database +from bot.handlers import register_handlers + +# --- Apply nest_asyncio for Colab compatibility --- +try: + nest_asyncio.apply() + logging.info("nest_asyncio applied.") +except RuntimeError: + logging.info("nest_asyncio already applied or not needed.") +except Exception as e: + logging.error(f"Error applying nest_asyncio: {e}") + +# --- Configure Logging --- +logging.basicConfig( + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + level=logging.INFO +) +logging.getLogger('httpx').setLevel(logging.WARNING) +logger = logging.getLogger(__name__) + + +async def main(): + """Main function to set up and run the bot.""" + + # --- Pre-run checks for critical configurations --- + if not config.BOT_TOKEN or config.BOT_TOKEN == "YOUR_BOT_TOKEN_HERE": + logger.critical("BOT_TOKEN is not configured. Please set it in Colab Secrets or .env file.") + return + + if not config.NGROK_AUTHTOKEN or config.NGROK_AUTHTOKEN == "YOUR_AUTHTOKEN": + logger.critical("NGROK_AUTHTOKEN is not configured. Webhook mode will fail.") + return + + # --- Initial Setup --- + logger.info("Setting up database...") + await setup_database() + + # --- Build Telegram Bot Application --- + logger.info("Building Telegram application...") + application = Application.builder().token(config.BOT_TOKEN).build() + + # --- Register Handlers --- + register_handlers(application) + + # --- Webhook Setup (using ngrok for Colab) --- + try: + logger.info(f"Setting ngrok auth token...") + ngrok.set_auth_token(config.NGROK_AUTHTOKEN) + + logger.info(f"Connecting ngrok to port {config.PORT}...") + public_url = ngrok.connect(config.PORT).public_url + logger.info(f"Ngrok tunnel established at: {public_url}") + + logger.info("Setting webhook...") + await application.bot.set_webhook(url=public_url) + + # --- Run the Bot --- + logger.info(f"Starting webhook listener on 0.0.0.0:{config.PORT}") + print(f"✅ Bot is running! Public URL: {public_url}") + + await application.run_webhook( + listen="0.0.0.0", + port=config.PORT, + webhook_url=public_url + ) + + except Exception as e: + logger.critical(f"Failed to start bot or ngrok tunnel: {e}", exc_info=True) + print(f"❌ Failed to start bot: {e}") + finally: + logger.info("Shutting down ngrok tunnel.") + ngrok.kill() + + +if __name__ == '__main__': + try: + asyncio.run(main()) + except KeyboardInterrupt: + logger.info("Bot stopped manually.") + print("\nBot stopped.") + except Exception as e: + logger.critical(f"Critical error in main execution block: {e}", exc_info=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fc69d02 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,20 @@ +# Telegram Bot +python-telegram-bot + +# Google Services +gspread +oauth2client +google-generativeai + +# OCR +Pillow +pytesseract + +# PDF Generation +fpdf + +# Utilities +python-dateutil +nest-asyncio +pyngrok +pydantic