add buttons

This commit is contained in:
2026-03-26 10:15:41 +03:00
parent d18270de43
commit 20531d1029
4 changed files with 113 additions and 68 deletions

View File

@@ -1,19 +1,13 @@
# Используем официальный slim-образ Python для уменьшения размера контейнера
FROM python:3.11-slim FROM python:3.11-slim
# Устанавливаем рабочую директорию
WORKDIR /app WORKDIR /app
# Копируем файл зависимостей и устанавливаем их
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Копируем исходный код
COPY bot.py weather.py ./ COPY bot.py weather.py ./
# Создаем не-root пользователя для безопасности (best practice)
RUN useradd -m botuser RUN useradd -m botuser
USER botuser USER botuser
# Команда для запуска бота
CMD ["python", "bot.py"] CMD ["python", "bot.py"]

86
bot.py
View File

@@ -7,35 +7,39 @@ from aiogram import Bot, Dispatcher, F, types
from aiogram.filters import Command from aiogram.filters import Command
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup 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 apscheduler.schedulers.asyncio import AsyncIOScheduler
from dotenv import load_dotenv 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() load_dotenv()
BOT_TOKEN = os.getenv("BOT_TOKEN") BOT_TOKEN = os.getenv("BOT_TOKEN")
# Настройка логирования
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
# Инициализация
bot = Bot(token=BOT_TOKEN, parse_mode="Markdown") bot = Bot(token=BOT_TOKEN, parse_mode="Markdown")
dp = Dispatcher() dp = Dispatcher()
scheduler = AsyncIOScheduler() scheduler = AsyncIOScheduler()
# Формат: user_id: {"lat": float, "lon": float, "time": "HH:MM", "job_id": str} # Импровизированная БД
users_db = {} users_db = {}
# Состояния
class WeatherSetup(StatesGroup): class WeatherSetup(StatesGroup):
waiting_for_location = State()
waiting_for_time = State() waiting_for_time = State()
# --- Клавиатуры --- # --- Клавиатуры ---
def get_main_keyboard(): def get_main_keyboard():
"""Главное меню бота"""
return ReplyKeyboardMarkup( return ReplyKeyboardMarkup(
keyboard=[ keyboard=[
[KeyboardButton(text="🌤 Погода сейчас")], [KeyboardButton(text="🌤 Погода сейчас")],
[ [
KeyboardButton(text="📍 Обновить локацию", request_location=True), KeyboardButton(text="📍 Изменить город"),
KeyboardButton(text="⏰ Изменить время") KeyboardButton(text="⏰ Изменить время")
] ]
], ],
@@ -43,17 +47,16 @@ def get_main_keyboard():
) )
def get_location_keyboard(): def get_location_keyboard():
"""Клавиатура для первичного запроса локации"""
return ReplyKeyboardMarkup( return ReplyKeyboardMarkup(
keyboard=[[KeyboardButton(text="📍 Отправить геолокацию", request_location=True)]], keyboard=[[KeyboardButton(text="📍 Отправить текущую геопозицию", request_location=True)]],
resize_keyboard=True resize_keyboard=True,
input_field_placeholder="Или напишите название города..."
) )
# --- Логика планировщика --- # --- Задача для планировщика ---
async def send_weather_update(user_id: int): async def send_weather_update(user_id: int):
"""Функция планировщика. Берет актуальные координаты из БД на момент отправки."""
user_data = users_db.get(user_id) user_data = users_db.get(user_id)
if not user_data: if not user_data or not user_data.get("lat"):
return return
weather_text = await get_daily_weather_summary(user_data["lat"], user_data["lon"]) 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: except Exception as e:
logging.error(f"Ошибка отправки пользователю {user_id}: {e}") logging.error(f"Ошибка отправки пользователю {user_id}: {e}")
# --- Обработчики команд --- # --- Обработчики ---
@dp.message(Command("start")) @dp.message(Command("start"))
async def cmd_start(message: types.Message, state: FSMContext): async def cmd_start(message: types.Message, state: FSMContext):
await state.set_state(WeatherSetup.waiting_for_location)
await message.answer( await message.answer(
"Привет! Я погодный бот. Отправь мне свою локацию, чтобы я знал, где смотреть погоду.", "Привет! Я погодный бот.\n\nОтправь мне свою геопозицию кнопкой ниже или **просто напиши название своего города**:",
reply_markup=get_location_keyboard() reply_markup=get_location_keyboard()
) )
@dp.message(F.content_type == "location") @dp.message(F.text == "📍 Изменить город")
async def process_location(message: types.Message, state: FSMContext): 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
city_name_display = "Выбранная локация"
if message.location:
lat = message.location.latitude lat = message.location.latitude
lon = message.location.longitude lon = message.location.longitude
user_id = message.from_user.id 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 in users_db: # Сохраняем или обновляем пользователя в БД
# Если пользователь уже зарегистрирован, просто обновляем координаты 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]["lat"] = lat
users_db[user_id]["lon"] = lon 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: else:
# Первичная настройка
users_db[user_id] = {"lat": lat, "lon": lon, "time": None, "job_id": None}
await state.set_state(WeatherSetup.waiting_for_time) await state.set_state(WeatherSetup.waiting_for_time)
await message.answer( await message.answer(
"Отлично! Теперь напиши время, в которое хочешь получать прогноз (в формате ЧЧ:ММ):", f"Отлично! Локация **{city_name_display}** найдена.\n\nТеперь напиши время, в которое хочешь получать ежедневный прогноз (в формате ЧЧ:ММ):",
reply_markup=types.ReplyKeyboardRemove() reply_markup=ReplyKeyboardRemove()
) )
@dp.message(F.text == "⏰ Изменить время") @dp.message(F.text == "⏰ Изменить время")
async def cmd_change_time(message: types.Message, state: FSMContext): async def cmd_change_time(message: types.Message, state: FSMContext):
if message.from_user.id not in users_db: if message.from_user.id not in users_db:
await message.answer("Сначала отправьте локацию через меню или команду /start.") await message.answer("Сначала отправьте локацию через команду /start.")
return return
await state.set_state(WeatherSetup.waiting_for_time) 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) @dp.message(WeatherSetup.waiting_for_time, F.text)
async def process_time(message: types.Message, state: FSMContext): async def process_time(message: types.Message, state: FSMContext):
@@ -108,12 +135,10 @@ async def process_time(message: types.Message, state: FSMContext):
try: try:
dt = datetime.strptime(time_text, "%H:%M") dt = datetime.strptime(time_text, "%H:%M")
# Удаляем старую задачу, если она была
old_job_id = users_db[user_id].get("job_id") old_job_id = users_db[user_id].get("job_id")
if old_job_id and scheduler.get_job(old_job_id): if old_job_id and scheduler.get_job(old_job_id):
scheduler.remove_job(old_job_id) scheduler.remove_job(old_job_id)
# Создаем новую задачу (передаем только user_id)
job = scheduler.add_job( job = scheduler.add_job(
send_weather_update, send_weather_update,
trigger='cron', trigger='cron',
@@ -143,14 +168,11 @@ async def cmd_weather_now(message: types.Message):
await message.answer("Сначала отправьте локацию через команду /start.") await message.answer("Сначала отправьте локацию через команду /start.")
return return
# Отправляем "заглушку", пока идет запрос к API
loading_msg = await message.answer("⏳ Узнаю погоду...") 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_data["lat"], user_data["lon"])
# Редактируем сообщение-заглушку готовым текстом
await loading_msg.edit_text(weather_text) await loading_msg.edit_text(weather_text)
# --- Запуск ---
async def main(): async def main():
scheduler.start() scheduler.start()
await bot.delete_webhook(drop_pending_updates=True) await bot.delete_webhook(drop_pending_updates=True)

View File

@@ -1,7 +1,11 @@
version: '3.8'
services: services:
weather_bot: weather_bot:
build: . build: .
container_name: tg_weather_bot container_name: tg_weather_bot
restart: unless-stopped restart: unless-stopped
environment:
- TZ=Europe/Moscow # Укажи здесь нужный базовый часовой пояс
env_file: env_file:
- .env - .env

View File

@@ -1,7 +1,29 @@
import aiohttp 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: async def get_daily_weather_summary(lat: float, lon: float) -> str:
# Добавили current=temperature_2m для получения текущей температуры """Получает текущую погоду и сводку на день по координатам."""
url = ( url = (
f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}" f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}"
f"&current=temperature_2m" f"&current=temperature_2m"
@@ -10,9 +32,10 @@ async def get_daily_weather_summary(lat: float, lon: float) -> str:
) )
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(url) as response: try:
async with session.get(url, timeout=5) as response:
if response.status != 200: if response.status != 200:
return "Не удалось получить данные о погоде." return "Не удалось получить данные о погоде от сервера."
data = await response.json() data = await response.json()
@@ -38,3 +61,5 @@ async def get_daily_weather_summary(lat: float, lon: float) -> str:
f"Одевайтесь по погоде и хорошего дня!" f"Одевайтесь по погоде и хорошего дня!"
) )
return summary return summary
except Exception as e:
return "Служба погоды временно недоступна."