1482 lines
51 KiB
Python
1482 lines
51 KiB
Python
#!/usr/bin/env python3
|
||
# coding: utf-8
|
||
|
||
import sqlite3
|
||
import asyncio
|
||
import logging
|
||
import os
|
||
import sys
|
||
import warnings
|
||
from datetime import datetime, date, timedelta
|
||
from typing import List, Optional
|
||
from zoneinfo import ZoneInfo, available_timezones
|
||
from dotenv import load_dotenv
|
||
|
||
# Отключаем предупреждения PTB
|
||
warnings.filterwarnings("ignore", category=UserWarning)
|
||
|
||
from telegram import (
|
||
Update,
|
||
ReplyKeyboardMarkup,
|
||
ReplyKeyboardRemove,
|
||
InlineKeyboardButton,
|
||
InlineKeyboardMarkup,
|
||
)
|
||
from telegram.ext import (
|
||
Application,
|
||
CommandHandler,
|
||
MessageHandler,
|
||
filters,
|
||
ContextTypes,
|
||
ConversationHandler,
|
||
CallbackQueryHandler,
|
||
)
|
||
|
||
# ----------------- LOAD ENV -----------------
|
||
load_dotenv()
|
||
|
||
# ----------------- CONFIG -----------------
|
||
TOKEN = os.getenv("BOT_TOKEN")
|
||
if not TOKEN or TOKEN == "ваш_токен_бота":
|
||
print("ERROR: BOT_TOKEN not set or is default value!")
|
||
print("Please set BOT_TOKEN in .env file")
|
||
sys.exit(1)
|
||
|
||
DB_FILE = os.getenv("DB_FILE", "pills.db")
|
||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
||
DEBUG = os.getenv("DEBUG", "False").lower() == "true"
|
||
|
||
# ----------------- LOGGING -----------------
|
||
logging.basicConfig(
|
||
level=getattr(logging, LOG_LEVEL),
|
||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||
handlers=[
|
||
logging.FileHandler("bot.log", encoding="utf-8"),
|
||
logging.StreamHandler()
|
||
]
|
||
)
|
||
|
||
# Уменьшаем логирование httpx
|
||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# UI константы
|
||
BTN_ADD = "➕ Добавить лекарство"
|
||
BTN_LIST = "📋 Мои лекарства"
|
||
BTN_EDIT = "✏️ Редактировать"
|
||
BTN_DELETE = "🗑 Удалить"
|
||
BTN_HISTORY = "📅 История (7 дней)"
|
||
BTN_TZ = "🌍 Установить часовой пояс"
|
||
BTN_CANCEL = "❌ Отмена"
|
||
|
||
FREQ_EVERY_DAY = "Каждый день"
|
||
FREQ_EVERY_N = "Каждые N дней"
|
||
FREQ_3 = "Каждые 3 дня"
|
||
FREQ_7 = "Каждые 7 дней"
|
||
FREQ_WEEKDAYS = "По дням недели"
|
||
|
||
TZ_SUGGESTIONS = [
|
||
"Europe/Moscow",
|
||
"Asia/Yekaterinburg",
|
||
"Asia/Krasnoyarsk",
|
||
"Asia/Irkutsk",
|
||
"Asia/Vladivostok",
|
||
"Europe/Kaliningrad",
|
||
"Asia/Novosibirsk"
|
||
]
|
||
|
||
# Conversation states
|
||
(
|
||
S_TZ_SELECT,
|
||
S_TZ_MANUAL,
|
||
S_GENDER,
|
||
S_NAME,
|
||
S_ADD_NAME,
|
||
S_ADD_DOSE,
|
||
S_ADD_FREQ_TYPE,
|
||
S_ADD_FREQ_N,
|
||
S_ADD_WEEKDAYS,
|
||
S_ADD_TIMES,
|
||
S_ADD_COURSE,
|
||
S_EDIT_SELECT,
|
||
S_EDIT_FIELD,
|
||
S_EDIT_VALUE,
|
||
S_DELETE_SELECT,
|
||
) = range(15)
|
||
|
||
# ----------------- DATABASE -----------------
|
||
def get_db_connection():
|
||
"""Создаёт соединение с БД"""
|
||
conn = sqlite3.connect(DB_FILE, check_same_thread=False)
|
||
conn.row_factory = sqlite3.Row
|
||
return conn
|
||
|
||
def init_db():
|
||
"""Инициализация базы данных"""
|
||
conn = get_db_connection()
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute('''
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
id INTEGER PRIMARY KEY,
|
||
tz TEXT,
|
||
gender TEXT,
|
||
name TEXT,
|
||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||
)''')
|
||
|
||
cursor.execute('''
|
||
CREATE TABLE IF NOT EXISTS pills (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER,
|
||
name TEXT,
|
||
dose TEXT,
|
||
interval_days INTEGER DEFAULT 1,
|
||
weekdays TEXT DEFAULT NULL,
|
||
course_days INTEGER DEFAULT 0,
|
||
start_date TEXT,
|
||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
)''')
|
||
|
||
cursor.execute('''
|
||
CREATE TABLE IF NOT EXISTS pill_times (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
pill_id INTEGER,
|
||
time TEXT,
|
||
last_sent TEXT DEFAULT NULL,
|
||
last_confirmed TEXT DEFAULT NULL,
|
||
FOREIGN KEY (pill_id) REFERENCES pills(id) ON DELETE CASCADE
|
||
)''')
|
||
|
||
cursor.execute('''
|
||
CREATE TABLE IF NOT EXISTS messages (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
gender TEXT,
|
||
text_message TEXT,
|
||
sticker_id TEXT
|
||
)''')
|
||
|
||
cursor.execute('''
|
||
CREATE TABLE IF NOT EXISTS history (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
pill_id INTEGER,
|
||
user_id INTEGER,
|
||
ts TEXT,
|
||
taken INTEGER,
|
||
FOREIGN KEY (pill_id) REFERENCES pills(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
)''')
|
||
|
||
# Индексы
|
||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_pills_user_id ON pills(user_id)')
|
||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_pill_times_pill_id ON pill_times(pill_id)')
|
||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_history_user_pill ON history(user_id, pill_id)')
|
||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_history_ts ON history(ts)')
|
||
|
||
# Default messages
|
||
cursor.execute("SELECT COUNT(*) FROM messages")
|
||
if cursor.fetchone()[0] == 0:
|
||
msg_rows = [
|
||
("Я мужчина", "Пора принять {pill} ({dose}).", None),
|
||
("Я мужчина", "Напоминание: {pill} — {dose}.", None),
|
||
("Я женщина", "Пора принять {pill} ({dose}).", None),
|
||
("Я женщина", "Напоминание: {pill} — {dose}.", None),
|
||
]
|
||
cursor.executemany("INSERT INTO messages (gender, text_message, sticker_id) VALUES (?, ?, ?)", msg_rows)
|
||
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
init_db()
|
||
|
||
# ----------------- HELPERS -----------------
|
||
def main_menu_kb():
|
||
kb = [
|
||
[BTN_ADD, BTN_LIST],
|
||
[BTN_EDIT, BTN_DELETE],
|
||
[BTN_HISTORY, BTN_TZ]
|
||
]
|
||
return ReplyKeyboardMarkup(kb, resize_keyboard=True, one_time_keyboard=False)
|
||
|
||
def parse_hhmm(s: str) -> Optional[str]:
|
||
s = s.strip()
|
||
try:
|
||
dt = datetime.strptime(s, "%H:%M")
|
||
return dt.strftime("%H:%M")
|
||
except ValueError:
|
||
return None
|
||
|
||
def weekdays_list_to_csv(lst: List[int]) -> str:
|
||
return ",".join(str(i) for i in sorted(set(lst))) if lst else ""
|
||
|
||
def weekdays_csv_to_list(csv: Optional[str]) -> List[int]:
|
||
if not csv:
|
||
return []
|
||
return [int(x) for x in csv.split(",") if x.isdigit()]
|
||
|
||
def now_utc() -> datetime:
|
||
return datetime.utcnow()
|
||
|
||
def utc_str_min(dt: datetime) -> str:
|
||
return dt.strftime("%Y-%m-%d %H:%M")
|
||
|
||
def utc_str_full(dt: datetime) -> str:
|
||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
def localized_now_for_tz(tzname: str) -> datetime:
|
||
try:
|
||
tz = ZoneInfo(tzname)
|
||
except Exception:
|
||
tz = ZoneInfo("UTC")
|
||
return datetime.now(tz)
|
||
|
||
def local_time_hhmm_for_user(tzname: str, when_utc: datetime) -> str:
|
||
try:
|
||
tz = ZoneInfo(tzname)
|
||
except Exception:
|
||
tz = ZoneInfo("UTC")
|
||
local = when_utc.astimezone(tz)
|
||
return local.strftime("%H:%M")
|
||
|
||
def user_local_hhmm_to_utc_dt(tzname: str, local_date: date, hhmm: str) -> datetime:
|
||
hour, minute = map(int, hhmm.split(":"))
|
||
local_dt = datetime(local_date.year, local_date.month, local_date.day, hour, minute)
|
||
try:
|
||
tz = ZoneInfo(tzname)
|
||
local_dt = local_dt.replace(tzinfo=tz)
|
||
except Exception:
|
||
tz = ZoneInfo("UTC")
|
||
local_dt = local_dt.replace(tzinfo=tz)
|
||
return local_dt.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||
|
||
def tz_keyboard():
|
||
kb = [[tz] for tz in TZ_SUGGESTIONS]
|
||
kb.append(["Ввести вручную"])
|
||
kb.append([BTN_CANCEL])
|
||
return ReplyKeyboardMarkup(kb, one_time_keyboard=True, resize_keyboard=True)
|
||
|
||
def build_wd_keyboard(wd_sel: set):
|
||
labels = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
|
||
kb = []
|
||
row = []
|
||
for i, lab in enumerate(labels):
|
||
prefix = "✅" if i in wd_sel else "◻️"
|
||
row.append(InlineKeyboardButton(f"{prefix} {lab}", callback_data=f"wd_toggle:{i}"))
|
||
if (i + 1) % 3 == 0:
|
||
kb.append(row)
|
||
row = []
|
||
if row:
|
||
kb.append(row)
|
||
kb.append([
|
||
InlineKeyboardButton("Готово", callback_data="wd_done"),
|
||
InlineKeyboardButton("Отмена", callback_data="wd_cancel")
|
||
])
|
||
return InlineKeyboardMarkup(kb)
|
||
|
||
# ----------------- HANDLERS -----------------
|
||
async def start_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
user = update.effective_user
|
||
user_id = user.id
|
||
|
||
conn = get_db_connection()
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT tz, gender, name FROM users WHERE id=?", (user_id,))
|
||
row = cursor.fetchone()
|
||
conn.close()
|
||
|
||
if row and row["tz"] and row["gender"] and row["name"]:
|
||
await update.message.reply_text(f"Привет, {row['name']}! Меню:", reply_markup=main_menu_kb())
|
||
return ConversationHandler.END
|
||
|
||
await update.message.reply_text(
|
||
"Добро пожаловать! Сначала выберите ваш часовой пояс:",
|
||
reply_markup=tz_keyboard()
|
||
)
|
||
return S_TZ_SELECT
|
||
|
||
async def tz_reconfigure(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
await update.message.reply_text("Выберите ваш часовой пояс:", reply_markup=tz_keyboard())
|
||
return S_TZ_SELECT
|
||
|
||
async def tz_select_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
text = update.message.text.strip()
|
||
user_id = update.effective_user.id
|
||
|
||
if text == "Ввести вручную":
|
||
await update.message.reply_text(
|
||
"Введите IANA имя часового пояса (например, Asia/Krasnoyarsk):",
|
||
reply_markup=ReplyKeyboardRemove()
|
||
)
|
||
return S_TZ_MANUAL
|
||
|
||
if text == BTN_CANCEL:
|
||
await update.message.reply_text("Отмена.", reply_markup=main_menu_kb())
|
||
return ConversationHandler.END
|
||
|
||
try:
|
||
_ = ZoneInfo(text)
|
||
except Exception:
|
||
await update.message.reply_text(
|
||
"Неверный timezone. Попробуйте ещё раз или выберите 'Ввести вручную'.",
|
||
reply_markup=tz_keyboard()
|
||
)
|
||
return S_TZ_SELECT
|
||
|
||
conn = get_db_connection()
|
||
cursor = conn.cursor()
|
||
cursor.execute(
|
||
"INSERT OR REPLACE INTO users (id, tz) VALUES (?, ?)",
|
||
(user_id, text)
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
await update.message.reply_text(
|
||
"Выберите пол:",
|
||
reply_markup=ReplyKeyboardMarkup(
|
||
[["Я мужчина", "Я женщина"]],
|
||
one_time_keyboard=True,
|
||
resize_keyboard=True
|
||
)
|
||
)
|
||
return S_GENDER
|
||
|
||
async def tz_manual_input_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
tzname = update.message.text.strip()
|
||
user_id = update.effective_user.id
|
||
|
||
try:
|
||
_ = ZoneInfo(tzname)
|
||
except Exception:
|
||
await update.message.reply_text(
|
||
"Неправильный IANA timezone. Попробуйте снова или отмените.",
|
||
reply_markup=tz_keyboard()
|
||
)
|
||
return S_TZ_MANUAL
|
||
|
||
conn = get_db_connection()
|
||
cursor = conn.cursor()
|
||
cursor.execute(
|
||
"INSERT OR REPLACE INTO users (id, tz) VALUES (?, ?)",
|
||
(user_id, tzname)
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
await update.message.reply_text(
|
||
"Выберите пол:",
|
||
reply_markup=ReplyKeyboardMarkup(
|
||
[["Я мужчина", "Я женщина"]],
|
||
one_time_keyboard=True,
|
||
resize_keyboard=True
|
||
)
|
||
)
|
||
return S_GENDER
|
||
|
||
async def gender_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
user_id = update.effective_user.id
|
||
text = update.message.text.strip()
|
||
|
||
if text not in ("Я мужчина", "Я женщина"):
|
||
await update.message.reply_text("Пожалуйста, выберите вариант на клавиатуре.")
|
||
return S_GENDER
|
||
|
||
conn = get_db_connection()
|
||
cursor = conn.cursor()
|
||
cursor.execute(
|
||
"UPDATE users SET gender=? WHERE id=?",
|
||
(text, user_id)
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
await update.message.reply_text(
|
||
"Теперь введите ваше имя:",
|
||
reply_markup=ReplyKeyboardRemove()
|
||
)
|
||
return S_NAME
|
||
|
||
async def name_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
user_id = update.effective_user.id
|
||
name = update.message.text.strip()
|
||
|
||
conn = get_db_connection()
|
||
cursor = conn.cursor()
|
||
cursor.execute(
|
||
"UPDATE users SET name=? WHERE id=?",
|
||
(name, user_id)
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
await update.message.reply_text(
|
||
f"Регистрация завершена. Меню:",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
return ConversationHandler.END
|
||
|
||
# ----------------- Add pill flow -----------------
|
||
async def start_add(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
await update.message.reply_text(
|
||
"Введите название лекарства (например, Витамин D3):",
|
||
reply_markup=ReplyKeyboardRemove()
|
||
)
|
||
return S_ADD_NAME
|
||
|
||
async def add_name(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
context.user_data['add_name'] = update.message.text.strip()
|
||
await update.message.reply_text(
|
||
"Введите дозу (например, 1 капсула / 1000 IU):"
|
||
)
|
||
return S_ADD_DOSE
|
||
|
||
async def add_dose(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
context.user_data['add_dose'] = update.message.text.strip()
|
||
kb = [
|
||
[FREQ_EVERY_DAY, FREQ_EVERY_N],
|
||
[FREQ_3, FREQ_7],
|
||
[FREQ_WEEKDAYS, BTN_CANCEL]
|
||
]
|
||
await update.message.reply_text(
|
||
"Выберите режим приёма:",
|
||
reply_markup=ReplyKeyboardMarkup(kb, one_time_keyboard=True)
|
||
)
|
||
return S_ADD_FREQ_TYPE
|
||
|
||
async def add_freq_type(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
choice = update.message.text.strip()
|
||
|
||
if choice == BTN_CANCEL:
|
||
await update.message.reply_text("Отмена.", reply_markup=main_menu_kb())
|
||
return ConversationHandler.END
|
||
|
||
if choice == FREQ_EVERY_DAY:
|
||
context.user_data['interval_days'] = 1
|
||
context.user_data['weekdays'] = None
|
||
await update.message.reply_text(
|
||
"Введите одно или несколько времён приёма через запятую (например: 08:00,21:00):",
|
||
reply_markup=ReplyKeyboardRemove()
|
||
)
|
||
return S_ADD_TIMES
|
||
|
||
if choice == FREQ_EVERY_N:
|
||
await update.message.reply_text(
|
||
"Введите N (через сколько дней принимать, например 2 = через день):",
|
||
reply_markup=ReplyKeyboardRemove()
|
||
)
|
||
return S_ADD_FREQ_N
|
||
|
||
if choice == FREQ_3:
|
||
context.user_data['interval_days'] = 3
|
||
context.user_data['weekdays'] = None
|
||
await update.message.reply_text(
|
||
"Введите времена через запятую (например: 08:00,20:00):",
|
||
reply_markup=ReplyKeyboardRemove()
|
||
)
|
||
return S_ADD_TIMES
|
||
|
||
if choice == FREQ_7:
|
||
context.user_data['interval_days'] = 7
|
||
context.user_data['weekdays'] = None
|
||
await update.message.reply_text(
|
||
"Введите времена через запятую (например: 09:00):",
|
||
reply_markup=ReplyKeyboardRemove()
|
||
)
|
||
return S_ADD_TIMES
|
||
|
||
if choice == FREQ_WEEKDAYS:
|
||
context.user_data['wd_sel'] = set()
|
||
await send_weekday_selector(update, context)
|
||
return S_ADD_WEEKDAYS
|
||
|
||
return S_ADD_FREQ_TYPE
|
||
|
||
async def add_freq_n(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
try:
|
||
n = int(update.message.text.strip())
|
||
if n < 1:
|
||
raise ValueError
|
||
except ValueError:
|
||
await update.message.reply_text(
|
||
"Некорректное число. Введите целое положительное N:"
|
||
)
|
||
return S_ADD_FREQ_N
|
||
|
||
context.user_data['interval_days'] = n
|
||
context.user_data['weekdays'] = None
|
||
await update.message.reply_text(
|
||
"Введите времена приёма через запятую (пример: 08:00,20:00):"
|
||
)
|
||
return S_ADD_TIMES
|
||
|
||
async def send_weekday_selector(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
if update.callback_query:
|
||
await update.callback_query.answer()
|
||
await update.callback_query.edit_message_text(
|
||
"Выберите дни недели:",
|
||
reply_markup=build_wd_keyboard(context.user_data.get('wd_sel', set()))
|
||
)
|
||
else:
|
||
await update.message.reply_text(
|
||
"Выберите дни недели:",
|
||
reply_markup=build_wd_keyboard(context.user_data.get('wd_sel', set()))
|
||
)
|
||
|
||
async def weekday_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
query = update.callback_query
|
||
await query.answer()
|
||
data = query.data
|
||
|
||
if data.startswith("wd_toggle:"):
|
||
idx = int(data.split(":")[1])
|
||
sel = context.user_data.get('wd_sel', set())
|
||
if idx in sel:
|
||
sel.remove(idx)
|
||
else:
|
||
sel.add(idx)
|
||
context.user_data['wd_sel'] = sel
|
||
await query.edit_message_reply_markup(
|
||
reply_markup=build_wd_keyboard(sel)
|
||
)
|
||
return
|
||
|
||
if data == "wd_done":
|
||
sel = context.user_data.get('wd_sel', set())
|
||
if not sel:
|
||
await query.edit_message_text(
|
||
"Нужно выбрать хотя бы один день или нажмите Отмена."
|
||
)
|
||
return
|
||
|
||
csv = weekdays_list_to_csv(list(sel))
|
||
context.user_data['weekdays'] = csv
|
||
context.user_data['interval_days'] = None
|
||
|
||
day_names = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
|
||
selected_days = [day_names[i] for i in sorted(sel)]
|
||
await query.edit_message_text(f"Выбраны дни: {', '.join(selected_days)}")
|
||
|
||
if query.message:
|
||
await query.message.reply_text(
|
||
"Введите времена приёма через запятую (например: 08:00,20:00):"
|
||
)
|
||
return S_ADD_TIMES
|
||
|
||
if data == "wd_cancel":
|
||
await query.edit_message_text("Отменено.")
|
||
if query.message:
|
||
await query.message.reply_text("Отмена.", reply_markup=main_menu_kb())
|
||
return ConversationHandler.END
|
||
|
||
return S_ADD_WEEKDAYS
|
||
|
||
async def add_times_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
text = update.message.text.strip()
|
||
parts = [p.strip() for p in text.split(",") if p.strip() != '']
|
||
times = []
|
||
|
||
for p in parts:
|
||
hh = parse_hhmm(p)
|
||
if not hh:
|
||
await update.message.reply_text(
|
||
f"Неверное время: {p}. Попробуйте снова ввод всех времён."
|
||
)
|
||
return S_ADD_TIMES
|
||
times.append(hh)
|
||
|
||
context.user_data['times'] = times
|
||
await update.message.reply_text(
|
||
"На сколько дней курс? (0 = бессрочно):"
|
||
)
|
||
return S_ADD_COURSE
|
||
|
||
async def add_course_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
try:
|
||
days = int(update.message.text.strip())
|
||
if days < 0:
|
||
raise ValueError
|
||
except ValueError:
|
||
await update.message.reply_text(
|
||
"Введите целое неотрицательное число дней (0 = бессрочно):"
|
||
)
|
||
return S_ADD_COURSE
|
||
|
||
context.user_data['course_days'] = days
|
||
|
||
user_id = update.effective_user.id
|
||
name = context.user_data.get('add_name')
|
||
dose = context.user_data.get('add_dose')
|
||
interval_days = context.user_data.get('interval_days') or 1
|
||
weekdays = context.user_data.get('weekdays')
|
||
course_days = context.user_data.get('course_days') or 0
|
||
start_date = datetime.now().strftime("%Y-%m-%d")
|
||
times = context.user_data.get('times', [])
|
||
|
||
if not name or not dose or not times:
|
||
await update.message.reply_text(
|
||
"Ошибка: не все данные заполнены.",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
return ConversationHandler.END
|
||
|
||
conn = get_db_connection()
|
||
cursor = conn.cursor()
|
||
|
||
try:
|
||
cursor.execute(
|
||
'''INSERT INTO pills
|
||
(user_id, name, dose, interval_days, weekdays, course_days, start_date)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)''',
|
||
(user_id, name, dose, interval_days, weekdays, course_days, start_date)
|
||
)
|
||
pid = cursor.lastrowid
|
||
|
||
for t in times:
|
||
cursor.execute(
|
||
'INSERT INTO pill_times (pill_id, time) VALUES (?, ?)',
|
||
(pid, t)
|
||
)
|
||
|
||
conn.commit()
|
||
|
||
await update.message.reply_text(
|
||
f"✅ Добавлено: {name} — {dose}\n"
|
||
f"⏰ Напоминания: {', '.join(times)}",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error adding pill: {e}")
|
||
await update.message.reply_text(
|
||
"Произошла ошибка при добавлении лекарства.",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
finally:
|
||
conn.close()
|
||
|
||
context.user_data.clear()
|
||
return ConversationHandler.END
|
||
|
||
# ----------------- List pills -----------------
|
||
async def list_pills(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
user_id = update.effective_user.id
|
||
|
||
conn = get_db_connection()
|
||
cursor = conn.cursor()
|
||
cursor.execute(
|
||
'''SELECT id, name, dose, interval_days, weekdays, course_days, start_date
|
||
FROM pills WHERE user_id=?''',
|
||
(user_id,)
|
||
)
|
||
rows = cursor.fetchall()
|
||
|
||
if not rows:
|
||
await update.message.reply_text(
|
||
"У вас нет добавленных лекарств.",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
conn.close()
|
||
return
|
||
|
||
lines = []
|
||
for r in rows:
|
||
pid = r["id"]
|
||
name = r["name"]
|
||
dose = r["dose"]
|
||
interval_days = r["interval_days"]
|
||
weekdays = r["weekdays"]
|
||
course_days = r["course_days"]
|
||
start_date = r["start_date"]
|
||
|
||
cursor.execute(
|
||
'SELECT time FROM pill_times WHERE pill_id=?',
|
||
(pid,)
|
||
)
|
||
times = [t["time"] for t in cursor.fetchall()]
|
||
|
||
freq = ""
|
||
if weekdays:
|
||
day_names = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
|
||
selected = [day_names[i] for i in weekdays_csv_to_list(weekdays)]
|
||
freq = f"по дням ({', '.join(selected)})"
|
||
else:
|
||
if interval_days > 1:
|
||
freq = f"каждые {interval_days} дн."
|
||
else:
|
||
freq = "каждый день"
|
||
|
||
course_info = f"курс {course_days} дн." if course_days > 0 else "бессрочно"
|
||
|
||
lines.append(
|
||
f"ID {pid}: {name} — {dose}\n"
|
||
f"⏰ Время: {', '.join(times)}\n"
|
||
f"📅 {freq} | {course_info} (с {start_date})\n"
|
||
)
|
||
|
||
conn.close()
|
||
|
||
await update.message.reply_text(
|
||
"\n".join(lines),
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
|
||
# ----------------- Delete -----------------
|
||
async def start_delete(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
await update.message.reply_text(
|
||
"Введите ID лекарства для удаления (см. список):",
|
||
reply_markup=ReplyKeyboardRemove()
|
||
)
|
||
return S_DELETE_SELECT
|
||
|
||
async def delete_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
txt = update.message.text.strip()
|
||
user_id = update.effective_user.id
|
||
|
||
try:
|
||
pid = int(txt)
|
||
except ValueError:
|
||
await update.message.reply_text(
|
||
"ID должен быть числом. Отмена.",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
return ConversationHandler.END
|
||
|
||
conn = get_db_connection()
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute(
|
||
'SELECT name FROM pills WHERE id=? AND user_id=?',
|
||
(pid, user_id)
|
||
)
|
||
pill = cursor.fetchone()
|
||
|
||
if not pill:
|
||
await update.message.reply_text(
|
||
"Лекарство не найдено.",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
conn.close()
|
||
return ConversationHandler.END
|
||
|
||
pill_name = pill["name"]
|
||
|
||
cursor.execute('DELETE FROM pills WHERE id=? AND user_id=?', (pid, user_id))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
await update.message.reply_text(
|
||
f"✅ Лекарство '{pill_name}' удалено.",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
return ConversationHandler.END
|
||
|
||
# ----------------- Edit flow -----------------
|
||
async def start_edit(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
await update.message.reply_text(
|
||
"Введите ID лекарства для редактирования:",
|
||
reply_markup=ReplyKeyboardRemove()
|
||
)
|
||
return S_EDIT_SELECT
|
||
|
||
async def edit_select_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
txt = update.message.text.strip()
|
||
user_id = update.effective_user.id
|
||
|
||
try:
|
||
pid = int(txt)
|
||
except ValueError:
|
||
await update.message.reply_text(
|
||
"ID должен быть числом. Отмена.",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
return ConversationHandler.END
|
||
|
||
conn = get_db_connection()
|
||
cursor = conn.cursor()
|
||
cursor.execute(
|
||
'SELECT name FROM pills WHERE id=? AND user_id=?',
|
||
(pid, user_id)
|
||
)
|
||
pill = cursor.fetchone()
|
||
conn.close()
|
||
|
||
if not pill:
|
||
await update.message.reply_text(
|
||
"Лекарство не найдено.",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
return ConversationHandler.END
|
||
|
||
context.user_data['edit_id'] = pid
|
||
kb = [
|
||
['Название', 'Доза'],
|
||
['Время(ы)', 'Частота'],
|
||
['Длительность (дни)', BTN_CANCEL]
|
||
]
|
||
|
||
await update.message.reply_text(
|
||
f"Редактирование: {pill['name']}\nВыберите что редактировать:",
|
||
reply_markup=ReplyKeyboardMarkup(kb, one_time_keyboard=True)
|
||
)
|
||
return S_EDIT_FIELD
|
||
|
||
async def edit_field_choice(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
choice = update.message.text.strip().lower()
|
||
pid = context.user_data.get('edit_id')
|
||
|
||
if choice == 'название':
|
||
context.user_data['edit_field'] = 'name'
|
||
await update.message.reply_text(
|
||
"Введите новое название:",
|
||
reply_markup=ReplyKeyboardRemove()
|
||
)
|
||
return S_EDIT_VALUE
|
||
|
||
if choice == 'доза':
|
||
context.user_data['edit_field'] = 'dose'
|
||
await update.message.reply_text(
|
||
"Введите новую дозу:",
|
||
reply_markup=ReplyKeyboardRemove()
|
||
)
|
||
return S_EDIT_VALUE
|
||
|
||
if choice == 'время(ы)':
|
||
context.user_data['edit_field'] = 'times'
|
||
await update.message.reply_text(
|
||
"Введите новые времена через запятую (пример: 08:00,20:00):",
|
||
reply_markup=ReplyKeyboardRemove()
|
||
)
|
||
return S_EDIT_VALUE
|
||
|
||
if choice == 'частота':
|
||
context.user_data['edit_field'] = 'freq'
|
||
kb = [
|
||
[FREQ_EVERY_DAY, FREQ_EVERY_N],
|
||
[FREQ_3, FREQ_7],
|
||
[FREQ_WEEKDAYS, BTN_CANCEL]
|
||
]
|
||
await update.message.reply_text(
|
||
"Выберите новую частоту:",
|
||
reply_markup=ReplyKeyboardMarkup(kb, one_time_keyboard=True)
|
||
)
|
||
return S_EDIT_VALUE
|
||
|
||
if choice == 'длительность (дни)':
|
||
context.user_data['edit_field'] = 'course'
|
||
await update.message.reply_text(
|
||
"Введите число дней курса (0 = бессрочно):",
|
||
reply_markup=ReplyKeyboardRemove()
|
||
)
|
||
return S_EDIT_VALUE
|
||
|
||
if choice == BTN_CANCEL.lower():
|
||
await update.message.reply_text(
|
||
"Отмена.",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
return ConversationHandler.END
|
||
|
||
await update.message.reply_text(
|
||
"Неизвестный выбор.",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
return ConversationHandler.END
|
||
|
||
async def edit_value_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
field = context.user_data.get('edit_field')
|
||
pid = context.user_data.get('edit_id')
|
||
user_id = update.effective_user.id
|
||
text = update.message.text.strip()
|
||
|
||
conn = get_db_connection()
|
||
cursor = conn.cursor()
|
||
|
||
try:
|
||
if field == 'name':
|
||
cursor.execute(
|
||
'UPDATE pills SET name=? WHERE id=? AND user_id=?',
|
||
(text, pid, user_id)
|
||
)
|
||
message = "Название обновлено."
|
||
|
||
elif field == 'dose':
|
||
cursor.execute(
|
||
'UPDATE pills SET dose=? WHERE id=? AND user_id=?',
|
||
(text, pid, user_id)
|
||
)
|
||
message = "Доза обновлена."
|
||
|
||
elif field == 'times':
|
||
parts = [p.strip() for p in text.split(",") if p.strip() != '']
|
||
times = []
|
||
for p in parts:
|
||
hh = parse_hhmm(p)
|
||
if not hh:
|
||
raise ValueError(f"Неверное время: {p}")
|
||
times.append(hh)
|
||
|
||
cursor.execute('DELETE FROM pill_times WHERE pill_id=?', (pid,))
|
||
for t in times:
|
||
cursor.execute(
|
||
'INSERT INTO pill_times (pill_id, time) VALUES (?, ?)',
|
||
(pid, t)
|
||
)
|
||
message = "Времена обновлены."
|
||
|
||
elif field == 'freq':
|
||
choice = text
|
||
if choice == FREQ_EVERY_DAY:
|
||
cursor.execute(
|
||
'UPDATE pills SET interval_days=1, weekdays=NULL WHERE id=? AND user_id=?',
|
||
(pid, user_id)
|
||
)
|
||
elif choice == FREQ_3:
|
||
cursor.execute(
|
||
'UPDATE pills SET interval_days=3, weekdays=NULL WHERE id=? AND user_id=?',
|
||
(pid, user_id)
|
||
)
|
||
elif choice == FREQ_7:
|
||
cursor.execute(
|
||
'UPDATE pills SET interval_days=7, weekdays=NULL WHERE id=? AND user_id=?',
|
||
(pid, user_id)
|
||
)
|
||
elif choice == FREQ_EVERY_N:
|
||
await update.message.reply_text(
|
||
"Введите N (целое число дней):"
|
||
)
|
||
context.user_data['expect_n_edit'] = True
|
||
return S_EDIT_VALUE
|
||
elif choice == FREQ_WEEKDAYS:
|
||
context.user_data['wd_sel'] = set()
|
||
await send_weekday_selector(update, context)
|
||
return S_EDIT_VALUE
|
||
else:
|
||
if context.user_data.pop('expect_n_edit', False):
|
||
try:
|
||
n = int(text)
|
||
cursor.execute(
|
||
'UPDATE pills SET interval_days=?, weekdays=NULL WHERE id=? AND user_id=?',
|
||
(n, pid, user_id)
|
||
)
|
||
except ValueError:
|
||
await update.message.reply_text(
|
||
"Неверное число. Отмена.",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
conn.close()
|
||
return ConversationHandler.END
|
||
|
||
message = "Частота обновлена."
|
||
|
||
elif field == 'course':
|
||
try:
|
||
days = int(text)
|
||
cursor.execute(
|
||
'UPDATE pills SET course_days=? WHERE id=? AND user_id=?',
|
||
(days, pid, user_id)
|
||
)
|
||
message = "Длительность курса обновлена."
|
||
except ValueError:
|
||
await update.message.reply_text(
|
||
"Неверный ввод. Отмена.",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
conn.close()
|
||
return ConversationHandler.END
|
||
|
||
else:
|
||
await update.message.reply_text(
|
||
"Неизвестная операция.",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
conn.close()
|
||
return ConversationHandler.END
|
||
|
||
conn.commit()
|
||
await update.message.reply_text(f"✅ {message}", reply_markup=main_menu_kb())
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error updating pill: {e}")
|
||
await update.message.reply_text(
|
||
f"Ошибка: {str(e)}",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
finally:
|
||
conn.close()
|
||
|
||
context.user_data.clear()
|
||
return ConversationHandler.END
|
||
|
||
# ----------------- History -----------------
|
||
async def history_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
user_id = update.effective_user.id
|
||
|
||
conn = get_db_connection()
|
||
cursor = conn.cursor()
|
||
|
||
try:
|
||
# Проверяем, есть ли вообще история за последние 7 дней
|
||
seven_days_ago = (datetime.utcnow() - timedelta(days=7)).date().strftime("%Y-%m-%d")
|
||
|
||
cursor.execute(
|
||
'''SELECT COUNT(*) as cnt FROM history
|
||
WHERE user_id=? AND date(ts) >= ?''',
|
||
(user_id, seven_days_ago)
|
||
)
|
||
has_history = cursor.fetchone()["cnt"] > 0
|
||
|
||
if not has_history:
|
||
await update.message.reply_text(
|
||
"📭 История приёмов за последние 7 дней пуста.\n\n"
|
||
"Как только вы отметите приём лекарства (кнопка '✅ Выпил(-а)'), "
|
||
"здесь появится история.",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
conn.close()
|
||
return
|
||
|
||
# Получаем все лекарства пользователя
|
||
cursor.execute('SELECT id, name FROM pills WHERE user_id=?', (user_id,))
|
||
pills = cursor.fetchall()
|
||
|
||
now = datetime.utcnow().date()
|
||
lines = []
|
||
|
||
for pill in pills:
|
||
pid = pill["id"]
|
||
name = pill["name"]
|
||
|
||
lines.append(f"💊 {name}:")
|
||
for d in range(6, -1, -1):
|
||
day = now - timedelta(days=d)
|
||
day_str = day.strftime("%Y-%m-%d")
|
||
cursor.execute('SELECT COUNT(*) as cnt FROM history WHERE pill_id=? AND user_id=? AND date(ts)=?',
|
||
(pid, user_id, day_str))
|
||
cnt = cursor.fetchone()["cnt"]
|
||
mark = "✅" if cnt > 0 else "❌"
|
||
lines.append(f"{day.strftime('%d.%m')}: {mark}")
|
||
lines.append("")
|
||
|
||
response = "\n".join(lines)
|
||
await update.message.reply_text(response, reply_markup=main_menu_kb())
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error in history_handler: {e}")
|
||
await update.message.reply_text(
|
||
"❌ Ошибка при получении истории.",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
finally:
|
||
conn.close()
|
||
|
||
# ----------------- Callback: taken -----------------
|
||
async def callback_taken(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
query = update.callback_query
|
||
await query.answer()
|
||
|
||
if not query.data or not query.data.startswith("take:"):
|
||
await query.edit_message_text("Неизвестная команда.")
|
||
return
|
||
|
||
try:
|
||
ptid = int(query.data.split(":", 1)[1])
|
||
except ValueError:
|
||
await query.edit_message_text("Ошибка в данных.")
|
||
return
|
||
|
||
conn = get_db_connection()
|
||
cursor = conn.cursor()
|
||
|
||
try:
|
||
cursor.execute(
|
||
'''SELECT pt.pill_id, p.user_id, p.name
|
||
FROM pill_times pt
|
||
JOIN pills p ON p.id = pt.pill_id
|
||
WHERE pt.id=?''',
|
||
(ptid,)
|
||
)
|
||
row = cursor.fetchone()
|
||
|
||
if not row:
|
||
await query.edit_message_text("Запись не найдена.")
|
||
conn.close()
|
||
return
|
||
|
||
pid = row["pill_id"]
|
||
user_id = row["user_id"]
|
||
pill_name = row["name"]
|
||
|
||
now_utc = datetime.utcnow()
|
||
now_min_str = utc_str_min(now_utc)
|
||
now_full_str = utc_str_full(now_utc)
|
||
|
||
cursor.execute(
|
||
'UPDATE pill_times SET last_confirmed=? WHERE id=?',
|
||
(now_min_str, ptid)
|
||
)
|
||
|
||
cursor.execute(
|
||
'''INSERT INTO history (pill_id, user_id, ts, taken)
|
||
VALUES (?, ?, ?, 1)''',
|
||
(pid, user_id, now_full_str)
|
||
)
|
||
|
||
conn.commit()
|
||
|
||
await query.edit_message_reply_markup(reply_markup=None)
|
||
await query.message.reply_text(
|
||
f"✅ Приём '{pill_name}' зарегистрирован!\n"
|
||
"Спасибо, что заботишься о своём здоровье 💖",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error in callback_taken: {e}")
|
||
await query.edit_message_text("Произошла ошибка.")
|
||
finally:
|
||
conn.close()
|
||
|
||
# ----------------- Helper handlers -----------------
|
||
async def help_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""Обработчик команды /help"""
|
||
help_text = """
|
||
🤖 *Помощь по боту-напоминалке*
|
||
|
||
*Основные команды:*
|
||
/start - Начать работу с ботом
|
||
/help - Показать это сообщение
|
||
|
||
*Как пользоваться:*
|
||
1. Сначала настройте часовой пояс
|
||
2. Добавьте лекарства через меню
|
||
3. Бот будет присылать напоминания
|
||
4. Нажимайте "✅ Выпил(-а)" в напоминании
|
||
|
||
*Меню:*
|
||
➕ Добавить лекарство - добавить новое лекарство
|
||
📋 Мои лекарства - список всех лекарств
|
||
✏️ Редактировать - изменить параметры лекарства
|
||
🗑 Удалить - удалить лекарство
|
||
📅 История (7 дней) - история приёмов
|
||
🌍 Установить часовой пояс - сменить часовой пояс
|
||
❌ Отмена - отменить текущее действие
|
||
"""
|
||
await update.message.reply_text(help_text, parse_mode='Markdown', reply_markup=main_menu_kb())
|
||
|
||
async def unknown_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""Обработчик неизвестных команд"""
|
||
await update.message.reply_text(
|
||
"Неизвестная команда. Используйте /help для списка команд.",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
|
||
async def handle_unknown_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""Обработка неизвестного текста"""
|
||
text = update.message.text
|
||
|
||
# Если это кнопка "Отмена" из ConversationHandler
|
||
if text == BTN_CANCEL:
|
||
await update.message.reply_text(
|
||
"Отмена.",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
return
|
||
|
||
# Проверяем другие кнопки (на всякий случай)
|
||
known_buttons = [BTN_ADD, BTN_LIST, BTN_EDIT, BTN_DELETE, BTN_HISTORY, BTN_TZ]
|
||
if text in known_buttons:
|
||
# Если это известная кнопка, но почему-то не обработалась выше
|
||
await update.message.reply_text(
|
||
"Пожалуйста, попробуйте нажать кнопку ещё раз.",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
return
|
||
|
||
# Если это неизвестный текст
|
||
await update.message.reply_text(
|
||
"Пожалуйста, используйте кнопки меню.",
|
||
reply_markup=main_menu_kb()
|
||
)
|
||
|
||
# ----------------- Background job -----------------
|
||
async def background_job(context: ContextTypes.DEFAULT_TYPE):
|
||
"""Задача для отправки напоминаний через JobQueue"""
|
||
try:
|
||
now_utc_dt = datetime.utcnow()
|
||
now_min_utc = utc_str_min(now_utc_dt)
|
||
|
||
conn = get_db_connection()
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute('''
|
||
SELECT pt.id, pt.pill_id, pt.time, pt.last_sent, pt.last_confirmed,
|
||
p.user_id, p.name, p.dose, p.interval_days, p.weekdays,
|
||
p.course_days, p.start_date, u.tz
|
||
FROM pill_times pt
|
||
JOIN pills p ON p.id = pt.pill_id
|
||
JOIN users u ON u.id = p.user_id
|
||
WHERE p.course_days = 0 OR
|
||
(julianday(?) - julianday(p.start_date)) < p.course_days
|
||
''', (now_utc_dt.strftime("%Y-%m-%d"),))
|
||
|
||
rows = cursor.fetchall()
|
||
|
||
for row in rows:
|
||
ptid = row["id"]
|
||
pid = row["pill_id"]
|
||
time_hhmm = row["time"]
|
||
last_sent = row["last_sent"]
|
||
last_confirmed = row["last_confirmed"]
|
||
user_id = row["user_id"]
|
||
pill_name = row["name"]
|
||
pill_dose = row["dose"]
|
||
interval_days = row["interval_days"]
|
||
weekdays_csv = row["weekdays"]
|
||
course_days = row["course_days"]
|
||
start_date = row["start_date"]
|
||
user_tz = row["tz"] or "UTC"
|
||
|
||
try:
|
||
tz = ZoneInfo(user_tz)
|
||
except Exception:
|
||
tz = ZoneInfo("UTC")
|
||
|
||
user_local_now = datetime.now(tz)
|
||
user_local_hhmm = user_local_now.strftime("%H:%M")
|
||
|
||
if user_local_hhmm != time_hhmm:
|
||
continue
|
||
|
||
if weekdays_csv:
|
||
wlist = weekdays_csv_to_list(weekdays_csv)
|
||
if user_local_now.weekday() not in wlist:
|
||
continue
|
||
else:
|
||
if interval_days and interval_days > 1 and start_date:
|
||
try:
|
||
sd = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||
diff = (user_local_now.date() - sd).days
|
||
if diff % interval_days != 0:
|
||
continue
|
||
except Exception:
|
||
pass
|
||
|
||
if last_sent == now_min_utc:
|
||
continue
|
||
|
||
if last_confirmed and last_sent and last_confirmed >= last_sent:
|
||
continue
|
||
|
||
kb = InlineKeyboardMarkup([[
|
||
InlineKeyboardButton("✅ Выпил(-а)", callback_data=f"take:{ptid}")
|
||
]])
|
||
|
||
try:
|
||
await context.bot.send_message(
|
||
chat_id=user_id,
|
||
text=f"💊 Пора принять {pill_name} ({pill_dose})",
|
||
reply_markup=kb
|
||
)
|
||
|
||
cursor.execute(
|
||
'UPDATE pill_times SET last_sent=? WHERE id=?',
|
||
(now_min_utc, ptid)
|
||
)
|
||
conn.commit()
|
||
|
||
logger.info(f"Sent reminder to user {user_id} for pill {pill_name}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to send reminder to user {user_id}: {e}")
|
||
|
||
conn.close()
|
||
|
||
except Exception as e:
|
||
logger.exception(f"Error in background job: {e}")
|
||
|
||
# ----------------- Setup handlers -----------------
|
||
def setup_handlers(application: Application):
|
||
"""Настройка всех обработчиков в правильном порядке"""
|
||
|
||
# 1. ConversationHandler для настройки
|
||
conv_tz = ConversationHandler(
|
||
entry_points=[
|
||
CommandHandler("start", start_handler),
|
||
MessageHandler(filters.Regex(f'^{BTN_TZ}$'), tz_reconfigure),
|
||
],
|
||
states={
|
||
S_TZ_SELECT: [
|
||
MessageHandler(
|
||
filters.Regex(f'^({"|".join(TZ_SUGGESTIONS)})$'),
|
||
tz_select_handler
|
||
),
|
||
MessageHandler(
|
||
filters.Regex("^Ввести вручную$"),
|
||
tz_select_handler
|
||
),
|
||
],
|
||
S_TZ_MANUAL: [
|
||
MessageHandler(
|
||
filters.TEXT & ~filters.COMMAND,
|
||
tz_manual_input_handler
|
||
)
|
||
],
|
||
S_GENDER: [
|
||
MessageHandler(
|
||
filters.Regex("^(Я мужчина|Я женщина)$"),
|
||
gender_handler
|
||
)
|
||
],
|
||
S_NAME: [
|
||
MessageHandler(
|
||
filters.TEXT & ~filters.COMMAND,
|
||
name_handler
|
||
)
|
||
],
|
||
},
|
||
fallbacks=[
|
||
MessageHandler(
|
||
filters.Regex(f'^{BTN_CANCEL}$'),
|
||
lambda u, c: (u.message.reply_text("Отмена.", reply_markup=main_menu_kb()), ConversationHandler.END)[1]
|
||
)
|
||
],
|
||
per_message=True
|
||
)
|
||
application.add_handler(conv_tz)
|
||
|
||
# 2. Добавление лекарства
|
||
conv_add = ConversationHandler(
|
||
entry_points=[MessageHandler(filters.Regex(f'^{BTN_ADD}$'), start_add)],
|
||
states={
|
||
S_ADD_NAME: [
|
||
MessageHandler(filters.TEXT & ~filters.COMMAND, add_name)
|
||
],
|
||
S_ADD_DOSE: [
|
||
MessageHandler(filters.TEXT & ~filters.COMMAND, add_dose)
|
||
],
|
||
S_ADD_FREQ_TYPE: [
|
||
MessageHandler(filters.TEXT & ~filters.COMMAND, add_freq_type)
|
||
],
|
||
S_ADD_FREQ_N: [
|
||
MessageHandler(filters.TEXT & ~filters.COMMAND, add_freq_n)
|
||
],
|
||
S_ADD_WEEKDAYS: [
|
||
CallbackQueryHandler(
|
||
weekday_callback,
|
||
pattern="^(wd_toggle:|wd_done|wd_cancel)$"
|
||
)
|
||
],
|
||
S_ADD_TIMES: [
|
||
MessageHandler(filters.TEXT & ~filters.COMMAND, add_times_handler)
|
||
],
|
||
S_ADD_COURSE: [
|
||
MessageHandler(filters.TEXT & ~filters.COMMAND, add_course_handler)
|
||
],
|
||
},
|
||
fallbacks=[
|
||
MessageHandler(
|
||
filters.Regex(f'^{BTN_CANCEL}$'),
|
||
lambda u, c: (u.message.reply_text("Отмена.", reply_markup=main_menu_kb()), ConversationHandler.END)[1]
|
||
)
|
||
],
|
||
per_message=True
|
||
)
|
||
application.add_handler(conv_add)
|
||
|
||
# 3. Редактирование
|
||
conv_edit = ConversationHandler(
|
||
entry_points=[MessageHandler(filters.Regex(f'^{BTN_EDIT}$'), start_edit)],
|
||
states={
|
||
S_EDIT_SELECT: [
|
||
MessageHandler(filters.TEXT & ~filters.COMMAND, edit_select_handler)
|
||
],
|
||
S_EDIT_FIELD: [
|
||
MessageHandler(filters.TEXT & ~filters.COMMAND, edit_field_choice)
|
||
],
|
||
S_EDIT_VALUE: [
|
||
MessageHandler(filters.TEXT & ~filters.COMMAND, edit_value_handler),
|
||
CallbackQueryHandler(
|
||
weekday_callback,
|
||
pattern="^(wd_toggle:|wd_done|wd_cancel)$"
|
||
)
|
||
],
|
||
},
|
||
fallbacks=[
|
||
MessageHandler(
|
||
filters.Regex(f'^{BTN_CANCEL}$'),
|
||
lambda u, c: (u.message.reply_text("Отмена.", reply_markup=main_menu_kb()), ConversationHandler.END)[1]
|
||
)
|
||
],
|
||
per_message=True
|
||
)
|
||
application.add_handler(conv_edit)
|
||
|
||
# 4. Удаление
|
||
conv_del = ConversationHandler(
|
||
entry_points=[MessageHandler(filters.Regex(f'^{BTN_DELETE}$'), start_delete)],
|
||
states={
|
||
S_DELETE_SELECT: [
|
||
MessageHandler(filters.TEXT & ~filters.COMMAND, delete_confirm)
|
||
]
|
||
},
|
||
fallbacks=[
|
||
MessageHandler(
|
||
filters.Regex(f'^{BTN_CANCEL}$'),
|
||
lambda u, c: (u.message.reply_text("Отмена.", reply_markup=main_menu_kb()), ConversationHandler.END)[1]
|
||
)
|
||
],
|
||
per_message=True
|
||
)
|
||
application.add_handler(conv_del)
|
||
|
||
# 5. Обработчики простых кнопок меню (ВАЖНО: до общего обработчика!)
|
||
application.add_handler(MessageHandler(filters.Regex(f'^{BTN_LIST}$'), list_pills))
|
||
application.add_handler(MessageHandler(filters.Regex(f'^{BTN_HISTORY}$'), history_handler))
|
||
|
||
# 6. Обработчик callback для принятия лекарства
|
||
application.add_handler(CallbackQueryHandler(callback_taken, pattern=r"^take:\d+$"))
|
||
|
||
# 7. Общий обработчик команды /help
|
||
application.add_handler(CommandHandler("help", help_handler))
|
||
|
||
# 8. Обработчик неизвестных команд
|
||
application.add_handler(MessageHandler(filters.COMMAND, unknown_command))
|
||
|
||
# 9. ОБЩИЙ обработчик текста - ДОЛЖЕН БЫТЬ ПОСЛЕДНИМ!
|
||
application.add_handler(MessageHandler(
|
||
filters.TEXT & ~filters.COMMAND,
|
||
handle_unknown_text
|
||
))
|
||
|
||
# ----------------- Main function -----------------
|
||
def main():
|
||
"""Основная функция запуска бота"""
|
||
logger.info(f"Starting Pill Reminder Bot")
|
||
logger.info(f"Database: {DB_FILE}")
|
||
logger.info(f"Log level: {LOG_LEVEL}")
|
||
|
||
# Создаем приложение
|
||
application = Application.builder().token(TOKEN).build()
|
||
|
||
# Настраиваем обработчики
|
||
setup_handlers(application)
|
||
|
||
# Настраиваем фоновую задачу через JobQueue
|
||
job_queue = application.job_queue
|
||
|
||
if job_queue:
|
||
# Запускаем задачу каждую минуту
|
||
job_queue.run_repeating(
|
||
background_job,
|
||
interval=60, # 60 секунд
|
||
first=10 # Запустить через 10 секунд после старта
|
||
)
|
||
logger.info("Background job scheduled via JobQueue")
|
||
|
||
# Запускаем бота
|
||
application.run_polling()
|
||
|
||
if __name__ == "__main__":
|
||
main() |