mirror of
https://github.com/olegvodyanov/docbot.git
synced 2025-12-19 23:57:05 +03:00
init commit docbot
This commit is contained in:
commit
c977d350f4
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
.env
|
||||||
|
.env_db
|
||||||
|
.idea
|
||||||
|
.venv
|
||||||
|
alembic.ini
|
||||||
|
alembic/
|
||||||
|
db/
|
||||||
|
__pycache__
|
||||||
11
docbot.iml
Normal file
11
docbot.iml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:17
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env_db
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/postgresql/data
|
||||||
|
- ./db/init:/docker-entrypoint-initdb.d:ro
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
1429
poetry.lock
generated
Normal file
1429
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
pyproject.toml
Normal file
40
pyproject.toml
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
[project]
|
||||||
|
name = "docbot"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = ""
|
||||||
|
authors = [
|
||||||
|
{name = "oleg.vodyanov",email = "oleg.vodyanov91@gmail.com"}
|
||||||
|
]
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
|
||||||
|
[tool.poetry]
|
||||||
|
packages = [{include = "docbot", from = "src"}]
|
||||||
|
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = "^3.12"
|
||||||
|
python-telegram-bot = "22.1" # или aiogram v3
|
||||||
|
fastapi = "0.115.12"
|
||||||
|
uvicorn = {extras = ["standard"], version = "0.34.2"}
|
||||||
|
sqlalchemy = "2.0.41"
|
||||||
|
asyncpg = "0.30.0" # асинхронный драйвер для Postgres
|
||||||
|
alembic = "1.16.1"
|
||||||
|
pydantic = "2.11.5"
|
||||||
|
pydantic-settings = "2.9.1"
|
||||||
|
python-dotenv = "1.1.0"
|
||||||
|
greenlet = "3.2.2"
|
||||||
|
psycopg2-binary = "2.9.10"
|
||||||
|
alembic_utils = "0.8.8"
|
||||||
|
alembic-postgresql-enum = "1.7.0"
|
||||||
|
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
pytest = "8.2"
|
||||||
|
pytest-asyncio = "1.0.0"
|
||||||
|
flake8 = "7.2.0"
|
||||||
|
black = "25.1.0"
|
||||||
0
src/core/__init__.py
Normal file
0
src/core/__init__.py
Normal file
12
src/core/config.py
Normal file
12
src/core/config.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file='../../.env', env_file_encoding='utf-8')
|
||||||
|
|
||||||
|
BOT_TOKEN: str
|
||||||
|
DATABASE_URL: str
|
||||||
|
ADMIN_API_KEY: str
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
9
src/core/enums/consultation_types.py
Normal file
9
src/core/enums/consultation_types.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class Consultation(str, Enum):
|
||||||
|
ASYNC_TEXT = "Асинхронный текст (без переписки)"
|
||||||
|
ASYNC_TEXT_WITH_DIALOG = "Асинхронный текст с перепиской"
|
||||||
|
ONLINE_CHAT = "Онлайн-чат (анонимный, в реальном времени)"
|
||||||
|
AUDION_CALL = "Аудиозвонок через внешнюю платформу"
|
||||||
|
VIDEO_CALL = "Видеозвонок через внешнюю платформу"
|
||||||
56
src/core/logging.py
Normal file
56
src/core/logging.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging():
|
||||||
|
"""
|
||||||
|
Инициализирует логирование для всего приложения.
|
||||||
|
Конфигурирует консольный и файловый логгеры с ротацией.
|
||||||
|
"""
|
||||||
|
# Получаем уровень логирования из настроек, по умолчанию INFO
|
||||||
|
level_name = settings.log_level.upper() if hasattr(settings, 'log_level') else 'INFO'
|
||||||
|
log_level = getattr(logging, level_name, logging.INFO)
|
||||||
|
|
||||||
|
# Форматтер для всех хэндлеров
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
fmt="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Консольный хэндлер
|
||||||
|
console_handler = logging.StreamHandler(sys.stdout)
|
||||||
|
console_handler.setLevel(log_level)
|
||||||
|
console_handler.setFormatter(formatter)
|
||||||
|
|
||||||
|
# Собираем хэндлеры
|
||||||
|
handlers = [console_handler]
|
||||||
|
|
||||||
|
# Файловый хэндлер с ротацией (если указан путь в настройках)
|
||||||
|
if hasattr(settings, 'log_file_path') and settings.log_file_path:
|
||||||
|
file_handler = RotatingFileHandler(
|
||||||
|
filename=settings.log_file_path,
|
||||||
|
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||||
|
backupCount=5,
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
file_handler.setLevel(log_level)
|
||||||
|
file_handler.setFormatter(formatter)
|
||||||
|
handlers.append(file_handler)
|
||||||
|
|
||||||
|
# Конфигурируем корневой логгер
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.setLevel(log_level)
|
||||||
|
# Отключаем стандартные хэндлеры, чтобы не дублировалось
|
||||||
|
root_logger.handlers.clear()
|
||||||
|
for handler in handlers:
|
||||||
|
root_logger.addHandler(handler)
|
||||||
|
|
||||||
|
|
||||||
|
# Автоматически запускаем настройку логирования при импорте модуля
|
||||||
|
setup_logging()
|
||||||
|
|
||||||
|
# Получаем logger для текущего модуля
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
0
src/docbot/__init__.py
Normal file
0
src/docbot/__init__.py
Normal file
BIN
src/docbot/conversations.pkl
Normal file
BIN
src/docbot/conversations.pkl
Normal file
Binary file not shown.
10
src/docbot/handlers/admins_handler.py
Normal file
10
src/docbot/handlers/admins_handler.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from telegram import Update
|
||||||
|
from telegram.ext import (
|
||||||
|
ContextTypes,
|
||||||
|
ConversationHandler,
|
||||||
|
CommandHandler,
|
||||||
|
MessageHandler,
|
||||||
|
filters,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
16
src/docbot/handlers/cancel_handler.py
Normal file
16
src/docbot/handlers/cancel_handler.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from telegram import Update
|
||||||
|
from telegram.ext import (
|
||||||
|
ContextTypes,
|
||||||
|
ConversationHandler,
|
||||||
|
CommandHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def __cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
await update.message.reply_text("❌ Отменено.")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
|
||||||
|
def get_cancel_handler() -> CommandHandler:
|
||||||
|
"""Фабрика для регистрации в Application."""
|
||||||
|
return CommandHandler("cancel", __cancel)
|
||||||
40
src/docbot/handlers/doctors_handler.py
Normal file
40
src/docbot/handlers/doctors_handler.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
from telegram import Update
|
||||||
|
from telegram.ext import (
|
||||||
|
ContextTypes,
|
||||||
|
ConversationHandler,
|
||||||
|
CommandHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
from docbot.services.doctors_service import get_doctors_names
|
||||||
|
from docbot.services.admins_service import get_admin_info
|
||||||
|
|
||||||
|
|
||||||
|
async def get_doctors(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
|
||||||
|
# проверяем, нет ли уже незавершённой сессии
|
||||||
|
admin = await get_admin_info(user_id)
|
||||||
|
if not admin:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"⚠️ Вы не можете смотреть информацию о врачах!",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
doctors = await get_doctors_names()
|
||||||
|
if doctors:
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"📝 Список активных врачей. \n {doctors}",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
return ConversationHandler.END
|
||||||
|
else:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"📝 Список врачей пуст! Никто не зарегистрировался 🥺"
|
||||||
|
)
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
|
||||||
|
def get_doctors_handler() -> CommandHandler:
|
||||||
|
"""Фабрика для регистрации в Application."""
|
||||||
|
return CommandHandler("doctors", get_doctors)
|
||||||
34
src/docbot/handlers/generate_ref.py
Normal file
34
src/docbot/handlers/generate_ref.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from telegram import Update
|
||||||
|
from telegram.ext import ContextTypes, CommandHandler
|
||||||
|
|
||||||
|
from core.logging import logger
|
||||||
|
from docbot.services.admins_service import get_admin_info
|
||||||
|
from docbot.services.referral_service import generate_referral_code
|
||||||
|
|
||||||
|
|
||||||
|
async def genref(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
"""
|
||||||
|
Команда /genref — Генерация нового реферального кода.
|
||||||
|
Доступна только администратору чата
|
||||||
|
"""
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
if not await get_admin_info(user_id):
|
||||||
|
logger.warning(f"Пользователь {user_id} пытался сгенерировать код без прав.")
|
||||||
|
await update.message.reply_text("❌ У вас нет прав для этой команды.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Генерируем новый код и сохраняем его
|
||||||
|
token = await generate_referral_code()
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"✅ Новый реферальный код: `{token}`\n"
|
||||||
|
"Передайте этот код врачу. Он действует только один раз.",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
logger.info(f"Admin {user_id} сгенерировал реферальный код {token}.")
|
||||||
|
|
||||||
|
|
||||||
|
def get_referral_handlers():
|
||||||
|
"""
|
||||||
|
Возвращает список готовых CommandHandler-ов для регистрации в Application.
|
||||||
|
"""
|
||||||
|
return CommandHandler("genref", genref)
|
||||||
69
src/docbot/handlers/get_form_handler.py
Normal file
69
src/docbot/handlers/get_form_handler.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
from telegram import Update
|
||||||
|
from telegram.ext import (
|
||||||
|
ContextTypes,
|
||||||
|
ConversationHandler,
|
||||||
|
CommandHandler,
|
||||||
|
MessageHandler,
|
||||||
|
filters,
|
||||||
|
)
|
||||||
|
from docbot.services.session_service import create_session_code, get_pending_session
|
||||||
|
from docbot.handlers.cancel_handler import get_cancel_handler
|
||||||
|
|
||||||
|
|
||||||
|
ASK_LINK = 1
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
|
||||||
|
# проверяем, нет ли уже незавершённой сессии
|
||||||
|
pending = await get_pending_session(user_id)
|
||||||
|
if pending:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"⚠️ У вас уже есть активный код для консультации: "
|
||||||
|
f"*{pending.code}*\n"
|
||||||
|
"Пожалуйста, дождитесь, пока врач отметит получение консультации, "
|
||||||
|
"и только потом создавайте новую сессию.",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
# если нет — продолжаем и запрашиваем ссылку
|
||||||
|
await update.message.reply_text(
|
||||||
|
"📝 Пожалуйста, пришлите ссылку на вашу анкету в формате URL."
|
||||||
|
)
|
||||||
|
return ASK_LINK
|
||||||
|
|
||||||
|
|
||||||
|
async def receive_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
link = update.message.text.strip()
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
|
||||||
|
# вызываем сервис, который создаст код сессии и запишет в БД
|
||||||
|
code = await create_session_code(telegram_id=user_id, form_link=link)
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"Ваш код для консультации: *{code}*\n"
|
||||||
|
"Сохраните его и передайте врачу.",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
|
||||||
|
def send_form_link_handler() -> CommandHandler:
|
||||||
|
"""Фабрика для регистрации в Application."""
|
||||||
|
return CommandHandler("get_form", ask_link)
|
||||||
|
|
||||||
|
|
||||||
|
def get_get_form_handler() -> ConversationHandler:
|
||||||
|
return ConversationHandler(
|
||||||
|
entry_points=[send_form_link_handler()],
|
||||||
|
states={
|
||||||
|
ASK_LINK: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_link)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fallbacks=[get_cancel_handler()],
|
||||||
|
name="form_conversation", # для тестов/логирования
|
||||||
|
persistent=True, # если используете хранение состояний
|
||||||
|
)
|
||||||
62
src/docbot/handlers/help.py
Normal file
62
src/docbot/handlers/help.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
from telegram import Update, ReplyKeyboardMarkup
|
||||||
|
from telegram.ext import ContextTypes, CommandHandler
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
from docbot.services.referral_service import ReferralService
|
||||||
|
from docbot.services.admins_service import get_admin_info
|
||||||
|
from docbot.services.doctors_service import get_doctor_info
|
||||||
|
|
||||||
|
async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
"""
|
||||||
|
Отдаёт список команд, доступных текущему пользователю:
|
||||||
|
- админ
|
||||||
|
- зарегистрированный доктор
|
||||||
|
- пациент (все остальные)
|
||||||
|
"""
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
|
||||||
|
# 1) Проверим, админ ли это
|
||||||
|
if await get_admin_info(user_id):
|
||||||
|
text = (
|
||||||
|
"🔐 *Админ-меню*:\n\n"
|
||||||
|
"/start ‒ Запустить бота\n"
|
||||||
|
"/genref ‒ Сгенерировать новый реферальный код для врача\n"
|
||||||
|
"/doctors ‒ Посмотреть список зарегистрированных врачей\n"
|
||||||
|
"/help ‒ Показать это меню\n"
|
||||||
|
)
|
||||||
|
# (При необходимости в будущем можно добавить другие admin-команды)
|
||||||
|
await update.message.reply_text(text, parse_mode="Markdown")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2) Проверим, зарегистрирован ли врач
|
||||||
|
if await get_doctor_info(user_id):
|
||||||
|
text = (
|
||||||
|
"👨⚕️ *Меню доктора*:\n\n"
|
||||||
|
"/start ‒ Запустить бота\n"
|
||||||
|
"/sessions ‒ Посмотреть список пациентов (или текущих сессий)\n"
|
||||||
|
"/markconsulted <CODE> ‒ Отметить, что пациент с кодом <CODE> получил консультацию\n"
|
||||||
|
"/help ‒ Показать это меню\n"
|
||||||
|
)
|
||||||
|
# Можно предлагать докторам также специальные кнопки:
|
||||||
|
keyboard = ReplyKeyboardMarkup(
|
||||||
|
[["/sessions"], ["/markconsulted <CODE>"], ["/help"]],
|
||||||
|
one_time_keyboard=True,
|
||||||
|
resize_keyboard=True
|
||||||
|
)
|
||||||
|
await update.message.reply_text(text, parse_mode="Markdown", reply_markup=keyboard)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3) Иначе — это пациент (незарегистрированный/обычный пользователь)
|
||||||
|
text = (
|
||||||
|
"👤 *Меню пациента*:\n\n"
|
||||||
|
"/start ‒ Запустить бота\n"
|
||||||
|
"/get_form ‒ Получить ссылку на форму анкеты для заполнения"
|
||||||
|
"/send_form ‒ Отправить ссылку на заполненную анкету\n"
|
||||||
|
"/help ‒ Показать это меню\n"
|
||||||
|
)
|
||||||
|
keyboard = ReplyKeyboardMarkup(
|
||||||
|
[["/form", "/consult"], ["/help"]],
|
||||||
|
one_time_keyboard=True,
|
||||||
|
resize_keyboard=True
|
||||||
|
)
|
||||||
|
await update.message.reply_text(text, parse_mode="Markdown", reply_markup=keyboard)
|
||||||
103
src/docbot/handlers/register_handler.py
Normal file
103
src/docbot/handlers/register_handler.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
from telegram import Update, ReplyKeyboardMarkup, ReplyKeyboardRemove
|
||||||
|
from telegram.ext import (
|
||||||
|
ContextTypes,
|
||||||
|
ConversationHandler,
|
||||||
|
CommandHandler,
|
||||||
|
MessageHandler,
|
||||||
|
filters,
|
||||||
|
)
|
||||||
|
|
||||||
|
from docbot.handlers.cancel_handler import get_cancel_handler
|
||||||
|
from docbot.services.referral_service import validate_referral_code, mark_referral_code_as_used
|
||||||
|
from core.enums.consultation_types import Consultation
|
||||||
|
|
||||||
|
ASK_REFERRAL_CODE = 1
|
||||||
|
ASK_NAME = 2
|
||||||
|
ASK_CONSULTATION_TYPE = 3
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_doctor_referral_code(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"📝 Пожалуйста, пришлите реферальный код.",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
return ASK_REFERRAL_CODE
|
||||||
|
|
||||||
|
|
||||||
|
async def receive_doctor_referral_code(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
doctor_referral_code = update.message.text.strip()
|
||||||
|
ref_obj = await validate_referral_code(doctor_referral_code)
|
||||||
|
|
||||||
|
if not ref_obj:
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
doctor_telegram_id = update.effective_user.id
|
||||||
|
context.user_data["doctor_telegram_id"] = doctor_telegram_id
|
||||||
|
context.user_data["ref_obj"] = ref_obj
|
||||||
|
await update.message.reply_text(
|
||||||
|
"📝 Пожалуйста, пришлите своё имя или псевдоним.",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
return ASK_NAME
|
||||||
|
|
||||||
|
|
||||||
|
async def receive_doctor_name(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
doctor_name = update.message.text.strip()
|
||||||
|
context.user_data["doctor_name"] = doctor_name
|
||||||
|
reply_keyboard = [
|
||||||
|
[
|
||||||
|
Consultation.ASYNC_TEXT.value,
|
||||||
|
Consultation.ASYNC_TEXT_WITH_DIALOG.value,
|
||||||
|
Consultation.ONLINE_CHAT.value,
|
||||||
|
Consultation.AUDION_CALL.value,
|
||||||
|
Consultation.VIDEO_CALL.value
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"Пожалуйста, выберите тип консультации.",
|
||||||
|
parse_mode="Markdown",
|
||||||
|
reply_markup=ReplyKeyboardMarkup(
|
||||||
|
reply_keyboard, one_time_keyboard=True, input_field_placeholder="Тип консультации"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ASK_CONSULTATION_TYPE
|
||||||
|
|
||||||
|
|
||||||
|
async def receive_doctor_consultation_type(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
doctor_consultation_type = update.message.text.strip()
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
"📝 Спасибо, сохраняю информацию.",
|
||||||
|
parse_mode="Markdown",
|
||||||
|
reply_markup=ReplyKeyboardRemove()
|
||||||
|
)
|
||||||
|
|
||||||
|
await mark_referral_code_as_used(context.user_data["ref_obj"], context.user_data["doctor_telegram_id"],
|
||||||
|
context.user_data["doctor_name"], Consultation(doctor_consultation_type))
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
|
||||||
|
def ask_doctor_info_handler() -> CommandHandler:
|
||||||
|
"""Фабрика для регистрации в Application."""
|
||||||
|
return CommandHandler("register", ask_doctor_referral_code)
|
||||||
|
|
||||||
|
|
||||||
|
def get_register_doctor_handler() -> ConversationHandler:
|
||||||
|
return ConversationHandler(
|
||||||
|
entry_points=[ask_doctor_info_handler()],
|
||||||
|
states={
|
||||||
|
ASK_REFERRAL_CODE: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_doctor_referral_code)
|
||||||
|
],
|
||||||
|
ASK_NAME: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_doctor_name)
|
||||||
|
],
|
||||||
|
ASK_CONSULTATION_TYPE: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_doctor_consultation_type)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fallbacks=[get_cancel_handler()],
|
||||||
|
name="register_doctor_conversation", # для тестов/логирования
|
||||||
|
persistent=True, # если используете хранение состояний
|
||||||
|
)
|
||||||
69
src/docbot/handlers/send_form_handler.py
Normal file
69
src/docbot/handlers/send_form_handler.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
from telegram import Update
|
||||||
|
from telegram.ext import (
|
||||||
|
ContextTypes,
|
||||||
|
ConversationHandler,
|
||||||
|
CommandHandler,
|
||||||
|
MessageHandler,
|
||||||
|
filters,
|
||||||
|
)
|
||||||
|
from docbot.services.session_service import create_session_code, get_pending_session
|
||||||
|
from docbot.handlers.cancel_handler import get_cancel_handler
|
||||||
|
|
||||||
|
|
||||||
|
ASK_LINK = 1
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
|
||||||
|
# проверяем, нет ли уже незавершённой сессии
|
||||||
|
pending = await get_pending_session(user_id)
|
||||||
|
if pending:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"⚠️ У вас уже есть активный код для консультации: "
|
||||||
|
f"*{pending.code}*\n"
|
||||||
|
"Пожалуйста, дождитесь, пока врач отметит получение консультации, "
|
||||||
|
"и только потом создавайте новую сессию.",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
# если нет — продолжаем и запрашиваем ссылку
|
||||||
|
await update.message.reply_text(
|
||||||
|
"📝 Пожалуйста, пришлите ссылку на вашу анкету в формате URL."
|
||||||
|
)
|
||||||
|
return ASK_LINK
|
||||||
|
|
||||||
|
|
||||||
|
async def receive_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
link = update.message.text.strip()
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
|
||||||
|
# вызываем сервис, который создаст код сессии и запишет в БД
|
||||||
|
code = await create_session_code(telegram_id=user_id, form_link=link)
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"Ваш код для консультации: *{code}*\n"
|
||||||
|
"Сохраните его и передайте врачу.",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
|
||||||
|
def ask_link_handler() -> CommandHandler:
|
||||||
|
"""Фабрика для регистрации в Application."""
|
||||||
|
return CommandHandler("send_form", ask_link)
|
||||||
|
|
||||||
|
|
||||||
|
def get_send_form_handler() -> ConversationHandler:
|
||||||
|
return ConversationHandler(
|
||||||
|
entry_points=[ask_link_handler()],
|
||||||
|
states={
|
||||||
|
ASK_LINK: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_link)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fallbacks=[get_cancel_handler()],
|
||||||
|
name="send_form_conversation", # для тестов/логирования
|
||||||
|
persistent=True, # если используете хранение состояний
|
||||||
|
)
|
||||||
22
src/docbot/handlers/start_handler.py
Normal file
22
src/docbot/handlers/start_handler.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
from telegram import Update
|
||||||
|
from telegram.ext import ContextTypes, CommandHandler
|
||||||
|
from core.logging import logger
|
||||||
|
|
||||||
|
|
||||||
|
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
"""
|
||||||
|
Отправляет приветствие и краткую инструкцию по работе с ботом.
|
||||||
|
"""
|
||||||
|
text = (
|
||||||
|
"👋 Добро пожаловать в DocBot!\n\n"
|
||||||
|
"Используйте команду /form чтобы прислать ссылку на анкету, если вы пациент.\n"
|
||||||
|
"После заполнения анкеты мы пришлем код для консультации врача.\n"
|
||||||
|
"Используйте команду /register, если у вас есть реферальный код."
|
||||||
|
)
|
||||||
|
|
||||||
|
await context.bot.send_message(chat_id=update.effective_chat.id, text=text)
|
||||||
|
|
||||||
|
|
||||||
|
def get_start_handler() -> CommandHandler:
|
||||||
|
"""Фабрика для регистрации в Application."""
|
||||||
|
return CommandHandler("start", start)
|
||||||
18
src/docbot/handlers/unknown.py
Normal file
18
src/docbot/handlers/unknown.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from telegram import Update
|
||||||
|
from telegram.ext import ContextTypes, CommandHandler, MessageHandler, filters
|
||||||
|
|
||||||
|
|
||||||
|
async def unknown_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
"""
|
||||||
|
Срабатывает на любую команду `/something`,
|
||||||
|
если нет соответствующего CommandHandler-а выше.
|
||||||
|
"""
|
||||||
|
await update.message.reply_text(
|
||||||
|
"❓ Извините, я не знаю такой команды. "
|
||||||
|
"Попробуйте /start."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_unknown_handler() -> MessageHandler:
|
||||||
|
"""Фабрика для регистрации в Application."""
|
||||||
|
return MessageHandler(filters.COMMAND, unknown_command)
|
||||||
36
src/docbot/main.py
Normal file
36
src/docbot/main.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
from core.logging import logger
|
||||||
|
from telegram.ext import ApplicationBuilder, PicklePersistence, ExtBot
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
from docbot.handlers.start_handler import get_start_handler
|
||||||
|
from docbot.handlers.send_form_handler import get_send_form_handler
|
||||||
|
from docbot.handlers.doctors_handler import get_doctors_handler
|
||||||
|
from docbot.handlers.register_handler import get_register_doctor_handler
|
||||||
|
from docbot.handlers.generate_ref import get_referral_handlers
|
||||||
|
from docbot.handlers.unknown import get_unknown_handler
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
persistence = PicklePersistence(filepath="conversations.pkl")
|
||||||
|
logger.info("Запуск DocBot…")
|
||||||
|
bot_instance = ExtBot(token=settings.BOT_TOKEN)
|
||||||
|
app = (
|
||||||
|
ApplicationBuilder()
|
||||||
|
.bot(bot_instance) # явно передаём наш Bot
|
||||||
|
.persistence(persistence)
|
||||||
|
.concurrent_updates(concurrent_updates=False)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
app.add_handler(get_start_handler())
|
||||||
|
app.add_handler(get_send_form_handler())
|
||||||
|
app.add_handler(get_doctors_handler())
|
||||||
|
app.add_handler(get_register_doctor_handler())
|
||||||
|
app.add_handler(get_referral_handlers())
|
||||||
|
app.add_handler(get_unknown_handler())
|
||||||
|
logger.debug("Все хэндлеры зарегистрированы, запускаем polling")
|
||||||
|
|
||||||
|
app.run_polling()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
14
src/docbot/services/admins_service.py
Normal file
14
src/docbot/services/admins_service.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from db.session import AsyncSessionLocal
|
||||||
|
from db.models import Admins
|
||||||
|
|
||||||
|
|
||||||
|
async def get_admin_info(telegram_id: int) -> Admins | None:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Admins.telegram_id)
|
||||||
|
.where(Admins.telegram_id.match(str(telegram_id)))
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
39
src/docbot/services/doctors_service.py
Normal file
39
src/docbot/services/doctors_service.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from db.session import AsyncSessionLocal
|
||||||
|
from db.models import Doctors
|
||||||
|
from core.enums.consultation_types import Consultation
|
||||||
|
|
||||||
|
|
||||||
|
async def get_doctor_info(telegram_id: int) -> Doctors | None:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Doctors)
|
||||||
|
.where(Doctors.telegram_id.match(telegram_id))
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_doctors_names() -> Doctors | None:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Doctors.name)
|
||||||
|
.where(Doctors.is_active)
|
||||||
|
)
|
||||||
|
return result.all()
|
||||||
|
|
||||||
|
|
||||||
|
async def add_doctor(telegram_id: str, name: str, available_formats: Consultation, is_active: bool):
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
async with session.begin():
|
||||||
|
session.add(Doctors(
|
||||||
|
telegram_id=str(telegram_id),
|
||||||
|
name=name,
|
||||||
|
available_formats=available_formats,
|
||||||
|
is_active=is_active,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
))
|
||||||
0
src/docbot/services/forms_service.py
Normal file
0
src/docbot/services/forms_service.py
Normal file
73
src/docbot/services/referral_service.py
Normal file
73
src/docbot/services/referral_service.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from db.session import AsyncSessionLocal
|
||||||
|
from db.models import ReferralCode, Doctors
|
||||||
|
from core.enums.consultation_types import Consultation
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_referral_code(length: int = 12) -> str:
|
||||||
|
"""
|
||||||
|
Генерирует уникальный реферальный код длиной length.
|
||||||
|
Сохраняет его в БД и возвращает строку кода.
|
||||||
|
"""
|
||||||
|
alphabet = string.ascii_uppercase + string.digits
|
||||||
|
while True:
|
||||||
|
referral_code = "".join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(ReferralCode)
|
||||||
|
.where(ReferralCode.code.match(referral_code))
|
||||||
|
)
|
||||||
|
exists = result.scalar_one_or_none()
|
||||||
|
if not exists:
|
||||||
|
new_ref = ReferralCode(code=referral_code, created_at=datetime.utcnow())
|
||||||
|
session.add(new_ref)
|
||||||
|
await session.commit()
|
||||||
|
return referral_code
|
||||||
|
# если сгенерированный код уже был в БД, пробуем снова
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_referral_code(referral_code: str) -> ReferralCode | None:
|
||||||
|
"""
|
||||||
|
Проверяет, есть ли в БД неиспользованный реферальный код referral_code.
|
||||||
|
Если да, возвращает объект ReferralCode, иначе None.
|
||||||
|
"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(ReferralCode)
|
||||||
|
.where(ReferralCode.code.match(referral_code),
|
||||||
|
ReferralCode.is_used.is_(False)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_referral_code_as_used(referral_obj: ReferralCode, telegram_id: str, name: str,
|
||||||
|
available_formats: Consultation) -> Doctors:
|
||||||
|
"""
|
||||||
|
Помечает referral_obj как использованный, создаёт запись в таблице Doctors
|
||||||
|
с привязкой к telegram_id и возвращает объект Doctor.
|
||||||
|
"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
async with session.begin():
|
||||||
|
# помечаем код
|
||||||
|
referral_obj.is_used = True
|
||||||
|
referral_obj.used_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# создаём врача
|
||||||
|
new_doc = Doctors(
|
||||||
|
telegram_id=str(telegram_id),
|
||||||
|
name=name,
|
||||||
|
available_formats=[Consultation(available_formats)],
|
||||||
|
is_active=True,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
referral=referral_obj
|
||||||
|
)
|
||||||
|
session.add(new_doc)
|
||||||
|
await session.commit()
|
||||||
|
return new_doc
|
||||||
|
|
||||||
79
src/docbot/services/session_service.py
Normal file
79
src/docbot/services/session_service.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from db.session import AsyncSessionLocal
|
||||||
|
from db.models import SessionCode
|
||||||
|
|
||||||
|
|
||||||
|
async def create_session_code(telegram_id: int, form_link: str) -> str:
|
||||||
|
"""
|
||||||
|
Генерирует уникальный код, сохраняет его вместе с Telegram ID и ссылкой на анкету.
|
||||||
|
Возвращает этот код.
|
||||||
|
"""
|
||||||
|
code = uuid.uuid4().hex[:8]
|
||||||
|
async with AsyncSessionLocal() as session: # безопасно создаём сессию
|
||||||
|
async with session.begin():
|
||||||
|
session.add(SessionCode(
|
||||||
|
code=code,
|
||||||
|
telegram_id=str(telegram_id), # храним как строку
|
||||||
|
form_link=form_link,
|
||||||
|
sent_at=datetime.utcnow()
|
||||||
|
))
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_consulted(code: str) -> bool:
|
||||||
|
"""
|
||||||
|
Отмечает, что консультация получена (добавляет consulted_at).
|
||||||
|
Возвращает True, если запись найдена и обновлена.
|
||||||
|
"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
async with session.begin():
|
||||||
|
result = await session.execute(
|
||||||
|
select(SessionCode).where(SessionCode.code.match(code))
|
||||||
|
)
|
||||||
|
sc: SessionCode | None = result.scalar_one_or_none()
|
||||||
|
if not sc:
|
||||||
|
return False
|
||||||
|
sc.consulted_at = datetime.utcnow()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pending_session(telegram_id: int) -> SessionCode | None:
|
||||||
|
"""
|
||||||
|
Ищет самую «свежую» сессию по telegram_id, где consulted_at ещё не заполнен.
|
||||||
|
Вернёт объект SessionCode или None.
|
||||||
|
"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(SessionCode)
|
||||||
|
.where(SessionCode.telegram_id.match(str(telegram_id)))
|
||||||
|
.where(SessionCode.consulted_at.is_(None))
|
||||||
|
.order_by(SessionCode.sent_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session_info(code: str) -> dict | None:
|
||||||
|
"""
|
||||||
|
Возвращает словарь с информацией по коду:
|
||||||
|
telegram_id, form_link, sent_at, consulted_at.
|
||||||
|
"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(SessionCode).where(SessionCode.code.match(code))
|
||||||
|
)
|
||||||
|
sc: SessionCode | None = result.scalar_one_or_none()
|
||||||
|
if not sc:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"telegram_id": sc.telegram_id,
|
||||||
|
"form_link": sc.form_link,
|
||||||
|
"sent_at": sc.sent_at,
|
||||||
|
"consulted_at": sc.consulted_at,
|
||||||
|
}
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
Loading…
x
Reference in New Issue
Block a user