From 20531d1029ee7ba417f34b2ce3aa3f14f2a541d4 Mon Sep 17 00:00:00 2001 From: cisterna Date: Thu, 26 Mar 2026 10:15:41 +0300 Subject: [PATCH] add buttons --- Dockerfile | 6 ---- bot.py | 90 ++++++++++++++++++++++++++++------------------ docker-compose.yml | 4 +++ weather.py | 81 ++++++++++++++++++++++++++--------------- 4 files changed, 113 insertions(+), 68 deletions(-) diff --git a/Dockerfile b/Dockerfile index d4258f1..26479c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,13 @@ -# Используем официальный 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 index 0b0b1e1..eb084b1 100644 --- a/bot.py +++ b/bot.py @@ -7,35 +7,39 @@ 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 aiogram.types import ReplyKeyboardMarkup, KeyboardButton, ReplyKeyboardRemove from apscheduler.schedulers.asyncio import AsyncIOScheduler from dotenv import load_dotenv -from weather import get_daily_weather_summary +from weather import get_daily_weather_summary, get_coords_by_city +# Загрузка переменных окружения 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_location = State() waiting_for_time = State() # --- Клавиатуры --- def get_main_keyboard(): - """Главное меню бота""" return ReplyKeyboardMarkup( keyboard=[ [KeyboardButton(text="🌤 Погода сейчас")], [ - KeyboardButton(text="📍 Обновить локацию", request_location=True), + KeyboardButton(text="📍 Изменить город"), KeyboardButton(text="⏰ Изменить время") ] ], @@ -43,17 +47,16 @@ def get_main_keyboard(): ) def get_location_keyboard(): - """Клавиатура для первичного запроса локации""" return ReplyKeyboardMarkup( - keyboard=[[KeyboardButton(text="📍 Отправить геолокацию", request_location=True)]], - resize_keyboard=True + keyboard=[[KeyboardButton(text="📍 Отправить текущую геопозицию", request_location=True)]], + resize_keyboard=True, + input_field_placeholder="Или напишите название города..." ) -# --- Логика планировщика --- +# --- Задача для планировщика --- async def send_weather_update(user_id: int): - """Функция планировщика. Берет актуальные координаты из БД на момент отправки.""" user_data = users_db.get(user_id) - if not user_data: + if not user_data or not user_data.get("lat"): return weather_text = await get_daily_weather_summary(user_data["lat"], user_data["lon"]) @@ -62,43 +65,67 @@ async def send_weather_update(user_id: int): 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) await message.answer( - "Привет! Я погодный бот. Отправь мне свою локацию, чтобы я знал, где смотреть погоду.", + "Привет! Я погодный бот.\n\nОтправь мне свою геопозицию кнопкой ниже или **просто напиши название своего города**:", 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 +@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() + ) + +@dp.message(WeatherSetup.waiting_for_location) +async def process_location_input(message: types.Message, state: FSMContext): user_id = message.from_user.id - - if user_id in users_db: - # Если пользователь уже зарегистрирован, просто обновляем координаты + city_name_display = "Выбранная локация" + + if message.location: + lat = message.location.latitude + lon = message.location.longitude + elif message.text: + lat, lon, found_name = await get_coords_by_city(message.text) + if not lat: + 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 message.answer("📍 Локация успешно обновлена!", reply_markup=get_main_keyboard()) + + # Если время уже было настроено ранее + if users_db[user_id].get("time"): + await message.answer(f"📍 Локация успешно обновлена на: **{city_name_display}**!", reply_markup=get_main_keyboard()) + await state.clear() 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() + 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: - await message.answer("Сначала отправьте локацию через меню или команду /start.") + await message.answer("Сначала отправьте локацию через команду /start.") return await state.set_state(WeatherSetup.waiting_for_time) - await message.answer("Введите новое время для ежедневного прогноза (в формате ЧЧ:ММ):", reply_markup=types.ReplyKeyboardRemove()) + await message.answer("Введите новое время для ежедневного прогноза (в формате ЧЧ:ММ):", reply_markup=ReplyKeyboardRemove()) @dp.message(WeatherSetup.waiting_for_time, F.text) async def process_time(message: types.Message, state: FSMContext): @@ -108,12 +135,10 @@ 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) - # Создаем новую задачу (передаем только user_id) job = scheduler.add_job( send_weather_update, trigger='cron', @@ -143,14 +168,11 @@ async def cmd_weather_now(message: types.Message): 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) diff --git a/docker-compose.yml b/docker-compose.yml index 5931d7e..86f1f61 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,11 @@ +version: '3.8' + services: weather_bot: build: . container_name: tg_weather_bot restart: unless-stopped + environment: + - TZ=Europe/Moscow # Укажи здесь нужный базовый часовой пояс env_file: - .env \ No newline at end of file diff --git a/weather.py b/weather.py index 32de0bf..1abbfac 100644 --- a/weather.py +++ b/weather.py @@ -1,7 +1,29 @@ import aiohttp +async def get_coords_by_city(city_name: str): + """Ищет координаты по названию города.""" + url = f"https://geocoding-api.open-meteo.com/v1/search?name={city_name}&count=1&language=ru&format=json" + + async with aiohttp.ClientSession() as session: + try: + # Добавили таймаут 5 секунд + async with session.get(url, timeout=5) as response: + if response.status != 200: + return None, None, None + + data = await response.json() + results = data.get("results") + + if not results: + return None, None, None + + city_data = results[0] + return city_data.get("latitude"), city_data.get("longitude"), city_data.get("name", city_name) + except Exception: + return None, None, None + 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" @@ -10,31 +32,34 @@ async def get_daily_weather_summary(lat: float, lon: float) -> str: ) 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 "Ошибка парсинга данных о погоде." + try: + async with session.get(url, timeout=5) 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 + 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 + except Exception as e: + return "Служба погоды временно недоступна." \ No newline at end of file