eb18994b7d
- 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
273 lines
9.7 KiB
Python
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()
|