add buttons
This commit is contained in:
@@ -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"]
|
||||
90
bot.py
90
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)
|
||||
|
||||
@@ -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
|
||||
81
weather.py
81
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
|
||||
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 "Служба погоды временно недоступна."
|
||||
Reference in New Issue
Block a user