update docker

This commit is contained in:
2026-01-06 12:27:18 +07:00
parent 8da8702004
commit 22a2182511

915
main.py
View File

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