From 9f724c07540244ebda3232d90b296908a98ad034 Mon Sep 17 00:00:00 2001
From: Leo <277007013+LeoR156@users.noreply.github.com>
Date: Tue, 30 Jun 2026 18:57:21 +0500
Subject: [PATCH] V2
---
.gitignore | 1 +
db.py | 57 ++++++++++++
keyboards.py | 32 +++++++
requirements.txt | 2 +
tg.py | 229 +++++++++++++++++++++++++++++++++++++----------
weather_api.py | 102 +++++++++++++++++++++
6 files changed, 375 insertions(+), 48 deletions(-)
create mode 100644 db.py
create mode 100644 keyboards.py
create mode 100644 requirements.txt
create mode 100644 weather_api.py
diff --git a/.gitignore b/.gitignore
index 83972fa..03ed408 100644
--- a/.gitignore
+++ b/.gitignore
@@ -216,3 +216,4 @@ __marimo__/
# Streamlit
.streamlit/secrets.toml
+bot_database.db
diff --git a/db.py b/db.py
new file mode 100644
index 0000000..8677fce
--- /dev/null
+++ b/db.py
@@ -0,0 +1,57 @@
+import sqlite3
+import logging
+from datetime import datetime
+from contextlib import contextmanager
+
+DB_PATH = 'bot_database.db'
+logger = logging.getLogger(__name__)
+
+@contextmanager
+def get_db_connection():
+ conn = sqlite3.connect(DB_PATH)
+ conn.row_factory = sqlite3.Row
+ try:
+ yield conn
+ finally:
+ conn.commit()
+ conn.close()
+
+def init_db():
+ with get_db_connection() as conn:
+ conn.execute('''
+ CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ telegram_id INTEGER UNIQUE NOT NULL,
+ username TEXT,
+ default_city TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ ''')
+ logger.info("База данных инициализирована.")
+
+def get_user(telegram_id):
+ with get_db_connection() as conn:
+ cursor = conn.execute('SELECT * FROM users WHERE telegram_id = ?', (telegram_id,))
+ return cursor.fetchone()
+
+def add_user(telegram_id, username):
+ with get_db_connection() as conn:
+ try:
+ conn.execute(
+ 'INSERT OR IGNORE INTO users (telegram_id, username) VALUES (?, ?)',
+ (telegram_id, username)
+ )
+ except Exception as e:
+ logger.error(f"Ошибка при добавлении пользователя: {e}")
+
+def update_default_city(telegram_id, city):
+ with get_db_connection() as conn:
+ conn.execute(
+ 'UPDATE users SET default_city = ? WHERE telegram_id = ?',
+ (city, telegram_id)
+ )
+
+def get_users_count():
+ with get_db_connection() as conn:
+ cursor = conn.execute('SELECT COUNT(*) FROM users')
+ return cursor.fetchone()[0]
diff --git a/keyboards.py b/keyboards.py
new file mode 100644
index 0000000..578bf5a
--- /dev/null
+++ b/keyboards.py
@@ -0,0 +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("Настройки")
+ )
+ return markup
+
+def get_settings_menu():
+ markup = ReplyKeyboardMarkup(resize_keyboard=True, row_width=1)
+ 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")
+ )
+ return markup
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..9e53857
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+pyTelegramBotAPI==4.14.0
+requests==2.31.0
diff --git a/tg.py b/tg.py
index 0e8a2b9..6b831ef 100644
--- a/tg.py
+++ b/tg.py
@@ -1,64 +1,197 @@
import os
import telebot
-import requests
+import logging
-# 1. Забираем токены из переменных окружения (вспоминаем флаг -e в Docker!)
+import db
+import keyboards as kb
+import weather_api as wa
+
+# Настройка логирования
+logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
+)
+logger = logging.getLogger(__name__)
+
+# Инициализация переменных окружения
TG_TOKEN = os.getenv("TG_TOKEN")
WEATHER_API_KEY = os.getenv("WEATHER_API_KEY")
+ADMIN_ID = os.getenv("ADMIN_ID")
-# Проверка, что переменные действительно переданы
if not TG_TOKEN or not WEATHER_API_KEY:
- print("ВНИМАНИЕ: Не заданы переменные окружения TG_TOKEN или WEATHER_API_KEY!")
+ logger.error("Не заданы переменные окружения TG_TOKEN или WEATHER_API_KEY!")
exit(1)
+# Инициализация БД
+db.init_db()
+
+# Инициализация бота
bot = telebot.TeleBot(TG_TOKEN)
-# 2. Реакция на команду /start
+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):
- bot.reply_to(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")
+ 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())
+ bot.register_next_step_handler(msg, process_city_input)
-# 3. Реакция на любой другой текст (названия городов)
-@bot.message_handler(func=lambda message: True)
-def get_weather(message):
- city = message.text.strip()
- url = "https://api.openweathermap.org/data/2.5/weather"
-
- params = {
- "q": city,
- "appid": WEATHER_API_KEY,
- "units": "metric",
- "lang": "ru"
- }
-
- try:
- response = requests.get(url, params=params)
+@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}")
+ else:
+ # Игнорируем или выдаем сообщение о недостатке прав
+ pass
+
+@bot.message_handler(func=lambda message: message.text == "Настройки")
+def settings_menu(message):
+ user_city = get_user_city(message.from_user.id)
+ text = "⚙️ Настройки\n"
+ if user_city:
+ text += f"Текущий город: {user_city}"
+ else:
+ text += "Город по умолчанию не установлен."
+ 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.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())
+ 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())
+ return
- # Если API вернуло код 200 (Всё Ок)
- if response.status_code == 200:
- data = response.json()
- temp = data["main"]["temp"]
- desc = data["weather"][0]["description"]
-
- # Формируем красивый ответ
- answer = f"🌡 Погода в городе {city.capitalize()}:\n"
- answer += f"Температура: {temp}°C\n"
- answer += f"На улице {desc}."
-
- bot.send_message(message.chat.id, answer)
-
- # Если API вернуло код 404 (Город не найден)
- elif response.status_code == 404:
- bot.send_message(message.chat.id, f"❌ Город '{city}' не найден. Проверь опечатку!")
-
- else:
- bot.send_message(message.chat.id, "⚠️ Ошибка связи с сервером погоды.")
- print(f"Ошибка API: Код {response.status_code}")
-
- except Exception as e:
- bot.send_message(message.chat.id, "🔌 Внутренняя ошибка бота.")
- print(f"Ошибка кода: {e}")
+ city = message.text.strip()
+ # Делаем проверочный запрос к API
+ 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"Сохранить этот город как основной?"
+ )
+ bot.send_message(
+ message.chat.id,
+ text,
+ reply_markup=kb.get_city_confirm_inline(real_city_name),
+ parse_mode="HTML"
+ )
+ else:
+ msg = bot.send_message(
+ message.chat.id,
+ f"❌ {weather_data['error']}\nПопробуй ввести название еще раз или нажми 'Отмена'.",
+ reply_markup=kb.get_cancel_menu()
+ )
+ bot.register_next_step_handler(msg, process_city_input)
-# Запуск постоянного опроса серверов Telegram
-print("Бот запущен и ждет сообщений...")
-bot.polling(none_stop=True)
+@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.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.answer_callback_query(call.id)
+
+@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())
+ 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"]:
+ answer = f"🌡 Погода в городе {data['city']}:\n"
+ answer += f"Температура: {data['temp']}°C\n"
+ answer += f"На улице: {data['desc']}."
+ 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)
+ if data["success"]:
+ answer = f"📅 Прогноз погоды для {data['city']} на {days} дн.:\n\n"
+ for day in data["forecast"]:
+ answer += f"🔹 {day['date']}\n"
+ answer += f" Мин: {day['min_temp']}°C | Макс: {day['max_temp']}°C\n"
+ answer += f" {day['desc']}\n\n"
+ bot.send_message(message.chat.id, answer, parse_mode="HTML")
+ 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"
+ answer += f"Температура: {data['temp']}°C\n"
+ 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())
+
+if __name__ == "__main__":
+ logger.info("Бот запущен и ждет сообщений...")
+ try:
+ bot.polling(none_stop=True)
+ except Exception as e:
+ logger.error(f"Критическая ошибка при поллинге: {e}")
diff --git a/weather_api.py b/weather_api.py
new file mode 100644
index 0000000..cdccbf5
--- /dev/null
+++ b/weather_api.py
@@ -0,0 +1,102 @@
+import os
+import requests
+import logging
+from collections import defaultdict
+from datetime import datetime
+
+logger = logging.getLogger(__name__)
+
+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 = {
+ "q": city,
+ "appid": WEATHER_API_KEY,
+ "units": "metric",
+ "lang": "ru"
+ }
+
+ try:
+ response = requests.get(url, params=params, timeout=TIMEOUT)
+ if response.status_code == 200:
+ data = response.json()
+ return {
+ "success": True,
+ "city": data["name"],
+ "temp": data["main"]["temp"],
+ "desc": data["weather"][0]["description"].capitalize()
+ }
+ 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": "Ошибка связи с сервером погоды."}
+ except requests.exceptions.RequestException as e:
+ logger.error(f"Request exception: {e}")
+ return {"success": False, "error": "Превышено время ожидания или ошибка сети."}
+
+def get_forecast(city: str, days: int) -> dict:
+ url = f"{BASE_URL}/forecast"
+ params = {
+ "q": city,
+ "appid": WEATHER_API_KEY,
+ "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"])
+
+ # Сортируем дни
+ 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()
+
+ # Перевод даты в читаемый формат
+ 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": "Ошибка связи с сервером погоды."}
+ except requests.exceptions.RequestException as e:
+ logger.error(f"Request exception (Forecast): {e}")
+ return {"success": False, "error": "Превышено время ожидания или ошибка сети."}