Compare commits

..
10 Commits
Author SHA1 Message Date
Leo b59c3c1a55 Fix CI/CD
My first CI/CD Pipeline / Formatting Check (push) Canceled after 0s
My first CI/CD Pipeline / Security Scan (push) Canceled after 0s
My first CI/CD Pipeline / Build and push to Docker Hub (push) Canceled after 0s
My first CI/CD Pipeline / Deploy to VPS via SSH (push) Canceled after 0s
2026-07-10 15:14:58 +05:00
Leo 2157ccd9ec Add CD 2026-07-10 15:01:38 +05:00
Leo becb16f282 Исправил форматирование кода по PEP8 2026-07-09 12:59:10 +05:00
Leo d0f08abed7 Update CI 2026-07-09 12:40:17 +05:00
Leo 068497e9f0 Fix CI 2026-07-09 09:52:11 +05:00
Leo 7f6c8095e3 Add test CI 2026-07-09 09:44:57 +05:00
Leo 9037b94067 Fix CI 2026-07-09 09:29:59 +05:00
Leo 48fb140bc4 Merge README 2026-07-09 09:23:45 +05:00
Leo 7bc2b84294 Add CI 2026-07-09 09:20:55 +05:00
LeoR156 13c4091f38 Update README.md 2026-07-04 18:17:39 +05:00
9 changed files with 396 additions and 133 deletions
+78
View File
@@ -0,0 +1,78 @@
name: My first CI/CD Pipeline
on:
push:
branches:
- main
jobs:
formatting-check:
name: Formatting Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: run YAPF to test if python code is correctly formatted
uses: AlexanderMelde/yapf-action@v2.0
with:
args: --verbose
security-scan:
name: Security Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run OWASP Noir
id: noir
uses: owasp-noir/noir@v1.1.0
with:
base_path: '.'
- name: Display results
run: echo '${{ steps.noir.outputs.endpoints }}' | jq .
build-and-push:
name: Build and push to Docker Hub
needs: [security-scan, formatting-check]
runs-on: ubuntu-latest
steps:
- name: Скачиваем код бота
uses: actions/checkout@v4
- name: Логинимся в Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.docker_login }}
password: ${{ secrets.docker_pass }}
- name: Сборка и пуш Docker образа
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: leor156/pogoda-api-tgbot:latest
deploy:
name: Deploy to VPS via SSH
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Подключение по SSH и обновление контейнера
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USERNAME }}
key: ${{ secrets.SSH_KEY }}
script: |
docker pull leor156/pogoda-api-tgbot:latest
docker stop weather_bot || true
docker rm weather_bot || true
docker run -d \
--name weather_bot \
--restart unless-stopped \
-e TG_TOKEN="${{ secrets.TG_TOKEN }}" \
-e WEATHER_API_KEY="${{ secrets.WEATHER_API_KEY }}" \
leor156/pogoda-api-tgbot:latest
docker image prune -f
+47 -7
View File
@@ -1,8 +1,48 @@
# ⛅ Weather Telegram Bot (with Ansible Deployment) # 🌤️ Weather Telegram Bot (with Ansible Deployment)
Бот для получения погоды, упакованный в Docker. В репозитории также находится Ansible-плейбук для автоматического развертывания на любом облачном сервере Ubuntu за 2 минуты.
## 🚀 Быстрый старт (Локально через Docker)
1. Склонируй репозиторий.
2. Создай файл `.env` и добавь туда переменные `TG_TOKEN` и `WEATHER_API_KEY`.
3. Запусти: `docker-compose up -d`.
# Инструкция написана не до конца. Более подробная версия выйдет позже Бот для получения прогноза погоды, упакованный в 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 закончит свою работу и вы получите работающих телеграмм ботов!
+63 -30
View File
@@ -1,57 +1,90 @@
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:
conn.commit() conn.commit()
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
+13 -13
View File
@@ -1,32 +1,32 @@
from telebot.types import ReplyKeyboardMarkup, KeyboardButton, InlineKeyboardMarkup, InlineKeyboardButton from telebot.types import ReplyKeyboardMarkup, KeyboardButton, InlineKeyboardMarkup, InlineKeyboardButton
def get_main_menu(): def get_main_menu():
markup = ReplyKeyboardMarkup(resize_keyboard=True, row_width=2) markup = ReplyKeyboardMarkup(resize_keyboard=True, row_width=2)
markup.add( markup.add(KeyboardButton("Текущая погода"),
KeyboardButton("Текущая погода"), KeyboardButton("Прогноз на 3 дня"),
KeyboardButton("Прогноз на 3 дня"), KeyboardButton("Прогноз на 5 дней"),
KeyboardButton("Прогноз на 5 дней"), KeyboardButton("Настройки"))
KeyboardButton("Настройки")
)
return markup return markup
def get_settings_menu(): def get_settings_menu():
markup = ReplyKeyboardMarkup(resize_keyboard=True, row_width=1) markup = ReplyKeyboardMarkup(resize_keyboard=True, row_width=1)
markup.add( markup.add(KeyboardButton("Изменить город"),
KeyboardButton("Изменить город"), KeyboardButton("🔙 Назад в меню"))
KeyboardButton("🔙 Назад в меню")
)
return markup return markup
def get_cancel_menu(): def get_cancel_menu():
markup = ReplyKeyboardMarkup(resize_keyboard=True, row_width=1) markup = ReplyKeyboardMarkup(resize_keyboard=True, row_width=1)
markup.add(KeyboardButton("Отмена")) markup.add(KeyboardButton("Отмена"))
return markup return markup
def get_city_confirm_inline(city_name): def get_city_confirm_inline(city_name):
markup = InlineKeyboardMarkup() markup = InlineKeyboardMarkup()
markup.add( markup.add(
InlineKeyboardButton("Да, сохранить", callback_data=f"save_city:{city_name}"), InlineKeyboardButton("Да, сохранить",
InlineKeyboardButton("Нет, отмена", callback_data="cancel_city") callback_data=f"save_city:{city_name}"),
) InlineKeyboardButton("Нет, отмена", callback_data="cancel_city"))
return markup return markup
+4
View File
@@ -1,2 +1,6 @@
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
pytest
+17
View File
@@ -0,0 +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) == "Нормальная погода ☁️"
+123 -62
View File
@@ -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
@@ -9,62 +11,83 @@ import weather_api as wa
# Настройка логирования # Настройка логирования
logging.basicConfig( logging.basicConfig(
level=logging.INFO, 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__) 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']:
return user['default_city'] return user['default_city']
return None return None
@bot.message_handler(commands=['start', 'help']) @bot.message_handler(commands=['start', 'help'])
def send_welcome(message): def send_welcome(message):
user_id = message.from_user.id user_id = message.from_user.id
username = message.from_user.username username = message.from_user.username
# Добавляем юзера в БД, если его еще нет # Добавляем юзера в БД, если его еще нет
db.add_user(user_id, username) db.add_user(user_id, username)
welcome_text = ( welcome_text = (
"Привет! 🌍 Я твой погодный бот.\n\n" "Привет! 🌍 Я твой погодный бот.\n\n"
"Я могу показывать текущую погоду и прогноз на несколько дней.\n" "Я могу показывать текущую погоду и прогноз на несколько дней.\n"
"Для удобства давай установим твой город по умолчанию." "Для удобства давай установим твой город по умолчанию.")
)
user_city = get_user_city(user_id) user_city = get_user_city(user_id)
if user_city: if user_city:
welcome_text += f"\n\nТвой текущий город: <b>{user_city}</b>" welcome_text += f"\n\nТвой текущий город: <b>{user_city}</b>"
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: else:
bot.send_message(message.chat.id, welcome_text, parse_mode="HTML") 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.register_next_step_handler(msg, process_city_input)
@bot.message_handler(commands=['stats']) @bot.message_handler(commands=['stats'])
def admin_stats(message): 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,
else: f"📊 Уникальных пользователей в базе: {count}")
# Игнорируем или выдаем сообщение о недостатке прав
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):
@@ -74,83 +97,98 @@ def settings_menu(message):
text += f"Текущий город: <b>{user_city}</b>" text += f"Текущий город: <b>{user_city}</b>"
else: else:
text += "Город по умолчанию не установлен." 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 == "🔙 Назад в меню") @bot.message_handler(func=lambda message: message.text == "🔙 Назад в меню")
def back_to_main(message): 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 == "Изменить город") @bot.message_handler(func=lambda message: message.text == "Изменить город")
def change_city_prompt(message): 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) bot.register_next_step_handler(msg, process_city_input)
def process_city_input(message): def process_city_input(message):
if message.text == "Отмена": 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 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"]:
real_city_name = weather_data["city"] real_city_name = weather_data["city"]
text = ( text = (f"Город <b>{real_city_name}</b> найден!\n"
f"Город <b>{real_city_name}</b> найден!\n" f"Текущая температура: {weather_data['temp']}°C\n\n"
f"Текущая температура: {weather_data['temp']}°C\n\n" f"Сохранить этот город как основной?")
f"Сохранить этот город как основной?"
)
bot.send_message( bot.send_message(
message.chat.id, message.chat.id,
text, text,
reply_markup=kb.get_city_confirm_inline(real_city_name), reply_markup=kb.get_city_confirm_inline(real_city_name),
parse_mode="HTML" parse_mode="HTML")
)
else: else:
msg = bot.send_message( msg = bot.send_message(
message.chat.id, message.chat.id,
f"❌ {weather_data['error']}\nПопробуй ввести название еще раз или нажми 'Отмена'.", 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.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): def callback_save_city(call):
city_name = call.data.split(':', 1)[1] city_name = call.data.split(':', 1)[1]
user_id = call.from_user.id user_id = call.from_user.id
db.update_default_city(user_id, city_name) db.update_default_city(user_id, city_name)
bot.edit_message_text( bot.edit_message_text(chat_id=call.message.chat.id,
chat_id=call.message.chat.id, message_id=call.message.message_id,
message_id=call.message.message_id, text=f"✅ Город <b>{city_name}</b> успешно сохранен!",
text=f"✅ Город <b>{city_name}</b> успешно сохранен!", parse_mode="HTML")
parse_mode="HTML" bot.send_message(call.message.chat.id,
) "Открываю главное меню:",
bot.send_message(call.message.chat.id, "Открываю главное меню:", reply_markup=kb.get_main_menu()) reply_markup=kb.get_main_menu())
bot.answer_callback_query(call.id) bot.answer_callback_query(call.id)
@bot.callback_query_handler(func=lambda call: call.data == 'cancel_city') @bot.callback_query_handler(func=lambda call: call.data == 'cancel_city')
def callback_cancel_city(call): def callback_cancel_city(call):
bot.edit_message_text( bot.edit_message_text(chat_id=call.message.chat.id,
chat_id=call.message.chat.id, message_id=call.message.message_id,
message_id=call.message.message_id, text="❌ Изменение города отменено.")
text="❌ Изменение города отменено." bot.send_message(call.message.chat.id,
) "Возвращаемся в главное меню:",
bot.send_message(call.message.chat.id, "Возвращаемся в главное меню:", reply_markup=kb.get_main_menu()) reply_markup=kb.get_main_menu())
bot.answer_callback_query(call.id) 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): def weather_requests(message):
user_city = get_user_city(message.from_user.id) user_city = get_user_city(message.from_user.id)
if not user_city: 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) bot.register_next_step_handler(msg, process_city_input)
return return
bot.send_chat_action(message.chat.id, 'typing') bot.send_chat_action(message.chat.id, 'typing')
if message.text == "Текущая погода": if message.text == "Текущая погода":
data = wa.get_current_weather(user_city) data = wa.get_current_weather(user_city)
if data["success"]: if data["success"]:
@@ -160,7 +198,7 @@ def weather_requests(message):
bot.send_message(message.chat.id, answer, parse_mode="HTML") bot.send_message(message.chat.id, answer, parse_mode="HTML")
else: else:
bot.send_message(message.chat.id, f"❌ Ошибка: {data['error']}") bot.send_message(message.chat.id, f"❌ Ошибка: {data['error']}")
elif message.text == "Прогноз на 3 дня" or message.text == "Прогноз на 5 дней": elif message.text == "Прогноз на 3 дня" or message.text == "Прогноз на 5 дней":
days = 3 if "3" in message.text else 5 days = 3 if "3" in message.text else 5
data = wa.get_forecast(user_city, days) data = wa.get_forecast(user_city, days)
@@ -174,12 +212,12 @@ def weather_requests(message):
else: else:
bot.send_message(message.chat.id, f"❌ Ошибка: {data['error']}") bot.send_message(message.chat.id, f"❌ Ошибка: {data['error']}")
@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')
data = wa.get_current_weather(city) data = wa.get_current_weather(city)
if data["success"]: if data["success"]:
answer = f"🌡 Текущая погода (разовый запрос) в <b>{data['city']}</b>:\n" answer = f"🌡 Текущая погода (разовый запрос) в <b>{data['city']}</b>:\n"
@@ -187,11 +225,34 @@ def unknown_text(message):
answer += f"На улице: {data['desc']}." answer += f"На улице: {data['desc']}."
bot.send_message(message.chat.id, answer, parse_mode="HTML") bot.send_message(message.chat.id, answer, parse_mode="HTML")
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)
+40 -21
View File
@@ -10,6 +10,7 @@ WEATHER_API_KEY = os.getenv("WEATHER_API_KEY")
BASE_URL = "https://api.openweathermap.org/data/2.5" BASE_URL = "https://api.openweathermap.org/data/2.5"
TIMEOUT = 10 TIMEOUT = 10
def get_current_weather(city: str) -> dict: def get_current_weather(city: str) -> dict:
url = f"{BASE_URL}/weather" url = f"{BASE_URL}/weather"
params = { params = {
@@ -18,7 +19,7 @@ def get_current_weather(city: str) -> dict:
"units": "metric", "units": "metric",
"lang": "ru" "lang": "ru"
} }
try: try:
response = requests.get(url, params=params, timeout=TIMEOUT) response = requests.get(url, params=params, timeout=TIMEOUT)
if response.status_code == 200: if response.status_code == 200:
@@ -32,11 +33,19 @@ def get_current_weather(city: str) -> dict:
elif response.status_code == 404: elif response.status_code == 404:
return {"success": False, "error": f"Город '{city}' не найден."} return {"success": False, "error": f"Город '{city}' не найден."}
else: else:
logger.error(f"API Error: Code {response.status_code} for city {city}") logger.error(
return {"success": False, "error": "Ошибка связи с сервером погоды."} f"API Error: Code {response.status_code} for city {city}")
return {
"success": False,
"error": "Ошибка связи с сервером погоды."
}
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
logger.error(f"Request exception: {e}") logger.error(f"Request exception: {e}")
return {"success": False, "error": "Превышено время ожидания или ошибка сети."} return {
"success": False,
"error": "Превышено время ожидания или ошибка сети."
}
def get_forecast(city: str, days: int) -> dict: def get_forecast(city: str, days: int) -> dict:
url = f"{BASE_URL}/forecast" url = f"{BASE_URL}/forecast"
@@ -46,57 +55,67 @@ def get_forecast(city: str, days: int) -> dict:
"units": "metric", "units": "metric",
"lang": "ru" "lang": "ru"
} }
try: try:
response = requests.get(url, params=params, timeout=TIMEOUT) response = requests.get(url, params=params, timeout=TIMEOUT)
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
city_name = data["city"]["name"] city_name = data["city"]["name"]
# Агрегация данных по дням # Агрегация данных по дням
daily_data = defaultdict(lambda: {"temps": [], "descs": []}) daily_data = defaultdict(lambda: {"temps": [], "descs": []})
for item in data["list"]: for item in data["list"]:
# дата в формате 'YYYY-MM-DD HH:MM:SS' # дата в формате 'YYYY-MM-DD HH:MM:SS'
date_str = item["dt_txt"].split(" ")[0] date_str = item["dt_txt"].split(" ")[0]
daily_data[date_str]["temps"].append(item["main"]["temp"]) 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()) sorted_dates = sorted(daily_data.keys())
# Ограничиваем количество дней # Ограничиваем количество дней
target_dates = sorted_dates[:days] target_dates = sorted_dates[:days]
result = [] result = []
for date_str in target_dates: for date_str in target_dates:
temps = daily_data[date_str]["temps"] temps = daily_data[date_str]["temps"]
descs = daily_data[date_str]["descs"] descs = daily_data[date_str]["descs"]
min_temp = min(temps) min_temp = min(temps)
max_temp = max(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") date_obj = datetime.strptime(date_str, "%Y-%m-%d")
formatted_date = date_obj.strftime("%d.%m.%Y") formatted_date = date_obj.strftime("%d.%m.%Y")
result.append({ result.append({
"date": formatted_date, "date": formatted_date,
"min_temp": min_temp, "min_temp": min_temp,
"max_temp": max_temp, "max_temp": max_temp,
"desc": most_common_desc "desc": most_common_desc
}) })
return {"success": True, "city": city_name, "forecast": result} return {"success": True, "city": city_name, "forecast": result}
elif response.status_code == 404: elif response.status_code == 404:
return {"success": False, "error": f"Город '{city}' не найден."} return {"success": False, "error": f"Город '{city}' не найден."}
else: else:
logger.error(f"API Error (Forecast): Code {response.status_code} for city {city}") logger.error(
return {"success": False, "error": "Ошибка связи с сервером погоды."} f"API Error (Forecast): Code {response.status_code} for city {city}"
)
return {
"success": False,
"error": "Ошибка связи с сервером погоды."
}
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
logger.error(f"Request exception (Forecast): {e}") logger.error(f"Request exception (Forecast): {e}")
return {"success": False, "error": "Превышено время ожидания или ошибка сети."} return {
"success": False,
"error": "Превышено время ожидания или ошибка сети."
}
+11
View File
@@ -0,0 +1,11 @@
# weather_utils.py
def get_weather_comment(temperature):
"""Возвращает текстовый комментарий и эмодзи в зависимости от температуры"""
if temperature >= 25:
return "Жара! ☀️"
elif temperature <= 0:
return "Дубак! ❄️"
else:
return "Нормальная погода ☁️"