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