diff --git a/.github/workflows/generator-generic-ossf-slsa3-publish.yml b/.github/workflows/generator-generic-ossf-slsa3-publish.yml new file mode 100644 index 0000000..35c829b --- /dev/null +++ b/.github/workflows/generator-generic-ossf-slsa3-publish.yml @@ -0,0 +1,66 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# This workflow lets you generate SLSA provenance file for your project. +# The generation satisfies level 3 for the provenance requirements - see https://slsa.dev/spec/v0.1/requirements +# The project is an initiative of the OpenSSF (openssf.org) and is developed at +# https://github.com/slsa-framework/slsa-github-generator. +# The provenance file can be verified using https://github.com/slsa-framework/slsa-verifier. +# For more information about SLSA and how it improves the supply-chain, visit slsa.dev. + +name: SLSA generic generator +on: + workflow_dispatch: + release: + types: [created] + +jobs: + build: + runs-on: ubuntu-latest + outputs: + digests: ${{ steps.hash.outputs.digests }} + + steps: + - uses: actions/checkout@v4 + + # ======================================================== + # + # Step 1: Build your artifacts. + # + # ======================================================== + - name: Build artifacts + run: | + # These are some amazing artifacts. + echo "artifact1" > artifact1 + echo "artifact2" > artifact2 + + # ======================================================== + # + # Step 2: Add a step to generate the provenance subjects + # as shown below. Update the sha256 sum arguments + # to include all binaries that you generate + # provenance for. + # + # ======================================================== + - name: Generate subject for provenance + id: hash + run: | + set -euo pipefail + + # List the artifacts the provenance will refer to. + files=$(ls artifact*) + # Generate the subjects (base64 encoded). + echo "hashes=$(sha256sum $files | base64 -w0)" >> "${GITHUB_OUTPUT}" + + provenance: + needs: [build] + permissions: + actions: read # To read the workflow path. + id-token: write # To sign the provenance. + contents: write # To add assets to a release. + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.4.0 + with: + base64-subjects: "${{ needs.build.outputs.digests }}" + upload-assets: true # Optional: Upload to a new release diff --git a/.github/workflows/huggingface.yml b/.github/workflows/huggingface.yml new file mode 100644 index 0000000..b5fffe6 --- /dev/null +++ b/.github/workflows/huggingface.yml @@ -0,0 +1,29 @@ + +name: Sync to Hugging Face space + +on: + push: + branches: + - main # Or whichever branch you want to deploy from + +jobs: + sync-to-hub: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + lfs: true + + - name: Push to HF Space + env: + HF_SPACE_GIT_REPO: "https://huggingface.co/spaces/nssuwan186/Bot_telegram" + run: | + git remote add space "https://USER:${{ secrets.HF_TOKEN }}@huggingface.co/spaces/nssuwan186/Bot_telegram" + git push --force space main + +# IMPORTANT: +# 1. Replace 'YOUR_HF_USERNAME' with your Hugging Face username. +# 2. Replace 'YOUR_HF_SPACE_NAME' with your Hugging Face Space name. +# 3. You must create a secret in your GitHub repository named 'HF_TOKEN'. +# This token should be a Hugging Face access token with 'write' permissions. diff --git a/.gitignore b/.gitignore index 7698ead..e87a8c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ -.streamlit/secrets.toml - # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -22,6 +20,7 @@ parts/ sdist/ var/ wheels/ +pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg @@ -51,7 +50,6 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ -cover/ # Translations *.mo @@ -74,7 +72,6 @@ instance/ docs/_build/ # PyBuilder -.pybuilder/ target/ # Jupyter Notebook @@ -85,35 +82,9 @@ profile_default/ ipython_config.py # pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +.python-version + +# PEP 582; used by Poetry, Flit, and PDM __pypackages__/ # Celery stuff @@ -129,8 +100,8 @@ celerybeat.pid env/ venv/ ENV/ -env.bak/ -venv.bak/ +env.bak +venv.bak # Spyder project settings .spyderproject @@ -150,15 +121,8 @@ dmypy.json # Pyre type checker .pyre/ -# pytype static type analyzer +# pytype static analyzer .pytype/ # Cython debug symbols cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..970cdf6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Use the official Python image +FROM python:3.10-slim + +# Set the working directory +WORKDIR /app + +# Install system dependencies +# - Tesseract for OCR +# - Cron for scheduled tasks +# - Git for Hugging Face integration +RUN apt-get update && apt-get install -y \ + tesseract-ocr \ + cron \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Copy the requirements file and install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the rest of the application code +COPY . . + +# Make the startup script executable +RUN chmod +x /app/start.sh + +# Expose the port FastAPI will run on +EXPOSE 7860 + +# Set the entrypoint to the startup script +CMD ["/app/start.sh"] diff --git a/README.md b/README.md index 9a5ed83..f33f834 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,115 @@ -# ๐Ÿ’ฌ Chatbot template +# Hotel OS Telegram Bot -A simple Streamlit app that shows how to build a chatbot using OpenAI's GPT-3.5. +This is a comprehensive Telegram bot designed to act as a "Hotel Operating System". It assists with managing bookings, verifying payments via OCR, tracking expenses, and controlling room hardware (lights, AC) via MQTT. -[![Open in Streamlit](https://static.streamlit.io/badges/streamlit_badge_black_white.svg)](https://chatbot-template.streamlit.app/) +The bot is built with Python, using `python-telegram-bot`, `FastAPI`, and `SQLAlchemy`. It's designed to be deployed as a container on Hugging Face Spaces. -### How to run it on your own machine +## Features -1. Install the requirements +- **AI Assistant**: Powered by Google Gemini for general-purpose questions. +- **Payment Verification**: Upload a payment slip image, and the bot uses Tesseract OCR to extract the customer name and amount, then matches it against an unpaid booking in the database. +- **Expense Tracking**: Add expenses via the Telegram Web App. +- **Hardware Control**: Control IoT devices (like lights and AC) using the MQTT protocol, either via commands or the Web App. +- **Daily Reports**: Get a summary of daily income and expenses with the `/daily_report` command. +- **Database**: Uses SQLite to store all data, with automatic daily backups sent to your Telegram. +- **Automated Deployment**: Automatically deploys to a configured Hugging Face Space on push to the `main` branch via GitHub Actions. - ``` - $ pip install -r requirements.txt - ``` +## Project Structure -2. Run the app +``` +/hotel-os-bot +โ”œโ”€โ”€ .github/workflows/huggingface.yml # GitHub Action for auto-deployment +โ”œโ”€โ”€ app/ # Main application source code +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”œโ”€โ”€ main.py # FastAPI app and Telegram bot logic +โ”‚ โ”œโ”€โ”€ config.py # Configuration and environment variables +โ”‚ โ”œโ”€โ”€ database.py # SQLAlchemy models and DB functions +โ”‚ โ”œโ”€โ”€ ocr.py # Tesseract OCR processing logic +โ”‚ โ””โ”€โ”€ mqtt.py # MQTT publishing logic +โ”œโ”€โ”€ scripts/ +โ”‚ โ””โ”€โ”€ backup_db.py # Script for daily DB backups +โ”œโ”€โ”€ webapp/ # Telegram Web App files +โ”‚ โ”œโ”€โ”€ index.html +โ”‚ โ”œโ”€โ”€ style.css +โ”‚ โ””โ”€โ”€ app.js +โ”œโ”€โ”€ .gitignore +โ”œโ”€โ”€ Dockerfile # Docker container definition for HF Spaces +โ”œโ”€โ”€ README.md # This file +โ”œโ”€โ”€ requirements.txt # Python dependencies +โ””โ”€โ”€ start.sh # Container startup script +``` - ``` - $ streamlit run streamlit_app.py - ``` +## Deployment Guide + +Follow these steps to get your bot running on Hugging Face Spaces, with automated deployments from your GitHub repository. + +### Step 1: Push to GitHub + +1. Create a new **private** repository on GitHub. +2. In your local terminal, add the GitHub repository as a remote: + ```bash + git remote add origin https://github.com/YOUR_USERNAME/YOUR_REPO_NAME.git + git branch -M main + ``` +3. Commit and push all the generated files: + ```bash + git add . + git commit -m "Initial project setup" + git push -u origin main + ``` + +### Step 2: Create a Hugging Face Space + +1. Go to [Hugging Face](https://huggingface.co/new-space) and create a new **Space**. +2. **Owner/Space name**: Choose a name for your project (e.g., `hotel-os-bot`). +3. **License**: Select `mit` or another license. +4. **Select the Space SDK**: Choose **Docker** and `Blank` template. +5. **Space hardware**: The free `CPU basic` is sufficient to start. +6. **Secrets**: You do not need to set secrets here initially if you are using the GitHub Action workflow, but you will need to add them later for the bot to run. +7. Click **Create Space**. The initial build will likely fail, which is normal. + +### Step 3: Configure GitHub Actions for Auto-Deployment + +1. **Get Hugging Face Token**: In Hugging Face, go to `Your Profile -> Settings -> Access Tokens`. Create a new token with the `write` role. Copy this token. +2. **Add Secret to GitHub**: In your GitHub repository, go to `Settings -> Secrets and variables -> Actions`. Create a **New repository secret**: + * **Name**: `HF_TOKEN` + * **Value**: Paste the Hugging Face token you just copied. +3. **Update Workflow File**: Open `.github/workflows/huggingface.yml` in your local project. Replace the placeholder `YOUR_HF_USERNAME` and `YOUR_HF_SPACE_NAME` with your actual HF username and Space name. + ```yaml + # ... + env: + HF_SPACE_GIT_REPO: "https://huggingface.co/spaces/your-hf-username/your-hf-space-name" + run: | + git remote add space "https://USER:${{ secrets.HF_TOKEN }}@huggingface.co/spaces/your-hf-username/your-hf-space-name" + git push --force space main + ``` +4. Commit and push this change. This will trigger the first deployment from GitHub Actions. You can monitor its progress in the "Actions" tab of your GitHub repository. + +### Step 4: Configure Bot Secrets in Hugging Face Space + +For your bot to run correctly, it needs its API keys. These **must** be set in the Hugging Face Space settings, not in your code. + +1. In your HF Space, go to the `Settings` tab. +2. Scroll down to **Repository secrets** and click **New secret**. +3. Add the following secrets one by one: + + * `TELEGRAM_BOT_TOKEN`: Your token from BotFather. + * `TELEGRAM_USER_ID`: Your numeric Telegram user ID. You can get this from a bot like `@userinfobot`. + * `GEMINI_API_KEY`: Your API key for Google Gemini. + * `MQTT_BROKER`: (Optional) The address of your MQTT broker. Defaults to `broker.hivemq.com`. + * `MQTT_TOPIC_PREFIX`: (Optional) The base topic for your MQTT devices. Defaults to `hotel/room1`. + +### Step 5: Update Web App URL and Final Test + +1. **Update URL**: Once your space is deployed, it will have a URL like `https://your-username-your-space-name.hf.space`. You need to put this URL into the `app/main.py` file. + ```python + # in app/main.py, inside start_command function + web_app_button = KeyboardButton( + "Open Hotel OS Web App", + web_app=WebAppInfo(url=f"https://your-hf-username-your-hf-space-name.hf.space/static/index.html") + ) + ``` + *Note: The URL should point to the static HTML file.* + +2. Commit and push this final change. The GitHub Action will run again and deploy the updated code. +3. **Test**: Open Telegram and talk to your bot. Use the `/start` command to see the Web App button and test all features. diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..507aeb5 --- /dev/null +++ b/app/config.py @@ -0,0 +1,49 @@ +import os +import logging +from dotenv import load_dotenv + +# Load environment variables from .env file for local development +load_dotenv() + +# --- Logging Configuration --- +LOGGING_LEVEL = os.getenv("LOGGING_LEVEL", "INFO").upper() +logging.basicConfig( + level=LOGGING_LEVEL, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[ + logging.StreamHandler() # Log to console + ] +) + +def get_logger(name): + """Creates a configured logger instance.""" + return logging.getLogger(name) + +# --- Telegram Configuration --- +TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +AUTHORIZED_USER_ID = int(os.getenv("TELEGRAM_USER_ID", 0)) +ADMIN_CHAT_ID = os.getenv("ADMIN_CHAT_ID") +if ADMIN_CHAT_ID: + try: + ADMIN_CHAT_ID = int(ADMIN_CHAT_ID) + except ValueError: + ADMIN_CHAT_ID = None + +# --- Gemini AI Configuration --- +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") + +# --- Database Configuration --- +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///hotel_os_bot.db") + +# --- MQTT Configuration --- +MQTT_BROKER = os.getenv("MQTT_BROKER", "broker.hivemq.com") +MQTT_PORT = int(os.getenv("MQTT_PORT", 1883)) +MQTT_TOPIC_PREFIX = os.getenv("MQTT_TOPIC_PREFIX", "hotel/room1") + +# --- Validation --- +if not TELEGRAM_BOT_TOKEN: + raise ValueError("TELEGRAM_BOT_TOKEN environment variable not set!") +if not AUTHORIZED_USER_ID: + raise ValueError("TELEGRAM_USER_ID environment variable not set!") +if not GEMINI_API_KEY: + raise ValueError("GEMINI_API_KEY environment variable not set!") diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..428c52e --- /dev/null +++ b/app/database.py @@ -0,0 +1,112 @@ + +import datetime +from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, Boolean, Text +from sqlalchemy.orm import sessionmaker, declarative_base +from sqlalchemy.sql import func + +from app.config import DATABASE_URL, get_logger + +logger = get_logger(__name__) + +# Define the database schema +Base = declarative_base() + +class Booking(Base): + __tablename__ = 'bookings' + id = Column(Integer, primary_key=True) + customer_name = Column(String, nullable=False) + check_in_date = Column(DateTime, nullable=False) + check_out_date = Column(DateTime, nullable=False) + room_number = Column(String, nullable=False) + total_price = Column(Float, nullable=False) + is_paid = Column(Boolean, default=False) + created_at = Column(DateTime, default=func.now()) + +class PaymentSlip(Base): + __tablename__ = 'payment_slips' + id = Column(Integer, primary_key=True) + booking_id = Column(Integer, nullable=False) + file_id = Column(String, nullable=False) # Telegram file_id + slip_data = Column(Text) # OCR extracted data + verified = Column(Boolean, default=False) + uploaded_at = Column(DateTime, default=func.now()) + +class Expense(Base): + __tablename__ = 'expenses' + id = Column(Integer, primary_key=True) + description = Column(String, nullable=False) + amount = Column(Float, nullable=False) + category = Column(String, default='General') + date = Column(DateTime, default=func.now()) + +# --- Database Engine and Session --- # +# The `check_same_thread=False` is needed only for SQLite. +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def get_db(): + """Dependency to get a DB session for each request.""" + db = SessionLocal() + try: + yield db + finally: + db.close() + +def init_db(): + """Initializes the database and creates tables if they don't exist.""" + try: + logger.info("Initializing database...") + Base.metadata.create_all(bind=engine) + logger.info("Database initialized successfully.") + except Exception as e: + logger.error(f"Error initializing database: {e}", exc_info=True) + +# --- CRUD Operations --- # + +def find_booking_by_details(db, name: str, amount: float): + """Finds an unpaid booking matching customer name and amount.""" + return db.query(Booking).filter( + Booking.customer_name.ilike(f'%{name}%'), + Booking.total_price == amount, + Booking.is_paid == False + ).first() + +def mark_booking_as_paid(db, booking_id: int): + """Marks a booking as paid.""" + booking = db.query(Booking).filter(Booking.id == booking_id).first() + if booking: + booking.is_paid = True + db.commit() + return booking + return None + +def create_payment_slip_record(db, booking_id: int, file_id: str, slip_data: str): + """Creates a record for a new payment slip.""" + slip = PaymentSlip( + booking_id=booking_id, + file_id=file_id, + slip_data=slip_data, + verified=True # Mark as verified since we found a matching booking + ) + db.add(slip) + db.commit() + return slip + +def get_daily_report_data(db, date: datetime.date): + """Fetches data for the daily financial report.""" + start_of_day = datetime.datetime.combine(date, datetime.time.min) + end_of_day = datetime.datetime.combine(date, datetime.time.max) + + income = db.query(func.sum(Booking.total_price)).filter( + Booking.is_paid == True, + Booking.created_at >= start_of_day, + Booking.created_at <= end_of_day + ).scalar() or 0.0 + + expenses = db.query(func.sum(Expense.amount)).filter( + Expense.date >= start_of_day, + Expense.date <= end_of_day + ).scalar() or 0.0 + + return {"income": income, "expenses": expenses, "net": income - expenses} diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..11d0359 --- /dev/null +++ b/app/main.py @@ -0,0 +1,201 @@ + +import asyncio +import threading +from fastapi import FastAPI, Request +from fastapi.staticfiles import StaticFiles +from fastapi.responses import HTMLResponse +import uvicorn +import google.generativeai as genai +from telegram import Update, WebAppInfo, KeyboardButton, ReplyKeyboardMarkup +import datetime +from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes, CallbackContext + +# Import from our modules +from app.config import ( + TELEGRAM_BOT_TOKEN, AUTHORIZED_USER_ID, GEMINI_API_KEY, + get_logger +) +from app.database import init_db, get_db, find_booking_by_details, mark_booking_as_paid, create_payment_slip_record, get_daily_report_data +from app.ocr import process_payment_slip +from app.mqtt import publish_command +from geofence import get_geofences_containing_point + +# --- Initialization --- +logger = get_logger(__name__) + +# Configure Gemini AI +genai.configure(api_key=GEMINI_API_KEY) +ai_model = genai.GenerativeModel('gemini-pro') + +# FastAPI app setup +app = FastAPI() +app.mount("/static", StaticFiles(directory="webapp"), name="static") + +# --- Authorization --- +def is_authorized(update: Update) -> bool: + """Checks if the user is authorized to use the bot.""" + return update.effective_user.id == AUTHORIZED_USER_ID + +# --- Telegram Bot Handlers --- +async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not is_authorized(update): + await update.message.reply_text("You are not authorized to use this bot.") + return + + # Create a button that opens the web app + web_app_button = KeyboardButton( + "Open Hotel OS Web App", + web_app=WebAppInfo(url=f"https://nssuwan186-Bot-telegram.hf.space/static/index.html") + ) + keyboard = [[web_app_button]] + reply_markup = ReplyKeyboardMarkup(keyboard, resize_keyboard=True) + + await update.message.reply_text( + "Welcome to Hotel OS! Use the button below to manage payments and expenses.", + reply_markup=reply_markup + ) + +async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not is_authorized(update): return + help_text = """ + Available Commands: + /start - Show the main menu and Web App button. + /help - Show this help message. + /daily_report - Get a summary of today's income and expenses. + /light - Control the lights. + /ac - Control the AC. + + You can also send me a payment slip image to verify it, or ask me anything else. + """ + await update.message.reply_text(help_text) + +async def daily_report_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not is_authorized(update): return + db = next(get_db()) + today = datetime.date.today() + report_data = get_daily_report_data(db, today) + message = ( + f"Financial Report for {today.strftime('%Y-%m-%d')}:\n" + f"- Total Income: {report_data['income']:.2f} THB\n" + f"- Total Expenses: {report_data['expenses']:.2f} THB\n" + f"--------------------\n" + f"- Net Profit: {report_data['net']:.2f} THB" + ) + await update.message.reply_text(message) + +async def handle_payment_slip(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not is_authorized(update): return + + file_id = update.message.photo[-1].file_id + await update.message.reply_text("Processing your payment slip... This may take a moment.") + + ocr_result = await asyncio.to_thread(process_payment_slip, context.bot, file_id) + + if not ocr_result: + await update.message.reply_text("Sorry, I couldn't read the details from the slip. Please check the image quality or enter the details manually.") + return + + db = next(get_db()) + booking = find_booking_by_details(db, name=ocr_result['name'], amount=ocr_result['amount']) + + if booking: + mark_booking_as_paid(db, booking.id) + create_payment_slip_record(db, booking.id, file_id, str(ocr_result)) + await update.message.reply_text( + f"Success! Payment verified for booking ID {booking.id} (Customer: {booking.customer_name}). The booking is now marked as paid." + ) + else: + await update.message.reply_text( + f"I read the slip (Name: {ocr_result['name']}, Amount: {ocr_result['amount']}), but couldn't find a matching unpaid booking. Please check the details." + ) + +async def handle_location(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not is_authorized(update): return + + lat = update.message.location.latitude + lon = update.message.location.longitude + + await update.message.reply_text(f"Received your location: Lat={lat}, Lon={lon}. Checking geofences...") + + db = next(get_db()) + containing_fences = get_geofences_containing_point(db, lat, lon) + + if containing_fences: + fence_names = [f.name for f in containing_fences] + await update.message.reply_text(f"You are currently inside the following geofence(s): {', '.join(fence_names)}") + else: + await update.message.reply_text("You are not currently inside any known geofence.") + +async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not is_authorized(update): return + + user_text = update.message.text + logger.info(f"Received text from user: {user_text}") + + # Simple routing for hardware commands + if user_text.lower().startswith('/light'): + parts = user_text.split() + if len(parts) > 1 and parts[1].upper() in ['ON', 'OFF']: + command = parts[1].upper() + if publish_command('light', command): + await update.message.reply_text(f"Turned the light {command}.") + else: + await update.message.reply_text("Failed to send command to the light.") + else: + await update.message.reply_text("Usage: /light ") + return + + if user_text.lower().startswith('/ac'): + parts = user_text.split() + if len(parts) > 1: + command = parts[1].upper() + if publish_command('ac', command): + await update.message.reply_text(f"Sent command '{command}' to the AC.") + else: + await update.message.reply_text("Failed to send command to the AC.") + else: + await update.message.reply_text("Usage: /ac ") + return + + # Fallback to Gemini AI + await update.message.reply_chat_action('typing') + try: + response = await asyncio.to_thread(ai_model.generate_content, user_text) + await update.message.reply_text(response.text) + except Exception as e: + logger.error(f"Error calling Gemini AI: {e}", exc_info=True) + await update.message.reply_text("Sorry, I'm having trouble connecting to my AI brain. Please try again later.") + +# --- FastAPI Endpoints --- +@app.get("/") +async def root(): + # This could be a simple health check page + return {"status": "running"} + +# --- Application Lifecycle --- +def run_bot(): + """Runs the Telegram bot in a polling loop.""" + logger.info("Starting Telegram bot polling...") + bot_app = Application.builder().token(TELEGRAM_BOT_TOKEN).build() + + # Add handlers + bot_app.add_handler(CommandHandler("start", start_command)) + bot_app.add_handler(CommandHandler("help", help_command)) + bot_app.add_handler(CommandHandler("daily_report", daily_report_command)) + bot_app.add_handler(MessageHandler(filters.PHOTO, handle_payment_slip)) + bot_app.add_handler(MessageHandler(filters.LOCATION, handle_location)) + bot_app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text_message)) + + bot_app.run_polling() + +@app.on_event("startup") +async def startup_event(): + """Actions to take on application startup.""" + logger.info("Application startup...") + init_db() # Initialize the database + + # Run the bot in a separate thread + bot_thread = threading.Thread(target=run_bot) + bot_thread.daemon = True + bot_thread.start() + logger.info("Telegram bot thread started.") diff --git a/app/mqtt.py b/app/mqtt.py new file mode 100644 index 0000000..7efd3d0 --- /dev/null +++ b/app/mqtt.py @@ -0,0 +1,24 @@ +import paho.mqtt.client as mqtt +from app.config import MQTT_BROKER, MQTT_PORT, MQTT_TOPIC_PREFIX, get_logger + +logger = get_logger(__name__) + +def publish_command(device: str, command: str): + """ + Publishes a command to a specific MQTT topic. + + Args: + device (str): The device to control (e.g., 'light', 'ac'). + command (str): The command to send (e.g., 'ON', 'OFF', '25'). + """ + topic = f"{MQTT_TOPIC_PREFIX}/{device}/command" + try: + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1) + client.connect(MQTT_BROKER, MQTT_PORT, 60) + client.publish(topic, command) + client.disconnect() + logger.info(f"Published to MQTT topic '{topic}' with message: '{command}'") + return True + except Exception as e: + logger.error(f"Failed to publish to MQTT topic '{topic}': {e}", exc_info=True) + return False diff --git a/app/ocr.py b/app/ocr.py new file mode 100644 index 0000000..b66cf9b --- /dev/null +++ b/app/ocr.py @@ -0,0 +1,76 @@ +import pytesseract +from PIL import Image +import requests +import io +import re + +from app.config import get_logger + +logger = get_logger(__name__) + +def process_payment_slip(bot, file_id: str) -> dict | None: + """ + Downloads a payment slip image from Telegram, performs OCR, and extracts details. + + Args: + bot: The Telegram bot instance. + file_id: The file_id of the image to process. + + Returns: + A dictionary containing extracted 'name' and 'amount', or None if processing fails. + """ + try: + logger.info(f"Processing payment slip with file_id: {file_id}") + file = bot.get_file(file_id) + file_url = file.file_path + + # Download the image + response = requests.get(file_url) + response.raise_for_status() # Raise an exception for bad status codes + image_bytes = io.BytesIO(response.content) + image = Image.open(image_bytes) + + # Perform OCR + ocr_text = pytesseract.image_to_string(image, lang='tha+eng') + logger.debug(f"OCR Raw Text:\n{ocr_text}") + + # Extract information (this is a simplified example, regex might need tuning) + name = extract_name(ocr_text) + amount = extract_amount(ocr_text) + + if name and amount: + logger.info(f"Successfully extracted Name: '{name}', Amount: {amount}") + return {"name": name, "amount": amount} + else: + logger.warning("Could not extract name or amount from OCR text.") + return None + + except Exception as e: + logger.error(f"An error occurred during OCR processing: {e}", exc_info=True) + return None + +def extract_name(text: str) -> str | None: + """Extracts a name from the OCR text.""" + # This regex looks for lines that might contain a name, common in Thai slips. + # It's a simple example and might need significant improvement. + match = re.search(r"(เธŠเธทเนˆเธญ|Name|To)[:\s]*(.+)", text, re.IGNORECASE) + if match: + return match.group(2).strip() + return None + +def extract_amount(text: str) -> float | None: + """Extracts the transfer amount from the OCR text.""" + # This regex looks for a floating point number, possibly with commas. + # It targets lines that often contain the total amount. + match = re.search(r"(เธˆเธณเธ™เธงเธ™เน€เธ‡เธดเธ™|Amount|Total)[:\s]*([\d,]+\.\d{2})", text, re.IGNORECASE) + if match: + amount_str = match.group(2).replace(",", "") + return float(amount_str) + + # Fallback for lines that just have the number + match = re.search(r"([\d,]+\.\d{2})", text) + if match: + amount_str = match.group(1).replace(",", "") + return float(amount_str) + + return None diff --git a/geofence.py b/geofence.py new file mode 100644 index 0000000..77751f3 --- /dev/null +++ b/geofence.py @@ -0,0 +1,72 @@ +import json +import logging +from shapely.geometry import Point, Polygon +from typing import List, Optional, Union + +from models import Geofence # เธ•เน‰เธญเธ‡เธกเธต models.py เนƒเธ™ project root + +logger = logging.getLogger(__name__) + + +def parse_polygon(polygon_text: Union[str, List[List[float]]]) -> Optional[Polygon]: + """ + Parse polygon JSON stored in DB. + Accept formats: + - [[lat, lon], [lat, lon], ...] + - [[lon, lat], [lon, lat], ...] + Return shapely.geometry.Polygon or None on error. + """ + try: + data = json.loads(polygon_text) if isinstance(polygon_text, str) else polygon_text + if not isinstance(data, list) or len(data) < 3: + return None + + first = data[0] + if not (isinstance(first, list) and len(first) >= 2): + return None + try: + # Convert all points to float tuples upfront. + all_coords = [(float(p[0]), float(p[1])) for p in data] + except (ValueError, TypeError, IndexError): + logger.warning("Invalid coordinate format in polygon data", exc_info=True) + return None + + a, b = all_coords[0] + # If first value looks like longitude (abs>90) assume [lon, lat] + if abs(a) > 90 and abs(b) <= 90: + coords = all_coords # already (lon, lat) + else: + # Assume (lat, lon) and swap to (lon, lat) for shapely + coords = [(p[1], p[0]) for p in all_coords] + + poly = Polygon(coords) + if not poly.is_valid: + poly = poly.buffer(0) + return poly + except Exception as e: + logger.exception("parse_polygon error: %s", e) + return None + + +def get_geofences_containing_point(session: "Session", lat: float, lon: float) -> List[Geofence]: + """ + Return list of Geofence model instances (from models.Geofence) that contain the point (lat, lon). + Uses SQLAlchemy session passed by caller. + """ + result = [] + try: + geofences = session.query(Geofence).all() + p = Point(lon, lat) # shapely Point expects (lon, lat) + for gf in geofences: + try: + poly = parse_polygon(gf.polygon) + if poly is None: + continue + if poly.contains(p) or poly.touches(p): + result.append(gf) + except Exception: + logger.exception("Error checking geofence id=%s name=%s", getattr(gf, "id", None), getattr(gf, "name", None)) + continue + except Exception: + logger.exception("get_geofences_containing_point failed") + return result diff --git a/requirements.txt b/requirements.txt index 1b20217..20956d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,11 @@ -streamlit -openai \ No newline at end of file +python-telegram-bot[ext] +fastapi +uvicorn[standard] +requests +apscheduler +sqlalchemy +google-generativeai +python-dotenv +pytesseract +pillow +paho-mqtt diff --git a/scripts/backup_db.py b/scripts/backup_db.py new file mode 100644 index 0000000..eae61aa --- /dev/null +++ b/scripts/backup_db.py @@ -0,0 +1,41 @@ +import requests +import os +import datetime +from dotenv import load_dotenv + +# Load environment variables +load_dotenv(dotenv_path='/app/.env') # Adjust path if .env is elsewhere + +TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +AUTHORIZED_USER_ID = os.getenv("TELEGRAM_USER_ID") +DATABASE_PATH = "/app/hotel_os_bot.db" # Path to the database file inside the container + +def send_db_backup(): + """Sends the SQLite database file to the authorized Telegram user.""" + if not all([TELEGRAM_BOT_TOKEN, AUTHORIZED_USER_ID]): + print("Error: Bot token or user ID not configured.") + return + + if not os.path.exists(DATABASE_PATH): + print(f"Error: Database file not found at {DATABASE_PATH}") + return + + url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendDocument" + + timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + file_name = f"backup_hotel_os_{timestamp}.db" + + with open(DATABASE_PATH, 'rb') as db_file: + files = {'document': (file_name, db_file)} + data = {'chat_id': AUTHORIZED_USER_ID, 'caption': f'Daily database backup from {timestamp}'} + + try: + response = requests.post(url, data=data, files=files) + response.raise_for_for_status() + print(f"Successfully sent database backup to user {AUTHORIZED_USER_ID}") + except requests.exceptions.RequestException as e: + print(f"Error sending backup: {e}") + +if __name__ == "__main__": + print("Executing daily database backup...") + send_db_backup() diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..27f71f4 --- /dev/null +++ b/start.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +# Start the cron service in the background +cron + +# Add the cron job for database backup +# This runs the backup script every day at midnight +# The output is redirected to /proc/1/fd/1 (container's stdout) to be visible in `docker logs` +(crontab -l 2>/dev/null; echo "0 0 * * * /usr/local/bin/python /app/scripts/backup_db.py >> /proc/1/fd/1 2>&1") | crontab - + +# Start the main application +# Use uvicorn to run the FastAPI app. The bot will run in a background thread. +echo "Starting FastAPI server and Telegram Bot..." +uvicorn app.main:app --host 0.0.0.0 --port 7860 diff --git a/webapp/app.js b/webapp/app.js new file mode 100644 index 0000000..8866c5f --- /dev/null +++ b/webapp/app.js @@ -0,0 +1,53 @@ + +document.addEventListener('DOMContentLoaded', function() { + const tg = window.Telegram.WebApp; + tg.ready(); + + // --- Hardware Control --- // + document.getElementById('light-on-btn').addEventListener('click', () => { + // Sending data to the bot + tg.sendData(JSON.stringify({ type: 'hardware_control', device: 'light', command: 'ON' })); + }); + + document.getElementById('light-off-btn').addEventListener('click', () => { + tg.sendData(JSON.stringify({ type: 'hardware_control', device: 'light', command: 'OFF' })); + }); + + document.getElementById('ac-on-btn').addEventListener('click', () => { + tg.sendData(JSON.stringify({ type: 'hardware_control', device: 'ac', command: 'ON' })); + }); + + document.getElementById('ac-off-btn').addEventListener('click', () => { + tg.sendData(JSON.stringify({ type: 'hardware_control', device: 'ac', command: 'OFF' })); + }); + + // --- Expense Form --- // + const expenseForm = document.getElementById('expense-form'); + expenseForm.addEventListener('submit', function(event) { + event.preventDefault(); + + const description = document.getElementById('expense-desc').value; + const amount = document.getElementById('expense-amount').value; + const category = document.getElementById('expense-category').value; + + if (description && amount) { + const expenseData = { + type: 'expense_add', + description: description, + amount: parseFloat(amount), + category: category + }; + // Send the data to the bot + tg.sendData(JSON.stringify(expenseData)); + + // Optional: Show feedback to user and close web app + tg.showPopup({title: "Success", message: "Expense has been recorded."}, () => { + tg.close(); + }); + + } else { + tg.showAlert('Please fill in all fields.'); + } + }); + +}); diff --git a/webapp/index.html b/webapp/index.html new file mode 100644 index 0000000..f604d73 --- /dev/null +++ b/webapp/index.html @@ -0,0 +1,57 @@ + + + + + + Hotel OS + + + + + +
+

Hotel OS Controls

+ + +
+

Hardware Control

+
+
+ Light +
+ + +
+
+
+ Air Conditioner +
+ + +
+
+
+
+ + +
+

Add Expense

+
+ + + + +
+
+ +
+ + + + diff --git a/webapp/style.css b/webapp/style.css new file mode 100644 index 0000000..f1c8417 --- /dev/null +++ b/webapp/style.css @@ -0,0 +1,114 @@ + +:root { + --bg-color: #f4f4f9; + --primary-color: #007bff; + --text-color: #333; + --card-bg: #ffffff; + --border-color: #ddd; + --danger-color: #dc3545; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background-color: var(--bg-color); + color: var(--text-color); + margin: 0; + padding: 15px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.container { + max-width: 600px; + margin: 0 auto; +} + +h1 { + text-align: center; + color: var(--primary-color); + margin-bottom: 20px; +} + +.section { + background-color: var(--card-bg); + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +h2 { + margin-top: 0; + border-bottom: 1px solid var(--border-color); + padding-bottom: 10px; + margin-bottom: 15px; + font-size: 1.2em; +} + +.control-grid { + display: grid; + grid-template-columns: 1fr; + gap: 15px; +} + +.control-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 6px; +} + +.control-item span { + font-weight: 500; +} + +.buttons { + display: flex; + gap: 10px; +} + +.btn { + padding: 8px 16px; + border: none; + border-radius: 5px; + cursor: pointer; + font-weight: bold; + background-color: var(--primary-color); + color: white; + transition: background-color 0.2s; +} + +.btn:hover { + background-color: #0056b3; +} + +.btn-off { + background-color: var(--danger-color); +} + +.btn-off:hover { + background-color: #c82333; +} + +#expense-form { + display: flex; + flex-direction: column; + gap: 10px; +} + +#expense-form input, #expense-form select { + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 5px; + font-size: 1em; +} + +.btn-primary { + background-color: #28a745; +} + +.btn-primary:hover { + background-color: #218838; +}