From becb16f282a5ad7e7ad9fab4e5782e9c0e92ae0d Mon Sep 17 00:00:00 2001
From: Leo <277007013+LeoR156@users.noreply.github.com>
Date: Thu, 9 Jul 2026 12:59:10 +0500
Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?=
=?UTF-8?q?=D0=BB=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B8=D1=80=D0=BE?=
=?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=B4=D0=B0=20?=
=?UTF-8?q?=D0=BF=D0=BE=20PEP8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
db.py | 29 +++++-----
keyboards.py | 26 ++++-----
test_bot.py | 3 +
tg.py | 143 +++++++++++++++++++++++++++++------------------
weather_api.py | 61 +++++++++++++-------
weather_utils.py | 1 +
6 files changed, 161 insertions(+), 102 deletions(-)
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: