From c31296f251fbf9494f0be841c85b157bc19e6aaf Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 3 Nov 2025 15:47:31 +0000 Subject: [PATCH 01/13] Initial project setup --- .github/workflows/huggingface.yml | 29 +++++ .gitignore | 128 +++++++++++++++++++++ Dockerfile | 31 +++++ README.md | 115 +++++++++++++++++++ app/config.py | 43 +++++++ app/database.py | 112 ++++++++++++++++++ app/main.py | 181 ++++++++++++++++++++++++++++++ app/mqtt.py | 24 ++++ app/ocr.py | 76 +++++++++++++ requirements.txt | 11 ++ scripts/backup_db.py | 41 +++++++ start.sh | 14 +++ webapp/app.js | 53 +++++++++ webapp/index.html | 57 ++++++++++ webapp/style.css | 114 +++++++++++++++++++ 15 files changed, 1029 insertions(+) create mode 100644 .github/workflows/huggingface.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/mqtt.py create mode 100644 app/ocr.py create mode 100644 requirements.txt create mode 100644 scripts/backup_db.py create mode 100644 start.sh create mode 100644 webapp/app.js create mode 100644 webapp/index.html create mode 100644 webapp/style.css diff --git a/.github/workflows/huggingface.yml b/.github/workflows/huggingface.yml new file mode 100644 index 0000000..c3a19f0 --- /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/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 + +# 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 new file mode 100644 index 0000000..e87a8c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,128 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# PEP 582; used by Poetry, Flit, and PDM +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak +venv.bak + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ 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 new file mode 100644 index 0000000..f33f834 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# Hotel OS Telegram Bot + +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. + +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. + +## Features + +- **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. + +## Project Structure + +``` +/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 +``` + +## 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..6ed0873 --- /dev/null +++ b/app/config.py @@ -0,0 +1,43 @@ +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)) + +# --- 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..861bda0 --- /dev/null +++ b/app/main.py @@ -0,0 +1,181 @@ + +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 +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 + +# --- 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://your-hugging-face-space-url.hf.space") # IMPORTANT: Replace with your actual URL + ) + 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_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.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/requirements.txt b/requirements.txt new file mode 100644 index 0000000..20956d0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +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; +} From ec91174370d2f752023d8278c7f73d1cf030f7e8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 3 Nov 2025 15:59:14 +0000 Subject: [PATCH 02/13] Configure Hugging Face deployment workflow --- .github/workflows/huggingface.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/huggingface.yml b/.github/workflows/huggingface.yml index c3a19f0..b5fffe6 100644 --- a/.github/workflows/huggingface.yml +++ b/.github/workflows/huggingface.yml @@ -17,9 +17,9 @@ jobs: - name: Push to HF Space env: - HF_SPACE_GIT_REPO: "https://huggingface.co/spaces/YOUR_HF_USERNAME/YOUR_HF_SPACE_NAME" + HF_SPACE_GIT_REPO: "https://huggingface.co/spaces/nssuwan186/Bot_telegram" run: | - git remote add space "https://USER:${{ secrets.HF_TOKEN }}@huggingface.co/spaces/YOUR_HF_USERNAME/YOUR_HF_SPACE_NAME" + git remote add space "https://USER:${{ secrets.HF_TOKEN }}@huggingface.co/spaces/nssuwan186/Bot_telegram" git push --force space main # IMPORTANT: From efb88356bb7e216ae7bf79f198992bf1b4b5fd6c Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 3 Nov 2025 16:04:24 +0000 Subject: [PATCH 03/13] Update Web App URL for Hugging Face Space --- app/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index 861bda0..d3fd0f4 100644 --- a/app/main.py +++ b/app/main.py @@ -43,7 +43,7 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): # Create a button that opens the web app web_app_button = KeyboardButton( "Open Hotel OS Web App", - web_app=WebAppInfo(url=f"https://your-hugging-face-space-url.hf.space") # IMPORTANT: Replace with your actual URL + web_app=WebAppInfo(url=f"https://nssuwan186-Bot-telegram.hf.space/static/index.html") # IMPORTANT: Replace with your actual URL ) keyboard = [[web_app_button]] reply_markup = ReplyKeyboardMarkup(keyboard, resize_keyboard=True) From e8160fc6669411eb70a7c4aa99c96125236dea1c Mon Sep 17 00:00:00 2001 From: Gemini Date: Tue, 4 Nov 2025 16:33:04 +0700 Subject: [PATCH 04/13] Fix geofence parsing and cast ADMIN_CHAT_ID to int --- app/config.py | 6 +++++ geofence.py | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 geofence.py diff --git a/app/config.py b/app/config.py index 6ed0873..ecb8a61 100644 --- a/app/config.py +++ b/app/config.py @@ -22,6 +22,12 @@ def get_logger(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 Exception: + ADMIN_CHAT_ID = None # --- Gemini AI Configuration --- GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") diff --git a/geofence.py b/geofence.py new file mode 100644 index 0000000..b80678c --- /dev/null +++ b/geofence.py @@ -0,0 +1,64 @@ +import json +import logging +from shapely.geometry import Point, Polygon +from typing import List + +from models import Geofence # ต้องมี models.py ใน project root + +logger = logging.getLogger(__name__) + + +def parse_polygon(polygon_text): + """ + 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 + a, b = float(first[0]), float(first[1]) + # If first value looks like longitude (abs>90) assume [lon, lat] + if abs(a) > 90 and abs(b) <= 90: + coords = [(float(p[0]), float(p[1])) for p in data] # already (lon, lat) + else: + coords = [(float(p[1]), float(p[0])) for p in data] # convert (lat, lon) -> (lon, lat) + + 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, 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(float(lon), float(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 From 6e5e3c9a8e4c6e4656b3bf9ed8e010c9de315638 Mon Sep 17 00:00:00 2001 From: Gemini Date: Tue, 4 Nov 2025 17:07:21 +0700 Subject: [PATCH 05/13] feat: Add location handling for geofencing --- app/main.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/main.py b/app/main.py index d3fd0f4..5565939 100644 --- a/app/main.py +++ b/app/main.py @@ -17,6 +17,7 @@ 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__) @@ -107,6 +108,23 @@ async def handle_payment_slip(update: Update, context: ContextTypes.DEFAULT_TYPE 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 @@ -164,6 +182,7 @@ def run_bot(): 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() From bea009943537ebe7e42fb99ccab67dd37fb3c238 Mon Sep 17 00:00:00 2001 From: Natthaphat Suwannaso <229412827+nssuwan186-dev@users.noreply.github.com> Date: Tue, 4 Nov 2025 17:17:49 +0700 Subject: [PATCH 06/13] Update geofence.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- geofence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/geofence.py b/geofence.py index b80678c..bc6c341 100644 --- a/geofence.py +++ b/geofence.py @@ -8,7 +8,7 @@ logger = logging.getLogger(__name__) -def parse_polygon(polygon_text): +def parse_polygon(polygon_text: Union[str, List[List[float]]]) -> Optional[Polygon]: """ Parse polygon JSON stored in DB. Accept formats: From fef9c7152ce181fcc41f47540a7438730a5a3c7a Mon Sep 17 00:00:00 2001 From: Natthaphat Suwannaso <229412827+nssuwan186-dev@users.noreply.github.com> Date: Tue, 4 Nov 2025 17:18:02 +0700 Subject: [PATCH 07/13] Update app/config.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/config.py b/app/config.py index ecb8a61..507aeb5 100644 --- a/app/config.py +++ b/app/config.py @@ -26,7 +26,7 @@ def get_logger(name): if ADMIN_CHAT_ID: try: ADMIN_CHAT_ID = int(ADMIN_CHAT_ID) - except Exception: + except ValueError: ADMIN_CHAT_ID = None # --- Gemini AI Configuration --- From de6c85e1efc3aa053426cabdca4e9d215073160d Mon Sep 17 00:00:00 2001 From: Natthaphat Suwannaso <229412827+nssuwan186-dev@users.noreply.github.com> Date: Tue, 4 Nov 2025 17:19:19 +0700 Subject: [PATCH 08/13] Update geofence.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- geofence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/geofence.py b/geofence.py index bc6c341..d93161a 100644 --- a/geofence.py +++ b/geofence.py @@ -1,7 +1,7 @@ import json import logging from shapely.geometry import Point, Polygon -from typing import List +from typing import List, Optional, Union from models import Geofence # ต้องมี models.py ใน project root From 1d49c9bfe0f39bf2ca09b5bdbbaa2cd8349f048c Mon Sep 17 00:00:00 2001 From: Natthaphat Suwannaso <229412827+nssuwan186-dev@users.noreply.github.com> Date: Tue, 4 Nov 2025 17:19:39 +0700 Subject: [PATCH 09/13] Update geofence.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- geofence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/geofence.py b/geofence.py index d93161a..e24c9c8 100644 --- a/geofence.py +++ b/geofence.py @@ -40,7 +40,7 @@ def parse_polygon(polygon_text: Union[str, List[List[float]]]) -> Optional[Polyg return None -def get_geofences_containing_point(session, lat: float, lon: float) -> List[Geofence]: +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. From 20bfd3d34529a7729e11c0669ddcd160002e7663 Mon Sep 17 00:00:00 2001 From: Natthaphat Suwannaso <229412827+nssuwan186-dev@users.noreply.github.com> Date: Tue, 4 Nov 2025 17:20:02 +0700 Subject: [PATCH 10/13] Update geofence.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- geofence.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/geofence.py b/geofence.py index e24c9c8..e0ce8f3 100644 --- a/geofence.py +++ b/geofence.py @@ -24,12 +24,20 @@ def parse_polygon(polygon_text: Union[str, List[List[float]]]) -> Optional[Polyg first = data[0] if not (isinstance(first, list) and len(first) >= 2): return None - a, b = float(first[0]), float(first[1]) + 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 = [(float(p[0]), float(p[1])) for p in data] # already (lon, lat) + coords = all_coords # already (lon, lat) else: - coords = [(float(p[1]), float(p[0])) for p in data] # convert (lat, lon) -> (lon, lat) + # 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: From a9602248ecde149ba630b5be2c1b4095c582f895 Mon Sep 17 00:00:00 2001 From: Natthaphat Suwannaso <229412827+nssuwan186-dev@users.noreply.github.com> Date: Tue, 4 Nov 2025 17:20:18 +0700 Subject: [PATCH 11/13] Update geofence.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- geofence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/geofence.py b/geofence.py index e0ce8f3..77751f3 100644 --- a/geofence.py +++ b/geofence.py @@ -56,7 +56,7 @@ def get_geofences_containing_point(session: "Session", lat: float, lon: float) - result = [] try: geofences = session.query(Geofence).all() - p = Point(float(lon), float(lat)) # shapely Point expects (lon, lat) + p = Point(lon, lat) # shapely Point expects (lon, lat) for gf in geofences: try: poly = parse_polygon(gf.polygon) From b981dceabe7e1b0b6d4664868f140be07affb952 Mon Sep 17 00:00:00 2001 From: Nattapong Suwan Date: Wed, 5 Nov 2025 17:33:28 +0700 Subject: [PATCH 12/13] Update Hugging Face Space Web App URL and fix datetime import --- app/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index 5565939..11d0359 100644 --- a/app/main.py +++ b/app/main.py @@ -7,6 +7,7 @@ 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 @@ -44,7 +45,7 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): # 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") # IMPORTANT: Replace with your actual URL + 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) From 8cbb949d23af34c5764921494e3dfebd41163f36 Mon Sep 17 00:00:00 2001 From: Natthaphat Suwannaso <229412827+nssuwan186-dev@users.noreply.github.com> Date: Thu, 6 Nov 2025 12:28:26 +0700 Subject: [PATCH 13/13] Create generator-generic-ossf-slsa3-publish.yml --- .../generator-generic-ossf-slsa3-publish.yml | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/workflows/generator-generic-ossf-slsa3-publish.yml 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