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": "Превышено время ожидания или ошибка сети."}