Files
botPillbox/bot.py
T
2026-01-06 12:39:39 +07:00

995 lines
44 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.
import sqlite3
import asyncio
import logging
import os
import sys
from datetime import datetime, date, timedelta
from typing import List, Optional
from zoneinfo import ZoneInfo, available_timezones
from dotenv import load_dotenv
from telegram import (
Update,
ReplyKeyboardMarkup,
ReplyKeyboardRemove,
InlineKeyboardButton,
InlineKeyboardMarkup,
)
from telegram.ext import (
Application,
CommandHandler,
MessageHandler,
filters,
ContextTypes,
ConversationHandler,
CallbackQueryHandler,
)
# ----------------- LOAD ENV -----------------
load_dotenv()
# ----------------- VALIDATE 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=logging.INFO)
logger = logging.getLogger(__name__)
# Затем настраиваем нормальное логирование
log_level = getattr(logging, LOG_LEVEL.upper(), logging.INFO)
logger.setLevel(log_level)
if DEBUG:
handler = logging.StreamHandler()
else:
handler = logging.FileHandler("bot.log", encoding="utf-8")
handler.setFormatter(logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
))
logger.addHandler(handler)
# Выводим информацию о конфигурации
logger.info(f"Starting Pill Reminder Bot")
logger.info(f"Database: {DB_FILE}")
logger.info(f"Log level: {LOG_LEVEL}")
# UI константы (не из .env, т.к. это статические строки интерфейса)
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)
# ----------------- LOGGING -----------------
logging.basicConfig(format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=LOG_LEVEL)
logger = logging.getLogger(__name__)
# ----------------- DB -----------------
conn = sqlite3.connect(DB_FILE, check_same_thread=False)
cursor = conn.cursor()
def init_db():
# create tables if not exists
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
tz TEXT, -- IANA timezone name
gender TEXT,
name TEXT
)''')
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, -- csv of 0..6 Monday..Sunday
course_days INTEGER DEFAULT 0,
start_date TEXT -- YYYY-MM-DD
)''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS pill_times (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pill_id INTEGER,
time TEXT, -- HH:MM in user's local tz
last_sent TEXT DEFAULT NULL, -- UTC YYYY-MM-DD HH:MM
last_confirmed TEXT DEFAULT NULL -- UTC YYYY-MM-DD HH:MM
)''')
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, -- UTC timestamp YYYY-MM-DD HH:MM:SS
taken INTEGER -- 1 or 0
)''')
conn.commit()
# populate default messages if empty
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()
init_db()
# ----------------- HELPERS -----------------
def main_menu_kb():
kb = [
[BTN_ADD, BTN_LIST],
[BTN_EDIT, BTN_DELETE],
[BTN_HISTORY, BTN_TZ],
[BTN_CANCEL]
]
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:
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!='']
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")
# Return user's localized now (datetime) given user's tz name (IANA)
def localized_now_for_tz(tzname: str) -> datetime:
try:
tz = ZoneInfo(tzname)
except Exception:
tz = ZoneInfo("UTC")
return datetime.now(tz)
# Convert UTC now to user's local time and return HH:MM
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")
# Convert user's local date/time (today + hh:mm) to UTC datetime (for comparison and storing last_sent)
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, tzinfo=ZoneInfo(tzname))
return local_dt.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
# ----------------- UI: Timezone selection -----------------
TZ_SUGGESTIONS = [
"Europe/Moscow",
"Asia/Yekaterinburg",
"Asia/Krasnoyarsk",
"Asia/Irkutsk",
"Asia/Vladivostok",
"Europe/Kaliningrad",
"Asia/Novosibirsk"
]
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)
# ----------------- HANDLERS -----------------
async def start_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
user_id = user.id
# check user in DB
cursor.execute("SELECT tz, gender, name FROM users WHERE id=?", (user_id,))
row = cursor.fetchone()
if row and row[0] and row[1] and row[2]: # все поля заполнены
tzname, gender, name = row
await update.message.reply_text(f"Привет, {name}! Меню:", reply_markup=main_menu_kb())
return ConversationHandler.END
# Not set -> ask tz and name and gender
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
# validate
try:
_ = ZoneInfo(text)
except Exception:
await update.message.reply_text("Неверный timezone. Попробуйте ещё раз или выберите 'Ввести вручную'.", reply_markup=tz_keyboard())
return S_TZ_SELECT
# store tz, ask gender
cursor.execute("INSERT OR REPLACE INTO users (id, tz) VALUES (?, ?)", (user_id, text))
conn.commit()
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
cursor.execute("INSERT OR REPLACE INTO users (id, tz) VALUES (?, ?)", (user_id, tzname))
conn.commit()
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
cursor.execute("UPDATE users SET gender=? WHERE id=?", (text, user_id))
conn.commit()
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()
cursor.execute("UPDATE users SET name=? WHERE id=?", (name, user_id))
conn.commit()
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()
# frequency choice
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:
# show inline weekday selector
# We'll manage selection in user_data['wd_sel'] as set
context.user_data['wd_sel'] = set()
await send_weekday_selector(update, context)
return S_ADD_WEEKDAYS
async def add_freq_n(update: Update, context: ContextTypes.DEFAULT_TYPE):
try:
n = int(update.message.text.strip())
if n < 1:
raise ValueError
except:
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
# Weekday inline keyboard builder and handler
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)
async def send_weekday_selector(update: Update, context: ContextTypes.DEFAULT_TYPE):
# if original is message, edit or send new
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
await query.edit_message_text("Выбраны дни: " + ", ".join(str(i) for i in sorted(sel)))
await query.message.reply_text("Введите времена приёма через запятую (например: 08:00,20:00):")
return S_ADD_TIMES
if data == "wd_cancel":
await query.edit_message_text("Отменено.")
await query.message.reply_text("Отмена.", reply_markup=main_menu_kb())
return ConversationHandler.END
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:
await update.message.reply_text("Введите целое неотрицательное число дней (0 = бессрочно):")
return S_ADD_COURSE
context.user_data['course_days'] = days
# All collected -> store pill and times
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') # csv or None
course_days = context.user_data.get('course_days') or 0
start_date = datetime.now().strftime("%Y-%m-%d")
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
times = context.user_data.get('times', [])
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}. Напоминания: {', '.join(times)}", reply_markup=main_menu_kb())
context.user_data.pop('wd_sel', None)
return ConversationHandler.END
# ----------------- List pills -----------------
async def list_pills(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
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())
return
lines = []
for r in rows:
pid, name, dose, interval_days, weekdays, course_days, start_date = r
times = []
cursor.execute('SELECT time FROM pill_times WHERE pill_id=?', (pid,))
for trow in cursor.fetchall():
times.append(trow[0])
freq = ""
if weekdays:
freq = f"по дням ({weekdays})"
else:
freq = f"каждые {interval_days} дн." if interval_days and interval_days>1 else "каждый день"
lines.append(f"ID {pid}: {name} — {dose} | {', '.join(times)} | {freq} | курс {course_days} (старт {start_date})")
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()
try:
pid = int(txt)
except:
await update.message.reply_text("ID должен быть числом. Отмена.", reply_markup=main_menu_kb())
return ConversationHandler.END
user_id = update.effective_user.id
cursor.execute('DELETE FROM pill_times WHERE pill_id=?', (pid,))
cursor.execute('DELETE FROM pills WHERE id=? AND user_id=?', (pid, user_id))
conn.commit()
await update.message.reply_text(f"Таблетка {pid} удалена (если была).", reply_markup=main_menu_kb())
return ConversationHandler.END
# ----------------- Edit flow (simple fields) -----------------
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()
try:
pid = int(txt)
except:
await update.message.reply_text("ID должен быть числом. Отмена.", reply_markup=main_menu_kb())
return ConversationHandler.END
context.user_data['edit_id'] = pid
kb = [
['Название', 'Доза'],
['Время(ы)', 'Частота'],
['Длительность (дни)', BTN_CANCEL]
]
await update.message.reply_text("Выберите что редактировать:", 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 == 'время(ы)':
# instruct to input comma-separated times; will replace existing times
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
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()
if field == 'name':
cursor.execute('UPDATE pills SET name=? WHERE id=? AND user_id=?', (text, pid, user_id))
conn.commit()
await update.message.reply_text("Название обновлено.", reply_markup=main_menu_kb())
return ConversationHandler.END
if field == 'dose':
cursor.execute('UPDATE pills SET dose=? WHERE id=? AND user_id=?', (text, pid, user_id))
conn.commit()
await update.message.reply_text("Доза обновлена.", reply_markup=main_menu_kb())
return ConversationHandler.END
if 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:
await update.message.reply_text(f"Неверное время: {p}. Отмена.", reply_markup=main_menu_kb())
return ConversationHandler.END
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))
conn.commit()
await update.message.reply_text("Времена обновлены.", reply_markup=main_menu_kb())
return ConversationHandler.END
if 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:
# launch inline selector similar to add flow
context.user_data['wd_sel'] = set()
await send_weekday_selector(update, context)
return S_EDIT_VALUE
else:
# fallback for N or week days text
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))
conn.commit()
await update.message.reply_text("Частота обновлена.", reply_markup=main_menu_kb())
return ConversationHandler.END
except:
await update.message.reply_text("Неверное число. Отмена.", reply_markup=main_menu_kb())
return ConversationHandler.END
conn.commit()
await update.message.reply_text("Частота обновлена.", reply_markup=main_menu_kb())
return ConversationHandler.END
if field == 'course':
try:
days = int(text)
cursor.execute('UPDATE pills SET course_days=? WHERE id=? AND user_id=?', (days, pid, user_id))
conn.commit()
await update.message.reply_text("Длительность курса обновлена.", reply_markup=main_menu_kb())
return ConversationHandler.END
except:
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
# ----------------- History (7 days) -----------------
async def history_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
conn = get_db_connection()
cursor = conn.cursor()
try:
# Получаем сегодняшнюю дату в UTC
today = datetime.utcnow().date()
# Получаем все лекарства пользователя
cursor.execute(
'SELECT id, name FROM pills WHERE user_id=? ORDER BY name',
(user_id,)
)
pills = cursor.fetchall()
if not pills:
await update.message.reply_text(
"💊 У вас пока нет добавленных лекарств.\n"
"Нажмите '➕ Добавить лекарство', чтобы начать.",
reply_markup=main_menu_kb()
)
conn.close()
return
# Формируем заголовок с датами
date_range = []
for d in range(6, -1, -1):
day = today - timedelta(days=d)
date_range.append(day)
date_header = " | ".join([d.strftime("%d.%m") for d in date_range])
lines = [f"📅 История за 7 дней:\n📅 {date_header}"]
total_pills_with_history = 0
for pill in pills:
pid = pill["id"]
name = pill["name"]
# Проверяем историю для каждого дня
day_marks = []
pill_has_history = False
for day in date_range:
day_str = day.strftime("%Y-%m-%d")
cursor.execute(
'''SELECT COUNT(*) as cnt FROM history
WHERE pill_id=? AND user_id=? AND date(ts)=? AND taken=1''',
(pid, user_id, day_str)
)
cnt = cursor.fetchone()["cnt"]
if cnt > 0:
day_marks.append("✅")
pill_has_history = True
else:
day_marks.append("❌")
# Добавляем только если есть хотя бы одна отметка
if pill_has_history:
total_pills_with_history += 1
marks_str = " | ".join(day_marks)
# Обрезаем длинное название лекарства
display_name = name[:20] + "..." if len(name) > 20 else name
lines.append(f"💊 {display_name:<23} {marks_str}")
# Добавляем статистику
lines.append("")
lines.append(f"📊 Статистика: {total_pills_with_history}/{len(pills)} лекарств с историей")
if total_pills_with_history == 0:
await update.message.reply_text(
"📭 История приёмов пуста.\n\n"
"Как использовать:\n"
"1. Бот пришлёт напоминание в указанное время\n"
"2. Нажмите кнопку '✅ Выпил(-а)' в напоминании\n"
"3. Здесь появится история ваших приёмов\n\n"
"Ждём первого подтверждения приёма! 💊",
reply_markup=main_menu_kb()
)
else:
response = "\n".join(lines)
# Добавляем легенду
response += "\n\n✅ - принято\n❌ - не принято"
await update.message.reply_text(
response,
reply_markup=main_menu_kb()
)
except Exception as e:
logger.error(f"Error getting history: {e}")
await update.message.reply_text(
"❌ Произошла ошибка при получении истории.\n"
"Попробуйте позже или обратитесь к администратору.",
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()
data = query.data # expected like "take:ptid" where ptid = pill_times.id
if not data or not data.startswith("take:"):
await query.edit_message_text("Неизвестная команда.")
return
ptid = int(data.split(":",1)[1])
# find pill_time and pill and user
cursor.execute('SELECT pill_id FROM pill_times WHERE id=?', (ptid,))
r = cursor.fetchone()
if not r:
await query.edit_message_text("Запись не найдена.")
return
pid = r[0]
cursor.execute('SELECT user_id FROM pills WHERE id=?', (pid,))
r2 = cursor.fetchone()
if not r2:
await query.edit_message_text("Пользователь не найден.")
return
user_id = r2[0]
nowutc = datetime.utcnow()
now_str = utc_str_min(nowutc)
# update pill_times.last_confirmed and insert history
cursor.execute('UPDATE pill_times SET last_confirmed=? WHERE id=?', (now_str, ptid))
cursor.execute('INSERT INTO history (pill_id, user_id, ts, taken) VALUES (?, ?, ?, 1)', (pid, user_id, utc_str_full(nowutc)))
conn.commit()
await query.edit_message_reply_markup(reply_markup=None)
await query.message.reply_text("✅ Спасибо, что заботишься о своём здоровье 💖", reply_markup=main_menu_kb())
# ----------------- Background loop -----------------
async def background_loop():
await asyncio.sleep(1)
logger.info("Background reminder loop started")
while True:
try:
now_utc_dt = datetime.utcnow()
# fetch all pill_times with linked pill
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
''')
rows = cursor.fetchall()
for row in rows:
(ptid, pid, time_hhmm, last_sent, last_confirmed,
user_id, pill_name, pill_dose, interval_days, weekdays_csv, course_days, start_date, user_tz) = row
if not user_tz:
user_tz = "UTC"
# User's local current datetime
try:
user_local_now = datetime.now(ZoneInfo(user_tz))
except Exception:
user_local_now = datetime.now(ZoneInfo("UTC"))
user_local_hhmm = user_local_now.strftime("%H:%M")
# If times equal -> candidate for sending
if user_local_hhmm != time_hhmm:
# Not this minute
# But also check for resend logic if last_sent exists and older than 60 minutes and not confirmed
if last_sent and (not last_confirmed or last_confirmed < last_sent):
try:
last_sent_dt = datetime.strptime(last_sent, "%Y-%m-%d %H:%M")
if (now_utc_dt - last_sent_dt) >= timedelta(minutes=60):
# resend (one resend)
cursor.execute('SELECT start_date FROM pills WHERE id=?', (pid,))
pstart = cursor.fetchone()
# check course end
if pstart and pstart[0] and course_days and course_days>0:
try:
sd = datetime.strptime(pstart[0], "%Y-%m-%d").date()
if (datetime.utcnow().date() - sd).days >= course_days:
continue
except:
pass
# send repeat
kb = InlineKeyboardMarkup([[InlineKeyboardButton("Выпил(-а)", callback_data=f"take:{ptid}")]])
text = f"⏰ Напоминание: всё ещё пора принять {pill_name} ({pill_dose})."
try:
await app.bot.send_message(chat_id=user_id, text=text, reply_markup=kb)
now_min = utc_str_min(now_utc_dt)
cursor.execute('UPDATE pill_times SET last_sent=? WHERE id=?', (now_min, ptid))
conn.commit()
except Exception as e:
logger.exception("Resend failed: %s", e)
except Exception:
pass
continue
# If time matches, check schedule constraints: weekdays or interval
send_flag = True
# check course end
if start_date and course_days and course_days>0:
try:
sd = datetime.strptime(start_date, "%Y-%m-%d").date()
if (user_local_now.date() - sd).days >= course_days:
send_flag = False
except:
pass
if not send_flag:
continue
# weekdays priority
if weekdays_csv:
wlist = weekdays_csv_to_list(weekdays_csv)
if user_local_now.weekday() not in wlist:
continue
else:
# interval_days logic: compute days since start_date
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:
pass
# Avoid sending multiple times same minute: compare last_sent (UTC minute)
now_min_utc = utc_str_min(now_utc_dt)
if last_sent == now_min_utc:
continue
# also if last_confirmed equals last_sent (i.e., already confirmed) skip
if last_confirmed and last_sent and last_confirmed >= last_sent:
continue
# send reminder
kb = InlineKeyboardMarkup([[InlineKeyboardButton("Выпил(-а)", callback_data=f"take:{ptid}")]])
text = f"💊 Пора принять {pill_name} ({pill_dose})."
try:
await app.bot.send_message(chat_id=user_id, text=text, reply_markup=kb)
# update last_sent to UTC minute
cursor.execute('UPDATE pill_times SET last_sent=? WHERE id=?', (now_min_utc, ptid))
conn.commit()
except Exception as e:
logger.exception("Send failed: %s", e)
# sleep until next minute boundary
except Exception as e:
logger.exception("Error in background loop: %s", e)
# wait to next minute
now2 = datetime.utcnow()
wait = 60 - now2.second
await asyncio.sleep(wait)
# ----------------- Register handlers -----------------
app = Application.builder().token(TOKEN).build()
# Главный ConversationHandler для настройки timezone/пола/имени
conv_tz = ConversationHandler(
entry_points=[
CommandHandler("start", start_handler),
MessageHandler(filters.Regex(f'^{BTN_TZ}$'), tz_reconfigure),
],
states={
S_TZ_SELECT: [
MessageHandler(filters.Regex("^(" + "|".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])]
)
app.add_handler(conv_tz)
# Обработчики кнопок главного меню
app.add_handler(MessageHandler(filters.Regex(f'^{BTN_LIST}$'), list_pills))
app.add_handler(MessageHandler(filters.Regex(f'^{BTN_HISTORY}$'), history_handler))
# add conv
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])]
)
app.add_handler(conv_add)
# edit conv
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])]
)
app.add_handler(conv_edit)
# delete conv
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])]
)
app.add_handler(conv_del)
# callback taken handler
app.add_handler(CallbackQueryHandler(callback_taken, pattern=r"^take:\d+$"))
# Общий обработчик текста (должен быть самым последним!)
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, lambda u,c: u.message.reply_text("Нажмите кнопку в меню.", reply_markup=main_menu_kb())))
# ----------------- START BOT -----------------
def main():
logger.info("Starting bot...")
# create background task
try:
loop = asyncio.get_event_loop()
loop.create_task(background_loop())
except RuntimeError:
# will be created by run_polling
pass
app.run_polling()
if __name__ == "__main__":
main()