2 Commits
Author SHA1 Message Date
prog1764 49d7af2d18 fix: пароль админа больше не остаётся в истории чата (PRJ-3)
- /admin не принимает пароль аргументом команды (оставался в истории Telegram)
- пароль вводится отдельным сообщением через FSM-состояние waiting_password
- сообщение с паролем удаляется сразу после проверки (best-effort)
- добавлена защита от брутфорса: 5 неудачных попыток за 5 минут
2026-08-04 19:36:19 +04:00
prog1764 896c26bd57 fix: закрыть IDOR в бронях, пароль админа и обработку callback_data (PRJ-3)
- IDOR: отмена/редактирование брони только владельцем (проверка user_id)
- пароль админа: убран дефолт admin123, обязателен из env; сравнение через hmac.compare_digest; TTL сессии 1ч
- callback_data: безопасный парсинг int + .get() вместо прямого индексирования (KeyError/ValueError)
2026-08-04 19:33:08 +04:00
36 changed files with 437 additions and 904 deletions
+7 -22
View File
@@ -1,25 +1,10 @@
# Python .env
.venv/
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*.egg-info/
.venv/
venv/
env/
# IDE
.idea/
*.swp
*.swo
.DS_Store .DS_Store
.idea/
# Project .vscode/
data/*.json *.egg-info/
logs/ dist/
backups/ build/
.env
# Backups
*.bak
# Runtime logs
*.log
+5
View File
@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.14 (PythonProject) (2)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+13
View File
@@ -0,0 +1,13 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<list>
<option value="aiogram" />
<option value="python-dotenv" />
</list>
</option>
</inspection_tool>
</profile>
</component>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.14 (PythonProject) (2)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.14 (PythonProject) (2)" project-jdk-type="Python SDK" />
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/PythonProject.iml" filepath="$PROJECT_DIR$/.idea/PythonProject.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
-10
View File
@@ -1,10 +0,0 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "-m", "app.main"]
+11 -39
View File
@@ -1,4 +1,3 @@
"""Configuration for SPA Telegram Bot."""
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -6,55 +5,28 @@ load_dotenv()
BOT_TOKEN = os.getenv("BOT_TOKEN") BOT_TOKEN = os.getenv("BOT_TOKEN")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD") ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD")
ADMIN_CHAT_ID = int(os.getenv("ADMIN_CHAT_ID", "0")) if os.getenv("ADMIN_CHAT_ID", "") else 0
if not BOT_TOKEN: if not BOT_TOKEN:
raise ValueError("BOT_TOKEN not found in environment") raise ValueError("BOT_TOKEN not found in environment")
if not ADMIN_PASSWORD: if not ADMIN_PASSWORD:
raise ValueError("ADMIN_PASSWORD not found in environment") raise ValueError("ADMIN_PASSWORD not found in environment (no default allowed)")
USE_POSTGRES = bool(os.getenv("DB_HOST", "")) # Services catalog
# Rich service catalog
SERVICES = { SERVICES = {
"service_massage": { "service_massage": "💪 Массаж",
"key": "service_massage", "service_spa": "🎈 SPA",
"name": "\u041c\u0430\u0441\u0441\u0430\u0436", "service_cosmetology": "💄 Косметология",
"emoji": "\U0001f4aa",
"desc": "\u0420\u0430\u0441\u0441\u043b\u0430\u0431\u043b\u044f\u044e\u0449\u0438\u0439 \u0438 \u043b\u0435\u0447\u0435\u0431\u043d\u044b\u0439 \u043c\u0430\u0441\u0441\u0430\u0436.\n\u0421\u043d\u0438\u043c\u0430\u0435\u0442 \u043d\u0430\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435, \u0443\u043b\u0443\u0447\u0448\u0430\u0435\u0442 \u043a\u0440\u043e\u0432\u043e\u043e\u0431\u0440\u0430\u0449\u0435\u043d\u0438\u0435 \u0438 \u0434\u0430\u0440\u0438\u0442 \u043b\u0451\u0433\u043a\u043e\u0441\u0442\u044c \u0432\u043e \u0432\u0441\u0451\u043c \u0442\u0435\u043b\u0435.",
"image": "assets/massage.png",
"duration": 60,
"price": 2500,
"category": "body",
},
"service_spa": {
"key": "service_spa",
"name": "SPA-\u043a\u043e\u043c\u043f\u043b\u0435\u043a\u0441",
"emoji": "\U0001f388",
"desc": "\u041a\u043e\u043c\u043f\u043b\u0435\u043a\u0441\u043d\u044b\u0435 SPA-\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u044b: \u043e\u0431\u0451\u0440\u0442\u044b\u0432\u0430\u043d\u0438\u044f, \u0430\u0440\u043e\u043c\u0430\u0442\u0435\u0440\u0430\u043f\u0438\u044f, \u0433\u0438\u0434\u0440\u043e\u043c\u0430\u0441\u0441\u0430\u0436.\n\u041f\u043e\u043b\u043d\u043e\u0435 \u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435 \u0438 \u0440\u0435\u043b\u0430\u043a\u0441\u0430\u0446\u0438\u044f.",
"image": "assets/spa.png",
"duration": 90,
"price": 4000,
"category": "spa",
},
"service_cosmetology": {
"key": "service_cosmetology",
"name": "\u041a\u043e\u0441\u043c\u0435\u0442\u043e\u043b\u043e\u0433\u0438\u044f",
"emoji": "\U0001f484",
"desc": "\u041f\u0440\u043e\u0444\u0435\u0441\u0441\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0439 \u0443\u0445\u043e\u0434 \u0437\u0430 \u043b\u0438\u0446\u043e\u043c: \u0447\u0438\u0441\u0442\u043a\u0430, \u043f\u0438\u043b\u0438\u043d\u0433, \u043c\u0430\u0441\u043a\u0438, \u043b\u0438\u043c\u0444\u043e\u0434\u0440\u0435\u043d\u0430\u0436.\n\u0421\u0438\u044f\u044e\u0449\u0430\u044f \u0438 \u0437\u0434\u043e\u0440\u043e\u0432\u0430\u044f \u043a\u043e\u0436\u0430.",
"image": "assets/cosmetology.png",
"duration": 45,
"price": 2000,
"category": "face",
},
} }
# Masters list
MASTERS = { MASTERS = {
"master_anna": {"key": "master_anna", "name": "\U0001f478 \u0410\u043d\u043d\u0430", "specialty": ["massage", "spa"]}, "master_anna": "👸 Анна",
"master_maria": {"key": "master_maria", "name": "\U0001f483 \u041c\u0430\u0440\u0438\u044f", "specialty": ["cosmetology"]}, "master_maria": "💃 Мария",
"master_alex": {"key": "master_alex", "name": "\U0001f9d1\U0000200d\U00002696\U0000fe0f \u0410\u043b\u0435\u043a\u0441\u0435\u0439", "specialty": ["massage", "spa"]}, "master_alex": "🧑‍⚖️ Алексей",
} }
# Available time slots
TIME_SLOTS = [ TIME_SLOTS = [
"09:00", "10:00", "11:00", "09:00", "10:00", "11:00",
"12:00", "13:00", "14:00", "12:00", "13:00", "14:00",
+14 -10
View File
@@ -1,17 +1,13 @@
"""Fallback JSON-based storage (sync).
Used only when DB_HOST is not set in .env.
"""
import json import json
from pathlib import Path
from app.logger import logger from app.logger import logger
from pathlib import Path
DB_DIR = Path(__file__).resolve().parent.parent.parent / "data" DB_DIR = Path(__file__).resolve().parent.parent.parent / "data"
DB_DIR.mkdir(exist_ok=True) DB_DIR.mkdir(exist_ok=True)
USERS_FILE = DB_DIR / "users.json" USERS_FILE = DB_DIR / "users.json"
BOOKINGS_FILE = DB_DIR / "bookings.json" BOOKINGS_FILE = DB_DIR / "bookings.json"
from app.config import USE_POSTGRES
def _load_json(path, default): def _load_json(path, default):
if path.exists(): if path.exists():
@@ -28,7 +24,6 @@ def _save_json(path, data):
json.dump(data, fh, ensure_ascii=False, indent=2) json.dump(data, fh, ensure_ascii=False, indent=2)
# --- Users ---
def load_users(): def load_users():
return _load_json(USERS_FILE, {}) return _load_json(USERS_FILE, {})
@@ -48,19 +43,19 @@ def set_user(user_id, data):
save_users(users) save_users(users)
# --- Bookings ---
def load_bookings(): def load_bookings():
return _load_json(BOOKINGS_FILE, []) return _load_json(BOOKINGS_FILE, [])
def save_bookings(bookings): def save_bookings(bookings):
from app.logger import logger
logger.debug("save_bookings: saving {n} bookings", n=len(bookings)) logger.debug("save_bookings: saving {n} bookings", n=len(bookings))
_save_json(BOOKINGS_FILE, bookings) _save_json(BOOKINGS_FILE, bookings)
def create_booking(user_id, service, master, date, time, user_name=""): def create_booking(user_id, service, master, date, time, user_name=""):
logger.debug("create_booking called: uid={uid}, svc={svc}, mst={mst}, dt={dt}, tm={tm}", from app.logger import logger
uid=user_id, svc=service, mst=master, dt=date, tm=time) logger.debug("create_booking called: uid={uid}, svc={svc}, master={mst}, date={dt}, time={tm}", uid=user_id, svc=service, mst=master, dt=date, tm=time)
bookings = load_bookings() bookings = load_bookings()
booking = { booking = {
"id": len(bookings) + 1, "id": len(bookings) + 1,
@@ -93,6 +88,15 @@ def get_user_bookings(user_id):
] ]
def get_booking(booking_id):
"""Return a booking by id regardless of status, or None."""
bookings = load_bookings()
for b in bookings:
if b["id"] == booking_id:
return b
return None
def cancel_booking(booking_id): def cancel_booking(booking_id):
bookings = load_bookings() bookings = load_bookings()
for b in bookings: for b in bookings:
-49
View File
@@ -1,49 +0,0 @@
"""Migrate data from JSON files to PostgreSQL."""
import asyncio
import json
from pathlib import Path
from app.db.postgres import PostgresDB
DB_DIR = Path(__file__).resolve().parent.parent.parent / "data"
USERS_FILE = DB_DIR / "users.json"
BOOKINGS_FILE = DB_DIR / "bookings.json"
async def migrate():
print("Starting migration from JSON to PostgreSQL...")
await PostgresDB.init_pool()
# Migrate users
if USERS_FILE.exists():
with open(USERS_FILE) as f:
users = json.load(f)
for uid, data in users.items():
name = data.get("name", "")
phone = data.get("phone", "")
await PostgresDB.set_user(int(uid), name, phone)
print(f"Migrated {len(users)} users")
# Migrate bookings
if BOOKINGS_FILE.exists():
with open(BOOKINGS_FILE) as f:
bookings = json.load(f)
for b in bookings:
# Re-create via raw INSERT to preserve IDs
pool = await PostgresDB.get_pool()
async with pool.acquire() as conn:
await conn.execute("""
INSERT INTO bookings (id, user_id, user_name, service, master, date, time, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (id) DO NOTHING
""", b.get("id"), b.get("user_id"), b.get("user_name", ""),
b.get("service"), b.get("master"), b.get("date"),
b.get("time"), b.get("status", "active"))
print(f"Migrated {len(bookings)} bookings")
print("Migration complete!")
await PostgresDB.close()
if __name__ == "__main__":
asyncio.run(migrate())
-181
View File
@@ -1,181 +0,0 @@
"""PostgreSQL storage for SPA Telegram Bot."""
import json
import os
from datetime import datetime
from typing import Optional
import asyncpg
from app.logger import logger
DB_HOST = os.getenv("DB_HOST", "10.0.77.9")
DB_PORT = int(os.getenv("DB_PORT", "5433"))
DB_USER = os.getenv("DB_USER", "spabot")
DB_PASSWORD = os.getenv("DB_PASSWORD")
DB_NAME = os.getenv("DB_NAME", "spabot")
if not DB_PASSWORD:
raise ValueError(
"DB_PASSWORD not set in environment. "
"Set it in .env or export DB_PASSWORD=your_password"
)
DB_CONFIG = {
"host": DB_HOST,
"port": DB_PORT,
"user": DB_USER,
"password": DB_PASSWORD,
"database": DB_NAME,
}
class PostgresDB:
_pool: Optional[asyncpg.Pool] = None
@classmethod
async def init_pool(cls):
"""Initialize connection pool."""
if cls._pool is None:
cls._pool = await asyncpg.create_pool(**DB_CONFIG, min_size=2, max_size=10)
await cls._migrate()
logger.info("Postgres pool initialized (host={host}, db={db})",
host=DB_CONFIG["host"], db=DB_CONFIG["database"])
return cls._pool
@classmethod
async def _migrate(cls):
"""Create tables if not exist."""
async with cls._pool.acquire() as conn:
await conn.execute("""
CREATE TABLE IF NOT EXISTS users (
user_id BIGINT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
phone TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS bookings (
id SERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(user_id),
user_name TEXT DEFAULT '',
service TEXT NOT NULL,
master TEXT NOT NULL,
date TEXT NOT NULL,
time TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_bookings_user_id ON bookings(user_id);
CREATE INDEX IF NOT EXISTS idx_bookings_master_date ON bookings(master, date);
CREATE INDEX IF NOT EXISTS idx_bookings_status ON bookings(status);
""")
logger.info("Postgres migrations applied")
@classmethod
async def close(cls):
if cls._pool:
await cls._pool.close()
cls._pool = None
logger.info("Postgres pool closed")
@classmethod
async def get_pool(cls):
if cls._pool is None:
await cls.init_pool()
return cls._pool
# --- Users ---
@classmethod
async def get_user(cls, user_id: int) -> Optional[dict]:
pool = await cls.get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM users WHERE user_id = $1", user_id)
if row:
return dict(row)
return None
@classmethod
async def set_user(cls, user_id: int, name: str, phone: str = ""):
pool = await cls.get_pool()
async with pool.acquire() as conn:
await conn.execute("""
INSERT INTO users (user_id, name, phone, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (user_id)
DO UPDATE SET name = $2, phone = $3, updated_at = NOW()
""", user_id, name, phone)
# --- Bookings ---
@classmethod
async def create_booking(cls, user_id: int, service: str, master: str,
date: str, time: str, user_name: str = "") -> dict:
pool = await cls.get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("""
INSERT INTO bookings (user_id, user_name, service, master, date, time)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
""", user_id, user_name, service, master, date, time)
return dict(row)
@classmethod
async def get_busy_times(cls, master: str, date: str) -> list:
pool = await cls.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT time FROM bookings WHERE master = $1 AND date = $2 AND status = 'active'",
master, date
)
return [r["time"] for r in rows]
@classmethod
async def get_user_bookings(cls, user_id: int, status: str = "active") -> list:
pool = await cls.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT * FROM bookings WHERE user_id = $1 AND status = $2 ORDER BY id DESC",
user_id, status
)
return [dict(r) for r in rows]
@classmethod
async def cancel_booking(cls, booking_id: int) -> bool:
pool = await cls.get_pool()
async with pool.acquire() as conn:
result = await conn.execute(
"UPDATE bookings SET status = 'cancelled', updated_at = NOW() WHERE id = $1 AND status = 'active'",
booking_id
)
return "UPDATE 1" in result
@classmethod
async def update_booking(cls, booking_id: int, service: str, master: str,
date: str, time: str) -> Optional[dict]:
pool = await cls.get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("""
UPDATE bookings
SET service = $1, master = $2, date = $3, time = $4, updated_at = NOW()
WHERE id = $5 AND status = 'active'
RETURNING *
""", service, master, date, time, booking_id)
return dict(row) if row else None
@classmethod
async def get_all_active_bookings(cls) -> list:
pool = await cls.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT * FROM bookings WHERE status = 'active' ORDER BY id DESC"
)
return [dict(r) for r in rows]
@classmethod
async def get_booking_by_id(cls, booking_id: int) -> Optional[dict]:
pool = await cls.get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM bookings WHERE id = $1", booking_id)
return dict(row) if row else None
@classmethod
async def get_user_by_id(cls, user_id: int) -> Optional[dict]:
return await cls.get_user(user_id)
+5 -16
View File
@@ -1,23 +1,12 @@
"""About handler - salon info page."""
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import Message from aiogram.types import Message
from aiogram.fsm.context import FSMContext
from app.logger import logger from app.logger import logger
from aiogram.fsm.context import FSMContext
router = Router() router = Router()
@router.message(F.text == "О нас")
@router.message(F.text == "\u2139\ufe0f \u041e \u043d\u0430\u0441")
async def about(message: Message, state: FSMContext): async def about(message: Message, state: FSMContext):
await state.clear() await state.clear() # 🔥 СБРОС FSM
logger.info("User {uid} opened About", uid=message.from_user.id)
text = ( await message.answer("Мы demo SPA бот 💆‍♂️")
"\u262f\ufe0f SPA-\u0441\u0430\u043b\u043e\u043d Demo\n\n"
"\u041c\u044b - \u0443\u044e\u0442\u043d\u044b\u0439 SPA-\u0441\u0430\u043b\u043e\u043d \u0432 \u0446\u0435\u043d\u0442\u0440\u0435 \u0433\u043e\u0440\u043e\u0434\u0430.\n\n"
"\U0001f4aa \u041f\u0440\u043e\u0444\u0435\u0441\u0441\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0435 \u043c\u0430\u0441\u0442\u0435\u0440\u0430\n"
"\U0001f9ec \u041d\u0430\u0442\u0443\u0440\u0430\u043b\u044c\u043d\u0430\u044f \u043a\u043e\u0441\u043c\u0435\u0442\u0438\u043a\u0430\n"
"\U0001f3e1 \u0423\u044e\u0442\u043d\u0430\u044f \u0430\u0442\u043c\u043e\u0441\u0444\u0435\u0440\u0430\n\n"
"\u0420\u0430\u0431\u043e\u0442\u0430\u0435\u043c \u0435\u0436\u0435\u0434\u043d\u0435\u0432\u043d\u043e \u0441 09:00 \u0434\u043e 19:00\n\n"
"\U0001f447 \u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u00ab\u0417\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u00bb \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f!"
)
await message.answer(text)
+131 -75
View File
@@ -1,120 +1,176 @@
"""Admin handler for spa-telegram-bot — secure with password.""" """Admin handler for spa-telegram-bot — secure with password."""
from aiogram import Router, F import hmac
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton import time
from aiogram.filters import Command, CommandObject
from aiogram import Bot
from app.config import USE_POSTGRES from aiogram import Router, F
from app.config import ADMIN_PASSWORD, BOT_TOKEN from aiogram.types import Message, CallbackQuery
from aiogram.filters import Command, CommandObject, StateFilter
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import StatesGroup, State
from app.db.memory import load_bookings
from app.config import ADMIN_PASSWORD
from app.logger import logger from app.logger import logger
router = Router() router = Router()
ADMIN_IDS = {991309145} ADMIN_IDS = {991309145}
_approved = set() # Store approved sessions — user_ids that passed password check
_notify_bot = None _approved: dict[int, float] = {}
APPROVED_TTL_SECONDS = 60 * 60 # 1 hour
# Failed attempt tracking (per user) to slow down brute force
_failed_attempts: dict[int, list[float]] = {}
MAX_ATTEMPTS = 5
ATTEMPT_WINDOW_SECONDS = 300 # 5 minutes
def _get_bot(): class AdminState(StatesGroup):
global _notify_bot waiting_password = State()
if _notify_bot is None:
_notify_bot = Bot(token=BOT_TOKEN)
return _notify_bot def _is_rate_limited(user_id: int) -> bool:
"""Return True if the user exceeded the allowed number of failed attempts."""
now = time.time()
attempts = [t for t in _failed_attempts.get(user_id, []) if now - t < ATTEMPT_WINDOW_SECONDS]
_failed_attempts[user_id] = attempts
return len(attempts) >= MAX_ATTEMPTS
def _register_failure(user_id: int) -> None:
_failed_attempts.setdefault(user_id, []).append(time.time())
def _clear_failures(user_id: int) -> None:
_failed_attempts.pop(user_id, None)
def is_admin(user_id: int, password: str | None = None) -> bool: def is_admin(user_id: int, password: str | None = None) -> bool:
"""Check if user is admin: by user_id, by password, or by approved session."""
if user_id in ADMIN_IDS: if user_id in ADMIN_IDS:
return True return True
if user_id in _approved: now = time.time()
expires_at = _approved.get(user_id)
if expires_at and expires_at > now:
return True return True
if password and password == ADMIN_PASSWORD: if expires_at:
_approved.add(user_id) _approved.pop(user_id, None) # expired session
if password and hmac.compare_digest(password.encode("utf-8"), ADMIN_PASSWORD.encode("utf-8")):
_approved[user_id] = now + APPROVED_TTL_SECONDS
_clear_failures(user_id)
return True return True
return False return False
@router.message(Command("admin")) async def _show_admin_panel(message: Message) -> None:
async def admin_panel(message: Message, command: CommandObject): logger.info("Admin {uid} opened admin panel", uid=message.from_user.id)
uid = message.from_user.id
password = command.args
if not is_admin(uid, password):
return
logger.info("Admin {uid} opened admin panel", uid=uid)
if USE_POSTGRES:
from app.db.postgres import PostgresDB
active = await PostgresDB.get_all_active_bookings()
else:
from app.db.memory import load_bookings
bookings = load_bookings() bookings = load_bookings()
active = [b for b in bookings if b.get("status") == "active"] active = [b for b in bookings if b.get("status") == "active"]
if not active: if not active:
await message.answer("\U0001f4cb \u0410\u043a\u0442\u0438\u0432\u043d\u044b\u0445 \u0437\u0430\u043f\u0438\u0441\u0435\u0439 \u043d\u0435\u0442") await message.answer("📋 Активных записей нет")
return return
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
for booking in active: for booking in active:
buttons = [[ buttons = [[
InlineKeyboardButton( InlineKeyboardButton(
text="\u274c \u041e\u0442\u043c\u0435\u043d\u0438\u0442\u044c", text="❌ Отменить",
callback_data=f"admin_cancel_{booking['id']}" callback_data=f"admin_cancel_{booking['id']}"
) )
]] ]]
user_name = booking.get("user_name") or f"id{booking['user_id']}" user_name = booking.get("user_name") or f"id{booking['user_id']}"
text = ( await message.answer(
"\U0001f4cb \u0417\u0430\u043f\u0438\u0441\u044c #" + str(booking['id']) + "\n" f"📋 Запись #{booking['id']}\n"
"\U0001f464 \u041a\u043b\u0438\u0435\u043d\u0442: " + user_name + "\n" f"👤 Клиент: {user_name}\n"
"\U0001f486 \u0423\u0441\u043b\u0443\u0433\u0430: " + booking.get('service', '?') + "\n" f"💆 Услуги: {booking.get('service', '?')}\n"
"\U0001f464 \u041c\u0430\u0441\u0442\u0435\u0440: " + booking.get('master', '?') + "\n" f"👤 Мастер: {booking.get('master', '?')}\n"
"\U0001f4c5 \u0414\u0430\u0442\u0430: " + booking.get('date', '?') + "\n" f"📅 Дата: {booking.get('date', '?')}\n"
"\U0001f552 \u0412\u0440\u0435\u043c\u044f: " + booking.get('time', '?') f"🕒 Время: {booking.get('time', '?')}",
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)
) )
await message.answer(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
async def _scrub_message(message: Message) -> None:
"""Delete a message that may contain a password. Best effort."""
try:
await message.delete()
except Exception:
logger.debug("Could not delete message with password (no rights?)")
@router.message(Command("admin"))
async def admin_panel(message: Message, command: CommandObject, state: FSMContext):
uid = message.from_user.id
# Never accept the password via command arguments — it stays in chat history.
# Scrub the command message too, in case someone typed a password after /admin.
if command.args:
await _scrub_message(message)
await message.answer("🔒 Не передавайте пароль в команде — он остаётся в истории чата.")
if is_admin(uid):
await _show_admin_panel(message)
return
if is_admin(uid):
await _show_admin_panel(message)
return
if _is_rate_limited(uid):
logger.warning("Admin password brute-force blocked for {uid}", uid=uid)
await message.answer("⛔ Слишком много попыток. Подождите 5 минут.")
return
await state.set_state(AdminState.waiting_password)
await message.answer(
"🔐 Введите пароль администратора отдельным сообщением.\n"
"Сообщение с паролем будет удалено и не останется в истории чата."
)
@router.message(StateFilter(AdminState.waiting_password), F.text)
async def admin_password_input(message: Message, state: FSMContext):
uid = message.from_user.id
password = message.text or ""
# Always delete the message containing the password — do not keep it in history.
await _scrub_message(message)
if _is_rate_limited(uid):
await state.clear()
await message.answer("⛔ Слишком много попыток. Подождите 5 минут.")
return
if is_admin(uid, password=password):
await state.clear()
await _show_admin_panel(message)
return
_register_failure(uid)
await state.clear()
logger.warning("Failed admin password attempt from {uid}", uid=uid)
await message.answer("⛔ Неверный пароль. Попробуйте ещё раз: /admin")
@router.callback_query(F.data.startswith("admin_cancel_")) @router.callback_query(F.data.startswith("admin_cancel_"))
async def admin_cancel_booking(callback: CallbackQuery): async def admin_cancel_booking(callback: CallbackQuery):
if not is_admin(callback.from_user.id): if not is_admin(callback.from_user.id):
await callback.answer("\u26d4 \u041d\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u0430") await callback.answer("⛔ Нет доступа")
return
try:
booking_id = int(callback.data.split("_", 2)[2])
except (ValueError, IndexError):
await callback.answer("Некорректные данные")
return return
booking_id = int(callback.data.split("_")[2])
logger.info("Admin {uid} cancelled booking #{bid}", uid=callback.from_user.id, bid=booking_id) logger.info("Admin {uid} cancelled booking #{bid}", uid=callback.from_user.id, bid=booking_id)
if USE_POSTGRES: from app.db.memory import cancel_booking
from app.db.postgres import PostgresDB cancel_booking(booking_id)
success = await PostgresDB.cancel_booking(booking_id)
booking = await PostgresDB.get_booking_by_id(booking_id) if success else None
else:
from app.db.memory import cancel_booking as json_cancel, load_bookings
success = json_cancel(booking_id)
bookings = load_bookings()
booking = next((b for b in bookings if b["id"] == booking_id), None)
if not success: await callback.message.edit_text(
await callback.message.edit_text("\u0417\u0430\u043f\u0438\u0441\u044c \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u0430 \u0438\u043b\u0438 \u0443\u0436\u0435 \u043e\u0442\u043c\u0435\u043d\u0435\u043d\u0430") f"✅ Запись #{booking_id} отменена админом"
await callback.answer()
return
await callback.message.edit_text("\u2705 \u0417\u0430\u043f\u0438\u0441\u044c #" + str(booking_id) + " \u043e\u0442\u043c\u0435\u043d\u0435\u043d\u0430 \u0430\u0434\u043c\u0438\u043d\u043e\u043c")
if booking:
try:
bot = _get_bot()
user_id = booking["user_id"]
notify_text = (
"\u26a0\ufe0f \u0412\u0430\u0448\u0430 \u0437\u0430\u043f\u0438\u0441\u044c \u043e\u0442\u043c\u0435\u043d\u0435\u043d\u0430 \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440\u043e\u043c.\n\n"
"\U0001f486 \u0423\u0441\u043b\u0443\u0433\u0430: " + booking.get('service', '?') + "\n"
"\U0001f464 \u041c\u0430\u0441\u0442\u0435\u0440: " + booking.get('master', '?') + "\n"
"\U0001f4c5 \u0414\u0430\u0442\u0430: " + booking.get('date', '?') + "\n"
"\U0001f552 \u0412\u0440\u0435\u043c\u044f: " + booking.get('time', '?') + "\n\n"
"\U0001f4cb \u0427\u0442\u043e\u0431\u044b \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f \u0441\u043d\u043e\u0432\u0430, \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u00ab\u0417\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f\u00bb"
) )
await bot.send_message(user_id, notify_text) await callback.answer("Запись отменена")
logger.info("Notified user {uid} about cancellation", uid=user_id)
except Exception as e:
logger.warning("Failed to notify user: {err}", err=e)
await callback.answer()
+22 -108
View File
@@ -1,14 +1,12 @@
"""Booking handler - full FSM flow for service reservation."""
import re import re
from aiogram import Router, F from aiogram import Router, F
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.filters import StateFilter from aiogram.filters import StateFilter
from aiogram.types import Message, CallbackQuery, FSInputFile, InlineKeyboardMarkup, InlineKeyboardButton from aiogram.types import Message, CallbackQuery
from app.keyboards.services import get_services_keyboard, get_extra_services_keyboard from app.keyboards.services import get_services_keyboard, get_extra_services_keyboard
from app.keyboards.masters import get_masters_keyboard from app.keyboards.masters import get_masters_keyboard
from app.states.booking import BookingState from app.states.booking import BookingState
from app.config import SERVICES as ALL_SERVICES, ADMIN_CHAT_ID
from app.services.booking_service import ( from app.services.booking_service import (
create_booking, create_booking,
update_booking, update_booking,
@@ -34,17 +32,6 @@ def _services_to_str(data):
return main return main
def _make_service_card(key):
"""Recreate the service card text for 'back' navigation from a photo card."""
svc = ALL_SERVICES[key]
return (
svc["emoji"] + " " + svc["name"] + "\n\n"
+ svc["desc"] + "\n\n"
+ "\u23f1 Продолжительность: " + str(svc["duration"]) + " мин\n"
+ "\U0001f4b0 Цена: " + str(svc["price"]) + "\u20bd"
)
# отмена записи на любом шаге # отмена записи на любом шаге
@router.message(F.text.lower() == "отмена") @router.message(F.text.lower() == "отмена")
async def cancel(message: Message, state: FSMContext): async def cancel(message: Message, state: FSMContext):
@@ -66,14 +53,19 @@ async def start_booking(message: Message, state: FSMContext):
) )
# выбор первой услуги (через кнопку "📅 Записаться") # выбор первой услуги
@router.callback_query( @router.callback_query(
StateFilter(BookingState.service), StateFilter(BookingState.service),
F.data.startswith("service_") F.data.startswith("service_")
) )
async def choose_service(callback: CallbackQuery, state: FSMContext): async def choose_service(callback: CallbackQuery, state: FSMContext):
svc_data = ALL_SERVICES[callback.data] from app.config import SERVICES
service_name = svc_data["name"] if isinstance(svc_data, dict) else svc_data
service_name = SERVICES.get(callback.data)
if not service_name:
await callback.answer("Услуга не найдена")
return
logger.debug("User {uid} selected first service: {srv}", uid=callback.from_user.id, srv=service_name) logger.debug("User {uid} selected first service: {srv}", uid=callback.from_user.id, srv=service_name)
await state.update_data(service=service_name, extra_services=[]) await state.update_data(service=service_name, extra_services=[])
@@ -94,7 +86,10 @@ async def choose_service(callback: CallbackQuery, state: FSMContext):
F.data.startswith("extra_") F.data.startswith("extra_")
) )
async def choose_extra_service(callback: CallbackQuery, state: FSMContext): async def choose_extra_service(callback: CallbackQuery, state: FSMContext):
from app.config import SERVICES
if callback.data == "extra_done": if callback.data == "extra_done":
# Go to master selection
await state.set_state(BookingState.master) await state.set_state(BookingState.master)
await callback.message.edit_text( await callback.message.edit_text(
"👤 Выберите специалиста:", "👤 Выберите специалиста:",
@@ -103,12 +98,12 @@ async def choose_extra_service(callback: CallbackQuery, state: FSMContext):
await callback.answer() await callback.answer()
return return
# Toggle extra service
actual_key = callback.data.replace("extra_", "", 1) actual_key = callback.data.replace("extra_", "", 1)
svc_data = ALL_SERVICES.get(actual_key) service_name = SERVICES.get(actual_key)
if not svc_data: if not service_name:
await callback.answer("Услуга не найдена") await callback.answer("Услуга не найдена")
return return
service_name = svc_data["name"] if isinstance(svc_data, dict) else svc_data
data = await state.get_data() data = await state.get_data()
extra = data.get("extra_services", []) extra = data.get("extra_services", [])
@@ -142,8 +137,11 @@ async def choose_extra_service(callback: CallbackQuery, state: FSMContext):
async def choose_master(callback: CallbackQuery, state: FSMContext): async def choose_master(callback: CallbackQuery, state: FSMContext):
from app.config import MASTERS from app.config import MASTERS
master_data = MASTERS[callback.data] master_name = MASTERS.get(callback.data)
master_name = master_data["name"] if isinstance(master_data, dict) else master_data if not master_name:
await callback.answer("Мастер не найден")
return
logger.debug("User {uid} selected master: {m}", uid=callback.from_user.id, m=master_name) logger.debug("User {uid} selected master: {m}", uid=callback.from_user.id, m=master_name)
await state.update_data(master=master_name) await state.update_data(master=master_name)
@@ -232,14 +230,6 @@ async def confirm_booking_callback(callback: CallbackQuery, state: FSMContext):
date = data.get("date", "?") date = data.get("date", "?")
time = data.get("time", "?") time = data.get("time", "?")
from app.db.postgres import PostgresDB as _PG
from app.config import USE_POSTGRES as _UPG
if _UPG:
_u = await _PG.get_user(callback.from_user.id)
if not _u:
_n = callback.from_user.full_name or callback.from_user.username or str(callback.from_user.id)
await _PG.set_user(callback.from_user.id, _n)
if await is_slot_busy(master, date, time): if await is_slot_busy(master, date, time):
logger.warning("Slot already busy: {mst} {dt} {tm}", mst=master, dt=date, tm=time) logger.warning("Slot already busy: {mst} {dt} {tm}", mst=master, dt=date, tm=time)
await callback.message.edit_text("❌ Это время уже занято. Пожалуйста, выберите другое время.") await callback.message.edit_text("❌ Это время уже занято. Пожалуйста, выберите другое время.")
@@ -258,26 +248,6 @@ async def confirm_booking_callback(callback: CallbackQuery, state: FSMContext):
cleanup_cancelled() cleanup_cancelled()
result_text = "✅ Запись успешно создана" result_text = "✅ Запись успешно создана"
# Notify admin about new booking
try:
if ADMIN_CHAT_ID:
from aiogram import Bot
from app.config import BOT_TOKEN
admin_bot = Bot(token=BOT_TOKEN)
user_str = callback.from_user.full_name or callback.from_user.username or str(callback.from_user.id)
await admin_bot.send_message(
ADMIN_CHAT_ID,
"🆕 Новая запись!\n\n"
f"👤 Клиент: {user_str} (id={callback.from_user.id})\n"
f"💆 Услуги: {services_str}\n"
f"👤 Мастер: {master}\n"
f"📅 Дата: {date}\n"
f"🕒 Время: {time}"
)
await admin_bot.session.close()
except Exception as e:
logger.warning("Failed to notify admin: {error}", error=e)
await state.clear() await state.clear()
await callback.message.edit_text(result_text) await callback.message.edit_text(result_text)
await callback.answer() await callback.answer()
@@ -309,80 +279,24 @@ async def edit_booking_callback(callback: CallbackQuery, state: FSMContext):
) )
# прямая запись с карточки услуги (без предварительного выбора)
@router.callback_query(F.data.startswith("service_"))
async def quick_book_service(callback: CallbackQuery, state: FSMContext):
svc_data = ALL_SERVICES[callback.data]
service_name = svc_data["name"] if isinstance(svc_data, dict) else svc_data
svc_key = callback.data
logger.info("User {uid} quick book from card: {srv}", uid=callback.from_user.id, srv=service_name)
await state.clear()
await state.update_data(
service=service_name,
extra_services=[],
_from_card=True,
_card_key=svc_key
)
await state.set_state(BookingState.extra_services)
text = (
"✅ Выбрано: " + service_name + "\n\n"
"Хотите добавить ещё услугу?\n"
"Нажмите нужную или «Готово»:"
)
# Карточка с фото — удаляем и отправляем новое сообщение
try:
await callback.message.delete()
await callback.message.answer(text, reply_markup=get_extra_services_keyboard([]))
except Exception:
await callback.message.edit_text(text, reply_markup=get_extra_services_keyboard([]))
await callback.answer()
@router.callback_query(F.data == "go_back") @router.callback_query(F.data == "go_back")
async def go_back_handler(callback: CallbackQuery, state: FSMContext): async def go_back_handler(callback: CallbackQuery, state: FSMContext):
current_state = await state.get_state() current_state = await state.get_state()
if current_state == BookingState.extra_services: if current_state == BookingState.extra_services:
# Назад к списку услуг — проверяем, с карточки пришёл или из меню # Back to first service
await state.set_state(BookingState.service) await state.set_state(BookingState.service)
data = await state.get_data() data = await state.get_data()
data.pop("extra_services", None) data.pop("extra_services", None)
data.pop("service", None) data.pop("service", None)
from_card = data.pop("_from_card", False)
card_key = data.pop("_card_key", None)
await state.set_data(data) await state.set_data(data)
if from_card and card_key and card_key in ALL_SERVICES:
# Возвращаем карточку с фото
svc = ALL_SERVICES[card_key]
card_text = _make_service_card(card_key)
book_btn = InlineKeyboardMarkup(inline_keyboard=[[
InlineKeyboardButton(text="\U0001f4c5 Записаться", callback_data=card_key)
]])
try:
img = FSInputFile(svc["image"])
await callback.message.answer_photo(photo=img, caption=card_text, reply_markup=book_btn)
try:
await callback.message.delete()
except Exception:
pass
except Exception:
await callback.message.edit_text(
"Выберите услугу:",
reply_markup=get_services_keyboard()
)
else:
await callback.message.edit_text( await callback.message.edit_text(
"Выберите услугу:", "Выберите услугу:",
reply_markup=get_services_keyboard() reply_markup=get_services_keyboard()
) )
elif current_state == BookingState.master: elif current_state == BookingState.master:
# Назад к доп. услугам # Back to extra services
await state.set_state(BookingState.extra_services) await state.set_state(BookingState.extra_services)
data = await state.get_data() data = await state.get_data()
extra = data.get("extra_services", []) extra = data.get("extra_services", [])
+4 -15
View File
@@ -1,23 +1,12 @@
"""Contacts handler - address, phone, social."""
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import Message from aiogram.types import Message
from aiogram.fsm.context import FSMContext
from app.logger import logger from app.logger import logger
from aiogram.fsm.context import FSMContext
router = Router() router = Router()
@router.message(F.text == "📞 Контакты")
@router.message(F.text == "\U0001f4de \u041a\u043e\u043d\u0442\u0430\u043a\u0442\u044b")
async def contacts(message: Message, state: FSMContext): async def contacts(message: Message, state: FSMContext):
await state.clear() await state.clear()
logger.info("User {uid} requested contacts", uid=message.from_user.id)
text = ( await message.answer("📍 Москва\n☎ +7 999 999 99 99")
"\U0001f4de \u041a\u043e\u043d\u0442\u0430\u043a\u0442\u044b\n\n"
"\U0001f4cd \u0433. \u041c\u043e\u0441\u043a\u0432\u0430, \u0443\u043b. \u0426\u0432\u0435\u0442\u043e\u0447\u043d\u0430\u044f, 15\n\n"
"\U0001f4de +7 (999) 123-45-67\n\n"
"\U0001f4e7 info@spa-demo.ru\n\n"
"\U0001f310 @spa_demo_salon\n\n"
"\u0420\u0435\u0436\u0438\u043c \u0440\u0430\u0431\u043e\u0442\u044b:\n"
"\u041f\u043d-\u0412\u0441: 09:00 - 19:00"
)
await message.answer(text)
+79 -65
View File
@@ -1,95 +1,109 @@
"""My bookings handler - view, edit, cancel bookings."""
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import Message, CallbackQuery from aiogram.types import Message, CallbackQuery
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from app.config import USE_POSTGRES from app.db.memory import get_user_bookings, get_booking, cancel_booking, update_booking
from app.services.booking_service import get_user_bookings, cancel_booking from app.keyboards.menu import menu
from app.keyboards.my_booking import get_booking_actions from app.keyboards.my_booking import get_booking_actions
from app.keyboards.services import get_services_keyboard
from app.states.booking import BookingState
from app.logger import logger from app.logger import logger
router = Router() router = Router()
@router.message(F.text == "\U0001f4c4 \u041c\u043e\u0438 \u0437\u0430\u043f\u0438\u0441\u0438") @router.message(
async def my_bookings(message: Message): F.text == "📄 Мои записи"
)
async def my_bookings(
message: Message
):
logger.info("User {uid} opened My Bookings", uid=message.from_user.id) logger.info("User {uid} opened My Bookings", uid=message.from_user.id)
try: try:
bookings = await get_user_bookings(message.from_user.id) bookings = get_user_bookings(
message.from_user.id
)
logger.debug("Found {n} bookings", n=len(bookings)) logger.debug("Found {n} bookings", n=len(bookings))
if not bookings: if not bookings:
await message.answer("У вас нет активных записей") await message.answer(
"У вас нет активных записей"
)
return return
# Group all bookings into one message for booking in bookings:
lines = [] svc = booking["service"]
for b in bookings: mst = booking["master"]
bid = b["id"] dat = booking["date"]
svc = b["service"] tim = booking["time"]
mst = b["master"] text = "💆 " + svc + "\n"
dat = b["date"] text += "👤 " + mst + "\n"
tim = b["time"] text += "📅 " + dat + "\n"
lines.append( text += "🕒 " + tim
f"#{bid} \U0001f486 {svc}\n"
f"\U0001f464 {mst}\n"
f"\U0001f4c5 {dat}\U0001f552 {tim}"
)
full_text = "\u0423 \u0432\u0430\u0441 " + str(len(bookings)) + " \u0437\u0430\u043f\u0438\u0441\u0438:\n\n" + "\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n".join(lines)
await message.answer(full_text)
# Send action buttons for each booking
for b in bookings:
await message.answer( await message.answer(
f"#{b['id']}: {b['service']}", text,
reply_markup=get_booking_actions(b["id"]) reply_markup=get_booking_actions(
booking["id"]
)
) )
except Exception as e: except Exception as e:
logger.exception("Error in my_bookings: {error}", error=e) logger.exception("Error in my_bookings: {error}", error=e)
await message.answer("Произошла ошибка. Попробуйте позже.") await message.answer(
"Произошла ошибка. Попробуйте позже."
)
@router.callback_query(F.data.startswith("cancel_booking_")) @router.callback_query(
F.data.startswith("cancel_booking_")
)
async def cancel_booking_handler(callback: CallbackQuery): async def cancel_booking_handler(callback: CallbackQuery):
booking_id = int(callback.data.split("_")[2]) try:
logger.info("User {uid} cancelled booking #{bid}", uid=callback.from_user.id, bid=booking_id) booking_id = int(callback.data.split("_", 2)[2])
await cancel_booking(booking_id) except (ValueError, IndexError):
await callback.message.edit_text("\u2705 Запись отменена") await callback.answer("Некорректные данные")
await callback.answer()
@router.callback_query(F.data.startswith("edit_booking_"))
async def edit_booking_handler(callback: CallbackQuery, state: FSMContext):
booking_id = int(callback.data.split("_")[2])
if USE_POSTGRES:
from app.db.postgres import PostgresDB
booking = await PostgresDB.get_booking_by_id(booking_id)
else:
from app.db.memory import load_bookings
bookings = load_bookings()
booking = next((b for b in bookings if b["id"] == booking_id), None)
if not booking:
await callback.message.edit_text("Запись не найдена")
await callback.answer()
return return
await state.set_data({ booking = get_booking(booking_id)
"editing_booking_id": booking_id, if not booking or booking.get("user_id") != callback.from_user.id:
"service": booking.get("service", ""), logger.warning("User {uid} tried to cancel foreign booking #{bid}", uid=callback.from_user.id, bid=booking_id)
"master": booking.get("master", ""), await callback.answer("⛔ Это не ваша запись")
"date": booking.get("date", ""), return
"time": booking.get("time", ""),
})
await state.set_state(BookingState.service) logger.info("User {uid} cancelled booking #{bid}", uid=callback.from_user.id, bid=booking_id)
cancel_booking(
booking_id
)
await callback.message.edit_text( await callback.message.edit_text(
"\u270f\ufe0f Редактирование записи #" + str(booking_id) + "\n\nВыберите новую услугу:", "Запись отменена"
reply_markup=get_services_keyboard()
) )
await callback.answer() await callback.answer()
@router.callback_query(
F.data.startswith("edit_booking_")
)
async def edit_booking_handler(
callback: CallbackQuery,
state: FSMContext
):
try:
booking_id = int(callback.data.split("_", 2)[2])
except (ValueError, IndexError):
await callback.answer("Некорректные данные")
return
booking = get_booking(booking_id)
if not booking or booking.get("user_id") != callback.from_user.id:
logger.warning("User {uid} tried to edit foreign booking #{bid}", uid=callback.from_user.id, bid=booking_id)
await callback.answer("⛔ Это не ваша запись")
return
update_booking(
booking_id=booking_id,
service="",
master="",
date="",
time=""
)
await callback.answer(
"Редактирование не поддерживается"
)
+6 -22
View File
@@ -1,34 +1,18 @@
"""User profile handler - auto-registers if missing."""
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import Message from aiogram.types import Message
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from app.db.postgres import PostgresDB from app.db.memory import get_user
from app.config import USE_POSTGRES
from app.logger import logger from app.logger import logger
router = Router() router = Router()
@router.message(F.text == "\U0001f464 \u041f\u0440\u043e\u0444\u0438\u043b\u044c") @router.message(F.text == "👤 Профиль")
async def profile(message: Message, state: FSMContext): async def profile(message: Message, state: FSMContext):
await state.clear() await state.clear()
user_id = message.from_user.id user = get_user(message.from_user.id)
name = message.from_user.full_name or "\u0413\u043e\u0441\u0442\u044c"
if USE_POSTGRES:
user = await PostgresDB.get_user(user_id)
if not user: if not user:
logger.info("Auto-registering user {uid} in PG", uid=user_id) await message.answer("Профиль не найден")
await PostgresDB.set_user(user_id, name) return
user = await PostgresDB.get_user(user_id) await message.answer(f"👤 Имя: {user['name']}")
user_name = user.get("name") or user.get("full_name", "\u0413\u043e\u0441\u0442\u044c")
else:
from app.db.memory import get_user, set_user
user = get_user(user_id)
if not user:
set_user(user_id, {"name": name})
user = get_user(user_id)
user_name = user.get("name", "\u0413\u043e\u0441\u0442\u044c")
await message.answer(f"\U0001f464 \u041f\u0440\u043e\u0444\u0438\u043b\u044c\n\n\U0001f464 \u0418\u043c\u044f: {user_name}")
+10 -28
View File
@@ -1,41 +1,23 @@
"""Services handler - show rich service cards with images."""
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import Message, FSInputFile, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery from aiogram.types import Message
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram import Bot
from app.config import SERVICES, BOT_TOKEN from app.keyboards.menu import menu
from app.keyboards.services import get_services_keyboard
from app.logger import logger from app.logger import logger
router = Router() router = Router()
_bot = None
def bot():
global _bot
if _bot is None:
_bot = Bot(token=BOT_TOKEN)
return _bot
@router.message(F.text == "\U0001f4cb \u0423\u0441\u043b\u0443\u0433\u0438") @router.message(F.text == "\U0001f4cb \u0423\u0441\u043b\u0443\u0433\u0438")
async def show_services(message: Message, state: FSMContext): async def show_services(message: Message, state: FSMContext):
await state.clear() await state.clear()
logger.info("User {uid} requested services list", uid=message.from_user.id) logger.info("User {uid} requested services list", uid=message.from_user.id)
await message.answer(
for key, svc in SERVICES.items(): "\U0001f4cb \u041d\u0430\u0448\u0438 \u0443\u0441\u043b\u0443\u0433\u0438:\n\n"
try: "\U0001f4aa \u041c\u0430\u0441\u0441\u0430\u0436 - \u0440\u0430\u0441\u0441\u043b\u0430\u0431\u043b\u044f\u044e\u0449\u0438\u0439 \u0438 \u043b\u0435\u0447\u0435\u0431\u043d\u044b\u0439\n"
card = ( "\U0001f388 SPA - \u043a\u043e\u043c\u043f\u043b\u0435\u043a\u0441\u043d\u044b\u0435 \u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u044b\n"
svc["emoji"] + " " + svc["name"] + "\n\n" "\U0001f484 \u041a\u043e\u0441\u043c\u0435\u0442\u043e\u043b\u043e\u0433\u0438\u044f - \u0443\u0445\u043e\u0434 \u0437\u0430 \u043b\u0438\u0446\u043e\u043c\n\n"
+ svc["desc"] + "\n\n" "\u0427\u0442\u043e\u0431\u044b \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f, \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u00ab\u0417\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f\u00bb",
+ "\u23f1 \u0414\u043b\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u044c: " + str(svc["duration"]) + " \u043c\u0438\u043d\n" reply_markup=get_services_keyboard()
+ "\U0001f4b0 \u0426\u0435\u043d\u0430: " + str(svc["price"]) + "\u20bd"
) )
book_btn = InlineKeyboardMarkup(inline_keyboard=[[
InlineKeyboardButton(text="\U0001f4c5 \u0417\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f", callback_data=key)
]])
img = FSInputFile(svc["image"])
await bot().send_photo(message.chat.id, photo=img, caption=card, reply_markup=book_btn)
except Exception as e:
logger.warning("Error sending service card for {key}: {err}", key=key, err=e)
+6 -23
View File
@@ -1,11 +1,9 @@
"""Start handler with welcome greeting and prices."""
from aiogram import Router from aiogram import Router
from aiogram.filters import CommandStart from aiogram.filters import CommandStart
from aiogram.types import Message, FSInputFile from aiogram.types import Message
from app.keyboards.menu import menu from app.keyboards.menu import menu
from app.db.postgres import PostgresDB from app.db.memory import get_user, set_user
from app.config import SERVICES
from app.logger import logger from app.logger import logger
router = Router() router = Router()
@@ -14,25 +12,10 @@ router = Router()
@router.message(CommandStart()) @router.message(CommandStart())
async def start(message: Message): async def start(message: Message):
user_id = message.from_user.id user_id = message.from_user.id
name = message.from_user.full_name or "\u0413\u043e\u0441\u0442\u044c"
from app.config import USE_POSTGRES
if USE_POSTGRES:
await PostgresDB.set_user(user_id, name)
else:
from app.db.memory import get_user, set_user
if not get_user(user_id): if not get_user(user_id):
set_user(user_id, {"name": name}) set_user(user_id, {"name": message.from_user.full_name})
greet = ( await message.answer(
"\U0001f44b \u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c, " + name + "!\n\n" "👋 Добро пожаловать в Demo Bot",
"\u262f\ufe0f SPA-\u0441\u0430\u043b\u043e\u043d Demo - \u0432\u0430\u0448 \u043e\u0441\u0442\u0440\u043e\u0432\u043e\u043a \u0440\u0435\u043b\u0430\u043a\u0441\u0430\u0446\u0438\u0438\n\n" reply_markup=menu,
"\U0001f4aa \u041c\u0430\u0441\u0441\u0430\u0436 - \u043e\u0442 " + str(SERVICES["service_massage"]["price"]) + "\u20bd\n"
"\U0001f388 SPA-\u043a\u043e\u043c\u043f\u043b\u0435\u043a\u0441 - \u043e\u0442 " + str(SERVICES["service_spa"]["price"]) + "\u20bd\n"
"\U0001f484 \u041a\u043e\u0441\u043c\u0435\u0442\u043e\u043b\u043e\u0433\u0438\u044f - \u043e\u0442 " + str(SERVICES["service_cosmetology"]["price"]) + "\u20bd\n\n"
"\U0001f447 \u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u00ab\u0417\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u00bb \u0447\u0442\u043e\u0431\u044b \u043d\u0430\u0447\u0430\u0442\u044c"
) )
try:
img = FSInputFile("assets/welcome.png")
await message.answer_photo(photo=img, caption=greet, reply_markup=menu)
except Exception:
await message.answer(greet, reply_markup=menu)
-42
View File
@@ -1,42 +0,0 @@
"""Healthcheck endpoint for monitoring."""
import asyncio
import json
from pathlib import Path
from aiohttp import web
from app.logger import logger
routes = web.RouteTableDef()
@routes.get("/health")
async def health(request):
"""Basic health check — returns bot status."""
health_data = {
"status": "ok",
"service": "spa-telegram-bot",
"timestamp": asyncio.get_event_loop().time(),
}
# Check if data files exist
data_dir = Path(__file__).resolve().parent.parent / "data"
health_data["data_dir_exists"] = data_dir.exists()
return web.json_response(health_data, status=200)
@routes.get("/health/ready")
async def ready(request):
"""Readiness check — can the bot accept requests?"""
return web.json_response({"status": "ready"}, status=200)
async def start_health_server(port: int = 8080):
"""Start healthcheck HTTP server on given port."""
app = web.Application()
app.add_routes(routes)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", port)
await site.start()
logger.info("Healthcheck server started on port {port}", port=port)
return runner
+1 -1
View File
@@ -15,7 +15,7 @@ def get_dates_keyboard():
[ [
InlineKeyboardButton( InlineKeyboardButton(
text=day.strftime("%d.%m"), text=day.strftime("%d.%m"),
callback_data=f"date_{day.strftime('%d.%m.%Y')}" callback_data=f"date_{day.strftime('%d.%m')}"
) )
] ]
) )
+7 -14
View File
@@ -1,21 +1,14 @@
"""Masters keyboard with specialty filtering."""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from app.config import MASTERS from app.config import MASTERS
from app.keyboards.back import get_back_button from app.keyboards.back import get_back_button
def get_masters_keyboard(service_key=None): def get_masters_keyboard():
buttons = [] buttons = [
svc_cats = { [InlineKeyboardButton(text=name, callback_data=key)]
"service_massage": "massage", for key, name in MASTERS.items()
"service_spa": "spa", ]
"service_cosmetology": "cosmetology",
}
for key, master in MASTERS.items():
if service_key and "specialty" in master:
needed = svc_cats.get(service_key, "")
if needed and needed not in master["specialty"]:
continue
buttons.append([InlineKeyboardButton(text=master["name"], callback_data=key)])
buttons.append(get_back_button()) buttons.append(get_back_button())
return InlineKeyboardMarkup(inline_keyboard=buttons) return InlineKeyboardMarkup(inline_keyboard=buttons)
+8 -9
View File
@@ -1,22 +1,21 @@
"""Main menu keyboard."""
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton from aiogram.types import ReplyKeyboardMarkup, KeyboardButton
menu = ReplyKeyboardMarkup( menu = ReplyKeyboardMarkup(
keyboard=[ keyboard=[
[KeyboardButton(text="\U0001f4cb \u0423\u0441\u043b\u0443\u0433\u0438"), [KeyboardButton(text="📋 Услуги"),
KeyboardButton(text="\U0001f4c5 \u0417\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f")], KeyboardButton(text="📅 Записаться")],
[KeyboardButton(text="\U0001f464 \u041f\u0440\u043e\u0444\u0438\u043b\u044c"), [KeyboardButton(text="👤 Профиль"),
KeyboardButton(text="\U0001f4c4 \u041c\u043e\u0438 \u0437\u0430\u043f\u0438\u0441\u0438")], KeyboardButton(text="📄 Мои записи")],
[KeyboardButton(text="\U0001f4de \u041a\u043e\u043d\u0442\u0430\u043a\u0442\u044b"), [KeyboardButton(text="📞 Контакты"),
KeyboardButton(text="\u2139\ufe0f \u041e \u043d\u0430\u0441")], KeyboardButton(text="О нас")],
], ],
resize_keyboard=True, resize_keyboard=True,
input_field_placeholder="\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435..." input_field_placeholder="Выберите действие..."
) )
cancel_menu = ReplyKeyboardMarkup( cancel_menu = ReplyKeyboardMarkup(
keyboard=[ keyboard=[
[KeyboardButton(text="\u274c \u041e\u0442\u043c\u0435\u043d\u0430")] [KeyboardButton(text="❌ Отмена")]
], ],
resize_keyboard=True resize_keyboard=True
) )
+15 -10
View File
@@ -1,28 +1,33 @@
"""Services keyboard with price & duration."""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from app.config import SERVICES from app.config import SERVICES
from app.keyboards.back import get_back_button from app.keyboards.back import get_back_button
def get_services_keyboard(): def get_services_keyboard():
buttons = [] buttons = [
for key, svc in SERVICES.items(): [InlineKeyboardButton(text=name, callback_data=key)]
label = svc["emoji"] + " " + svc["name"] + " - " + str(svc["price"]) + "\u20bd / " + str(svc["duration"]) + "\u043c\u0438\u043d" for key, name in SERVICES.items()
buttons.append([InlineKeyboardButton(text=label, callback_data=key)]) ]
buttons.append(get_back_button()) buttons.append(get_back_button())
return InlineKeyboardMarkup(inline_keyboard=buttons) return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_extra_services_keyboard(already_selected=None): def get_extra_services_keyboard(already_selected: list[str] | None = None):
"""Keyboard for selecting extra services. Shows ticked/unticked."""
if already_selected is None: if already_selected is None:
already_selected = [] already_selected = []
buttons = [] buttons = []
for key, svc in SERVICES.items(): for key, name in SERVICES.items():
if svc["name"] in already_selected: if name in already_selected:
label = "\u2705 " + svc["emoji"] + " " + svc["name"] label = "\u2705 " + name
else: else:
label = svc["emoji"] + " " + svc["name"] label = name
buttons.append([InlineKeyboardButton(text=label, callback_data="extra_" + key)]) buttons.append([InlineKeyboardButton(text=label, callback_data="extra_" + key)])
# Done button
buttons.append([InlineKeyboardButton(text="\u2705 \u0413\u043e\u0442\u043e\u0432\u043e", callback_data="extra_done")]) buttons.append([InlineKeyboardButton(text="\u2705 \u0413\u043e\u0442\u043e\u0432\u043e", callback_data="extra_done")])
buttons.append(get_back_button()) buttons.append(get_back_button())
return InlineKeyboardMarkup(inline_keyboard=buttons) return InlineKeyboardMarkup(inline_keyboard=buttons)
+4 -40
View File
@@ -4,9 +4,8 @@ import signal
import sys import sys
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.fsm.storage.redis import RedisStorage, DefaultKeyBuilder
from app.config import BOT_TOKEN, USE_POSTGRES from app.config import BOT_TOKEN
from app.logger import logger from app.logger import logger
# Import routers # Import routers
@@ -20,13 +19,7 @@ from app.handlers.contacts import router as contacts_router
from app.handlers.admin import router as admin_router from app.handlers.admin import router as admin_router
bot = Bot(token=BOT_TOKEN) bot = Bot(token=BOT_TOKEN)
dp = Dispatcher()
# Redis FSM storage — survives bot restarts, persistent across polling sessions
redis_storage = RedisStorage.from_url(
"redis://localhost:6379/0",
key_builder=DefaultKeyBuilder(with_destiny=True)
)
dp = Dispatcher(storage=redis_storage)
# Register routers # Register routers
dp.include_routers( dp.include_routers(
@@ -40,20 +33,9 @@ dp.include_routers(
admin_router, admin_router,
) )
_health_runner = None
async def on_startup(): async def on_startup():
"""Called when bot starts.""" """Called when bot starts."""
# Initialize Postgres pool if available
if USE_POSTGRES:
try:
from app.db.postgres import PostgresDB
await PostgresDB.init_pool()
logger.info("PostgreSQL pool initialized")
except Exception as e:
logger.warning("PostgreSQL init failed, falling back to JSON: {error}", error=e)
me = await bot.get_me() me = await bot.get_me()
logger.info( logger.info(
"Bot started | @{username} (id={id}) | Token: {token_preview}...", "Bot started | @{username} (id={id}) | Token: {token_preview}...",
@@ -61,31 +43,12 @@ async def on_startup():
id=me.id, id=me.id,
token_preview=BOT_TOKEN[:10], token_preview=BOT_TOKEN[:10],
) )
# Start healthcheck server
try:
from app.health import start_health_server
global _health_runner
_health_runner = await start_health_server(port=8080)
except Exception as e:
logger.warning("Healthcheck server not started: {error}", error=e)
async def on_shutdown(): async def on_shutdown():
"""Called when bot shuts down.""" """Called when bot shuts down."""
logger.info("Bot shutting down...") logger.info("Bot shutting down...")
if USE_POSTGRES: # Give running tasks time to finish
try:
from app.db.postgres import PostgresDB
await PostgresDB.close()
logger.info("PostgreSQL pool closed")
except Exception as e:
logger.warning("PostgreSQL pool close error: {error}", error=e)
if _health_runner:
await _health_runner.cleanup()
logger.info("Healthcheck server stopped")
if redis_storage:
await redis_storage.close()
logger.info("Redis storage closed")
pending = asyncio.all_tasks() pending = asyncio.all_tasks()
if pending: if pending:
logger.debug("Cancelling {count} pending tasks", count=len(pending)) logger.debug("Cancelling {count} pending tasks", count=len(pending))
@@ -111,6 +74,7 @@ async def main():
try: try:
loop.add_signal_handler(sig, lambda s=sig: _signal_handler(s, None)) loop.add_signal_handler(sig, lambda s=sig: _signal_handler(s, None))
except NotImplementedError: except NotImplementedError:
# Windows fallback
signal.signal(sig, _signal_handler) signal.signal(sig, _signal_handler)
try: try:
+24 -43
View File
@@ -1,83 +1,64 @@
"""Booking service — async layer between handlers and DB."""
from app.db.postgres import PostgresDB
from app.db.memory import ( from app.db.memory import (
create_booking as json_create_booking, create_booking as db_create_booking,
get_busy_times as json_get_busy_times, get_busy_times as db_get_busy_times,
get_user_bookings as json_get_user_bookings, get_user_bookings as db_get_user_bookings,
cancel_booking as json_cancel_booking, cancel_booking as db_cancel_booking,
update_booking as json_update_booking, update_booking as db_update_booking,
load_bookings as json_load_bookings, load_bookings as db_load_bookings,
save_bookings as json_save_bookings, save_bookings as db_save_bookings,
USE_POSTGRES, BOOKINGS_FILE,
) )
from app.logger import logger from app.logger import logger
async def create_booking(user_id, service, master, date, time, user_name=""): async def create_booking(user_id, service, master, date, time, user_name=""):
if USE_POSTGRES: return db_create_booking(user_id, service, master, date, time, user_name)
booking = await PostgresDB.create_booking(user_id, service, master, date, time, user_name)
return {"id": booking["id"], "user_id": booking["user_id"], "service": booking["service"],
"master": booking["master"], "date": booking["date"], "time": booking["time"],
"status": booking["status"]}
return json_create_booking(user_id, service, master, date, time, user_name)
async def get_busy_times(master, date): async def get_busy_times(master, date):
if USE_POSTGRES: return db_get_busy_times(master, date)
return await PostgresDB.get_busy_times(master, date)
return json_get_busy_times(master, date)
async def get_user_bookings(user_id): async def get_user_bookings(user_id):
if USE_POSTGRES: return db_get_user_bookings(user_id)
bookings = await PostgresDB.get_user_bookings(user_id)
return [{"id": b["id"], "user_id": b["user_id"], "service": b["service"],
"master": b["master"], "date": b["date"], "time": b["time"],
"status": b["status"]} for b in bookings]
return json_get_user_bookings(user_id)
async def cancel_booking(booking_id): async def cancel_booking(booking_id):
if USE_POSTGRES: return db_cancel_booking(booking_id)
return await PostgresDB.cancel_booking(booking_id)
return json_cancel_booking(booking_id)
async def update_booking(booking_id, service, master, date, time): async def update_booking(booking_id, service, master, date, time):
if USE_POSTGRES: return db_update_booking(booking_id, service, master, date, time)
result = await PostgresDB.update_booking(booking_id, service, master, date, time)
if result:
return {"id": result["id"], "service": result["service"], "master": result["master"],
"date": result["date"], "time": result["time"]}
return None
return json_update_booking(booking_id, service, master, date, time)
async def is_slot_busy(master, date, time): async def is_slot_busy(master, date, time):
busy = await get_busy_times(master, date) """Check if a time slot is already taken by an active booking."""
busy = db_get_busy_times(master, date)
return time in busy return time in busy
async def cleanup_cancelled(): def cleanup_cancelled():
"""Remove cancelled bookings older than the head (keep only last 5 cancelled per user).""" """Remove cancelled bookings older than the head (keep only last 5 cancelled per user)."""
if USE_POSTGRES: bookings = db_load_bookings()
# Postgres handles this with status column, no cleanup needed
pass
else:
bookings = json_load_bookings()
active = [b for b in bookings if b["status"] == "active"] active = [b for b in bookings if b["status"] == "active"]
cancelled = [b for b in bookings if b["status"] == "cancelled"] cancelled = [b for b in bookings if b["status"] == "cancelled"]
# Group cancelled by user, keep last 5 per user
from collections import defaultdict from collections import defaultdict
by_user = defaultdict(list) by_user = defaultdict(list)
for b in cancelled: for b in cancelled:
by_user[b["user_id"]].append(b) by_user[b["user_id"]].append(b)
keep_ids = set() keep_ids = set()
for uid, user_cancel in by_user.items(): for uid, user_cancel in by_user.items():
# Keep the 5 most recent
user_cancel.sort(key=lambda x: x.get("id", 0), reverse=True) user_cancel.sort(key=lambda x: x.get("id", 0), reverse=True)
for b in user_cancel[:5]: for b in user_cancel[:5]:
keep_ids.add(b["id"]) keep_ids.add(b["id"])
# Rebuild: keep active + keep recent cancelled
bookings = active + [b for b in cancelled if b["id"] in keep_ids] bookings = active + [b for b in cancelled if b["id"] in keep_ids]
json_save_bookings(bookings) db_save_bookings(bookings)
removed = len(cancelled) - len(keep_ids) removed = len(cancelled) - len(keep_ids)
if removed > 0: if removed > 0:
logger.info("Cleaned up {n} old cancelled bookings", n=removed) logger.info("Cleaned up {n} old cancelled bookings", n=removed)
-20
View File
@@ -1,20 +0,0 @@
"""Sticker helper."""
from aiogram import Bot
from app.config import BOT_TOKEN
from app.logger import logger
_bot = None
def _sticker_bot():
global _bot
if _bot is None:
_bot = Bot(token=BOT_TOKEN)
return _bot
async def send_sticker(chat_id, sticker_id: str):
try:
await _sticker_bot().send_sticker(chat_id, sticker=sticker_id)
except Exception as e:
logger.warning("Sticker send failed: {err}", err=e)
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

-32
View File
@@ -1,32 +0,0 @@
version: '3.8'
services:
bot:
build: .
container_name: spa-bot
restart: unless-stopped
env_file:
- .env
volumes:
- ./data:/app/data
- ./logs:/app/logs
ports:
- "8080:8080"
depends_on:
- db
db:
image: postgres:16-alpine
container_name: spa-bot-db
restart: unless-stopped
environment:
POSTGRES_USER: spabot
POSTGRES_PASSWORD: spabot_password
POSTGRES_DB: spabot
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
pgdata:
+2 -4
View File
@@ -1,4 +1,2 @@
aiogram==3.17.0 aiogram
python-dotenv==1.1.0 python-dotenv
loguru==0.7.3
asyncpg==0.30.0