nssuwan186 commited on
Commit
cf064e5
·
verified ·
1 Parent(s): f4597b4

Initial upload of Hotel OS Bot project

Browse files
.dockerignore ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.pyd
5
+ .Python
6
+ env/
7
+ venv/
8
+ .env
9
+ .env.*
10
+ .pytest_cache/
11
+ .vscode/
12
+ .idea/
13
+ *.swp
14
+ *.swo
15
+ *.swn
16
+ data/
17
+ *.db
18
+ *.db-journal
19
+ slips/
.env.example ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ TELEGRAM_BOT_TOKEN=123456789:YOUR_BOT_TOKEN
2
+ TELEGRAM_USER_ID=123456789
3
+ GEMINI_API_KEY=AIzaSy...
4
+ GEMINI_MODEL=gemini-pro
Dockerfile ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ RUN apt-get update && apt-get install -y \
4
+ tesseract-ocr tesseract-ocr-tha libgl1 \
5
+ && rm -rf /var/lib/apt/lists/*
6
+
7
+ WORKDIR /app
8
+
9
+ COPY requirements.txt .
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ COPY . .
13
+
14
+ RUN mkdir -p /app/data && chmod 777 /app/data
15
+
16
+ EXPOSE 8080
17
+
18
+ CMD ["python", "-m", "app.main"]
19
+
README.md CHANGED
@@ -1,11 +1,60 @@
1
- ---
2
- title: Bot Telegram
3
- emoji: 📈
4
- colorFrom: red
5
- colorTo: pink
6
- sdk: docker
7
- pinned: false
8
- license: apache-2.0
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hotel OS Bot
2
+
3
+ A Telegram bot for managing hotel operations, including bookings, payments, and expenses.
4
+
5
+ ## Project Structure
6
+
7
+ ```
8
+ hotel_os_bot/
9
+ ├── app/
10
+ │ ├── __init__.py
11
+ │ ├── main.py # Bot + FastAPI + Polling
12
+ │ ├── utils.py # OCR, Verify, DB helpers
13
+ │ └── resources/
14
+ │ └── pms_blueprint.txt
15
+ ├── webapp/
16
+ │ ├── index.html
17
+ │ ├── style.css
18
+ │ └── app.js
19
+ ├── scripts/
20
+ │ ├── backup_db.py
21
+ │ └── notify_pending.py
22
+ ├── data/
23
+ ├── requirements.txt
24
+ ├── Dockerfile
25
+ ├── .dockerignore
26
+ ├── start.sh
27
+ ├── space.yaml
28
+ ├── .env.example
29
+ └── README.md
30
+ ```
31
+
32
+ ## How to Run
33
+
34
+ 1. **Set up the environment:**
35
+ ```bash
36
+ cp .env.example .env
37
+ ```
38
+ Then, edit the `.env` file with your actual credentials (Telegram Bot Token, User ID, Gemini API Key).
39
+
40
+ 2. **Install dependencies:**
41
+ ```bash
42
+ pip install -r requirements.txt
43
+ ```
44
+
45
+ 3. **Run the application:**
46
+ ```bash
47
+ chmod +x start.sh
48
+ ./start.sh
49
+ ```
50
+ Alternatively, you can run the main module directly:
51
+ ```bash
52
+ python -m app.main
53
+ ```
54
+
55
+ ## Deploy to Hugging Face Spaces
56
+
57
+ 1. Push the code to a GitHub repository.
58
+ 2. Create a new Space on Hugging Face, selecting the "Docker" runtime.
59
+ 3. Connect the Space to your GitHub repository.
60
+ 4. In the Space settings, add the secrets from your `.env` file (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_USER_ID`, `GEMINI_API_KEY`).
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Empty file to make this a package
app/main.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import os
7
+ import sqlite3
8
+ from dataclasses import dataclass
9
+ from datetime import date, datetime
10
+ from decimal import Decimal
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ import google.generativeai as genai
15
+ import pytesseract
16
+ import uvicorn
17
+ from fastapi import FastAPI
18
+ from telegram import KeyboardButton, ReplyKeyboardMarkup, Update, WebAppInfo
19
+ from telegram.ext import (
20
+ Application,
21
+ CommandHandler,
22
+ ContextTypes,
23
+ MessageHandler,
24
+ filters,
25
+ )
26
+
27
+ from .utils import (
28
+ Settings,
29
+ SlipExtractionResult,
30
+ extract_slip_information,
31
+ verify_booking_amount,
32
+ save_base64_image,
33
+ chunk_for_telegram,
34
+ )
35
+
36
+ # FastAPI app
37
+ app = FastAPI()
38
+ bot_app: Optional[Application] = None
39
+
40
+ # Logging
41
+ logging.basicConfig(
42
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
43
+ level=logging.INFO,
44
+ )
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ def load_settings() -> Settings:
49
+ """Load settings from environment variables."""
50
+ def req_env(name: str) -> str:
51
+ val = os.getenv(name)
52
+ if not val:
53
+ raise RuntimeError(f"Missing env var: {name}")
54
+ return val
55
+
56
+ return Settings(
57
+ telegram_bot_token=req_env("TELEGRAM_BOT_TOKEN"),
58
+ authorized_user_id=int(req_env("TELEGRAM_USER_ID")),
59
+ gemini_api_key=req_env("GEMINI_API_KEY"),
60
+ gemini_model=os.getenv("GEMINI_MODEL", "gemini-pro"),
61
+ pms_blueprint_path=Path("app/resources/pms_blueprint.txt"),
62
+ database_path=Path("./data/hotel_os_bot.db"),
63
+ slip_storage_dir=Path("./data/slips"),
64
+ webapp_base_url=os.getenv("WEBAPP_BASE_URL", "http://localhost:8080/index.html"),
65
+ webapp_static_dir=Path("./webapp"),
66
+ webapp_host="0.0.0.0",
67
+ webapp_port=int(os.getenv("PORT", "8080")),
68
+ ocr_language=os.getenv("OCR_LANGUAGE", "eng+tha"),
69
+ tesseract_cmd=os.getenv("TESSERACT_CMD"),
70
+ amount_tolerance=Decimal("1.00"),
71
+ )
72
+
73
+
74
+ def init_db(settings: Settings):
75
+ """Initialize SQLite database."""
76
+ settings.database_path.parent.mkdir(parents=True, exist_ok=True)
77
+ settings.slip_storage_dir.mkdir(parents=True, exist_ok=True)
78
+
79
+ with sqlite3.connect(settings.database_path) as conn:
80
+ conn.executescript("""
81
+ CREATE TABLE IF NOT EXISTS bookings (
82
+ id TEXT PRIMARY KEY,
83
+ guest_name TEXT,
84
+ total_due REAL,
85
+ currency TEXT,
86
+ status TEXT,
87
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
88
+ );
89
+
90
+ CREATE TABLE IF NOT EXISTS payment_slips (
91
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
92
+ booking_id TEXT,
93
+ reference_code TEXT,
94
+ slip_image_path TEXT,
95
+ extracted_amount REAL,
96
+ extracted_date TEXT,
97
+ extracted_time TEXT,
98
+ status TEXT,
99
+ ocr_text TEXT,
100
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
101
+ );
102
+
103
+ CREATE TABLE IF NOT EXISTS expenses (
104
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
105
+ amount REAL NOT NULL,
106
+ category TEXT NOT NULL,
107
+ note TEXT,
108
+ created_by TEXT,
109
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
110
+ );
111
+ """)
112
+
113
+
114
+ def is_authorized(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool:
115
+ """Check if user is authorized."""
116
+ user = update.effective_user
117
+ if not user:
118
+ return False
119
+ auth_id = context.application.bot_data.get("authorized_user_id")
120
+ return auth_id is not None and user.id == auth_id
121
+
122
+
123
+ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
124
+ """Handle /start command."""
125
+ if not is_authorized(update, context):
126
+ await update.message.reply_text("🔐 คุณไม่มีสิทธิ์ใช้งานบอทนี้")
127
+ return
128
+
129
+ stg: Settings = context.application.bot_data["settings"]
130
+ keyboard = ReplyKeyboardMarkup(
131
+ [[KeyboardButton("🛎️ เปิดระบบบริหารโรงแรม", web_app=WebAppInfo(url=stg.webapp_base_url))]],
132
+ resize_keyboard=True
133
+ )
134
+ await update.message.reply_text("ยินดีต้อนรับสู่ Hotel OS Bot!", reply_markup=keyboard)
135
+
136
+
137
+ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
138
+ """Handle /help command."""
139
+ if not is_authorized(update, context):
140
+ return
141
+ await update.message.reply_text(
142
+ "/start – เปิดระบบ\n"
143
+ "/daily_report – รายงานประจำวัน\n"
144
+ "/receipt <ID> – ใบเสร็จ\n"
145
+ "พิมพ์ข้อความ → AI ตอบ"
146
+ )
147
+
148
+
149
+ async def daily_report(update: Update, context: ContextTypes.DEFAULT_TYPE):
150
+ """Handle /daily_report command."""
151
+ if not is_authorized(update, context):
152
+ return
153
+
154
+ stg: Settings = context.application.bot_data["settings"]
155
+ today = date.today().isoformat()
156
+
157
+ with sqlite3.connect(stg.database_path) as conn:
158
+ bookings = conn.execute("SELECT COUNT(*) FROM bookings WHERE DATE(created_at) = ?", (today,)).fetchone()[0]
159
+ paid = conn.execute("SELECT SUM(extracted_amount) FROM payment_slips WHERE status = 'verified' AND DATE(created_at) = ?", (today,)).fetchone()[0] or 0
160
+ expense = conn.execute("SELECT SUM(amount) FROM expenses WHERE DATE(created_at) = ?", (today,)).fetchone()[0] or 0
161
+
162
+ await update.message.reply_text(
163
+ f"📊 รายงานวันที่ {today}:\n"
164
+ f"• การจอง: {bookings} ราย\n"
165
+ f"• ชำระ: {paid:,.2f} บาท\n"
166
+ f"• ค่าใช้จ่าย: {expense:,.2f} บาท\n"
167
+ f"• กำไร: {paid - expense:,.2f} บาท"
168
+ )
169
+
170
+
171
+ async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE):
172
+ """Handle text messages with AI."""
173
+ if not is_authorized(update, context) or not update.message.text:
174
+ return
175
+
176
+ model = context.application.bot_data.get("gemini_model")
177
+ if not model:
178
+ await update.message.reply_text("AI ยังไม่พร้อม")
179
+ return
180
+
181
+ try:
182
+ resp = await model.generate_content_async(update.message.text)
183
+ await update.message.reply_text(resp.text or "ขอโทษครับ ผมตอบไม่ได้")
184
+ except Exception as e:
185
+ logger.error(f"Gemini error: {e}")
186
+ await update.message.reply_text("เกิดข้อผิดพลาดกับ AI")
187
+
188
+
189
+ async def web_app_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
190
+ """Handle data from Web App."""
191
+ if not is_authorized(update, context) or not update.message.web_app_data:
192
+ return
193
+
194
+ try:
195
+ payload = json.loads(update.message.web_app_data.data)
196
+ except json.JSONDecodeError:
197
+ await update.message.reply_text("ข้อมูลผิดพลาด")
198
+ return
199
+
200
+ if payload.get("type") == "payment_slip":
201
+ await process_slip(update, context, payload)
202
+ elif payload.get("type") == "expense_entry":
203
+ await process_expense(update, context, payload)
204
+
205
+
206
+ async def process_slip(update: Update, context: ContextTypes.DEFAULT_TYPE, payload: dict):
207
+ """Process payment slip from Web App."""
208
+ stg: Settings = context.application.bot_data["settings"]
209
+ booking_id = payload.get("booking_id")
210
+ file_b64 = payload.get("file_base64")
211
+
212
+ if not booking_id or not file_b64:
213
+ await update.message.reply_text("ข้อมูลไม่ครบ")
214
+ return
215
+
216
+ dest = stg.slip_storage_dir / f"{booking_id}_{int(datetime.utcnow().timestamp())}.jpg"
217
+
218
+ try:
219
+ await asyncio.to_thread(save_base64_image, file_b64, dest)
220
+ extraction = await asyncio.to_thread(extract_slip_information, dest, stg)
221
+ verification = await asyncio.to_thread(verify_booking_amount, stg, booking_id, extraction.amount)
222
+ except Exception as e:
223
+ logger.error(f"Slip processing error: {e}")
224
+ await update.message.reply_text(f"เกิดข้อผิดพลาด: {e}")
225
+ return
226
+
227
+ # Save to DB
228
+ with sqlite3.connect(stg.database_path) as conn:
229
+ conn.execute(
230
+ "INSERT INTO payment_slips (booking_id, slip_image_path, extracted_amount, status, ocr_text) VALUES (?, ?, ?, ?, ?)",
231
+ (booking_id, str(dest), float(extraction.amount) if extraction.amount else None, verification["status"], extraction.raw_text)
232
+ )
233
+
234
+ if verification["status"] == "verified":
235
+ await update.message.reply_text(f"✅ ตรวจสอบสลิปสำเร็จ ({booking_id})")
236
+ else:
237
+ await update.message.reply_text(f"⚠️ สลิปไม่ตรงกับข้อมูล: {verification['status']}")
238
+
239
+
240
+ async def process_expense(update: Update, context: ContextTypes.DEFAULT_TYPE, payload: dict):
241
+ """Process expense entry from Web App."""
242
+ stg: Settings = context.application.bot_data["settings"]
243
+
244
+ try:
245
+ amount = Decimal(str(payload["amount"]))
246
+ except:
247
+ await update.message.reply_text("จำนวนเงินไม่ถูกต้อง")
248
+ return
249
+
250
+ with sqlite3.connect(stg.database_path) as conn:
251
+ conn.execute(
252
+ "INSERT INTO expenses (amount, category, note) VALUES (?, ?, ?)",
253
+ (float(amount), payload.get("category", "อื่นๆ"), payload.get("note", ""))
254
+ )
255
+
256
+ await update.message.reply_text(f"✅ บันทึกค่าใช้จ่าย {amount} บาท")
257
+
258
+
259
+ @app.on_event("startup")
260
+ async def startup():
261
+ """Initialize bot on startup."""
262
+ global bot_app
263
+
264
+ settings = load_settings()
265
+ init_db(settings)
266
+
267
+ if settings.tesseract_cmd:
268
+ pytesseract.pytesseract.tesseract_cmd = settings.tesseract_cmd
269
+
270
+ genai.configure(api_key=settings.gemini_api_key)
271
+ gemini_model = genai.GenerativeModel(settings.gemini_model)
272
+
273
+ bot_app = Application.builder().token(settings.telegram_bot_token).build()
274
+ bot_app.bot_data["settings"] = settings
275
+ bot_app.bot_data["authorized_user_id"] = settings.authorized_user_id
276
+ bot_app.bot_data["gemini_model"] = gemini_model
277
+
278
+ # Add handlers
279
+ bot_app.add_handler(CommandHandler("start", start))
280
+ bot_app.add_handler(CommandHandler("help", help_command))
281
+ bot_app.add_handler(CommandHandler("daily_report", daily_report))
282
+ bot_app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo))
283
+ bot_app.add_handler(MessageHandler(filters.StatusUpdate.WEB_APP_DATA, web_app_handler))
284
+
285
+ await bot_app.initialize()
286
+ await bot_app.start()
287
+
288
+ asyncio.create_task(bot_app.updater.start_polling())
289
+ logger.info("✅ Bot started in polling mode")
290
+
291
+
292
+ @app.get("/health")
293
+ async def health():
294
+ return {"status": "ok"}
295
+
296
+
297
+ if __name__ == "__main__":
298
+ port = int(os.getenv("PORT", "8080")),
299
+ uvicorn.run(app, host="0.0.0.0", port=port)
app/resources/pms_blueprint.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ระบบ PMS (Property Management System) ของ Hotel OS ประกอบด้วย:
2
+
3
+ 1. การจองห้อง (Bookings) – id, guest_name, total_due, currency, status
4
+ 2. การชำระเงิน – รับสลิป, OCR, ตรวจสอบยอด, บันทึกใน payment_slips
5
+ 3. การบันทึกค่าใช้จ่าย (Expenses) – amount, category, note, created_by
6
+ 4. รายงานสรุป – จำนวนการจอง, ยอดรวมที่ชำระ, รายจ่ายทั้งหมด
7
+
8
+ Gemini AI จะอ้างอิงข้อมูลนี้เมื่อตอบคำถามเกี่ยวกับ:
9
+ • สถานะการจอง
10
+ • ยอดค้างชำระ
11
+ • วิธีชำระเงิน
12
+ • รายจ่ายโรงแรม
app/utils.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import re
3
+ import sqlite3
4
+ from datetime import datetime, date, time
5
+ from decimal import Decimal, InvalidOperation
6
+ from pathlib import Path
7
+ from typing import Optional, Dict
8
+ from dataclasses import dataclass
9
+
10
+ import pytesseract
11
+ from PIL import Image
12
+
13
+
14
+ @dataclass
15
+ class Settings:
16
+ telegram_bot_token: str
17
+ authorized_user_id: int
18
+ gemini_api_key: str
19
+ gemini_model: str
20
+ pms_blueprint_path: Path
21
+ database_path: Path
22
+ slip_storage_dir: Path
23
+ webapp_base_url: str
24
+ webapp_static_dir: Path
25
+ webapp_host: str
26
+ webapp_port: int
27
+ ocr_language: str
28
+ tesseract_cmd: Optional[str]
29
+ amount_tolerance: Decimal
30
+
31
+
32
+ @dataclass
33
+ class SlipExtractionResult:
34
+ raw_text: str
35
+ amount: Optional[Decimal]
36
+ payment_date: Optional[date]
37
+ payment_time: Optional[time]
38
+
39
+
40
+ def save_base64_image(b64: str, dest: Path) -> None:
41
+ """Decode base64-encoded image and save to file."""
42
+ try:
43
+ data = base64.b64decode(b64)
44
+ dest.write_bytes(data)
45
+ except Exception as exc:
46
+ raise RuntimeError(f"ไม่สามารถบันทึกรูปภาพได้: {exc}")
47
+
48
+
49
+ def extract_slip_information(image_path: Path, settings: Settings) -> SlipExtractionResult:
50
+ """Use Tesseract OCR to extract payment info from slip."""
51
+ try:
52
+ img = Image.open(image_path)
53
+ except Exception as exc:
54
+ raise RuntimeError(f"เปิดไฟล์ภาพไม่ได้: {exc}")
55
+
56
+ raw_text = pytesseract.image_to_string(img, lang=settings.ocr_language)
57
+
58
+ # Extract amount
59
+ amount: Optional[Decimal] = None
60
+ amt_match = re.search(r'(\d{1,3}(?:[,\s]\d{3})*(?:\.\d{2})|\d+\.\d{2})', raw_text)
61
+ if amt_match:
62
+ amt_raw = amt_match.group(1).replace(',', '').replace(' ', '')
63
+ try:
64
+ amount = Decimal(amt_raw)
65
+ except InvalidOperation:
66
+ pass
67
+
68
+ # Extract date
69
+ payment_date: Optional[date] = None
70
+ date_match = re.search(r'(\d{1,2}[\/\-\.]\d{1,2}[\/\-\.]\d{2,4})', raw_text)
71
+ if date_match:
72
+ for fmt in ("%d/%m/%Y", "%d-%m-%Y", "%Y-%m-%d", "%d.%m.%Y"):
73
+ try:
74
+ payment_date = datetime.strptime(date_match.group(1), fmt).date()
75
+ break
76
+ except ValueError:
77
+ continue
78
+
79
+ # Extract time
80
+ payment_time: Optional[time] = None
81
+ time_match = re.search(r'(\d{1,2}:\d{2})(?:\s?(AM|PM))?', raw_text, re.IGNORECASE)
82
+ if time_match:
83
+ try:
84
+ t_str = time_match.group(1)
85
+ ampm = time_match.group(2)
86
+ if ampm:
87
+ payment_time = datetime.strptime(f"{t_str} {ampm}", "%I:%M %p").time()
88
+ else:
89
+ payment_time = datetime.strptime(t_str, "%H:%M").time()
90
+ except ValueError:
91
+ pass
92
+
93
+ return SlipExtractionResult(
94
+ raw_text=raw_text,
95
+ amount=amount,
96
+ payment_date=payment_date,
97
+ payment_time=payment_time,
98
+ )
99
+
100
+
101
+ def verify_booking_amount(
102
+ settings: Settings,
103
+ booking_id: str,
104
+ extracted_amount: Optional[Decimal],
105
+ ) -> Dict[str, str]:
106
+ """Verify extracted amount against booking."""
107
+ with sqlite3.connect(settings.database_path) as conn:
108
+ row = conn.execute("SELECT total_due FROM bookings WHERE id = ?", (booking_id,)).fetchone()
109
+
110
+ if not row:
111
+ return {"status": "booking_not_found"}
112
+
113
+ expected = Decimal(str(row[0]))
114
+
115
+ if extracted_amount is None:
116
+ return {"status": "amount_missing"}
117
+
118
+ if abs(expected - extracted_amount) <= settings.amount_tolerance:
119
+ return {"status": "verified", "expected_amount": str(expected)}
120
+ else:
121
+ return {"status": "amount_mismatch", "expected_amount": str(expected)}
122
+
123
+
124
+ def chunk_for_telegram(text: str, limit: int = 4096) -> list[str]:
125
+ """Split text into Telegram-compatible chunks."""
126
+ if len(text) <= limit:
127
+ return [text]
128
+ return [text[i:i + limit] for i in range(0, len(text), limit)]
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ python-telegram-bot[fastapi]==20.8
2
+ fastapi==0.115.0
3
+ uvicorn[standard]==0.30.1
4
+ pillow==10.4.0
5
+ pytesseract==0.3.13
6
+ google-generativeai==0.7.2
scripts/backup_db.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # This script can be used to backup the database.
scripts/notify_pending.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # This script can be used to notify about pending tasks.
space.yaml ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ runtime:
2
+ type: docker
start.sh ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ #!/bin/bash
2
+ export PORT=${PORT:-8080}
3
+ mkdir -p ./data ./data/slips
4
+ python -m app.main
webapp/app.js ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ window.onload = function () {
2
+ const tg = window.Telegram?.WebApp;
3
+ if (!tg) {
4
+ console.warn('Not in Telegram Web App');
5
+ return;
6
+ }
7
+
8
+ // Payment Slip Form
9
+ document.getElementById('slip-form').addEventListener('submit', async (e) => {
10
+ e.preventDefault();
11
+ const formData = new FormData(e.target);
12
+ const file = formData.get('slip_image');
13
+
14
+ const reader = new FileReader();
15
+ reader.onload = () => {
16
+ const base64 = reader.result.split(',')[1];
17
+ tg.sendData(JSON.stringify({
18
+ type: "payment_slip",
19
+ booking_id: formData.get('booking_id'),
20
+ reference: formData.get('reference') || "",
21
+ file_base64: base64,
22
+ file_name: file.name
23
+ }));
24
+ tg.close();
25
+ };
26
+ reader.readAsDataURL(file);
27
+ });
28
+
29
+ // Expense Form
30
+ document.getElementById('expense-form').addEventListener('submit', (e) => {
31
+ e.preventDefault();
32
+ const data = new FormData(e.target);
33
+ tg.sendData(JSON.stringify({
34
+ type: "expense_entry",
35
+ amount: data.get('amount'),
36
+ category: data.get('category') || "อื่นๆ",
37
+ note: data.get('note') || ""
38
+ }));
39
+ tg.close();
40
+ });
41
+ };
webapp/index.html ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="th">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Hotel OS</title>
7
+ <link rel="stylesheet" href="style.css">
8
+ <script src="https://telegram.org/js/telegram-web-app.js"></script>
9
+ </head>
10
+ <body>
11
+ <h1>🏨 Hotel OS</h1>
12
+
13
+ <section>
14
+ <h2>📄 ส่งสลิปชำระเงิน</h2>
15
+ <form id="slip-form">
16
+ <label>Booking ID: <input type="text" name="booking_id" required /></label>
17
+ <label>Reference: <input type="text" name="reference" /></label>
18
+ <label>รูปสลิป: <input type="file" accept="image/*" name="slip_image" required /></label>
19
+ <button type="submit">ส่งสลิป</button>
20
+ </form>
21
+ </section>
22
+
23
+ <hr>
24
+
25
+ <section>
26
+ <h2>💰 บันทึกค่าใช้จ่าย</h2>
27
+ <form id="expense-form">
28
+ <label>จำนวนเงิน: <input type="number" step="0.01" name="amount" required /></label>
29
+ <label>หมวดหมู่: <input type="text" name="category" placeholder="เช่น ทำความสะอาด" /></label>
30
+ <label>หมายเหตุ: <input type="text" name="note" /></label>
31
+ <button type="submit">บันทึก</button>
32
+ </form>
33
+ </section>
34
+
35
+ <script src="app.js"></script>
36
+ </body>
37
+ </html>
webapp/style.css ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ body {
2
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
3
+ margin: 20px;
4
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
5
+ color: #fff;
6
+ }
7
+
8
+ h1 { color: #fff; text-align: center; }
9
+ h2 { color: #f0f0f0; }
10
+
11
+ section {
12
+ background: rgba(255,255,255,0.1);
13
+ backdrop-filter: blur(10px);
14
+ padding: 20px;
15
+ border-radius: 15px;
16
+ margin-bottom: 20px;
17
+ }
18
+
19
+ label {
20
+ display: block;
21
+ margin: 10px 0;
22
+ font-weight: 500;
23
+ }
24
+
25
+ input, button {
26
+ padding: 10px;
27
+ font-size: 1rem;
28
+ border-radius: 8px;
29
+ border: none;
30
+ width: 100%;
31
+ box-sizing: border-box;
32
+ }
33
+
34
+ input {
35
+ background: rgba(255,255,255,0.9);
36
+ color: #333;
37
+ }
38
+
39
+ button {
40
+ background: #27ae60;
41
+ color: #fff;
42
+ cursor: pointer;
43
+ font-weight: bold;
44
+ margin-top: 10px;
45
+ transition: 0.3s;
46
+ }
47
+
48
+ button:hover {
49
+ background: #1e8449;
50
+ transform: scale(1.02);
51
+ }