From fd77c24d6b735777f99f58dac0a179f1eebf1f3e Mon Sep 17 00:00:00 2001 From: FerraSoft Date: Thu, 16 Oct 2025 16:21:49 +0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A5=20Version=201.4.0=20-=20Critical?= =?UTF-8?q?=20Security=20&=20Stability=20Fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 23 +- TODO.md | 31 +- __pycache__/database_sqlite.cpython-310.pyc | Bin 20839 -> 21103 bytes bot.py | 458 +++++++++++++++++--- database_sqlite.py | 211 +++++++-- test_ranks.py | 83 ++++ 6 files changed, 710 insertions(+), 96 deletions(-) create mode 100644 test_ranks.py diff --git a/README.md b/README.md index 2b8d919..1664b75 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,16 @@ ## 📋 История версий -### Версия 1.3.0 (16 октября 2025) +### Версия 1.4.0 (16 октября 2025) - Критические исправления **Дата релиза:** 16.10.2025 -#### 🐛 Исправления ошибок +#### 🚨 Критические исправления безопасности и стабильности +- ✅ **Исправлена система рангов** - синхронизированы поля очков и репутации в базе данных +- ✅ **Улучшена обработка ошибок внешних API** - добавлены таймауты, детальная обработка HTTP-ошибок +- ✅ **Добавлена валидация входящих данных** - защита от некорректных данных и злоупотреблений +- ✅ **Улучшена обработка исключений в БД** - валидация параметров, автоматический rollback транзакций + +#### Исправления ошибок - ✅ Исправлена ошибка с повторяющимся сообщением о "первом сообщении" при вводе суммы доната - ✅ Исправлена проблема с кодировкой Unicode символов при запуске бота на Windows - ✅ Исправлена система донатов - теперь корректно работает кнопка "Другая сумма" @@ -21,6 +27,12 @@ - ✅ Оптимизирована логика проверки достижений в базе данных - ✅ Добавлена проверка существующих достижений перед разблокировкой - ✅ Улучшена обработка ошибок в системе донатов +- ✅ Создан тестовый скрипт для проверки корректности рангов +- ✅ Добавлена защита от само-действий в командах модерации + +### Версия 1.3.0 (Предыдущая версия) +- ✅ Исправления ошибок донатной системы и кодировки Unicode +- ✅ Улучшения системы достижений ### Версия 1.2.0 (Предыдущая версия) - 🚀 Добавлены расширенные игры: 2048, Тетрис, Змейка @@ -37,6 +49,13 @@ - ✅ Автоматическое удаление приветственных сообщений через 2 минуты - ✅ Интеграция донатов в приветственные сообщения +### Безопасность и надежность +- 🔒 **Валидация входящих данных** - все команды проверяют корректность параметров +- 🛡️ **Защита от злоупотреблений** - ограничения на длину текстов, ID пользователей +- ⚡ **Обработка ошибок API** - таймауты, повторные попытки, детальные сообщения об ошибках +- 💾 **Надежность базы данных** - валидация запросов, автоматический rollback транзакций +- 🚫 **Предотвращение само-действий** - пользователи не могут модерировать сами себя + ### Игровые элементы - 🎮 Мини-игры: Камень-ножницы-бумага, Крестики-нолики, Викторина, Морской бой - 🎯 Расширенные игры: 2048, Тетрис, Змейка diff --git a/TODO.md b/TODO.md index e9461bd..da316df 100644 --- a/TODO.md +++ b/TODO.md @@ -3,9 +3,10 @@ ## 🚀 Приоритетные задачи (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] ✅ Исправлена кодировка Unicode символов при запуске на Windows - [x] ✅ Исправлена система донатов - кнопка "Другая сумма" @@ -58,6 +63,10 @@ - [x] ✅ Улучшена система достижений (разблокировка только один раз) - [x] ✅ Обновлена документация с историей версий +### Версия 1.3.0 +- [x] ✅ Исправления ошибок донатной системы и кодировки Unicode +- [x] ✅ Улучшения системы достижений + ### Версия 1.2.0 - [x] ✅ Добавлены расширенные игры: 2048, Тетрис, Змейка - [x] ✅ Улучшена система достижений @@ -65,13 +74,17 @@ ## 📅 Планирование релизов -### Следующий релиз (v1.4.0) - Декабрь 2025 -- Цель: Улучшение стабильности и производительности -- Основной фокус: Исправление ошибок и оптимизация +### Следующий релиз (v1.5.0) - Декабрь 2025 +- Цель: Расширение игровых возможностей +- Основной фокус: Новые мини-игры и улучшение ИИ ### Релиз Q1 2026 (v2.0.0) - Март 2026 - Цель: Значительное расширение функциональности -- Основной фокус: Новые игры и улучшенный интерфейс +- Основной фокус: Веб-интерфейс управления и многоязычность + +### Релиз Q2 2026 (v2.1.0) - Июнь 2026 +- Цель: Масштабирование и производительность +- Основной фокус: Миграция на PostgreSQL и Redis кеширование ## 🛠 Инструкции для contributors @@ -89,4 +102,4 @@ --- -*Последнее обновление: 16 октября 2025* \ No newline at end of file +*Последнее обновление: 16 октября 2025 (добавлена версия 1.4.0 с критическими исправлениями)* \ No newline at end of file diff --git a/__pycache__/database_sqlite.cpython-310.pyc b/__pycache__/database_sqlite.cpython-310.pyc index 88346a69f708b091f9d2f347a8480dff6c877865..be89b39bc915e8dba2f35118229c09909c0872da 100644 GIT binary patch delta 1528 zcmZvbZERCj7{~8(?n}F_TetOf?5&&NTGnk8*$NEHmQg_0%}yjpBb2h+$p&k;+gnD< z-DdQInqVA0m}mg)j2|>12Dk)`65>8+qQ;~oMv-L3#Y9XbB8E={&~v)6Pu`}#KKK0p z=jl1;InT`x;P!`5o+>Ldar6=Y{QJ;V;Sx5wo3Pey#Z}H4Y_(65k4^Ye(1x!&*W(rYhAIcmMu-U{FcRUR ziLW3R9cds+1D&(X;9&(M_#qbfKmj=s>8}3zzj&t((BSK3g9qP|z3L zJb^`hvFRe6qMuqb4$zKb^GT_L&Qv-vS?@As(g`Itk{nL`g;$&3uvnlC&4SR?YB#?qx42dI>$vvb=SU18xsD(JXMPO|=h`>~-L#DuK&Y zs=O-5wrUQ4^mf2+XkP0zI7XebkhS8LwV%49!a#g15jF0nn@iFt#}Bmk3`EKEktqLc zC~^<2EgkSLHu~!D*Xjy9+LD!gZY7>PjNM)Kw|rY+p{Sh~1^lwXjAOpAVM+I{jHkNI zc++RW&(~FAo38?Gt*?R$&$RkzExu_DNH>UlQ1Re{*8NpG=y^s>L+PV2B@vesiqeN; zZE3LJVq0g4OAzjxM2UCl?)LitH?Yp%QX4Hx4;@O#nInmqIzl&M8lp4Rgd97VjiT(| z2z~llzgdKJxY$)~QoO93ehN(aSK-Z08|>BZcWR`lTk*uEZkW`+-(&$efD4=Nmd2U? zAcF%3LoWOzDB-;>3k>0e--S(EZ19>M*s_n%)w~^amn4|8`ceApK?l5}-wxh_$`|RF zS5!(L=~2AZ9fXhcmY#nA_G5kTR&p8eWW62L-W}2@YRk3Zd~f?qvnJ3U5j1+6SdSEM z36~c%m;-uWSSN`~wU@Y8|+wWUtMg)h`q<) zrq6Sk0M2$h(H088AcjMp>ZlYSOJ$O3)Hsw;ltgkUtJv9&8`)|3%yC(c$*E*q9Z4m> z0@^vNskq$1yzsy*89^dTy{vy1`hmcLp5M8VKo|b}v`jBcu5Q~C`SI^aVA_#U7ca~lFPH;j3$0S@!o=qUwAL?ch5Wb z_dDmjd(ON24*c{s41`@De*4?>#o(o!E(@l*v|z=R!rc{PL>5PhNMd4G8*c@Y z6oDknF)5~#g%T3hRtc+G&DC3bwVw*Ze=6@eC`snC;WVQ~e)01`-o_)nfj zoh*!k;4q-yWyL#I7yek}hI6W|xK)5Oj_z91+L>ZmZ7w|s%rRHyfGg^9S%m=C)z$I= z0dA;zyGDTTRAbd;CQ+|f&jC2mS$kgE%apVoKdZIFI{sXHF4xE$9`;0w1ws<*_iYQ%BnsA>%5*?;kh_3+nEDxuBb3*%Rmaj1=LQ?h2gWe@!~dd~_O{ zI$B@`109WUMt!+sLSWg~J1gKAn!4QZs_N}p2lFhWI0iW|Kn@F@DcbtP#jFneMl z5=-lY3zUW;!8o<@l5KdqvKTLTO(mZIo5ynI+xjc?8u4$Vqz5CYUwLl{u%=$@cMEU; zZ$B)v-gyUV;4hpV=+iyL!$b+%2g@LVUk})-6z<_8z6B<3J;Pv=WuVXUx96~Qu#&Mm z2P+&qu_g9vY_U&qi8GVop#-xX8po``qMkwyF1Lg#sIO&}7dbKH^wb914HOc|HZ%-}r9tvDuq4t7u% ze0GLk_>M7b8ybW=>e7%Gv}YNo8gC4@S)H6MF>nYz8ssZbip3+ICo1?(B?mi)Iu5NI z+Bmp5WD1(#)+C1zhcJiZ93mWek(rw5Np8hCyu{%wu8y=oHvT$dyU${<823?;xg9!c Q=DWijGT-zF)*tHq7ZZqbPyhe` diff --git a/bot.py b/bot.py index bf091ce..991ddfa 100644 --- a/bot.py +++ b/bot.py @@ -312,12 +312,46 @@ ID: {user_info['ID']} 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: + # Добавляем таймаут для запроса 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() - 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( city=data['name'], temp=data['main']['temp'], @@ -325,12 +359,27 @@ ID: {user_info['ID']} humidity=data['main']['humidity'], description=data['weather'][0]['description'] ) + await update.message.reply_text(weather_text, parse_mode='HTML') else: - weather_text = WEATHER_MESSAGES['city_not_found'] + # Обработка различных кодов ошибок от 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', 'Неизвестная ошибка')}") - await update.message.reply_text(weather_text, parse_mode='HTML') + 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: - 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): """Получить новости""" @@ -339,20 +388,81 @@ ID: {user_info['ID']} return try: + # Добавляем таймаут для запроса 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() - if data['status'] == 'ok' and data['articles']: - news_text = NEWS_MESSAGES['news_title'] - for i, article in enumerate(data['articles'][:5], 1): - news_text += f"{i}. {article['title']}\n{article['url']}\n\n" - else: - news_text = NEWS_MESSAGES['news_not_found'] + if data.get('status') == 'ok' and data.get('articles'): + # Проверяем, что статьи содержат необходимые поля + articles = data['articles'][:5] # Берем только первые 5 + valid_articles = [] - await update.message.reply_text(news_text, parse_mode='HTML') + 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'] + for i, article in enumerate(valid_articles, 1): + # Ограничиваем длину заголовка для безопасности + title = article['title'][:200] if len(article['title']) > 200 else article['title'] + 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') + 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: - 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): """Перевод текста""" @@ -363,12 +473,31 @@ ID: {user_info['ID']} text = ' '.join(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 ключ) try: # В реальном проекте используйте 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: - 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): """Запустить мини-игру""" @@ -874,14 +1003,40 @@ ID: {user_info['ID']} await update.message.reply_text(MODERATION_MESSAGES['ban_usage']) return - user_id = context.args[0] + user_id_str = context.args[0] reason = ' '.join(context.args[1:]) + # Валидация ID пользователя 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)) 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): """Разбанить пользователя (только админы)""" @@ -911,11 +1066,40 @@ ID: {user_info['ID']} await update.message.reply_text(MODERATION_MESSAGES['mute_usage']) return - user_id = context.args[0] + user_id_str = context.args[0] + time_str = context.args[1] + + # Валидация ID пользователя try: - mute_time = int(context.args[1]) + user_id = int(user_id_str) 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 from datetime import datetime, timedelta @@ -923,13 +1107,13 @@ ID: {user_info['ID']} try: await update.effective_chat.restrict_member( - int(user_id), + user_id, until_date=until_date, can_send_messages=False ) await update.message.reply_text(MODERATION_MESSAGES['user_muted'].format(user_id=user_id, time=mute_time)) 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): """Снять заглушку с пользователя (только админы)""" @@ -965,15 +1149,41 @@ ID: {user_info['ID']} await update.message.reply_text(MODERATION_MESSAGES['kick_usage']) return - user_id = context.args[0] + user_id_str = context.args[0] reason = ' '.join(context.args[1:]) + # Валидация ID пользователя try: - await update.effective_chat.ban_member(int(user_id)) - await update.effective_chat.unban_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.effective_chat.unban_member(user_id) # Разбан сразу после бана = кик await update.message.reply_text(MODERATION_MESSAGES['user_kicked'].format(user_id=user_id, reason=reason)) 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): """Повысить пользователя до модератора (только админы)""" @@ -1032,11 +1242,40 @@ ID: {user_info['ID']} await update.message.reply_text(MODERATION_MESSAGES['warn_usage']) return - user_id = context.args[0] + user_id_str = context.args[0] reason = ' '.join(context.args[1:]) - 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)) + # Валидация 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) + 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): """Показать информацию о системе рангов""" @@ -1054,6 +1293,22 @@ ID: {user_info['ID']} 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 if not os.path.isabs(csv_file): csv_file = os.path.join('telegram_bot', csv_file) @@ -1093,16 +1348,40 @@ ID: {user_info['ID']} time_str = args[0] 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: schedule_time = self.parse_schedule_time(time_str) 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 if schedule_time <= datetime.now(): await update.message.reply_text(SCHEDULER_MESSAGES['time_in_past']) 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""" 📝 Предварительный просмотр поста: @@ -1177,10 +1456,22 @@ ID: {user_info['ID']} await update.message.reply_text(SCHEDULER_MESSAGES['usage_delete']) return + post_id_str = context.args[0] + + # Валидация ID поста try: - post_id = int(context.args[0]) + post_id = int(post_id_str) 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 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']) return + post_id_str = context.args[0] + + # Валидация ID поста try: - post_id = int(context.args[0]) + post_id = int(post_id_str) 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 # Получаем пост из базы данных @@ -1501,24 +1804,57 @@ ID: {user_info['ID']} if query.startswith('weather'): city = query.split(' ', 1)[1] if len(query.split(' ', 1)) > 1 else 'Moscow' - if OPENWEATHER_API_KEY: - url = f"http://api.openweathermap.org/data/2.5/weather?q={city}&appid={OPENWEATHER_API_KEY}&units=metric&lang=ru" - response = requests.get(url) - data = response.json() + # Валидация города + 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" + response = requests.get(url, timeout=5) - if data['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']}" + if response.status_code == 200: + 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( + id='1', + title=f"Погода в {city}", + input_message_content=InputTextMessageContent(weather_text) + )) + else: + results.append(InlineQueryResultArticle( + id='1', + title="Город не найден", + 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=f"Погода в {city}", - input_message_content=InputTextMessageContent(weather_text) - )) - else: - results.append(InlineQueryResultArticle( - id='1', - title="Город не найден", - input_message_content=InputTextMessageContent("Город не найден") + title="Ошибка сети", + input_message_content=InputTextMessageContent("Ошибка подключения к серверу погоды") )) + else: + results.append(InlineQueryResultArticle( + id='1', + title="API недоступен", + input_message_content=InputTextMessageContent("API погоды не настроен") + )) elif query.startswith('translate'): # Базовая заглушка для перевода @@ -1526,17 +1862,33 @@ ID: {user_info['ID']} if len(text_parts) >= 3: text = text_parts[1] 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( + id='1', + title=f"Перевод на {lang}", + input_message_content=InputTextMessageContent(result_text) + )) + else: + results.append(InlineQueryResultArticle( + id='1', + title="Ошибка", + input_message_content=InputTextMessageContent("Использование: текст для перевода") + )) + else: results.append(InlineQueryResultArticle( id='1', - title=f"Перевод на {lang}", - input_message_content=InputTextMessageContent(result_text) + title="Использование", + input_message_content=InputTextMessageContent("Использование: weather [город] или translate [текст] [язык]") )) await update.inline_query.answer(results) except Exception as e: print(f"Ошибка при обработке инлайнового запроса: {e}") + # Пустой ответ в случае ошибки + await update.inline_query.answer([]) async def show_start_menu(self, query): """Показать стартовое меню""" diff --git a/database_sqlite.py b/database_sqlite.py index 0d5da63..b2618fe 100644 --- a/database_sqlite.py +++ b/database_sqlite.py @@ -130,26 +130,80 @@ class Database: def update_score(self, user_id, points=1): """Обновление очков пользователя""" 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.execute(""" - UPDATE users SET score = score + ?, message_count = message_count + 1, last_message = CURRENT_TIMESTAMP WHERE user_id = ? - """, (points, user_id)) + UPDATE users SET score = score + ?, reputation = reputation + ?, message_count = message_count + 1, last_message = CURRENT_TIMESTAMP WHERE user_id = ? + """, (points, points, user_id)) + + if cursor.rowcount == 0: + print(f"Предупреждение: пользователь с ID {user_id} не найден для обновления очков") + return False + self.connection.commit() + # Обновление ранга на основе репутации (которая теперь синхронизирована с очками) + self.update_rank(user_id) + return True 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): """Обновление репутации пользователя""" 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.execute(""" UPDATE users SET reputation = reputation + ? WHERE user_id = ? """, (rep_points, user_id)) + + if cursor.rowcount == 0: + print(f"Предупреждение: пользователь с ID {user_id} не найден для обновления репутации") + return False + self.connection.commit() - # Обновление ранга на основе репутации + # Обновление ранга на основе репутации (теперь вызывается только здесь) self.update_rank(user_id) + return True 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): """Обновление ранга пользователя на основе репутации""" @@ -236,34 +290,53 @@ class Database: def get_user_info(self, user_id): """Получение информации о пользователе""" 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.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 FROM users WHERE user_id = ? """, (user_id,)) result = cursor.fetchone() + if result: - return { - 'ID': result[0], - 'Имя': result[1], - 'Имя пользователя': result[2] or 'не указано', - 'Репутация': result[3], - 'Ранг': result[4], - 'Количество сообщений': result[5], - 'Активные дни': result[6], - 'Дней с присоединения': result[7], - 'Последнее сообщение': result[8], - 'Присоединился': result[9], - 'Покинул': result[10], - 'Язык': result[11], - 'Действия': result[12], - 'Очки': result[13], - 'Предупреждений': result[14], - 'Роль': result[15] - } + try: + return { + 'ID': result[0], + 'Имя': result[1] or 'Неизвестно', + 'Имя пользователя': result[2] or 'не указано', + 'Репутация': result[3] or 0, + 'Ранг': result[4] or 'Рядовой', + 'Количество сообщений': result[5] or 0, + 'Активные дни': result[6] or 0, + 'Дней с присоединения': result[7] or 0, + 'Последнее сообщение': result[8], + 'Присоединился': result[9], + 'Покинул': result[10], + 'Язык': result[11] or 'ru', + 'Действия': result[12] or '[]', + 'Очки': result[13] or 0, + 'Предупреждений': result[14] or 0, + 'Роль': result[15] or 'user' + } + except (IndexError, TypeError) as e: + print(f"Ошибка обработки данных пользователя {user_id}: {e}") + return None + return None 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 def import_users_from_csv(self, csv_file_path): @@ -301,16 +374,16 @@ class Database: # Обновляем существующего пользователя cursor.execute(""" UPDATE users - SET username = ?, first_name = ?, last_name = ?, score = ?, warnings = ?, reputation = ? + SET username = ?, first_name = ?, last_name = ?, score = ?, warnings = ?, reputation = ?, rank = ? 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 else: # Добавляем нового пользователя cursor.execute(""" - INSERT INTO users (user_id, username, first_name, last_name, score, warnings, reputation) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, (user_id, username, first_name, last_name, xp, rep, rep)) + INSERT INTO users (user_id, username, first_name, last_name, score, warnings, reputation, rank) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (user_id, username, first_name, last_name, xp, rep, xp, db.calculate_rank(xp))) imported_count += 1 self.connection.commit() @@ -329,15 +402,49 @@ class Database: def add_scheduled_post(self, chat_id, text, schedule_time, created_by, image_path=None): """Добавление запланированного поста""" 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.execute(""" INSERT INTO scheduled_posts (chat_id, text, image_path, schedule_time, created_by) VALUES (?, ?, ?, ?, ?) """, (chat_id, text, image_path, schedule_time, created_by)) + self.connection.commit() return cursor.lastrowid 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 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'): """Добавить донат пользователя""" 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.execute(""" INSERT INTO donations (user_id, amount, currency) VALUES (?, ?, ?) @@ -460,12 +592,27 @@ class Database: # Начисляем очки за донат (1 очко за каждые 100 рублей) points = int(amount // 100) 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() return True 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 def get_total_donations(self, user_id, year=None): diff --git a/test_ranks.py b/test_ranks.py new file mode 100644 index 0000000..16e798b --- /dev/null +++ b/test_ranks.py @@ -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() \ No newline at end of file