Files
botPillbox/bot.py
T
2026-01-06 13:10:15 +07:00

1482 lines
51 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()