diff --git a/ai_example.py b/ai_example.py deleted file mode 100644 index 39a6f31..0000000 --- a/ai_example.py +++ /dev/null @@ -1,4 +0,0 @@ -from core.ai.ai_bot import detect_eyes - - -detect_eyes("/Users/o.vodianov/IdeaProjects/docbot/image.png") diff --git a/docker-compose.yml b/docker-compose.yml index db5b9ec..8bfa89a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,8 @@ services: - ./db/init:/docker-entrypoint-initdb.d:ro networks: - docbot-network + ports: + - "127.0.0.1:5432:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s @@ -27,12 +29,10 @@ services: environment: - BOT_TOKEN=${BOT_TOKEN} - DATABASE_URL=${DATABASE_URL} - - ADMIN_API_KEY=${ADMIN_API_KEY} - LOGGING_LEVEL=${LOGGING_LEVEL:-INFO} - PRODAMUS_TOKEN=${PRODAMUS_TOKEN} volumes: - - ./data:/app/data - - ./conversations.pkl:/app/conversations.pkl + - ./data:/app/data:rw - ./.env:/app/.env - ./.env_db:/app/.env_db depends_on: @@ -54,7 +54,6 @@ services: environment: - BOT_TOKEN=${BOT_TOKEN} - DATABASE_URL=${DATABASE_URL} - - ADMIN_API_KEY=${ADMIN_API_KEY} - LOGGING_LEVEL=${LOGGING_LEVEL:-INFO} - PRODAMUS_TOKEN=${PRODAMUS_TOKEN} ports: @@ -68,6 +67,7 @@ services: profiles: - production - development + - chat_only networks: docbot-network: diff --git a/poetry.lock b/poetry.lock index 003aca7..a27ead4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "alembic" @@ -1086,6 +1086,18 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "python-multipart" +version = "0.0.20" +description = "A streaming multipart parser for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104"}, + {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, +] + [[package]] name = "python-telegram-bot" version = "22.1" @@ -1585,4 +1597,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "c50f083c47c32cf0ca58d1ccfec01aec63db5dc07be39cac76bb1e792c255951" +content-hash = "337def2431e36d8fde59d4273d3db93edf87a73ac78c74307f5c44def8c0a04d" diff --git a/pyproject.toml b/pyproject.toml index 809779f..05fe402 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ alembic_utils = "0.8.8" alembic-postgresql-enum = "1.7.0" pytz = "2025.2" opencv-python = "4.12.0.88" +python-multipart = "0.0.20" [tool.poetry.group.dev.dependencies] diff --git a/src/core/config.py b/src/core/config.py index 52405ba..b04bccc 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -6,7 +6,6 @@ class Settings(BaseSettings): BOT_TOKEN: str DATABASE_URL: str - ADMIN_API_KEY: str LOGGING_LEVEL: str PRODAMUS_TOKEN: str diff --git a/src/core/utils.py b/src/core/utils.py index a5a2fe9..3ef0d26 100644 --- a/src/core/utils.py +++ b/src/core/utils.py @@ -63,3 +63,10 @@ def is_valid_url(text: str) -> bool: return all([result.scheme in ["http", "https"], result.netloc]) except Exception: return False + + +def make_a_payment_link(base_link: str, code: str, phone: str) -> str: + if base_link and code and phone: + return f"{base_link}/?order_id={code}&customer_phone={phone}&do=pay" + else: + return null \ No newline at end of file diff --git a/src/db/models.py b/src/db/models.py index 5ada838..0bca12d 100644 --- a/src/db/models.py +++ b/src/db/models.py @@ -172,3 +172,10 @@ class PaymentMethod(Base): created_at: Mapped[datetime] = mapped_column(nullable=False) doctor: Mapped["Doctors"] = relationship(back_populates="payment_methods") + + +class PaymentsRegistered(Base): + __tablename__ = "payments_registered" + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(8), unique=True, nullable=False) + mapped: Mapped[bool] = mapped_column(nullable=True) diff --git a/src/docbot/handlers/patients/consultation_handler.py b/src/docbot/handlers/patients/consultation_handler.py index 090a51d..65ade9b 100644 --- a/src/docbot/handlers/patients/consultation_handler.py +++ b/src/docbot/handlers/patients/consultation_handler.py @@ -1,6 +1,7 @@ from telegram import ( Update, InlineKeyboardButton, InlineKeyboardMarkup ) +from telegram.constants import ParseMode from telegram.ext import ( ContextTypes, ConversationHandler, @@ -11,8 +12,6 @@ from telegram.ext import ( ) from docbot.handlers.utils.cancel_handler import get_cancel_handler from docbot.handlers.start_handler import get_start_handler -from core.enums.dialog_helpers import ConfirmationMessage -from core.utils import is_phone_correct from docbot.services.patients_service import ( create_patient, update_patient_phone, get_patient_by_telegram_id, is_user_has_phone @@ -22,7 +21,9 @@ from docbot.services.doctors_service import ( ) from docbot.services.session_service import create_session from core.logging import logger -from core.exceptions import DatabaseError # Assuming a custom exception for database-related issues +from core.exceptions import DatabaseError +from core.enums.dialog_helpers import ConfirmationMessage +from core.utils import is_phone_correct, make_a_payment_link SEND_ACKNOWLEDGEMENT_INFO = 1 @@ -304,8 +305,10 @@ async def pay_consultation(update: Update, context: ContextTypes.DEFAULT_TYPE) - ) return ConversationHandler.END + session_code = "" + try: - await create_session( + session_code = await create_session( telegram_id=user_id, phone=patient.phone, consultation_date_time=consultation_date_time, @@ -319,20 +322,23 @@ async def pay_consultation(update: Update, context: ContextTypes.DEFAULT_TYPE) - ) return ConversationHandler.END - link = await get_doctors_payment_link(context.user_data['doctor_number']) + base_link = await get_doctors_payment_link(context.user_data['doctor_number']) - if not link: + if not base_link: await update.callback_query.answer() await update.callback_query.message.reply_text( - "❌ Извините, но по введённому серийному номеру врача не найдено ссылки на оплату. Пожалуйста, проверьте правильность введённого номера и попробуйте снова.", + "❌ Извините, но по введённому серийному номеру врача не найдено ссылки на оплату." + "Пожалуйста, проверьте правильность введённого номера и попробуйте снова.", parse_mode="Markdown" ) return ConversationHandler.END + payment_link = make_a_payment_link(base_link=base_link, code=session_code, phone=patient.phone) + await update.callback_query.answer() await update.callback_query.message.reply_text( - f"Чтобы оплатить консультацию перейдите по ссылке {link}.", - parse_mode="Markdown" + f"Чтобы оплатить консультацию перейдите по ссылке {payment_link}.", + parse_mode=ParseMode.HTML ) context.user_data.clear() diff --git a/src/docbot/main.py b/src/docbot/main.py index dfd41cc..6be376c 100644 --- a/src/docbot/main.py +++ b/src/docbot/main.py @@ -16,7 +16,7 @@ from docbot.handlers.utils.cancel_handler import get_cancel_handler def main(): - persistence = PicklePersistence(filepath="conversations.pkl") + persistence = PicklePersistence(filepath="data/conversations.pkl") logger.info("Запуск DocBot…") bot_instance = ExtBot(token=settings.BOT_TOKEN) app = ( diff --git a/src/docbot/services/forms_service.py b/src/docbot/services/forms_service.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/docbot/services/session_service.py b/src/docbot/services/session_service.py index 1ded613..bedd92f 100644 --- a/src/docbot/services/session_service.py +++ b/src/docbot/services/session_service.py @@ -5,7 +5,10 @@ from datetime import datetime from sqlalchemy import select from db.session import AsyncSessionLocal -from db.models import Sessions, Patients, SessionStatusHistory, SessionDateTimeHistory +from db.models import ( + Sessions, Patients, SessionStatusHistory, SessionDateTimeHistory, + PaymentsRegistered +) from core.utils import (generate_session_code, date_time_formatter) from core.logging import logger @@ -45,6 +48,17 @@ async def create_session(telegram_id: int, phone: str, consultation_date_time: s return code +async def save_payment_completed_info_from_prodamus(code: str) -> bool: + async with AsyncSessionLocal() as session: + async with session.begin(): + payment_completed = PaymentsRegistered( + code=code + ) + session.add(payment_completed) + return True + return False + + async def mark_consulted(code: str) -> bool: """ Отмечает, что консультация получена (добавляет consulted_at). diff --git a/src/webhook/main.py b/src/webhook/main.py index 0350036..060f159 100644 --- a/src/webhook/main.py +++ b/src/webhook/main.py @@ -1,15 +1,96 @@ -from fastapi import FastAPI, Request +from fastapi import FastAPI, Request, Header, HTTPException +from starlette.background import BackgroundTask +from typing import Any, Dict +from docbot.services.session_service import save_payment_completed_info_from_prodamus +import json +import logging app = FastAPI() +log = logging.getLogger("webhook") + +def _mask(s: str | None) -> str | None: + if not s: + return s + # замаскируем телефон/почту/длинные токены + if "@" in s: + name, _, dom = s.partition("@") + return (name[:1] + "***@" + dom) if dom else "***" + if s.startswith("+") and len(s) > 5: + return s[:3] + "****" + s[-2:] + if len(s) > 16: + return s[:6] + "…" + return s + +def normalize_payload(d: Dict[str, Any]) -> Dict[str, Any]: + # Приведём продукты из строки к JSON (если пришли строкой) + products = d.get("products") + if isinstance(products, str): + try: + d["products"] = json.loads(products) + except Exception: + pass + # Маскируем чувствительные поля + for k in ("customer_phone", "customer_email"): + if k in d: + d[k] = _mask(str(d[k])) + return d + +@app.post("/webhook") +async def receive_webhook( + request: Request, + sign: str | None = Header(default=None, alias="Sign"), # подпись от Prodamus +): + # 1) читаем «сырое» тело и тип + ctype = request.headers.get("content-type", "").lower() + raw = await request.body() + + # 2) парсим тело независимо от формата + data: Dict[str, Any] = {} + try: + if "application/json" in ctype: + data = json.loads(raw.decode("utf-8")) + elif "application/x-www-form-urlencoded" in ctype or "multipart/form-data" in ctype: + form = await request.form() + data = {k: v for k, v in form.multi_items()} + else: + # если тип неизвестен — попробуем как JSON, затем как форма + try: + data = json.loads(raw.decode("utf-8")) + except Exception: + form = await request.form() + data = {k: v for k, v in form.multi_items()} + except Exception as e: + # не роняем хендпоинт + log.exception("Webhook parse error: %s", e) + raise HTTPException(status_code=400, detail="Invalid webhook payload") + + # 3)Sign может прийти как "Sign: " — уберём префикс, если он там повторно + if sign and sign.lower().startswith("sign:"): + sign = sign.split(":", 1)[1].strip() + + # 4) нормализуем, маскируем PII для логов + safe = normalize_payload(dict(data)) + print(safe) + + code=safe['order_id'] + + await save_payment_completed_info_from_prodamus(code=code) + + # 5) ЛОГИ (для отладки — потом переведите на структурные логи) + log.info("Prodamus webhook: sign=%s, payload=%s, ctype=%s", _mask(sign), safe, ctype) + + # 6) (опционально) в фоне проверим подпись и обновим БД/уведомим в Телеграм, + # чтобы ответить Prodamus быстро и не словить ретраи из-за таймаута. + #def process_business(): + # TODO: verify signature HMAC по правилам Prodamus + # TODO: валидация суммы/валюты/статуса + # TODO: идемпотентно обновить платеж в Postgres + # TODO: отправить уведомление в бот (create_task/queue) + #pass + + return {"ok": True}#, BackgroundTask(process_business) @app.get("/") async def home(): return {"message": "Hello World"} - - -@app.post("/webhook") -async def receive_webhook(request: Request): - data = await request.json() - # Process your data here - return {"message": "Webhook received!"} \ No newline at end of file