diff --git a/main.py b/main.py index e69de29..40d3b86 100644 --- a/main.py +++ b/main.py @@ -0,0 +1,915 @@ +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 + cursor.execute('SELECT id, name FROM pills WHERE user_id=?', (user_id,)) + pills = cursor.fetchall() + if not pills: + await update.message.reply_text("Нет данных о лекарствах.", reply_markup=main_menu_kb()) + return + now = datetime.utcnow().date() + lines = [] + for pid, name in pills: + 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(*) FROM history WHERE pill_id=? AND user_id=? AND date(ts)=?', (pid, user_id, day_str)) + cnt = cursor.fetchone()[0] + mark = "✅" if cnt>0 else "❌" + lines.append(f"{day.strftime('%d.%m')}: {mark}") + lines.append("") + await update.message.reply_text("\n".join(lines), reply_markup=main_menu_kb()) + +# ----------------- 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() \ No newline at end of file