mirror of
https://github.com/olegvodyanov/docbot.git
synced 2025-12-19 23:57:05 +03:00
add webhook
This commit is contained in:
parent
97bf38f0c0
commit
070f446159
@ -1,4 +0,0 @@
|
|||||||
from core.ai.ai_bot import detect_eyes
|
|
||||||
|
|
||||||
|
|
||||||
detect_eyes("/Users/o.vodianov/IdeaProjects/docbot/image.png")
|
|
||||||
@ -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
16
poetry.lock
generated
@ -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"
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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 = (
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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!"}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user