From d18270de43d500b984f044fe113e4505f07248db Mon Sep 17 00:00:00 2001 From: cisterna Date: Thu, 26 Mar 2026 09:37:50 +0300 Subject: [PATCH] first commit --- .gitignore | 1 + Dockerfile | 19 ++++++ bot.py | 160 +++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 7 ++ requirements.txt | 4 ++ weather.py | 40 ++++++++++++ 6 files changed, 231 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 bot.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 weather.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d4258f1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# Используем официальный slim-образ Python для уменьшения размера контейнера +FROM python:3.11-slim + +# Устанавливаем рабочую директорию +WORKDIR /app + +# Копируем файл зависимостей и устанавливаем их +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копируем исходный код +COPY bot.py weather.py ./ + +# Создаем не-root пользователя для безопасности (best practice) +RUN useradd -m botuser +USER botuser + +# Команда для запуска бота +CMD ["python", "bot.py"] \ No newline at end of file diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..0b0b1e1 --- /dev/null +++ b/bot.py @@ -0,0 +1,160 @@ +import asyncio +import logging +import os +from datetime import datetime + +from aiogram import Bot, Dispatcher, F, types +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.types import ReplyKeyboardMarkup, KeyboardButton +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from dotenv import load_dotenv + +from weather import get_daily_weather_summary + +load_dotenv() +BOT_TOKEN = os.getenv("BOT_TOKEN") + +logging.basicConfig(level=logging.INFO) + +bot = Bot(token=BOT_TOKEN, parse_mode="Markdown") +dp = Dispatcher() +scheduler = AsyncIOScheduler() + +# Формат: user_id: {"lat": float, "lon": float, "time": "HH:MM", "job_id": str} +users_db = {} + +class WeatherSetup(StatesGroup): + waiting_for_time = State() + +# --- Клавиатуры --- +def get_main_keyboard(): + """Главное меню бота""" + return ReplyKeyboardMarkup( + keyboard=[ + [KeyboardButton(text="🌤 Погода сейчас")], + [ + KeyboardButton(text="📍 Обновить локацию", request_location=True), + KeyboardButton(text="⏰ Изменить время") + ] + ], + resize_keyboard=True + ) + +def get_location_keyboard(): + """Клавиатура для первичного запроса локации""" + return ReplyKeyboardMarkup( + keyboard=[[KeyboardButton(text="📍 Отправить геолокацию", request_location=True)]], + resize_keyboard=True + ) + +# --- Логика планировщика --- +async def send_weather_update(user_id: int): + """Функция планировщика. Берет актуальные координаты из БД на момент отправки.""" + user_data = users_db.get(user_id) + if not user_data: + return + + weather_text = await get_daily_weather_summary(user_data["lat"], user_data["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 message.answer( + "Привет! Я погодный бот. Отправь мне свою локацию, чтобы я знал, где смотреть погоду.", + reply_markup=get_location_keyboard() + ) + +@dp.message(F.content_type == "location") +async def process_location(message: types.Message, state: FSMContext): + """Срабатывает как при первой настройке, так и при нажатии 'Обновить локацию'""" + lat = message.location.latitude + lon = message.location.longitude + user_id = message.from_user.id + + if user_id in users_db: + # Если пользователь уже зарегистрирован, просто обновляем координаты + users_db[user_id]["lat"] = lat + users_db[user_id]["lon"] = lon + await message.answer("📍 Локация успешно обновлена!", reply_markup=get_main_keyboard()) + else: + # Первичная настройка + users_db[user_id] = {"lat": lat, "lon": lon, "time": None, "job_id": None} + await state.set_state(WeatherSetup.waiting_for_time) + await message.answer( + "Отлично! Теперь напиши время, в которое хочешь получать прогноз (в формате ЧЧ:ММ):", + reply_markup=types.ReplyKeyboardRemove() + ) + +@dp.message(F.text == "⏰ Изменить время") +async def cmd_change_time(message: types.Message, state: FSMContext): + if message.from_user.id not in users_db: + await message.answer("Сначала отправьте локацию через меню или команду /start.") + return + + await state.set_state(WeatherSetup.waiting_for_time) + await message.answer("Введите новое время для ежедневного прогноза (в формате ЧЧ:ММ):", reply_markup=types.ReplyKeyboardRemove()) + +@dp.message(WeatherSetup.waiting_for_time, F.text) +async def process_time(message: types.Message, state: FSMContext): + time_text = message.text.strip() + user_id = message.from_user.id + + 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) + + # Создаем новую задачу (передаем только user_id) + job = scheduler.add_job( + send_weather_update, + trigger='cron', + hour=dt.hour, + minute=dt.minute, + args=[user_id] + ) + + 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 state.clear() + + except ValueError: + await message.answer("Неверный формат времени. Пожалуйста, используй формат ЧЧ:ММ (например, 07:00).") + +@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) + + if not user_data or not user_data.get("lat"): + await message.answer("Сначала отправьте локацию через команду /start.") + return + + # Отправляем "заглушку", пока идет запрос к API + loading_msg = await message.answer("⏳ Узнаю погоду...") + + weather_text = await get_daily_weather_summary(user_data["lat"], user_data["lon"]) + + # Редактируем сообщение-заглушку готовым текстом + await loading_msg.edit_text(weather_text) + +async def main(): + scheduler.start() + await bot.delete_webhook(drop_pending_updates=True) + await dp.start_polling(bot) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5931d7e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +services: + weather_bot: + build: . + container_name: tg_weather_bot + restart: unless-stopped + env_file: + - .env \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e54a30b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +aiogram==3.4.1 +APScheduler==3.10.4 +aiohttp==3.9.3 +python-dotenv==1.0.1 \ No newline at end of file diff --git a/weather.py b/weather.py new file mode 100644 index 0000000..32de0bf --- /dev/null +++ b/weather.py @@ -0,0 +1,40 @@ +import aiohttp + +async def get_daily_weather_summary(lat: float, lon: float) -> str: + # Добавили current=temperature_2m для получения текущей температуры + url = ( + f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}" + f"¤t=temperature_2m" + f"&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max" + f"&timezone=auto&forecast_days=1" + ) + + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + if response.status != 200: + return "Не удалось получить данные о погоде." + + data = await response.json() + + current = data.get("current", {}) + daily = data.get("daily", {}) + + if not daily or not current: + return "Ошибка парсинга данных о погоде." + + current_temp = current.get("temperature_2m", "N/A") + temp_max = daily["temperature_2m_max"][0] + temp_min = daily["temperature_2m_min"][0] + precip_prob = daily["precipitation_probability_max"][0] + + temp_diff = round(temp_max - temp_min, 1) + + summary = ( + f"🌤 **Погода на сегодня:**\n\n" + f"🌡 **Сейчас:** {current_temp}°C\n" + f"📊 **Дневная норма:** от {temp_min}°C до {temp_max}°C\n" + f"📉 **Перепад температур за день:** {temp_diff}°C\n" + f"🌧 **Макс. вероятность осадков:** {precip_prob}%\n\n" + f"Одевайтесь по погоде и хорошего дня!" + ) + return summary \ No newline at end of file