diff --git a/README.md b/README.md index 225e031..f27ef73 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,48 @@ -## pogoda_tg_bot -### Телеграмм бот, который по названию города отправляет текущую погоду в нём. -___ +# 🌤️ Weather Telegram Bot (with Ansible Deployment) -## **Инструкция по установке выйдет позже** +Бот для получения прогноза погоды, упакованный в Docker. В репозитории также находится Ansible-плейбук для автоматического развертывания на любом облачном сервере Ubuntu за 2 минуты. + +## 🚀 Быстрый старт за 2 минуты + +1. **Склонируйте репозиторий** командой: + ```bash + git clone https://github.com/LeoR156/pogoda_tg_bot.git + cd pogoda_tg_bot + ``` + +2. **Получите токены и ключи**: + - Создайте бота(ов) и скопируйте токен(ы) у [@BotFather](https://t.me/BotFather) в Telegram. + - Получите бесплатный API ключ на сайте OpenWeatherMap. + - Узнайте свой Telegram ID у официального бота [@userinfobot](https://t.me/userinfobot) (чтобы получать уведомления от бота как админ). + +3. **Создайте файл инвентори**: + Создайте файл `inventory.ini` и добавьте туда серверы для установки. Вы можете задать общие переменные `admin` (ваш ID) и `api_pogoda` (ключ погоды) для всех серверов, чтобы не дублировать их. А вот токен телеграм-бота `tg_token` **должен быть обязательно разным для каждого сервера/бота**, иначе боты будут конфликтовать за входящие сообщения. + + *Пример синтаксиса `inventory.ini`:* + ```ini + [tg_bot_3x] + # Указываем IP серверов и уникальные токены ботов для каждого сервера + server1 ansible_host=222.222.222.222 tg_token="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" + server2 ansible_host=111.111.111.111 tg_token="987654:XYZ-ABC1234ghIkl-zyx57W2v1u123ew11" + + [tg_bot_3x:vars] + # Общие переменные для всех хостов в этой группе + api_pogoda="ваш_ключ_openweathermap" + admin=123456789 + ``` + *(Примечание: файл `.env` вам не нужен, так как Ansible сам пробросит переменные из inventory прямо в Docker-контейнеры!)* + +4. **Настройте Playbook**: + В файле `playbook.yml` убедитесь, что в поле `hosts:` указана группа из вашего `inventory.ini` (в примере выше это `tg_bot_3x`). + +5. **Запуск**: + Проверьте файл на ошибки командой: + ```bash + ansible-playbook playbook.yml --syntax-check + ``` + В случае неудачи исправьте синтаксическую ошибку. Если всё хорошо, запускайте автоматическую установку командой: + ```bash + ansible-playbook playbook.yml -i inventory.ini + ``` + +Готово! Через пару минут Ansible самостоятельно установит Docker, скачает образ бота с Docker Hub и запустит контейнеры с вашими переменными. diff --git a/db.py b/db.py index 8677fce..de130a5 100644 --- a/db.py +++ b/db.py @@ -1,15 +1,30 @@ -import sqlite3 +import os +import psycopg2 +from psycopg2.extras import RealDictCursor import logging from datetime import datetime from contextlib import contextmanager -DB_PATH = 'bot_database.db' logger = logging.getLogger(__name__) +# Читаем параметры подключения из окружения (или используем дефолтные для локальной разработки) +DB_HOST = os.getenv("DB_HOST", "localhost") +DB_PORT = os.getenv("DB_PORT", "5432") +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(): - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row + # Используем 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 + ) try: yield conn finally: @@ -17,41 +32,56 @@ def get_db_connection(): 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("База данных инициализирована.") + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + # В PostgreSQL используем SERIAL для автоинкремента + cursor.execute(''' + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + telegram_id BIGINT UNIQUE NOT NULL, + username TEXT, + default_city TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + logger.info("База данных PostgreSQL успешно инициализирована.") + except Exception as e: + logger.error(f"Ошибка при инициализации PostgreSQL: {e}") + # Если база обязательна для запуска, можно сделать raise e, чтобы бот не стартовал впустую + raise e 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() + with conn.cursor() as cursor: + 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: - try: - conn.execute( - 'INSERT OR IGNORE INTO users (telegram_id, username) VALUES (?, ?)', - (telegram_id, username) - ) - except Exception as e: - logger.error(f"Ошибка при добавлении пользователя: {e}") + with conn.cursor() as cursor: + try: + # В 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) + ) + 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) - ) + with conn.cursor() as cursor: + cursor.execute( + 'UPDATE users SET default_city = %s WHERE telegram_id = %s', + (city, telegram_id) + ) def get_users_count(): with get_db_connection() as conn: - cursor = conn.execute('SELECT COUNT(*) FROM users') - return cursor.fetchone()[0] + with conn.cursor() as cursor: + cursor.execute('SELECT COUNT(*) FROM users') + # Так как мы используем RealDictCursor, результат будет {'count': X} + # Но для COUNT(*) безопаснее забрать по индексу или ключу + res = cursor.fetchone() + return res['count'] if res else 0 diff --git a/requirements.txt b/requirements.txt index 9e53857..d38f26d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,5 @@ pyTelegramBotAPI==4.14.0 requests==2.31.0 +psycopg2-binary==2.9.9 +Flask==3.0.3 +gunicorn==22.0.0 diff --git a/tg.py b/tg.py index 6b831ef..ff1d1fa 100644 --- a/tg.py +++ b/tg.py @@ -1,6 +1,8 @@ import os +import time import telebot import logging +from flask import Flask, request, abort import db import keyboards as kb @@ -17,17 +19,30 @@ logger = logging.getLogger(__name__) TG_TOKEN = os.getenv("TG_TOKEN") WEATHER_API_KEY = os.getenv("WEATHER_API_KEY") ADMIN_ID = os.getenv("ADMIN_ID") +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!") exit(1) +if not WEBHOOK_HOST: + logger.error("Не задана переменная WEBHOOK_HOST! (например: https://mydomain.com)") + exit(1) + +# Формируем URL вебхука (скрываем токен в пути для безопасности) +WEBHOOK_PATH = f"/{TG_TOKEN}/" +WEBHOOK_URL = f"{WEBHOOK_HOST}{WEBHOOK_PATH}" + # Инициализация БД db.init_db() # Инициализация бота 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']: @@ -62,9 +77,6 @@ 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): @@ -91,7 +103,6 @@ def process_city_input(message): return city = message.text.strip() - # Делаем проверочный запрос к API weather_data = wa.get_current_weather(city) if weather_data["success"]: @@ -176,7 +187,6 @@ def weather_requests(message): @bot.message_handler(func=lambda message: True) def unknown_text(message): - # Если введен какой-то произвольный текст, предполагаем, что юзер хочет узнать погоду для этого города (разово) city = message.text.strip() bot.send_chat_action(message.chat.id, 'typing') @@ -189,9 +199,27 @@ def unknown_text(message): else: 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': + json_string = request.get_data().decode('utf-8') + update = telebot.types.Update.de_json(json_string) + bot.process_new_updates([update]) + return '', 200 + else: + abort(403) + if __name__ == "__main__": - logger.info("Бот запущен и ждет сообщений...") - try: - bot.polling(none_stop=True) - except Exception as e: - logger.error(f"Критическая ошибка при поллинге: {e}") + 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)