From a6c6f061ce079c50e927eeebc9b18fef753e16e0 Mon Sep 17 00:00:00 2001 From: cisterna Date: Thu, 26 Mar 2026 12:42:59 +0300 Subject: [PATCH] add database --- Dockerfile | 5 +-- bot.py | 109 ++++++++++++++++++++++++--------------------- data/users.db | Bin 0 -> 8192 bytes database.py | 51 +++++++++++++++++++++ docker-compose.yml | 7 ++- requirements.txt | 3 +- 6 files changed, 117 insertions(+), 58 deletions(-) create mode 100644 data/users.db create mode 100644 database.py diff --git a/Dockerfile b/Dockerfile index 26479c4..1e3d427 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,9 +5,6 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY bot.py weather.py ./ - -RUN useradd -m botuser -USER botuser +COPY *.py ./ CMD ["python", "bot.py"] \ No newline at end of file diff --git a/bot.py b/bot.py index eb084b1..56e5e1f 100644 --- a/bot.py +++ b/bot.py @@ -8,32 +8,26 @@ from aiogram.filters import Command from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from aiogram.types import ReplyKeyboardMarkup, KeyboardButton, ReplyKeyboardRemove +from aiogram.client.default import DefaultBotProperties from apscheduler.schedulers.asyncio import AsyncIOScheduler from dotenv import load_dotenv from weather import get_daily_weather_summary, get_coords_by_city +import database as db # Импортируем нашу базу данных -# Загрузка переменных окружения load_dotenv() BOT_TOKEN = os.getenv("BOT_TOKEN") -# Настройка логирования logging.basicConfig(level=logging.INFO) -# Инициализация -bot = Bot(token=BOT_TOKEN, parse_mode="Markdown") +bot = Bot(token=BOT_TOKEN, default=DefaultBotProperties(parse_mode="Markdown")) dp = Dispatcher() scheduler = AsyncIOScheduler() -# Импровизированная БД -users_db = {} - -# Состояния class WeatherSetup(StatesGroup): waiting_for_location = State() waiting_for_time = State() -# --- Клавиатуры --- def get_main_keyboard(): return ReplyKeyboardMarkup( keyboard=[ @@ -53,19 +47,18 @@ def get_location_keyboard(): input_field_placeholder="Или напишите название города..." ) -# --- Задача для планировщика --- async def send_weather_update(user_id: int): - user_data = users_db.get(user_id) - if not user_data or not user_data.get("lat"): + """Отправка погоды по расписанию""" + user = await db.get_user(user_id) + if not user or not user["lat"]: return - weather_text = await get_daily_weather_summary(user_data["lat"], user_data["lon"]) + weather_text = await get_daily_weather_summary(user["lat"], user["lon"]) try: await bot.send_message(chat_id=user_id, text=weather_text) except Exception as e: logging.error(f"Ошибка отправки пользователю {user_id}: {e}") -# --- Обработчики --- @dp.message(Command("start")) async def cmd_start(message: types.Message, state: FSMContext): await state.set_state(WeatherSetup.waiting_for_location) @@ -77,10 +70,7 @@ async def cmd_start(message: types.Message, state: FSMContext): @dp.message(F.text == "📍 Изменить город") async def cmd_change_city(message: types.Message, state: FSMContext): await state.set_state(WeatherSetup.waiting_for_location) - await message.answer( - "Напиши название нового города или отправь геопозицию:", - reply_markup=get_location_keyboard() - ) + await message.answer("Напиши название нового города или отправь геопозицию:", reply_markup=get_location_keyboard()) @dp.message(WeatherSetup.waiting_for_location) async def process_location_input(message: types.Message, state: FSMContext): @@ -93,34 +83,31 @@ async def process_location_input(message: types.Message, state: FSMContext): elif message.text: lat, lon, found_name = await get_coords_by_city(message.text) if not lat: - await message.answer("К сожалению, я не нашел такой город 😔. Попробуй уточнить название или отправить геопозицию.") + await message.answer("К сожалению, я не нашел такой город 😔. Попробуй уточнить название.") return city_name_display = found_name else: - await message.answer("Пожалуйста, отправь геопозицию или напиши название города текстом.") return - # Сохраняем или обновляем пользователя в БД - if user_id not in users_db: - users_db[user_id] = {"lat": lat, "lon": lon, "time": None, "job_id": None} - else: - users_db[user_id]["lat"] = lat - users_db[user_id]["lon"] = lon + # Сохраняем локацию в БД + await db.save_user_location(user_id, lat, lon) - # Если время уже было настроено ранее - if users_db[user_id].get("time"): + # Проверяем, есть ли уже у пользователя настроенное время + user = await db.get_user(user_id) + if user and user["time"]: await message.answer(f"📍 Локация успешно обновлена на: **{city_name_display}**!", reply_markup=get_main_keyboard()) await state.clear() else: await state.set_state(WeatherSetup.waiting_for_time) await message.answer( - f"Отлично! Локация **{city_name_display}** найдена.\n\nТеперь напиши время, в которое хочешь получать ежедневный прогноз (в формате ЧЧ:ММ):", + f"Отлично! Локация **{city_name_display}** найдена.\n\nТеперь напиши время (в формате ЧЧ:ММ):", reply_markup=ReplyKeyboardRemove() ) @dp.message(F.text == "⏰ Изменить время") async def cmd_change_time(message: types.Message, state: FSMContext): - if message.from_user.id not in users_db: + user = await db.get_user(message.from_user.id) + if not user: await message.answer("Сначала отправьте локацию через команду /start.") return @@ -135,48 +122,68 @@ async def process_time(message: types.Message, state: FSMContext): try: dt = datetime.strptime(time_text, "%H:%M") - old_job_id = users_db[user_id].get("job_id") - if old_job_id and scheduler.get_job(old_job_id): - scheduler.remove_job(old_job_id) - - job = scheduler.add_job( + # Сохраняем время в БД + await db.update_user_time(user_id, time_text) + + # Обновляем или создаем задачу. id задачи = строковый user_id. + # replace_existing=True автоматически заменит старую задачу, если она была. + scheduler.add_job( send_weather_update, trigger='cron', hour=dt.hour, minute=dt.minute, - args=[user_id] + args=[user_id], + id=str(user_id), + replace_existing=True ) - users_db[user_id]["time"] = time_text - users_db[user_id]["job_id"] = job.id - - await message.answer( - f"✅ Готово! Ежедневный прогноз установлен на {time_text}.", - reply_markup=get_main_keyboard() - ) + await message.answer(f"✅ Готово! Ежедневный прогноз установлен на {time_text}.", reply_markup=get_main_keyboard()) await state.clear() except ValueError: - await message.answer("Неверный формат времени. Пожалуйста, используй формат ЧЧ:ММ (например, 07:00).") + await message.answer("Неверный формат времени. Пожалуйста, используй формат ЧЧ:ММ.") @dp.message(F.text == "🌤 Погода сейчас") async def cmd_weather_now(message: types.Message): - user_id = message.from_user.id - user_data = users_db.get(user_id) + user = await db.get_user(message.from_user.id) - if not user_data or not user_data.get("lat"): + if not user or not user["lat"]: await message.answer("Сначала отправьте локацию через команду /start.") return loading_msg = await message.answer("⏳ Узнаю погоду...") - weather_text = await get_daily_weather_summary(user_data["lat"], user_data["lon"]) + weather_text = await get_daily_weather_summary(user["lat"], user["lon"]) await loading_msg.edit_text(weather_text) -# --- Запуск --- +# --- ВОССТАНОВЛЕНИЕ ПЛАНИРОВЩИКА --- +async def restore_jobs(): + """Загружает задачи из БД в планировщик после перезапуска бота""" + users = await db.get_all_users_with_time() + count = 0 + for user in users: + try: + dt = datetime.strptime(user["time"], "%H:%M") + scheduler.add_job( + send_weather_update, + trigger='cron', + hour=dt.hour, + minute=dt.minute, + args=[user["user_id"]], + id=str(user["user_id"]), + replace_existing=True + ) + count += 1 + except Exception as e: + logging.error(f"Не удалось восстановить задачу для {user['user_id']}: {e}") + logging.info(f"Восстановлено задач из БД: {count}") + async def main(): - scheduler.start() + await db.init_db() # 1. Инициализируем БД + await restore_jobs() # 2. Восстанавливаем расписание из БД + scheduler.start() # 3. Запускаем планировщик + await bot.delete_webhook(drop_pending_updates=True) - await dp.start_polling(bot) + await dp.start_polling(bot) # 4. Запускаем бота if __name__ == "__main__": asyncio.run(main()) \ No newline at end of file diff --git a/data/users.db b/data/users.db new file mode 100644 index 0000000000000000000000000000000000000000..316cff21f621c46ffaf61f678e2eae61cdeed86f GIT binary patch literal 8192 zcmWFz^vNtqRY=P(%1ta$FlG>7U}R))P*7lCU|o2n4v8WDnGnkJ-g{(yo1!O#x=QP+=# zz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2BS4gsmgxvzFOu`;l;i#vFDJmX+D Tt#fc*#W&@hrJjMgm4N{OWDY!V literal 0 HcmV?d00001 diff --git a/database.py b/database.py new file mode 100644 index 0000000..7547c1c --- /dev/null +++ b/database.py @@ -0,0 +1,51 @@ +import aiosqlite +import os + +# База будет храниться в папке data +DB_PATH = "data/users.db" + +async def init_db(): + """Создает таблицу, если она еще не существует.""" + os.makedirs("data", exist_ok=True) + async with aiosqlite.connect(DB_PATH) as db: + await db.execute(''' + CREATE TABLE IF NOT EXISTS users ( + user_id INTEGER PRIMARY KEY, + lat REAL, + lon REAL, + time TEXT + ) + ''') + await db.commit() + +async def get_user(user_id: int): + """Возвращает данные пользователя по его ID.""" + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row # Чтобы обращаться к колонкам по именам (как к словарю) + async with db.execute("SELECT * FROM users WHERE user_id = ?", (user_id,)) as cursor: + return await cursor.fetchone() + +async def save_user_location(user_id: int, lat: float, lon: float): + """Сохраняет или обновляет только координаты пользователя.""" + async with aiosqlite.connect(DB_PATH) as db: + await db.execute(''' + INSERT INTO users (user_id, lat, lon) + VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + lat=excluded.lat, + lon=excluded.lon + ''', (user_id, lat, lon)) + await db.commit() + +async def update_user_time(user_id: int, time: str): + """Обновляет время отправки для пользователя.""" + async with aiosqlite.connect(DB_PATH) as db: + await db.execute("UPDATE users SET time = ? WHERE user_id = ?", (time, user_id)) + await db.commit() + +async def get_all_users_with_time(): + """Получает всех пользователей, у которых настроено время (для восстановления после перезапуска).""" + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute("SELECT * FROM users WHERE time IS NOT NULL") as cursor: + return await cursor.fetchall() \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 86f1f61..c16617c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,10 @@ services: build: . container_name: tg_weather_bot restart: unless-stopped + network_mode: "host" environment: - - TZ=Europe/Moscow # Укажи здесь нужный базовый часовой пояс + - TZ=Europe/Moscow env_file: - - .env \ No newline at end of file + - .env + volumes: + - ./data:/app/data \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e54a30b..9cbc3b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ aiogram==3.4.1 APScheduler==3.10.4 aiohttp==3.9.3 -python-dotenv==1.0.1 \ No newline at end of file +python-dotenv==1.0.1 +aiosqlite==0.20.0 \ No newline at end of file