🔥 Version 1.4.0 - Critical Security & Stability Fixes

This commit is contained in:
2025-10-16 16:21:49 +03:00
parent 7d8e4ff9b1
commit fd77c24d6b
6 changed files with 710 additions and 96 deletions
+21 -2
View File
@@ -4,10 +4,16 @@
## 📋 История версий ## 📋 История версий
### Версия 1.3.0 (16 октября 2025) ### Версия 1.4.0 (16 октября 2025) - Критические исправления
**Дата релиза:** 16.10.2025 **Дата релиза:** 16.10.2025
#### 🐛 Исправления ошибок #### 🚨 Критические исправления безопасности и стабильности
-**Исправлена система рангов** - синхронизированы поля очков и репутации в базе данных
-**Улучшена обработка ошибок внешних API** - добавлены таймауты, детальная обработка HTTP-ошибок
-**Добавлена валидация входящих данных** - защита от некорректных данных и злоупотреблений
-**Улучшена обработка исключений в БД** - валидация параметров, автоматический rollback транзакций
#### Исправления ошибок
- ✅ Исправлена ошибка с повторяющимся сообщением о "первом сообщении" при вводе суммы доната - ✅ Исправлена ошибка с повторяющимся сообщением о "первом сообщении" при вводе суммы доната
- ✅ Исправлена проблема с кодировкой Unicode символов при запуске бота на Windows - ✅ Исправлена проблема с кодировкой Unicode символов при запуске бота на Windows
- ✅ Исправлена система донатов - теперь корректно работает кнопка "Другая сумма" - ✅ Исправлена система донатов - теперь корректно работает кнопка "Другая сумма"
@@ -21,6 +27,12 @@
- ✅ Оптимизирована логика проверки достижений в базе данных - ✅ Оптимизирована логика проверки достижений в базе данных
- ✅ Добавлена проверка существующих достижений перед разблокировкой - ✅ Добавлена проверка существующих достижений перед разблокировкой
- ✅ Улучшена обработка ошибок в системе донатов - ✅ Улучшена обработка ошибок в системе донатов
- ✅ Создан тестовый скрипт для проверки корректности рангов
- ✅ Добавлена защита от само-действий в командах модерации
### Версия 1.3.0 (Предыдущая версия)
- ✅ Исправления ошибок донатной системы и кодировки Unicode
- ✅ Улучшения системы достижений
### Версия 1.2.0 (Предыдущая версия) ### Версия 1.2.0 (Предыдущая версия)
- 🚀 Добавлены расширенные игры: 2048, Тетрис, Змейка - 🚀 Добавлены расширенные игры: 2048, Тетрис, Змейка
@@ -37,6 +49,13 @@
- ✅ Автоматическое удаление приветственных сообщений через 2 минуты - ✅ Автоматическое удаление приветственных сообщений через 2 минуты
- ✅ Интеграция донатов в приветственные сообщения - ✅ Интеграция донатов в приветственные сообщения
### Безопасность и надежность
- 🔒 **Валидация входящих данных** - все команды проверяют корректность параметров
- 🛡️ **Защита от злоупотреблений** - ограничения на длину текстов, ID пользователей
-**Обработка ошибок API** - таймауты, повторные попытки, детальные сообщения об ошибках
- 💾 **Надежность базы данных** - валидация запросов, автоматический rollback транзакций
- 🚫 **Предотвращение само-действий** - пользователи не могут модерировать сами себя
### Игровые элементы ### Игровые элементы
- 🎮 Мини-игры: Камень-ножницы-бумага, Крестики-нолики, Викторина, Морской бой - 🎮 Мини-игры: Камень-ножницы-бумага, Крестики-нолики, Викторина, Морской бой
- 🎯 Расширенные игры: 2048, Тетрис, Змейка - 🎯 Расширенные игры: 2048, Тетрис, Змейка
+22 -9
View File
@@ -3,9 +3,10 @@
## 🚀 Приоритетные задачи (High Priority) ## 🚀 Приоритетные задачи (High Priority)
### 🔧 Критические исправления ### 🔧 Критические исправления
- [ ] Исправить обработку ошибок при недоступности внешних API (OpenWeatherMap, NewsAPI) - [x] ✅ При начислении очков не меняется ранг. Например, /info Очков 6112 Ранг:Рядовой, а должен быть Капитан
- [ ] Добавить валидацию входящих данных для всех команд - [x] ✅ Исправить обработку ошибок при недоступности внешних API (OpenWeatherMap, NewsAPI)
- [ ] Улучшить обработку исключений в базе данных - [x] ✅ Добавить валидацию входящих данных для всех команд
- [x] ✅ Улучшить обработку исключений в базе данных
### ✨ Новые функции ### ✨ Новые функции
- [ ] Добавить поддержку медиа файлов в планировщик постов (видео, документы) - [ ] Добавить поддержку медиа файлов в планировщик постов (видео, документы)
@@ -49,7 +50,11 @@
## ✅ Завершенные задачи ## ✅ Завершенные задачи
### Версия 1.3.0 (16 октября 2025) ### Версия 1.4.0 (16 октября 2025) - Критические исправления
- [x]**Исправлена система рангов** - синхронизированы поля очков и репутации в базе данных
- [x]**Улучшена обработка ошибок внешних API** - добавлены таймауты, детальная обработка HTTP-ошибок
- [x]**Добавлена валидация входящих данных** - защита от некорректных данных и злоупотреблений
- [x]**Улучшена обработка исключений в БД** - валидация параметров, автоматический rollback транзакций
- [x] ✅ Исправлена ошибка с повторяющимся "первым сообщением" при донатах - [x] ✅ Исправлена ошибка с повторяющимся "первым сообщением" при донатах
- [x] ✅ Исправлена кодировка Unicode символов при запуске на Windows - [x] ✅ Исправлена кодировка Unicode символов при запуске на Windows
- [x] ✅ Исправлена система донатов - кнопка "Другая сумма" - [x] ✅ Исправлена система донатов - кнопка "Другая сумма"
@@ -58,6 +63,10 @@
- [x] ✅ Улучшена система достижений (разблокировка только один раз) - [x] ✅ Улучшена система достижений (разблокировка только один раз)
- [x] ✅ Обновлена документация с историей версий - [x] ✅ Обновлена документация с историей версий
### Версия 1.3.0
- [x] ✅ Исправления ошибок донатной системы и кодировки Unicode
- [x] ✅ Улучшения системы достижений
### Версия 1.2.0 ### Версия 1.2.0
- [x] ✅ Добавлены расширенные игры: 2048, Тетрис, Змейка - [x] ✅ Добавлены расширенные игры: 2048, Тетрис, Змейка
- [x] ✅ Улучшена система достижений - [x] ✅ Улучшена система достижений
@@ -65,13 +74,17 @@
## 📅 Планирование релизов ## 📅 Планирование релизов
### Следующий релиз (v1.4.0) - Декабрь 2025 ### Следующий релиз (v1.5.0) - Декабрь 2025
- Цель: Улучшение стабильности и производительности - Цель: Расширение игровых возможностей
- Основной фокус: Исправление ошибок и оптимизация - Основной фокус: Новые мини-игры и улучшение ИИ
### Релиз Q1 2026 (v2.0.0) - Март 2026 ### Релиз Q1 2026 (v2.0.0) - Март 2026
- Цель: Значительное расширение функциональности - Цель: Значительное расширение функциональности
- Основной фокус: Новые игры и улучшенный интерфейс - Основной фокус: Веб-интерфейс управления и многоязычность
### Релиз Q2 2026 (v2.1.0) - Июнь 2026
- Цель: Масштабирование и производительность
- Основной фокус: Миграция на PostgreSQL и Redis кеширование
## 🛠 Инструкции для contributors ## 🛠 Инструкции для contributors
@@ -89,4 +102,4 @@
--- ---
*Последнее обновление: 16 октября 2025* *Последнее обновление: 16 октября 2025 (добавлена версия 1.4.0 с критическими исправлениями)*
Binary file not shown.
+392 -40
View File
@@ -312,12 +312,46 @@ ID: {user_info['ID']}
city = ' '.join(context.args) if context.args else 'Moscow' city = ' '.join(context.args) if context.args else 'Moscow'
# Валидация города
if not city or len(city.strip()) < 2:
await update.message.reply_text("❌ Название города должно содержать минимум 2 символа")
return
# Ограничиваем длину названия города для безопасности
if len(city) > 50:
await update.message.reply_text("❌ Название города слишком длинное (максимум 50 символов)")
return
try: try:
# Добавляем таймаут для запроса
url = f"http://api.openweathermap.org/data/2.5/weather?q={city}&appid={OPENWEATHER_API_KEY}&units=metric&lang=ru" url = f"http://api.openweathermap.org/data/2.5/weather?q={city}&appid={OPENWEATHER_API_KEY}&units=metric&lang=ru"
response = requests.get(url)
# Создаем сессию с таймаутом
import requests
response = requests.get(url, timeout=10)
# Проверяем статус код
if response.status_code == 401:
await update.message.reply_text("❌ Ошибка API: неверный ключ погоды")
return
elif response.status_code == 429:
await update.message.reply_text("❌ Превышен лимит запросов к API погоды. Попробуйте позже.")
return
elif response.status_code >= 500:
await update.message.reply_text("❌ Сервер погоды временно недоступен. Попробуйте позже.")
return
elif response.status_code != 200:
await update.message.reply_text("❌ Ошибка при получении данных о погоде")
return
data = response.json() data = response.json()
if data['cod'] == 200: if data.get('cod') == 200:
# Проверяем, что все необходимые данные присутствуют
if 'name' not in data or 'main' not in data or 'weather' not in data:
await update.message.reply_text("❌ Неполные данные о погоде от сервера")
return
weather_text = WEATHER_MESSAGES['weather_info'].format( weather_text = WEATHER_MESSAGES['weather_info'].format(
city=data['name'], city=data['name'],
temp=data['main']['temp'], temp=data['main']['temp'],
@@ -325,12 +359,27 @@ ID: {user_info['ID']}
humidity=data['main']['humidity'], humidity=data['main']['humidity'],
description=data['weather'][0]['description'] description=data['weather'][0]['description']
) )
else:
weather_text = WEATHER_MESSAGES['city_not_found']
await update.message.reply_text(weather_text, parse_mode='HTML') await update.message.reply_text(weather_text, parse_mode='HTML')
else:
# Обработка различных кодов ошибок от API
if data.get('cod') == '404':
await update.message.reply_text(f"❌ Город '{city}' не найден")
elif data.get('cod') == '401':
await update.message.reply_text("❌ Ошибка авторизации API погоды")
else:
await update.message.reply_text(f"❌ Ошибка API погоды: {data.get('message', 'Неизвестная ошибка')}")
except requests.exceptions.Timeout:
await update.message.reply_text("❌ Превышено время ожидания ответа от сервера погоды. Попробуйте позже.")
except requests.exceptions.ConnectionError:
await update.message.reply_text("❌ Ошибка подключения к серверу погоды. Проверьте интернет-соединение.")
except requests.exceptions.RequestException as e:
await update.message.reply_text(f"❌ Ошибка сети при получении погоды: {str(e)[:100]}")
except (KeyError, ValueError, TypeError) as e:
await update.message.reply_text(f"❌ Ошибка обработки данных погоды: {str(e)[:100]}")
except Exception as e: except Exception as e:
await update.message.reply_text(WEATHER_MESSAGES['weather_error'].format(error=e)) print(f"Неожиданная ошибка в weather: {e}")
await update.message.reply_text("❌ Неожиданная ошибка при получении погоды. Попробуйте позже.")
async def news(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def news(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Получить новости""" """Получить новости"""
@@ -339,20 +388,81 @@ ID: {user_info['ID']}
return return
try: try:
# Добавляем таймаут для запроса
url = f"https://newsapi.org/v2/top-headlines?country=ru&apiKey={NEWS_API_KEY}" url = f"https://newsapi.org/v2/top-headlines?country=ru&apiKey={NEWS_API_KEY}"
response = requests.get(url)
# Создаем сессию с таймаутом
response = requests.get(url, timeout=10)
# Проверяем статус код
if response.status_code == 401:
await update.message.reply_text("❌ Ошибка API: неверный ключ новостей")
return
elif response.status_code == 429:
await update.message.reply_text("❌ Превышен лимит запросов к API новостей. Попробуйте позже.")
return
elif response.status_code >= 500:
await update.message.reply_text("❌ Сервер новостей временно недоступен. Попробуйте позже.")
return
elif response.status_code != 200:
await update.message.reply_text("❌ Ошибка при получении новостей")
return
data = response.json() data = response.json()
if data['status'] == 'ok' and data['articles']: if data.get('status') == 'ok' and data.get('articles'):
# Проверяем, что статьи содержат необходимые поля
articles = data['articles'][:5] # Берем только первые 5
valid_articles = []
for article in articles:
if article.get('title') and article.get('url'):
valid_articles.append(article)
if not valid_articles:
await update.message.reply_text("❌ Новости получены, но не содержат корректных данных")
return
news_text = NEWS_MESSAGES['news_title'] news_text = NEWS_MESSAGES['news_title']
for i, article in enumerate(data['articles'][:5], 1): for i, article in enumerate(valid_articles, 1):
news_text += f"{i}. {article['title']}\n{article['url']}\n\n" # Ограничиваем длину заголовка для безопасности
else: title = article['title'][:200] if len(article['title']) > 200 else article['title']
news_text = NEWS_MESSAGES['news_not_found'] url = article['url'][:500] if len(article['url']) > 500 else article['url'] # Ограничиваем URL
news_text += f"{i}. {title}\n{url}\n\n"
# Ограничиваем общую длину сообщения
if len(news_text) > 4000:
news_text = news_text[:3997] + "..."
await update.message.reply_text(news_text, parse_mode='HTML') await update.message.reply_text(news_text, parse_mode='HTML')
else:
# Обработка различных ошибок от API
if data.get('status') == 'error':
error_code = data.get('code', 'unknown')
error_message = data.get('message', 'Неизвестная ошибка')
if error_code == 'apiKeyInvalid':
await update.message.reply_text("❌ Ошибка API: неверный ключ новостей")
elif error_code == 'rateLimited':
await update.message.reply_text("❌ Превышен лимит запросов к API новостей")
elif error_code == 'sourcesUnavailable':
await update.message.reply_text("❌ Источники новостей недоступны")
else:
await update.message.reply_text(f"❌ Ошибка API новостей: {error_message}")
else:
await update.message.reply_text(NEWS_MESSAGES['news_not_found'])
except requests.exceptions.Timeout:
await update.message.reply_text("❌ Превышено время ожидания ответа от сервера новостей. Попробуйте позже.")
except requests.exceptions.ConnectionError:
await update.message.reply_text("❌ Ошибка подключения к серверу новостей. Проверьте интернет-соединение.")
except requests.exceptions.RequestException as e:
await update.message.reply_text(f"❌ Ошибка сети при получении новостей: {str(e)[:100]}")
except (KeyError, ValueError, TypeError) as e:
await update.message.reply_text(f"❌ Ошибка обработки данных новостей: {str(e)[:100]}")
except Exception as e: except Exception as e:
await update.message.reply_text(NEWS_MESSAGES['news_error'].format(error=e)) print(f"Неожиданная ошибка в news: {e}")
await update.message.reply_text("❌ Неожиданная ошибка при получении новостей. Попробуйте позже.")
async def translate(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def translate(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Перевод текста""" """Перевод текста"""
@@ -363,12 +473,31 @@ ID: {user_info['ID']}
text = ' '.join(context.args[:-1]) text = ' '.join(context.args[:-1])
target_lang = context.args[-1] target_lang = context.args[-1]
# Валидация входных данных
if not text or not text.strip():
await update.message.reply_text("❌ Текст для перевода не может быть пустым")
return
if not target_lang or len(target_lang) != 2:
await update.message.reply_text("❌ Укажите правильный код языка (например: en, ru, de)")
return
# Ограничиваем длину текста для безопасности и производительности
if len(text) > 1000:
await update.message.reply_text("❌ Текст слишком длинный для перевода (максимум 1000 символов)")
return
# Простой перевод с помощью Google Translate API (нужен API ключ) # Простой перевод с помощью Google Translate API (нужен API ключ)
try: try:
# В реальном проекте используйте Google Translate API или другой сервис # В реальном проекте используйте Google Translate API или другой сервис
await update.message.reply_text(TRANSLATE_MESSAGES['result'].format(text=text, lang=target_lang)) # Заглушка для демонстрации - в продакшене нужно реализовать реальный перевод
await update.message.reply_text(
f"🔄 Функция перевода: '{text[:50]}{'...' if len(text) > 50 else ''}' на {target_lang}\n\n"
f"💡 В текущей версии бота перевод недоступен. Интегрируйте Google Translate API или другой сервис перевода."
)
except Exception as e: except Exception as e:
await update.message.reply_text(TRANSLATE_MESSAGES['error'].format(error=e)) print(f"Ошибка в translate: {e}")
await update.message.reply_text("❌ Ошибка при переводе текста. Попробуйте позже.")
async def play_game(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def play_game(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Запустить мини-игру""" """Запустить мини-игру"""
@@ -874,14 +1003,40 @@ ID: {user_info['ID']}
await update.message.reply_text(MODERATION_MESSAGES['ban_usage']) await update.message.reply_text(MODERATION_MESSAGES['ban_usage'])
return return
user_id = context.args[0] user_id_str = context.args[0]
reason = ' '.join(context.args[1:]) reason = ' '.join(context.args[1:])
# Валидация ID пользователя
try: try:
await update.effective_chat.ban_member(int(user_id)) user_id = int(user_id_str)
except ValueError:
await update.message.reply_text("❌ Неверный формат ID пользователя. Должен быть числом.")
return
# Валидация причины
if not reason or not reason.strip():
await update.message.reply_text("❌ Укажите причину бана")
return
if len(reason) > 500:
await update.message.reply_text("❌ Причина слишком длинная (максимум 500 символов)")
return
# Проверка, что пользователь не пытается забанить себя
if user_id == update.effective_user.id:
await update.message.reply_text("❌ Вы не можете забанить самого себя")
return
# Проверка, что пользователь не пытается забанить бота
if user_id == context.bot.id:
await update.message.reply_text("❌ Вы не можете забанить бота")
return
try:
await update.effective_chat.ban_member(user_id)
await update.message.reply_text(MODERATION_MESSAGES['user_banned'].format(user_id=user_id, reason=reason)) await update.message.reply_text(MODERATION_MESSAGES['user_banned'].format(user_id=user_id, reason=reason))
except Exception as e: except Exception as e:
await update.message.reply_text(MODERATION_MESSAGES['ban_error'].format(error=e)) await update.message.reply_text(f"❌ Ошибка при бане пользователя: {str(e)[:100]}")
async def unban_user(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def unban_user(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Разбанить пользователя (только админы)""" """Разбанить пользователя (только админы)"""
@@ -911,11 +1066,40 @@ ID: {user_info['ID']}
await update.message.reply_text(MODERATION_MESSAGES['mute_usage']) await update.message.reply_text(MODERATION_MESSAGES['mute_usage'])
return return
user_id = context.args[0] user_id_str = context.args[0]
time_str = context.args[1]
# Валидация ID пользователя
try: try:
mute_time = int(context.args[1]) user_id = int(user_id_str)
except ValueError: except ValueError:
await update.message.reply_text(MODERATION_MESSAGES['mute_invalid_time']) await update.message.reply_text("❌ Неверный формат ID пользователя. Должен быть числом.")
return
# Валидация времени
try:
mute_time = int(time_str)
except ValueError:
await update.message.reply_text("❌ Время должно быть числом в секундах.")
return
# Проверка диапазона времени
if mute_time < 1:
await update.message.reply_text("❌ Время должно быть положительным числом.")
return
if mute_time > 365 * 24 * 3600: # Максимум 1 год
await update.message.reply_text("❌ Время не может превышать 1 год (31536000 секунд).")
return
# Проверка, что пользователь не пытается заглушить себя
if user_id == update.effective_user.id:
await update.message.reply_text("❌ Вы не можете заглушить самого себя")
return
# Проверка, что пользователь не пытается заглушить бота
if user_id == context.bot.id:
await update.message.reply_text("❌ Вы не можете заглушить бота")
return return
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -923,13 +1107,13 @@ ID: {user_info['ID']}
try: try:
await update.effective_chat.restrict_member( await update.effective_chat.restrict_member(
int(user_id), user_id,
until_date=until_date, until_date=until_date,
can_send_messages=False can_send_messages=False
) )
await update.message.reply_text(MODERATION_MESSAGES['user_muted'].format(user_id=user_id, time=mute_time)) await update.message.reply_text(MODERATION_MESSAGES['user_muted'].format(user_id=user_id, time=mute_time))
except Exception as e: except Exception as e:
await update.message.reply_text(MODERATION_MESSAGES['mute_error'].format(error=e)) await update.message.reply_text(f"❌ Ошибка при заглушке пользователя: {str(e)[:100]}")
async def unmute_user(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def unmute_user(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Снять заглушку с пользователя (только админы)""" """Снять заглушку с пользователя (только админы)"""
@@ -965,15 +1149,41 @@ ID: {user_info['ID']}
await update.message.reply_text(MODERATION_MESSAGES['kick_usage']) await update.message.reply_text(MODERATION_MESSAGES['kick_usage'])
return return
user_id = context.args[0] user_id_str = context.args[0]
reason = ' '.join(context.args[1:]) reason = ' '.join(context.args[1:])
# Валидация ID пользователя
try: try:
await update.effective_chat.ban_member(int(user_id)) user_id = int(user_id_str)
await update.effective_chat.unban_member(int(user_id)) # Разбан сразу после бана = кик except ValueError:
await update.message.reply_text("❌ Неверный формат ID пользователя. Должен быть числом.")
return
# Валидация причины
if not reason or not reason.strip():
await update.message.reply_text("❌ Укажите причину кика")
return
if len(reason) > 500:
await update.message.reply_text("❌ Причина слишком длинная (максимум 500 символов)")
return
# Проверка, что пользователь не пытается кикнуть себя
if user_id == update.effective_user.id:
await update.message.reply_text("❌ Вы не можете кикнуть самого себя")
return
# Проверка, что пользователь не пытается кикнуть бота
if user_id == context.bot.id:
await update.message.reply_text("❌ Вы не можете кикнуть бота")
return
try:
await update.effective_chat.ban_member(user_id)
await update.effective_chat.unban_member(user_id) # Разбан сразу после бана = кик
await update.message.reply_text(MODERATION_MESSAGES['user_kicked'].format(user_id=user_id, reason=reason)) await update.message.reply_text(MODERATION_MESSAGES['user_kicked'].format(user_id=user_id, reason=reason))
except Exception as e: except Exception as e:
await update.message.reply_text(MODERATION_MESSAGES['kick_error'].format(error=e)) await update.message.reply_text(f"❌ Ошибка при кике пользователя: {str(e)[:100]}")
async def promote_user(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def promote_user(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Повысить пользователя до модератора (только админы)""" """Повысить пользователя до модератора (только админы)"""
@@ -1032,11 +1242,40 @@ ID: {user_info['ID']}
await update.message.reply_text(MODERATION_MESSAGES['warn_usage']) await update.message.reply_text(MODERATION_MESSAGES['warn_usage'])
return return
user_id = context.args[0] user_id_str = context.args[0]
reason = ' '.join(context.args[1:]) reason = ' '.join(context.args[1:])
# Валидация ID пользователя
try:
user_id = int(user_id_str)
except ValueError:
await update.message.reply_text("❌ Неверный формат ID пользователя. Должен быть числом.")
return
# Валидация причины
if not reason or not reason.strip():
await update.message.reply_text("❌ Укажите причину предупреждения")
return
if len(reason) > 500:
await update.message.reply_text("❌ Причина слишком длинная (максимум 500 символов)")
return
# Проверка, что пользователь не пытается выдать предупреждение себе
if user_id == update.effective_user.id:
await update.message.reply_text("❌ Вы не можете выдать предупреждение самому себе")
return
# Проверка, что пользователь не пытается выдать предупреждение боту
if user_id == context.bot.id:
await update.message.reply_text("❌ Вы не можете выдать предупреждение боту")
return
try:
db.add_warning(user_id, reason, update.effective_user.id) db.add_warning(user_id, reason, update.effective_user.id)
await update.message.reply_text(MODERATION_MESSAGES['warning_issued'].format(user_id=user_id, reason=reason)) await update.message.reply_text(MODERATION_MESSAGES['warning_issued'].format(user_id=user_id, reason=reason))
except Exception as e:
await update.message.reply_text(f"❌ Ошибка при выдаче предупреждения: {str(e)[:100]}")
async def ranks_info(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def ranks_info(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Показать информацию о системе рангов""" """Показать информацию о системе рангов"""
@@ -1054,6 +1293,22 @@ ID: {user_info['ID']}
csv_file = context.args[0] if context.args else 'chat_-1001519866478_users_full_20251014.csv' csv_file = context.args[0] if context.args else 'chat_-1001519866478_users_full_20251014.csv'
# Валидация имени файла
if not csv_file or not csv_file.strip():
await update.message.reply_text("❌ Укажите имя CSV файла")
return
# Проверка на потенциально опасные символы в имени файла
import re
if not re.match(r'^[a-zA-Z0-9._\-/\s]+$', csv_file):
await update.message.reply_text("❌ Имя файла содержит недопустимые символы")
return
# Ограничение длины имени файла
if len(csv_file) > 255:
await update.message.reply_text("❌ Имя файла слишком длинное (максимум 255 символов)")
return
# Если путь относительный, добавляем путь к директории telegram_bot # Если путь относительный, добавляем путь к директории telegram_bot
if not os.path.isabs(csv_file): if not os.path.isabs(csv_file):
csv_file = os.path.join('telegram_bot', csv_file) csv_file = os.path.join('telegram_bot', csv_file)
@@ -1093,16 +1348,40 @@ ID: {user_info['ID']}
time_str = args[0] time_str = args[0]
text = ' '.join(args[1:]) text = ' '.join(args[1:])
# Валидация текста поста
if not text or not text.strip():
await update.message.reply_text("❌ Текст поста не может быть пустым")
return
if len(text) > 4000:
await update.message.reply_text("❌ Текст поста слишком длинный (максимум 4000 символов)")
return
# Валидация времени
if not time_str or not time_str.strip():
await update.message.reply_text("❌ Укажите время публикации")
return
if len(time_str) > 100:
await update.message.reply_text("❌ Строка времени слишком длинная")
return
try: try:
schedule_time = self.parse_schedule_time(time_str) schedule_time = self.parse_schedule_time(time_str)
except ValueError as e: except ValueError as e:
await update.message.reply_text(SCHEDULER_MESSAGES['invalid_format'].format(error=str(e))) await update.message.reply_text(f"❌ Ошибка формата времени: {str(e)[:100]}")
return return
if schedule_time <= datetime.now(): if schedule_time <= datetime.now():
await update.message.reply_text(SCHEDULER_MESSAGES['time_in_past']) await update.message.reply_text(SCHEDULER_MESSAGES['time_in_past'])
return return
# Проверка, что время не слишком далеко в будущем (максимум 1 год)
max_future_time = datetime.now() + timedelta(days=365)
if schedule_time > max_future_time:
await update.message.reply_text("❌ Время публикации не может быть больше чем через 1 год")
return
# Показываем предварительный просмотр поста # Показываем предварительный просмотр поста
preview_text = f""" preview_text = f"""
📝 <b>Предварительный просмотр поста:</b> 📝 <b>Предварительный просмотр поста:</b>
@@ -1177,10 +1456,22 @@ ID: {user_info['ID']}
await update.message.reply_text(SCHEDULER_MESSAGES['usage_delete']) await update.message.reply_text(SCHEDULER_MESSAGES['usage_delete'])
return return
post_id_str = context.args[0]
# Валидация ID поста
try: try:
post_id = int(context.args[0]) post_id = int(post_id_str)
except ValueError: except ValueError:
await update.message.reply_text(SCHEDULER_MESSAGES['invalid_id']) await update.message.reply_text("❌ ID поста должен быть числом")
return
# Проверка диапазона ID
if post_id < 1:
await update.message.reply_text("❌ ID поста должен быть положительным числом")
return
if post_id > 999999999: # Разумный максимум для SQLite
await update.message.reply_text("❌ ID поста слишком большой")
return return
success = db.delete_scheduled_post(post_id, user.id) success = db.delete_scheduled_post(post_id, user.id)
@@ -1203,10 +1494,22 @@ ID: {user_info['ID']}
await update.message.reply_text(SCHEDULER_MESSAGES['usage_publish']) await update.message.reply_text(SCHEDULER_MESSAGES['usage_publish'])
return return
post_id_str = context.args[0]
# Валидация ID поста
try: try:
post_id = int(context.args[0]) post_id = int(post_id_str)
except ValueError: except ValueError:
await update.message.reply_text(SCHEDULER_MESSAGES['invalid_id']) await update.message.reply_text("❌ ID поста должен быть числом")
return
# Проверка диапазона ID
if post_id < 1:
await update.message.reply_text("❌ ID поста должен быть положительным числом")
return
if post_id > 999999999: # Разумный максимум для SQLite
await update.message.reply_text("❌ ID поста слишком большой")
return return
# Получаем пост из базы данных # Получаем пост из базы данных
@@ -1501,13 +1804,28 @@ ID: {user_info['ID']}
if query.startswith('weather'): if query.startswith('weather'):
city = query.split(' ', 1)[1] if len(query.split(' ', 1)) > 1 else 'Moscow' city = query.split(' ', 1)[1] if len(query.split(' ', 1)) > 1 else 'Moscow'
if OPENWEATHER_API_KEY: # Валидация города
if not city or len(city.strip()) < 2:
results.append(InlineQueryResultArticle(
id='1',
title="Ошибка",
input_message_content=InputTextMessageContent("Название города должно содержать минимум 2 символа")
))
elif len(city) > 50:
results.append(InlineQueryResultArticle(
id='1',
title="Ошибка",
input_message_content=InputTextMessageContent("Название города слишком длинное")
))
elif OPENWEATHER_API_KEY:
try:
url = f"http://api.openweathermap.org/data/2.5/weather?q={city}&appid={OPENWEATHER_API_KEY}&units=metric&lang=ru" url = f"http://api.openweathermap.org/data/2.5/weather?q={city}&appid={OPENWEATHER_API_KEY}&units=metric&lang=ru"
response = requests.get(url) response = requests.get(url, timeout=5)
data = response.json()
if data['cod'] == 200: if response.status_code == 200:
weather_text = f"🌤️ Погода в {data['name']}:\nТемпература: {data['main']['temp']}°C\nОщущается как: {data['main']['feels_like']}°C\nВлажность: {data['main']['humidity']}%\nОписание: {data['weather'][0]['description']}" data = response.json()
if data.get('cod') == 200:
weather_text = f"Погода в {data['name']}:\nТемпература: {data['main']['temp']}°C\nОщущается как: {data['main']['feels_like']}°C\nВлажность: {data['main']['humidity']}%\nОписание: {data['weather'][0]['description']}"
results.append(InlineQueryResultArticle( results.append(InlineQueryResultArticle(
id='1', id='1',
title=f"Погода в {city}", title=f"Погода в {city}",
@@ -1517,7 +1835,25 @@ ID: {user_info['ID']}
results.append(InlineQueryResultArticle( results.append(InlineQueryResultArticle(
id='1', id='1',
title="Город не найден", title="Город не найден",
input_message_content=InputTextMessageContent("Город не найден") input_message_content=InputTextMessageContent(f"Город '{city}' не найден")
))
else:
results.append(InlineQueryResultArticle(
id='1',
title="Ошибка API",
input_message_content=InputTextMessageContent("Ошибка при получении погоды")
))
except Exception:
results.append(InlineQueryResultArticle(
id='1',
title="Ошибка сети",
input_message_content=InputTextMessageContent("Ошибка подключения к серверу погоды")
))
else:
results.append(InlineQueryResultArticle(
id='1',
title="API недоступен",
input_message_content=InputTextMessageContent("API погоды не настроен")
)) ))
elif query.startswith('translate'): elif query.startswith('translate'):
@@ -1526,17 +1862,33 @@ ID: {user_info['ID']}
if len(text_parts) >= 3: if len(text_parts) >= 3:
text = text_parts[1] text = text_parts[1]
lang = text_parts[2] lang = text_parts[2]
result_text = f"Перевод '{text}' на {lang}: [здесь будет перевод]"
if text and lang and len(text) <= 500 and len(lang) == 2:
result_text = f"Перевод '{text[:50]}{'...' if len(text) > 50 else ''}' на {lang}\n[Функция перевода недоступна в текущей версии]"
results.append(InlineQueryResultArticle( results.append(InlineQueryResultArticle(
id='1', id='1',
title=f"Перевод на {lang}", title=f"Перевод на {lang}",
input_message_content=InputTextMessageContent(result_text) input_message_content=InputTextMessageContent(result_text)
)) ))
else:
results.append(InlineQueryResultArticle(
id='1',
title="Ошибка",
input_message_content=InputTextMessageContent("Использование: текст для перевода")
))
else:
results.append(InlineQueryResultArticle(
id='1',
title="Использование",
input_message_content=InputTextMessageContent("Использование: weather [город] или translate [текст] [язык]")
))
await update.inline_query.answer(results) await update.inline_query.answer(results)
except Exception as e: except Exception as e:
print(f"Ошибка при обработке инлайнового запроса: {e}") print(f"Ошибка при обработке инлайнового запроса: {e}")
# Пустой ответ в случае ошибки
await update.inline_query.answer([])
async def show_start_menu(self, query): async def show_start_menu(self, query):
"""Показать стартовое меню""" """Показать стартовое меню"""
+172 -25
View File
@@ -130,26 +130,80 @@ class Database:
def update_score(self, user_id, points=1): def update_score(self, user_id, points=1):
"""Обновление очков пользователя""" """Обновление очков пользователя"""
try: try:
# Валидация входных данных
if not isinstance(user_id, int) or user_id <= 0:
print(f"Ошибка валидации: некорректный user_id = {user_id}")
return False
if not isinstance(points, (int, float)) or points < 0:
print(f"Ошибка валидации: некорректное количество очков = {points}")
return False
# Проверка соединения с базой данных
if not self.connection:
print("Ошибка: соединение с базой данных не установлено")
return False
cursor = self.connection.cursor() cursor = self.connection.cursor()
cursor.execute(""" cursor.execute("""
UPDATE users SET score = score + ?, message_count = message_count + 1, last_message = CURRENT_TIMESTAMP WHERE user_id = ? UPDATE users SET score = score + ?, reputation = reputation + ?, message_count = message_count + 1, last_message = CURRENT_TIMESTAMP WHERE user_id = ?
""", (points, user_id)) """, (points, points, user_id))
if cursor.rowcount == 0:
print(f"Предупреждение: пользователь с ID {user_id} не найден для обновления очков")
return False
self.connection.commit() self.connection.commit()
# Обновление ранга на основе репутации (которая теперь синхронизирована с очками)
self.update_rank(user_id)
return True
except sqlite3.Error as error: except sqlite3.Error as error:
print(TECH_MESSAGES['score_update_error'].format(error=error)) print(f"Ошибка базы данных при обновлении очков: {error}")
if self.connection:
self.connection.rollback()
return False
except Exception as error:
print(f"Неожиданная ошибка при обновлении очков: {error}")
return False
def update_reputation(self, user_id, rep_points=1): def update_reputation(self, user_id, rep_points=1):
"""Обновление репутации пользователя""" """Обновление репутации пользователя"""
try: try:
# Валидация входных данных
if not isinstance(user_id, int) or user_id <= 0:
print(f"Ошибка валидации: некорректный user_id = {user_id}")
return False
if not isinstance(rep_points, (int, float)):
print(f"Ошибка валидации: некорректное количество репутации = {rep_points}")
return False
# Проверка соединения с базой данных
if not self.connection:
print("Ошибка: соединение с базой данных не установлено")
return False
cursor = self.connection.cursor() cursor = self.connection.cursor()
cursor.execute(""" cursor.execute("""
UPDATE users SET reputation = reputation + ? WHERE user_id = ? UPDATE users SET reputation = reputation + ? WHERE user_id = ?
""", (rep_points, user_id)) """, (rep_points, user_id))
if cursor.rowcount == 0:
print(f"Предупреждение: пользователь с ID {user_id} не найден для обновления репутации")
return False
self.connection.commit() self.connection.commit()
# Обновление ранга на основе репутации # Обновление ранга на основе репутации (теперь вызывается только здесь)
self.update_rank(user_id) self.update_rank(user_id)
return True
except sqlite3.Error as error: except sqlite3.Error as error:
print(TECH_MESSAGES['reputation_update_error'].format(error=error)) print(f"Ошибка базы данных при обновлении репутации: {error}")
if self.connection:
self.connection.rollback()
return False
except Exception as error:
print(f"Неожиданная ошибка при обновлении репутации: {error}")
return False
def update_rank(self, user_id, chat_id=None, first_name=None): def update_rank(self, user_id, chat_id=None, first_name=None):
"""Обновление ранга пользователя на основе репутации""" """Обновление ранга пользователя на основе репутации"""
@@ -236,34 +290,53 @@ class Database:
def get_user_info(self, user_id): def get_user_info(self, user_id):
"""Получение информации о пользователе""" """Получение информации о пользователе"""
try: try:
# Валидация входных данных
if not isinstance(user_id, int) or user_id <= 0:
print(f"Ошибка валидации: некорректный user_id = {user_id}")
return None
# Проверка соединения с базой данных
if not self.connection:
print("Ошибка: соединение с базой данных не установлено")
return None
cursor = self.connection.cursor() cursor = self.connection.cursor()
cursor.execute(""" cursor.execute("""
SELECT user_id, first_name, username, reputation, rank, message_count, active_days, days_since_join, last_message, joined_at, left_at, language, actions, score, warnings, role SELECT user_id, first_name, username, reputation, rank, message_count, active_days, days_since_join, last_message, joined_at, left_at, language, actions, score, warnings, role
FROM users WHERE user_id = ? FROM users WHERE user_id = ?
""", (user_id,)) """, (user_id,))
result = cursor.fetchone() result = cursor.fetchone()
if result: if result:
try:
return { return {
'ID': result[0], 'ID': result[0],
'Имя': result[1], 'Имя': result[1] or 'Неизвестно',
'Имя пользователя': result[2] or 'не указано', 'Имя пользователя': result[2] or 'не указано',
'Репутация': result[3], 'Репутация': result[3] or 0,
'Ранг': result[4], 'Ранг': result[4] or 'Рядовой',
'Количество сообщений': result[5], 'Количество сообщений': result[5] or 0,
'Активные дни': result[6], 'Активные дни': result[6] or 0,
'Дней с присоединения': result[7], 'Дней с присоединения': result[7] or 0,
'Последнее сообщение': result[8], 'Последнее сообщение': result[8],
'Присоединился': result[9], 'Присоединился': result[9],
'Покинул': result[10], 'Покинул': result[10],
'Язык': result[11], 'Язык': result[11] or 'ru',
'Действия': result[12], 'Действия': result[12] or '[]',
'Очки': result[13], 'Очки': result[13] or 0,
'Предупреждений': result[14], 'Предупреждений': result[14] or 0,
'Роль': result[15] 'Роль': result[15] or 'user'
} }
except (IndexError, TypeError) as e:
print(f"Ошибка обработки данных пользователя {user_id}: {e}")
return None
return None return None
except sqlite3.Error as error: except sqlite3.Error as error:
print(TECH_MESSAGES['user_info_error'].format(error=error)) print(f"Ошибка базы данных при получении информации о пользователе {user_id}: {error}")
return None
except Exception as error:
print(f"Неожиданная ошибка при получении информации о пользователе {user_id}: {error}")
return None return None
def import_users_from_csv(self, csv_file_path): def import_users_from_csv(self, csv_file_path):
@@ -301,16 +374,16 @@ class Database:
# Обновляем существующего пользователя # Обновляем существующего пользователя
cursor.execute(""" cursor.execute("""
UPDATE users UPDATE users
SET username = ?, first_name = ?, last_name = ?, score = ?, warnings = ?, reputation = ? SET username = ?, first_name = ?, last_name = ?, score = ?, warnings = ?, reputation = ?, rank = ?
WHERE user_id = ? WHERE user_id = ?
""", (username, first_name, last_name, xp, rep, rep, user_id)) """, (username, first_name, last_name, xp, rep, xp, db.calculate_rank(xp), user_id))
updated_count += 1 updated_count += 1
else: else:
# Добавляем нового пользователя # Добавляем нового пользователя
cursor.execute(""" cursor.execute("""
INSERT INTO users (user_id, username, first_name, last_name, score, warnings, reputation) INSERT INTO users (user_id, username, first_name, last_name, score, warnings, reputation, rank)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (user_id, username, first_name, last_name, xp, rep, rep)) """, (user_id, username, first_name, last_name, xp, rep, xp, db.calculate_rank(xp)))
imported_count += 1 imported_count += 1
self.connection.commit() self.connection.commit()
@@ -329,15 +402,49 @@ class Database:
def add_scheduled_post(self, chat_id, text, schedule_time, created_by, image_path=None): def add_scheduled_post(self, chat_id, text, schedule_time, created_by, image_path=None):
"""Добавление запланированного поста""" """Добавление запланированного поста"""
try: try:
# Валидация входных данных
if not isinstance(chat_id, int):
print(f"Ошибка валидации: некорректный chat_id = {chat_id}")
return None
if not text or not text.strip():
print("Ошибка валидации: текст поста пустой")
return None
if not isinstance(created_by, int) or created_by <= 0:
print(f"Ошибка валидации: некорректный created_by = {created_by}")
return None
# Ограничение длины текста
if len(text) > 4000:
print("Ошибка валидации: текст поста слишком длинный")
return None
# Валидация пути к изображению
if image_path and not isinstance(image_path, str):
print(f"Ошибка валидации: некорректный путь к изображению = {image_path}")
return None
# Проверка соединения с базой данных
if not self.connection:
print("Ошибка: соединение с базой данных не установлено")
return None
cursor = self.connection.cursor() cursor = self.connection.cursor()
cursor.execute(""" cursor.execute("""
INSERT INTO scheduled_posts (chat_id, text, image_path, schedule_time, created_by) INSERT INTO scheduled_posts (chat_id, text, image_path, schedule_time, created_by)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
""", (chat_id, text, image_path, schedule_time, created_by)) """, (chat_id, text, image_path, schedule_time, created_by))
self.connection.commit() self.connection.commit()
return cursor.lastrowid return cursor.lastrowid
except sqlite3.Error as error: except sqlite3.Error as error:
print(f"Ошибка при добавлении поста: {error}") print(f"Ошибка базы данных при добавлении поста: {error}")
if self.connection:
self.connection.rollback()
return None
except Exception as error:
print(f"Неожиданная ошибка при добавлении поста: {error}")
return None return None
def get_scheduled_posts(self, chat_id=None, limit=50): def get_scheduled_posts(self, chat_id=None, limit=50):
@@ -451,7 +558,32 @@ class Database:
def add_donation(self, user_id, amount, currency='RUB'): def add_donation(self, user_id, amount, currency='RUB'):
"""Добавить донат пользователя""" """Добавить донат пользователя"""
try: try:
# Валидация входных данных
if not isinstance(user_id, int) or user_id <= 0:
print(f"Ошибка валидации: некорректный user_id = {user_id}")
return False
if not isinstance(amount, (int, float)) or amount <= 0:
print(f"Ошибка валидации: некорректная сумма доната = {amount}")
return False
if not isinstance(currency, str) or not currency.strip():
print(f"Ошибка валидации: некорректная валюта = {currency}")
return False
# Ограничение максимальной суммы доната для предотвращения злоупотреблений
if amount > 1000000: # 1 миллион рублей максимум
print(f"Ошибка валидации: сумма доната слишком большая = {amount}")
return False
# Проверка соединения с базой данных
if not self.connection:
print("Ошибка: соединение с базой данных не установлено")
return False
cursor = self.connection.cursor() cursor = self.connection.cursor()
# Вставляем запись о донате
cursor.execute(""" cursor.execute("""
INSERT INTO donations (user_id, amount, currency) INSERT INTO donations (user_id, amount, currency)
VALUES (?, ?, ?) VALUES (?, ?, ?)
@@ -460,12 +592,27 @@ class Database:
# Начисляем очки за донат (1 очко за каждые 100 рублей) # Начисляем очки за донат (1 очко за каждые 100 рублей)
points = int(amount // 100) points = int(amount // 100)
if points > 0: if points > 0:
self.update_score(user_id, points) # Обновляем score и reputation одновременно, и вызываем update_rank только один раз
cursor.execute("""
UPDATE users SET score = score + ?, reputation = reputation + ?, last_message = CURRENT_TIMESTAMP WHERE user_id = ?
""", (points, points, user_id))
if cursor.rowcount == 0:
print(f"Предупреждение: пользователь с ID {user_id} не найден при начислении очков за донат")
self.connection.rollback()
return False
self.update_rank(user_id)
self.connection.commit() self.connection.commit()
return True return True
except sqlite3.Error as error: except sqlite3.Error as error:
print(f"Ошибка при добавлении доната: {error}") print(f"Ошибка базы данных при добавлении доната: {error}")
if self.connection:
self.connection.rollback()
return False
except Exception as error:
print(f"Неожиданная ошибка при добавлении доната: {error}")
return False return False
def get_total_donations(self, user_id, year=None): def get_total_donations(self, user_id, year=None):
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Тест системы рангов
"""
import sys
import os
# Добавляем текущую директорию в путь для импорта
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from database_sqlite import Database
from messages import RANK_THRESHOLDS
def test_rank_calculation():
"""Тестирование расчета рангов"""
db = Database()
print("Testing rank system...\n")
# Тестовые значения репутации и ожидаемые ранги
test_cases = [
(0, "Рядовой"),
(50, "Рядовой"),
(100, "Ефрейтор"),
(200, "Ефрейтор"),
(235, "Младший сержант"),
(400, "Младший сержант"),
(505, "Сержант"),
(700, "Сержант"),
(810, "Старший сержант"),
(1000, "Старший сержант"),
(1250, "Старшина"),
(1500, "Старшина"),
(1725, "Прапорщик"),
(2000, "Прапорщик"),
(2335, "Старший прапорщик"),
(2500, "Старший прапорщик"),
(2980, "Младший лейтенант"),
(3500, "Младший лейтенант"),
(3760, "Лейтенант"),
(4000, "Лейтенант"),
(4575, "Старший лейтенант"),
(5000, "Старший лейтенант"),
(5525, "Капитан"),
(6000, "Капитан"),
(6112, "Капитан"), # Это значение из примера в TODO
(6510, "Майор"),
(7000, "Майор"),
(256000, "Маршал"), # Максимальный ранг
]
print("Rank thresholds:")
for threshold, rank_name in RANK_THRESHOLDS:
print(f" {threshold:6} points -> {rank_name}")
print()
print("Test results:")
all_passed = True
for reputation, expected_rank in test_cases:
calculated_rank = db.calculate_rank(reputation)
status = "PASS" if calculated_rank == expected_rank else "FAIL"
if calculated_rank != expected_rank:
all_passed = False
print(f" {status} {reputation:6} points -> {calculated_rank}"+" "*(20-len(calculated_rank)) + f"(expected: {expected_rank})")
print()
if all_passed:
print("All tests passed successfully!")
else:
print("Some tests failed!")
# Проверка конкретного примера из TODO (6112 очков должны давать Капитана)
print(f"\nChecking TODO example:")
print(f" 6112 points -> {db.calculate_rank(6112)} (should be Капитан)")
db.close()
return all_passed
if __name__ == "__main__":
test_rank_calculation()