Files
remnawave-bedolaga-telegram…/app/services/version_service.py
T
Fringg eb18994b7d fix: complete datetime.utcnow() → datetime.now(UTC) migration
- Migrate 660+ datetime.utcnow() across 153 files to datetime.now(UTC)
- Migrate 30+ datetime.now() without UTC to datetime.now(UTC)
- Convert all 170 DateTime columns to DateTime(timezone=True)
- Add migrate_datetime_to_timestamptz() in universal_migration with SET LOCAL timezone='UTC' safety
- Remove 70+ .replace(tzinfo=None) workarounds
- Fix utcfromtimestamp → fromtimestamp(..., tz=UTC)
- Fix fromtimestamp() without tz= (system_logs, backup_service, referral_diagnostics)
- Fix fromisoformat/isoparse to ensure aware output (platega, yookassa, wata, miniapp, nalogo)
- Fix strptime() to add .replace(tzinfo=UTC) (backup_service, referral_diagnostics)
- Fix datetime.combine() to include tzinfo=UTC (remnawave_sync, traffic_monitoring)
- Fix datetime.max/datetime.min sentinels with .replace(tzinfo=UTC)
- Rename panel_datetime_to_naive_utc → panel_datetime_to_utc
- Remove DTZ003 from ruff ignore list
2026-02-17 04:45:40 +03:00

273 lines
9.7 KiB
Python

import asyncio
import re
from datetime import UTC, 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(UTC) - 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(UTC)
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()