Files
remnawave-bedolaga-telegram…/app/services/version_service.py
Fringg 1f0fef114b refactor: complete structlog migration with contextvars, kwargs, and logging hardening
- Add ContextVarsMiddleware for automatic user_id/chat_id/username binding
  via structlog contextvars (aiogram) and http_method/http_path (FastAPI)
- Use bound_contextvars() context manager instead of clear_contextvars()
  to safely restore previous state instead of wiping all context
- Register ContextVarsMiddleware as outermost middleware (before GlobalError)
  so all error logs include user context
- Replace structlog.get_logger() with structlog.get_logger(__name__) across
  270 calls in 265 files for meaningful logger names
- Switch wrapper_class from BoundLogger to make_filtering_bound_logger()
  for pre-processor level filtering (performance optimization)
- Migrate 1411 %-style positional arg logger calls to structlog kwargs
  style across 161 files via AST script
- Migrate log_rotation_service.py from stdlib logging to structlog
- Add payment module prefixes to TelegramNotifierProcessor.IGNORED_LOGGER_PREFIXES
  and ExcludePaymentFilter.PAYMENT_MODULES to prevent payment data leaking
  to Telegram notifications and general log files
- Fix LoggingMiddleware: add from_user null-safety for channel posts,
  switch time.time() to time.monotonic() for duration measurement
- Remove duplicate logger assignments in purchase.py, config.py,
  inline.py, and admin/payments.py
2026-02-16 09:18:12 +03:00

273 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import re
from datetime import datetime, timedelta
import aiohttp
import structlog
from packaging import version
from app.config import settings
logger = structlog.get_logger(__name__)
class VersionInfo:
def __init__(self, tag_name: str, published_at: str, name: str, body: str, prerelease: bool = False):
self.tag_name = tag_name
self.published_at = datetime.fromisoformat(published_at.replace('Z', '+00:00'))
self.name = name or tag_name
self.body = body
self.prerelease = prerelease
self.is_dev = 'dev' in tag_name.lower()
@property
def clean_version(self) -> str:
return re.sub(r'^v', '', self.tag_name)
@property
def version_obj(self):
try:
clean_ver = self.clean_version
if 'dev' in clean_ver:
base_ver = clean_ver.split('-dev')[0]
return version.parse(f'{base_ver}.dev')
return version.parse(clean_ver)
except Exception:
return version.parse('0.0.0')
@property
def formatted_date(self) -> str:
return self.published_at.strftime('%d.%m.%Y %H:%M')
@property
def short_description(self) -> str:
if not self.body:
return 'Без описания'
description = self.body.strip()
if len(description) > 350:
description = description[:347] + '...'
return description
@property
def full_description(self) -> str:
"""Full release body text, stripped of leading/trailing whitespace."""
if not self.body:
return ''
return self.body.strip()
class VersionService:
def __init__(self, bot=None):
self.bot = bot
self.repo = getattr(settings, 'VERSION_CHECK_REPO', 'fr1ngg/remnawave-bedolaga-telegram-bot')
self.enabled = getattr(settings, 'VERSION_CHECK_ENABLED', True)
self.current_version = self._get_current_version()
self.cache_ttl = 3600
self._cache: dict = {}
self._last_check: datetime | None = None
self._notification_service = None
async def get_latest_stable_version(self) -> str:
try:
url = f'https://api.github.com/repos/{self.repo}/releases/latest'
async with aiohttp.ClientSession() as session, session.get(url) as response:
if response.status == 200:
data = await response.json()
return data['tag_name']
except Exception:
pass
return 'UNKNOW'
def _get_current_version(self) -> str:
try:
from pathlib import Path
pyproject_path = Path(__file__).resolve().parents[2] / 'pyproject.toml'
if pyproject_path.exists():
for line in pyproject_path.read_text().splitlines():
if line.strip().startswith('version'):
ver = line.split('=', 1)[1].strip().strip('"').strip("'")
if ver:
return ver
except Exception:
pass
return 'UNKNOW'
def set_notification_service(self, notification_service):
self._notification_service = notification_service
async def check_for_updates(self, force: bool = False) -> tuple[bool, list[VersionInfo]]:
if not self.enabled:
return False, []
try:
releases = await self._fetch_releases(force)
if not releases:
return False, []
current_ver = self._parse_version(self.current_version)
newer_releases = []
for release in releases:
release_ver = release.version_obj
if release_ver > current_ver:
newer_releases.append(release)
newer_releases.sort(key=lambda x: x.version_obj, reverse=True)
has_updates = len(newer_releases) > 0
if has_updates and not force:
await self._send_update_notification(newer_releases)
return has_updates, newer_releases
except Exception as e:
logger.error('Ошибка проверки обновлений', error=e)
return False, []
async def _fetch_releases(self, force: bool = False) -> list[VersionInfo]:
if not force and self._cache and self._last_check:
if datetime.now() - self._last_check < timedelta(seconds=self.cache_ttl):
return self._cache.get('releases', [])
url = f'https://api.github.com/repos/{self.repo}/releases'
try:
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session, session.get(url) as response:
if response.status == 200:
data = await response.json()
releases = []
for release_data in data[:20]:
release = VersionInfo(
tag_name=release_data['tag_name'],
published_at=release_data['published_at'],
name=release_data['name'],
body=release_data['body'] or '',
prerelease=release_data['prerelease'],
)
releases.append(release)
self._cache['releases'] = releases
self._last_check = datetime.now()
logger.info('Получено релизов из GitHub', releases_count=len(releases))
return releases
logger.warning('GitHub API вернул статус', status=response.status)
return []
except TimeoutError:
logger.warning('Таймаут при запросе к GitHub API')
return []
except Exception as e:
logger.error('Ошибка запроса к GitHub API', error=e)
return []
def _parse_version(self, version_str: str):
try:
clean_ver = re.sub(r'^v', '', version_str)
if 'dev' in clean_ver:
base_ver = clean_ver.split('-dev')[0]
return version.parse(f'{base_ver}.dev')
if 'unknow' in clean_ver.lower():
return version.parse('0.0.0')
return version.parse(clean_ver)
except Exception:
return version.parse('0.0.0')
async def _send_update_notification(self, newer_releases: list[VersionInfo]):
if not self._notification_service or not newer_releases:
return
try:
latest_version = newer_releases[0]
cache_key = f'notified_{latest_version.tag_name}'
if self._cache.get(cache_key):
return
await self._notification_service.send_version_update_notification(
current_version=self.current_version, latest_version=latest_version, total_updates=len(newer_releases)
)
self._cache[cache_key] = True
except Exception as e:
logger.error('Ошибка отправки уведомления об обновлении', error=e)
async def get_version_info(self) -> dict:
try:
has_updates, newer_releases = await self.check_for_updates()
all_releases = await self._fetch_releases()
current_release = None
current_ver = self._parse_version(self.current_version)
for release in all_releases:
if release.version_obj == current_ver:
current_release = release
break
return {
'current_version': self.current_version,
'current_release': current_release,
'has_updates': has_updates,
'newer_releases': newer_releases[:5],
'total_newer': len(newer_releases),
'last_check': self._last_check,
'repo_url': f'https://github.com/{self.repo}',
}
except Exception as e:
logger.error('Ошибка получения информации о версиях', error=e)
return {
'current_version': self.current_version,
'current_release': None,
'has_updates': False,
'newer_releases': [],
'total_newer': 0,
'last_check': None,
'repo_url': f'https://github.com/{self.repo}',
'error': str(e),
}
async def start_periodic_check(self):
if not self.enabled:
logger.info('Проверка версий отключена')
return
logger.info('Запуск периодической проверки обновлений для', repo=self.repo)
logger.info('Текущая версия', current_version=self.current_version)
while True:
try:
await asyncio.sleep(3600)
await self.check_for_updates()
except asyncio.CancelledError:
logger.info('Остановка проверки обновлений')
break
except Exception as e:
logger.error('Ошибка в периодической проверке обновлений', error=e)
await asyncio.sleep(300)
def format_version_display(self, version_info: VersionInfo) -> str:
status_icon = ''
if version_info.prerelease:
status_icon = '🧪'
elif version_info.is_dev:
status_icon = '🔧'
else:
status_icon = '📦'
return f'{status_icon} {version_info.tag_name}'
version_service = VersionService()