Add CI
This commit is contained in:
@@ -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 и запустит контейнеры с вашими переменными.
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
import sqlite3
|
import os
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
DB_PATH = 'bot_database.db'
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
@contextmanager
|
||||||
def get_db_connection():
|
def get_db_connection():
|
||||||
conn = sqlite3.connect(DB_PATH)
|
# Используем RealDictCursor, чтобы код в tg.py мог обращаться к полям как к словарю: user['default_city']
|
||||||
conn.row_factory = sqlite3.Row
|
conn = psycopg2.connect(
|
||||||
|
host=DB_HOST,
|
||||||
|
port=DB_PORT,
|
||||||
|
dbname=DB_NAME,
|
||||||
|
user=DB_USER,
|
||||||
|
password=DB_PASS,
|
||||||
|
cursor_factory=RealDictCursor
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
yield conn
|
yield conn
|
||||||
finally:
|
finally:
|
||||||
@@ -17,41 +32,56 @@ def get_db_connection():
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
with get_db_connection() as conn:
|
try:
|
||||||
conn.execute('''
|
with get_db_connection() as conn:
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
with conn.cursor() as cursor:
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
# В PostgreSQL используем SERIAL для автоинкремента
|
||||||
telegram_id INTEGER UNIQUE NOT NULL,
|
cursor.execute('''
|
||||||
username TEXT,
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
default_city TEXT,
|
id SERIAL PRIMARY KEY,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
telegram_id BIGINT UNIQUE NOT NULL,
|
||||||
)
|
username TEXT,
|
||||||
''')
|
default_city TEXT,
|
||||||
logger.info("База данных инициализирована.")
|
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):
|
def get_user(telegram_id):
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
cursor = conn.execute('SELECT * FROM users WHERE telegram_id = ?', (telegram_id,))
|
with conn.cursor() as cursor:
|
||||||
return cursor.fetchone()
|
cursor.execute('SELECT * FROM users WHERE telegram_id = %s', (telegram_id,))
|
||||||
|
return cursor.fetchone()
|
||||||
|
|
||||||
def add_user(telegram_id, username):
|
def add_user(telegram_id, username):
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
try:
|
with conn.cursor() as cursor:
|
||||||
conn.execute(
|
try:
|
||||||
'INSERT OR IGNORE INTO users (telegram_id, username) VALUES (?, ?)',
|
# В PostgreSQL синтаксис избегания дубликатов - ON CONFLICT DO NOTHING
|
||||||
(telegram_id, username)
|
cursor.execute(
|
||||||
)
|
'INSERT INTO users (telegram_id, username) VALUES (%s, %s) ON CONFLICT (telegram_id) DO NOTHING',
|
||||||
except Exception as e:
|
(telegram_id, username)
|
||||||
logger.error(f"Ошибка при добавлении пользователя: {e}")
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при добавлении пользователя: {e}")
|
||||||
|
|
||||||
def update_default_city(telegram_id, city):
|
def update_default_city(telegram_id, city):
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
conn.execute(
|
with conn.cursor() as cursor:
|
||||||
'UPDATE users SET default_city = ? WHERE telegram_id = ?',
|
cursor.execute(
|
||||||
(city, telegram_id)
|
'UPDATE users SET default_city = %s WHERE telegram_id = %s',
|
||||||
)
|
(city, telegram_id)
|
||||||
|
)
|
||||||
|
|
||||||
def get_users_count():
|
def get_users_count():
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
cursor = conn.execute('SELECT COUNT(*) FROM users')
|
with conn.cursor() as cursor:
|
||||||
return cursor.fetchone()[0]
|
cursor.execute('SELECT COUNT(*) FROM users')
|
||||||
|
# Так как мы используем RealDictCursor, результат будет {'count': X}
|
||||||
|
# Но для COUNT(*) безопаснее забрать по индексу или ключу
|
||||||
|
res = cursor.fetchone()
|
||||||
|
return res['count'] if res else 0
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
pyTelegramBotAPI==4.14.0
|
pyTelegramBotAPI==4.14.0
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
Flask==3.0.3
|
||||||
|
gunicorn==22.0.0
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import os
|
import os
|
||||||
|
import time
|
||||||
import telebot
|
import telebot
|
||||||
import logging
|
import logging
|
||||||
|
from flask import Flask, request, abort
|
||||||
|
|
||||||
import db
|
import db
|
||||||
import keyboards as kb
|
import keyboards as kb
|
||||||
@@ -17,17 +19,30 @@ logger = logging.getLogger(__name__)
|
|||||||
TG_TOKEN = os.getenv("TG_TOKEN")
|
TG_TOKEN = os.getenv("TG_TOKEN")
|
||||||
WEATHER_API_KEY = os.getenv("WEATHER_API_KEY")
|
WEATHER_API_KEY = os.getenv("WEATHER_API_KEY")
|
||||||
ADMIN_ID = os.getenv("ADMIN_ID")
|
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:
|
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)
|
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()
|
db.init_db()
|
||||||
|
|
||||||
# Инициализация бота
|
# Инициализация бота
|
||||||
bot = telebot.TeleBot(TG_TOKEN)
|
bot = telebot.TeleBot(TG_TOKEN)
|
||||||
|
|
||||||
|
# Инициализация Flask приложения
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
def get_user_city(user_id):
|
def get_user_city(user_id):
|
||||||
user = db.get_user(user_id)
|
user = db.get_user(user_id)
|
||||||
if user and user['default_city']:
|
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):
|
if ADMIN_ID and str(message.from_user.id) == str(ADMIN_ID):
|
||||||
count = db.get_users_count()
|
count = db.get_users_count()
|
||||||
bot.send_message(message.chat.id, f"📊 Уникальных пользователей в базе: {count}")
|
bot.send_message(message.chat.id, f"📊 Уникальных пользователей в базе: {count}")
|
||||||
else:
|
|
||||||
# Игнорируем или выдаем сообщение о недостатке прав
|
|
||||||
pass
|
|
||||||
|
|
||||||
@bot.message_handler(func=lambda message: message.text == "Настройки")
|
@bot.message_handler(func=lambda message: message.text == "Настройки")
|
||||||
def settings_menu(message):
|
def settings_menu(message):
|
||||||
@@ -91,7 +103,6 @@ def process_city_input(message):
|
|||||||
return
|
return
|
||||||
|
|
||||||
city = message.text.strip()
|
city = message.text.strip()
|
||||||
# Делаем проверочный запрос к API
|
|
||||||
weather_data = wa.get_current_weather(city)
|
weather_data = wa.get_current_weather(city)
|
||||||
|
|
||||||
if weather_data["success"]:
|
if weather_data["success"]:
|
||||||
@@ -176,7 +187,6 @@ def weather_requests(message):
|
|||||||
|
|
||||||
@bot.message_handler(func=lambda message: True)
|
@bot.message_handler(func=lambda message: True)
|
||||||
def unknown_text(message):
|
def unknown_text(message):
|
||||||
# Если введен какой-то произвольный текст, предполагаем, что юзер хочет узнать погоду для этого города (разово)
|
|
||||||
city = message.text.strip()
|
city = message.text.strip()
|
||||||
bot.send_chat_action(message.chat.id, 'typing')
|
bot.send_chat_action(message.chat.id, 'typing')
|
||||||
|
|
||||||
@@ -189,9 +199,27 @@ def unknown_text(message):
|
|||||||
else:
|
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':
|
||||||
|
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__":
|
if __name__ == "__main__":
|
||||||
logger.info("Бот запущен и ждет сообщений...")
|
logger.info("Удаляем старый вебхук...")
|
||||||
try:
|
bot.remove_webhook()
|
||||||
bot.polling(none_stop=True)
|
time.sleep(1)
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Критическая ошибка при поллинге: {e}")
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user