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()