diff --git a/db.py b/db.py index de130a5..4863ba3 100644 --- a/db.py +++ b/db.py @@ -14,23 +14,23 @@ DB_NAME = os.getenv("DB_NAME", "postgres") DB_USER = os.getenv("DB_USER", "postgres") DB_PASS = os.getenv("DB_PASS", "") + @contextmanager def get_db_connection(): # Используем RealDictCursor, чтобы код в tg.py мог обращаться к полям как к словарю: user['default_city'] - conn = psycopg2.connect( - host=DB_HOST, - port=DB_PORT, - dbname=DB_NAME, - user=DB_USER, - password=DB_PASS, - cursor_factory=RealDictCursor - ) + conn = psycopg2.connect(host=DB_HOST, + port=DB_PORT, + dbname=DB_NAME, + user=DB_USER, + password=DB_PASS, + cursor_factory=RealDictCursor) try: yield conn finally: conn.commit() conn.close() + def init_db(): try: with get_db_connection() as conn: @@ -51,12 +51,15 @@ def init_db(): # Если база обязательна для запуска, можно сделать raise e, чтобы бот не стартовал впустую raise e + def get_user(telegram_id): with get_db_connection() as conn: with conn.cursor() as cursor: - cursor.execute('SELECT * FROM users WHERE telegram_id = %s', (telegram_id,)) + cursor.execute('SELECT * FROM users WHERE telegram_id = %s', + (telegram_id, )) return cursor.fetchone() + def add_user(telegram_id, username): with get_db_connection() as conn: with conn.cursor() as cursor: @@ -64,18 +67,18 @@ def add_user(telegram_id, username): # В PostgreSQL синтаксис избегания дубликатов - ON CONFLICT DO NOTHING cursor.execute( 'INSERT INTO users (telegram_id, username) VALUES (%s, %s) ON CONFLICT (telegram_id) DO NOTHING', - (telegram_id, username) - ) + (telegram_id, username)) except Exception as e: logger.error(f"Ошибка при добавлении пользователя: {e}") + def update_default_city(telegram_id, city): with get_db_connection() as conn: with conn.cursor() as cursor: cursor.execute( 'UPDATE users SET default_city = %s WHERE telegram_id = %s', - (city, telegram_id) - ) + (city, telegram_id)) + def get_users_count(): with get_db_connection() as conn: diff --git a/keyboards.py b/keyboards.py index 578bf5a..571e940 100644 --- a/keyboards.py +++ b/keyboards.py @@ -1,32 +1,32 @@ from telebot.types import ReplyKeyboardMarkup, KeyboardButton, InlineKeyboardMarkup, InlineKeyboardButton + def get_main_menu(): markup = ReplyKeyboardMarkup(resize_keyboard=True, row_width=2) - markup.add( - KeyboardButton("Текущая погода"), - KeyboardButton("Прогноз на 3 дня"), - KeyboardButton("Прогноз на 5 дней"), - KeyboardButton("Настройки") - ) + markup.add(KeyboardButton("Текущая погода"), + KeyboardButton("Прогноз на 3 дня"), + KeyboardButton("Прогноз на 5 дней"), + KeyboardButton("Настройки")) return markup + def get_settings_menu(): markup = ReplyKeyboardMarkup(resize_keyboard=True, row_width=1) - markup.add( - KeyboardButton("Изменить город"), - KeyboardButton("🔙 Назад в меню") - ) + markup.add(KeyboardButton("Изменить город"), + KeyboardButton("🔙 Назад в меню")) return markup + def get_cancel_menu(): markup = ReplyKeyboardMarkup(resize_keyboard=True, row_width=1) markup.add(KeyboardButton("Отмена")) return markup + def get_city_confirm_inline(city_name): markup = InlineKeyboardMarkup() markup.add( - InlineKeyboardButton("Да, сохранить", callback_data=f"save_city:{city_name}"), - InlineKeyboardButton("Нет, отмена", callback_data="cancel_city") - ) + InlineKeyboardButton("Да, сохранить", + callback_data=f"save_city:{city_name}"), + InlineKeyboardButton("Нет, отмена", callback_data="cancel_city")) return markup diff --git a/test_bot.py b/test_bot.py index 6098555..74a1c7e 100644 --- a/test_bot.py +++ b/test_bot.py @@ -1,14 +1,17 @@ # test_bot.py from weather_utils import get_weather_comment + def test_weather_comment_hot(): # Проверяем, что при +30 будет "Жара! ☀️" assert get_weather_comment(30) == "Жара! ☀️" + def test_weather_comment_cold(): # Проверяем, что при -10 будет "Дубак! ❄️" assert get_weather_comment(-10) == "Дубак! ❄️" + def test_weather_comment_normal(): # Проверяем промежуточное значение assert get_weather_comment(15) == "Нормальная погода ☁️" diff --git a/tg.py b/tg.py index ff1d1fa..0305817 100644 --- a/tg.py +++ b/tg.py @@ -11,8 +11,7 @@ import weather_api as wa # Настройка логирования logging.basicConfig( level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) # Инициализация переменных окружения @@ -23,11 +22,13 @@ WEBHOOK_HOST = os.getenv("WEBHOOK_HOST") # e.g., https://my-bot.com WEBHOOK_PORT = int(os.getenv("WEBHOOK_PORT", 8080)) if not TG_TOKEN or not WEATHER_API_KEY: - logger.error("Не заданы переменные окружения TG_TOKEN или WEATHER_API_KEY!") + logger.error( + "Не заданы переменные окружения TG_TOKEN или WEATHER_API_KEY!") exit(1) if not WEBHOOK_HOST: - logger.error("Не задана переменная WEBHOOK_HOST! (например: https://mydomain.com)") + logger.error( + "Не задана переменная WEBHOOK_HOST! (например: https://mydomain.com)") exit(1) # Формируем URL вебхука (скрываем токен в пути для безопасности) @@ -43,40 +44,50 @@ bot = telebot.TeleBot(TG_TOKEN) # Инициализация Flask приложения app = Flask(__name__) + def get_user_city(user_id): user = db.get_user(user_id) if user and user['default_city']: return user['default_city'] return None + @bot.message_handler(commands=['start', 'help']) def send_welcome(message): user_id = message.from_user.id username = message.from_user.username - + # Добавляем юзера в БД, если его еще нет db.add_user(user_id, username) - + welcome_text = ( "Привет! 🌍 Я твой погодный бот.\n\n" "Я могу показывать текущую погоду и прогноз на несколько дней.\n" - "Для удобства давай установим твой город по умолчанию." - ) - + "Для удобства давай установим твой город по умолчанию.") + user_city = get_user_city(user_id) if user_city: welcome_text += f"\n\nТвой текущий город: {user_city}" - bot.send_message(message.chat.id, welcome_text, reply_markup=kb.get_main_menu(), parse_mode="HTML") + bot.send_message(message.chat.id, + welcome_text, + reply_markup=kb.get_main_menu(), + parse_mode="HTML") else: bot.send_message(message.chat.id, welcome_text, parse_mode="HTML") - msg = bot.send_message(message.chat.id, "Напиши название города, который хочешь установить по умолчанию:", reply_markup=kb.get_cancel_menu()) + msg = bot.send_message( + message.chat.id, + "Напиши название города, который хочешь установить по умолчанию:", + reply_markup=kb.get_cancel_menu()) bot.register_next_step_handler(msg, process_city_input) + @bot.message_handler(commands=['stats']) def admin_stats(message): if ADMIN_ID and str(message.from_user.id) == str(ADMIN_ID): count = db.get_users_count() - bot.send_message(message.chat.id, f"📊 Уникальных пользователей в базе: {count}") + bot.send_message(message.chat.id, + f"📊 Уникальных пользователей в базе: {count}") + @bot.message_handler(func=lambda message: message.text == "Настройки") def settings_menu(message): @@ -86,82 +97,98 @@ def settings_menu(message): text += f"Текущий город: {user_city}" else: text += "Город по умолчанию не установлен." - bot.send_message(message.chat.id, text, reply_markup=kb.get_settings_menu(), parse_mode="HTML") + bot.send_message(message.chat.id, + text, + reply_markup=kb.get_settings_menu(), + parse_mode="HTML") + @bot.message_handler(func=lambda message: message.text == "🔙 Назад в меню") def back_to_main(message): - bot.send_message(message.chat.id, "Возвращаемся в главное меню.", reply_markup=kb.get_main_menu()) + bot.send_message(message.chat.id, + "Возвращаемся в главное меню.", + reply_markup=kb.get_main_menu()) + @bot.message_handler(func=lambda message: message.text == "Изменить город") def change_city_prompt(message): - msg = bot.send_message(message.chat.id, "Напиши название нового города:", reply_markup=kb.get_cancel_menu()) + msg = bot.send_message(message.chat.id, + "Напиши название нового города:", + reply_markup=kb.get_cancel_menu()) bot.register_next_step_handler(msg, process_city_input) + def process_city_input(message): if message.text == "Отмена": - bot.send_message(message.chat.id, "Действие отменено.", reply_markup=kb.get_main_menu()) + bot.send_message(message.chat.id, + "Действие отменено.", + reply_markup=kb.get_main_menu()) return - + city = message.text.strip() weather_data = wa.get_current_weather(city) - + if weather_data["success"]: real_city_name = weather_data["city"] - text = ( - f"Город {real_city_name} найден!\n" - f"Текущая температура: {weather_data['temp']}°C\n\n" - f"Сохранить этот город как основной?" - ) + text = (f"Город {real_city_name} найден!\n" + f"Текущая температура: {weather_data['temp']}°C\n\n" + f"Сохранить этот город как основной?") bot.send_message( - message.chat.id, - text, + message.chat.id, + text, reply_markup=kb.get_city_confirm_inline(real_city_name), - parse_mode="HTML" - ) + parse_mode="HTML") else: msg = bot.send_message( - message.chat.id, + message.chat.id, f"❌ {weather_data['error']}\nПопробуй ввести название еще раз или нажми 'Отмена'.", - reply_markup=kb.get_cancel_menu() - ) + reply_markup=kb.get_cancel_menu()) bot.register_next_step_handler(msg, process_city_input) -@bot.callback_query_handler(func=lambda call: call.data.startswith('save_city:')) + +@bot.callback_query_handler( + func=lambda call: call.data.startswith('save_city:')) def callback_save_city(call): city_name = call.data.split(':', 1)[1] user_id = call.from_user.id db.update_default_city(user_id, city_name) - - bot.edit_message_text( - chat_id=call.message.chat.id, - message_id=call.message.message_id, - text=f"✅ Город {city_name} успешно сохранен!", - parse_mode="HTML" - ) - bot.send_message(call.message.chat.id, "Открываю главное меню:", reply_markup=kb.get_main_menu()) + + bot.edit_message_text(chat_id=call.message.chat.id, + message_id=call.message.message_id, + text=f"✅ Город {city_name} успешно сохранен!", + parse_mode="HTML") + bot.send_message(call.message.chat.id, + "Открываю главное меню:", + reply_markup=kb.get_main_menu()) bot.answer_callback_query(call.id) + @bot.callback_query_handler(func=lambda call: call.data == 'cancel_city') def callback_cancel_city(call): - bot.edit_message_text( - chat_id=call.message.chat.id, - message_id=call.message.message_id, - text="❌ Изменение города отменено." - ) - bot.send_message(call.message.chat.id, "Возвращаемся в главное меню:", reply_markup=kb.get_main_menu()) + bot.edit_message_text(chat_id=call.message.chat.id, + message_id=call.message.message_id, + text="❌ Изменение города отменено.") + bot.send_message(call.message.chat.id, + "Возвращаемся в главное меню:", + reply_markup=kb.get_main_menu()) bot.answer_callback_query(call.id) -@bot.message_handler(func=lambda message: message.text in ["Текущая погода", "Прогноз на 3 дня", "Прогноз на 5 дней"]) + +@bot.message_handler( + func=lambda message: message.text in + ["Текущая погода", "Прогноз на 3 дня", "Прогноз на 5 дней"]) def weather_requests(message): user_city = get_user_city(message.from_user.id) - + if not user_city: - msg = bot.send_message(message.chat.id, "Сначала укажите город. Напишите название:", reply_markup=kb.get_cancel_menu()) + msg = bot.send_message(message.chat.id, + "Сначала укажите город. Напишите название:", + reply_markup=kb.get_cancel_menu()) bot.register_next_step_handler(msg, process_city_input) return - + bot.send_chat_action(message.chat.id, 'typing') - + if message.text == "Текущая погода": data = wa.get_current_weather(user_city) if data["success"]: @@ -171,7 +198,7 @@ def weather_requests(message): bot.send_message(message.chat.id, answer, parse_mode="HTML") else: bot.send_message(message.chat.id, f"❌ Ошибка: {data['error']}") - + elif message.text == "Прогноз на 3 дня" or message.text == "Прогноз на 5 дней": days = 3 if "3" in message.text else 5 data = wa.get_forecast(user_city, days) @@ -185,11 +212,12 @@ def weather_requests(message): else: bot.send_message(message.chat.id, f"❌ Ошибка: {data['error']}") + @bot.message_handler(func=lambda message: True) def unknown_text(message): city = message.text.strip() bot.send_chat_action(message.chat.id, 'typing') - + data = wa.get_current_weather(city) if data["success"]: answer = f"🌡 Текущая погода (разовый запрос) в {data['city']}:\n" @@ -197,11 +225,15 @@ def unknown_text(message): answer += f"На улице: {data['desc']}." bot.send_message(message.chat.id, answer, parse_mode="HTML") else: - bot.send_message(message.chat.id, f"❌ {data['error']}\nИспользуйте меню или введите корректное название города.", reply_markup=kb.get_main_menu()) + bot.send_message( + message.chat.id, + f"❌ {data['error']}\nИспользуйте меню или введите корректное название города.", + reply_markup=kb.get_main_menu()) # --- ВЕБХУКИ (Flask) --- + @app.route(WEBHOOK_PATH, methods=['POST']) def webhook(): if request.headers.get('content-type') == 'application/json': @@ -212,14 +244,15 @@ def webhook(): else: abort(403) + if __name__ == "__main__": logger.info("Удаляем старый вебхук...") bot.remove_webhook() time.sleep(1) - + logger.info(f"Устанавливаем новый вебхук: {WEBHOOK_URL}") bot.set_webhook(url=WEBHOOK_URL) - + logger.info(f"Flask сервер запущен на порту {WEBHOOK_PORT}") # Для продакшена лучше использовать Gunicorn, но для Docker часто достаточно и встроенного (или Waitress) app.run(host='0.0.0.0', port=WEBHOOK_PORT) diff --git a/weather_api.py b/weather_api.py index cdccbf5..fd4d138 100644 --- a/weather_api.py +++ b/weather_api.py @@ -10,6 +10,7 @@ WEATHER_API_KEY = os.getenv("WEATHER_API_KEY") BASE_URL = "https://api.openweathermap.org/data/2.5" TIMEOUT = 10 + def get_current_weather(city: str) -> dict: url = f"{BASE_URL}/weather" params = { @@ -18,7 +19,7 @@ def get_current_weather(city: str) -> dict: "units": "metric", "lang": "ru" } - + try: response = requests.get(url, params=params, timeout=TIMEOUT) if response.status_code == 200: @@ -32,11 +33,19 @@ def get_current_weather(city: str) -> dict: elif response.status_code == 404: return {"success": False, "error": f"Город '{city}' не найден."} else: - logger.error(f"API Error: Code {response.status_code} for city {city}") - return {"success": False, "error": "Ошибка связи с сервером погоды."} + logger.error( + f"API Error: Code {response.status_code} for city {city}") + return { + "success": False, + "error": "Ошибка связи с сервером погоды." + } except requests.exceptions.RequestException as e: logger.error(f"Request exception: {e}") - return {"success": False, "error": "Превышено время ожидания или ошибка сети."} + return { + "success": False, + "error": "Превышено время ожидания или ошибка сети." + } + def get_forecast(city: str, days: int) -> dict: url = f"{BASE_URL}/forecast" @@ -46,57 +55,67 @@ def get_forecast(city: str, days: int) -> dict: "units": "metric", "lang": "ru" } - + try: response = requests.get(url, params=params, timeout=TIMEOUT) if response.status_code == 200: data = response.json() city_name = data["city"]["name"] - + # Агрегация данных по дням daily_data = defaultdict(lambda: {"temps": [], "descs": []}) - + for item in data["list"]: # дата в формате 'YYYY-MM-DD HH:MM:SS' date_str = item["dt_txt"].split(" ")[0] daily_data[date_str]["temps"].append(item["main"]["temp"]) - daily_data[date_str]["descs"].append(item["weather"][0]["description"]) - + daily_data[date_str]["descs"].append( + item["weather"][0]["description"]) + # Сортируем дни sorted_dates = sorted(daily_data.keys()) - + # Ограничиваем количество дней target_dates = sorted_dates[:days] - + result = [] for date_str in target_dates: temps = daily_data[date_str]["temps"] descs = daily_data[date_str]["descs"] - + min_temp = min(temps) max_temp = max(temps) - + # Самое частое описание погоды за день - most_common_desc = max(set(descs), key=descs.count).capitalize() - + most_common_desc = max(set(descs), + key=descs.count).capitalize() + # Перевод даты в читаемый формат date_obj = datetime.strptime(date_str, "%Y-%m-%d") formatted_date = date_obj.strftime("%d.%m.%Y") - + result.append({ "date": formatted_date, "min_temp": min_temp, "max_temp": max_temp, "desc": most_common_desc }) - + return {"success": True, "city": city_name, "forecast": result} - + elif response.status_code == 404: return {"success": False, "error": f"Город '{city}' не найден."} else: - logger.error(f"API Error (Forecast): Code {response.status_code} for city {city}") - return {"success": False, "error": "Ошибка связи с сервером погоды."} + logger.error( + f"API Error (Forecast): Code {response.status_code} for city {city}" + ) + return { + "success": False, + "error": "Ошибка связи с сервером погоды." + } except requests.exceptions.RequestException as e: logger.error(f"Request exception (Forecast): {e}") - return {"success": False, "error": "Превышено время ожидания или ошибка сети."} + return { + "success": False, + "error": "Превышено время ожидания или ошибка сети." + } diff --git a/weather_utils.py b/weather_utils.py index dddb7c2..31cb7c9 100644 --- a/weather_utils.py +++ b/weather_utils.py @@ -1,5 +1,6 @@ # weather_utils.py + def get_weather_comment(temperature): """Возвращает текстовый комментарий и эмодзи в зависимости от температуры""" if temperature >= 25: