add webhook

This commit is contained in:
oleg.vodyanov91@gmail.com 2025-10-05 19:25:42 +04:00
parent 97bf38f0c0
commit 070f446159
12 changed files with 153 additions and 30 deletions

View File

@ -1,4 +0,0 @@
from core.ai.ai_bot import detect_eyes
detect_eyes("/Users/o.vodianov/IdeaProjects/docbot/image.png")

View File

@ -9,6 +9,8 @@ services:
- ./db/init:/docker-entrypoint-initdb.d:ro - ./db/init:/docker-entrypoint-initdb.d:ro
networks: networks:
- docbot-network - docbot-network
ports:
- "127.0.0.1:5432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"] test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s interval: 10s
@ -27,12 +29,10 @@ services:
environment: environment:
- BOT_TOKEN=${BOT_TOKEN} - BOT_TOKEN=${BOT_TOKEN}
- DATABASE_URL=${DATABASE_URL} - DATABASE_URL=${DATABASE_URL}
- ADMIN_API_KEY=${ADMIN_API_KEY}
- LOGGING_LEVEL=${LOGGING_LEVEL:-INFO} - LOGGING_LEVEL=${LOGGING_LEVEL:-INFO}
- PRODAMUS_TOKEN=${PRODAMUS_TOKEN} - PRODAMUS_TOKEN=${PRODAMUS_TOKEN}
volumes: volumes:
- ./data:/app/data - ./data:/app/data:rw
- ./conversations.pkl:/app/conversations.pkl
- ./.env:/app/.env - ./.env:/app/.env
- ./.env_db:/app/.env_db - ./.env_db:/app/.env_db
depends_on: depends_on:
@ -54,7 +54,6 @@ services:
environment: environment:
- BOT_TOKEN=${BOT_TOKEN} - BOT_TOKEN=${BOT_TOKEN}
- DATABASE_URL=${DATABASE_URL} - DATABASE_URL=${DATABASE_URL}
- ADMIN_API_KEY=${ADMIN_API_KEY}
- LOGGING_LEVEL=${LOGGING_LEVEL:-INFO} - LOGGING_LEVEL=${LOGGING_LEVEL:-INFO}
- PRODAMUS_TOKEN=${PRODAMUS_TOKEN} - PRODAMUS_TOKEN=${PRODAMUS_TOKEN}
ports: ports:
@ -68,6 +67,7 @@ services:
profiles: profiles:
- production - production
- development - development
- chat_only
networks: networks:
docbot-network: docbot-network:

16
poetry.lock generated
View File

@ -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]] [[package]]
name = "alembic" name = "alembic"
@ -1086,6 +1086,18 @@ files = [
[package.extras] [package.extras]
cli = ["click (>=5.0)"] 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]] [[package]]
name = "python-telegram-bot" name = "python-telegram-bot"
version = "22.1" version = "22.1"
@ -1585,4 +1597,4 @@ files = [
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "c50f083c47c32cf0ca58d1ccfec01aec63db5dc07be39cac76bb1e792c255951" content-hash = "337def2431e36d8fde59d4273d3db93edf87a73ac78c74307f5c44def8c0a04d"

View File

@ -33,6 +33,7 @@ alembic_utils = "0.8.8"
alembic-postgresql-enum = "1.7.0" alembic-postgresql-enum = "1.7.0"
pytz = "2025.2" pytz = "2025.2"
opencv-python = "4.12.0.88" opencv-python = "4.12.0.88"
python-multipart = "0.0.20"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]

View File

@ -6,7 +6,6 @@ class Settings(BaseSettings):
BOT_TOKEN: str BOT_TOKEN: str
DATABASE_URL: str DATABASE_URL: str
ADMIN_API_KEY: str
LOGGING_LEVEL: str LOGGING_LEVEL: str
PRODAMUS_TOKEN: str PRODAMUS_TOKEN: str

View File

@ -63,3 +63,10 @@ def is_valid_url(text: str) -> bool:
return all([result.scheme in ["http", "https"], result.netloc]) return all([result.scheme in ["http", "https"], result.netloc])
except Exception: except Exception:
return False 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

View File

@ -172,3 +172,10 @@ class PaymentMethod(Base):
created_at: Mapped[datetime] = mapped_column(nullable=False) created_at: Mapped[datetime] = mapped_column(nullable=False)
doctor: Mapped["Doctors"] = relationship(back_populates="payment_methods") 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)

View File

@ -1,6 +1,7 @@
from telegram import ( from telegram import (
Update, InlineKeyboardButton, InlineKeyboardMarkup Update, InlineKeyboardButton, InlineKeyboardMarkup
) )
from telegram.constants import ParseMode
from telegram.ext import ( from telegram.ext import (
ContextTypes, ContextTypes,
ConversationHandler, ConversationHandler,
@ -11,8 +12,6 @@ from telegram.ext import (
) )
from docbot.handlers.utils.cancel_handler import get_cancel_handler from docbot.handlers.utils.cancel_handler import get_cancel_handler
from docbot.handlers.start_handler import get_start_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 ( from docbot.services.patients_service import (
create_patient, update_patient_phone, get_patient_by_telegram_id, create_patient, update_patient_phone, get_patient_by_telegram_id,
is_user_has_phone is_user_has_phone
@ -22,7 +21,9 @@ from docbot.services.doctors_service import (
) )
from docbot.services.session_service import create_session from docbot.services.session_service import create_session
from core.logging import logger 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 SEND_ACKNOWLEDGEMENT_INFO = 1
@ -304,8 +305,10 @@ async def pay_consultation(update: Update, context: ContextTypes.DEFAULT_TYPE) -
) )
return ConversationHandler.END return ConversationHandler.END
session_code = ""
try: try:
await create_session( session_code = await create_session(
telegram_id=user_id, telegram_id=user_id,
phone=patient.phone, phone=patient.phone,
consultation_date_time=consultation_date_time, consultation_date_time=consultation_date_time,
@ -319,20 +322,23 @@ async def pay_consultation(update: Update, context: ContextTypes.DEFAULT_TYPE) -
) )
return ConversationHandler.END 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.answer()
await update.callback_query.message.reply_text( await update.callback_query.message.reply_text(
"❌ Извините, но по введённому серийному номеру врача не найдено ссылки на оплату. Пожалуйста, проверьте правильность введённого номера и попробуйте снова.", "❌ Извините, но по введённому серийному номеру врача не найдено ссылки на оплату."
"Пожалуйста, проверьте правильность введённого номера и попробуйте снова.",
parse_mode="Markdown" parse_mode="Markdown"
) )
return ConversationHandler.END 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.answer()
await update.callback_query.message.reply_text( await update.callback_query.message.reply_text(
f"Чтобы оплатить консультацию перейдите по ссылке {link}.", f"Чтобы оплатить консультацию перейдите по ссылке {payment_link}.",
parse_mode="Markdown" parse_mode=ParseMode.HTML
) )
context.user_data.clear() context.user_data.clear()

View File

@ -16,7 +16,7 @@ from docbot.handlers.utils.cancel_handler import get_cancel_handler
def main(): def main():
persistence = PicklePersistence(filepath="conversations.pkl") persistence = PicklePersistence(filepath="data/conversations.pkl")
logger.info("Запуск DocBot…") logger.info("Запуск DocBot…")
bot_instance = ExtBot(token=settings.BOT_TOKEN) bot_instance = ExtBot(token=settings.BOT_TOKEN)
app = ( app = (

View File

@ -5,7 +5,10 @@ from datetime import datetime
from sqlalchemy import select from sqlalchemy import select
from db.session import AsyncSessionLocal 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.utils import (generate_session_code, date_time_formatter)
from core.logging import logger from core.logging import logger
@ -45,6 +48,17 @@ async def create_session(telegram_id: int, phone: str, consultation_date_time: s
return code 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: async def mark_consulted(code: str) -> bool:
""" """
Отмечает, что консультация получена (добавляет consulted_at). Отмечает, что консультация получена (добавляет consulted_at).

View File

@ -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() 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: <hex>" — уберём префикс, если он там повторно
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("/") @app.get("/")
async def home(): async def home():
return {"message": "Hello World"} 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!"}