Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
git \
curl \
&& rm -rf /var/lib/apt/lists/*

RUN pip install --no-cache-dir uv
Expand All @@ -12,4 +14,8 @@ COPY . /app/

RUN uv pip install -e . --system

# Health check configuration
HEALTHCHECK --interval=60s --timeout=10s --start-period=10s --retries=3 \
CMD curl -f http://localhost:5068/health || exit 1

CMD ["tgmusic"]
76 changes: 8 additions & 68 deletions TgMusic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
StartTime = datetime.now()


from TgMusic.core import call, tg, db, config
from TgMusic.core import call, tg, db, config, HealthCheck


class Bot(Client):
Expand All @@ -32,7 +32,7 @@ def __init__(self) -> None:
database_encryption_key="",
options={"ignore_background_updates": config.IGNORE_BACKGROUND_UPDATES},
)

self.health_check = HealthCheck(client=self, port=config.PORT)
self._initialize_services()

def _initialize_services(self) -> None:
Expand Down Expand Up @@ -80,9 +80,13 @@ async def _initialize_components(self) -> None:
await self.call.register_decorators()
await super().start()
await self.call_manager.start()
try:
await self.health_check.start()
except Exception as e:
self.logger.error(f"Failed to start health check: {e}")

self.logger.info("Bot started successfully")
self.loop.create_task(self.watch_dog())


async def stop(self, graceful: bool = True) -> None:
self.logger.info("Stopping bot...")
Expand All @@ -91,6 +95,7 @@ async def stop(self, graceful: bool = True) -> None:
self.db.close(),
self.call_manager.stop(),
self.call.stop_all_clients(),
self.health_check.stop(),
]

if graceful:
Expand All @@ -105,69 +110,4 @@ def _get_uptime(self) -> float:
"""Calculate bot uptime in seconds."""
return (datetime.now() - self._start_time).total_seconds()

async def watch_dog(self):
consecutive_failures = 0
max_backoff = 300
while True:
try:
if not self.is_running:
self.logger.warning("Bot not running, attempting restart...")
await self._restart()

try:
await self.call.health_check()
consecutive_failures = 0
await asyncio.sleep(300)
continue
except Exception as e:
self.logger.error(f"Health check failed: {e}", exc_info=True)
consecutive_failures += 1

backoff = min(5 * (2 ** consecutive_failures), max_backoff)
backoff = backoff * (0.5 + random.random())

self.logger.warning(
f"Health check failed {consecutive_failures} times. "
f"Retrying in {backoff:.1f}s..."
)

if consecutive_failures >= 3:
await self._restart()

await asyncio.sleep(backoff)

except asyncio.CancelledError:
self.logger.info("Watchdog stopped by cancellation")
raise
except Exception as e:
self.logger.critical(
f"Critical error in watchdog: {e}",
exc_info=True
)
await asyncio.sleep(100)

async def _restart(self):
import traceback

try:
self.logger.info("Initiating safe restart...")
await self.stop(graceful=True)
await asyncio.sleep(2)
if hasattr(self, 'call') and hasattr(self.call, 'pyrogram_clients'):
for _client in self.call.pyrogram_clients.values():
try:
if _client.is_connected:
await client.stop()
except Exception as e:
self.logger.error(f"Error stopping client: {e}")
await self.start()
self.logger.info("Restart completed successfully")

except Exception as e:
self.logger.critical(
f"Failed to restart: {e}\n{traceback.format_exc()}"
)
raise


client: Client = Bot()
2 changes: 2 additions & 0 deletions TgMusic/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from ._filters import Filter
from .buttons import SupportButton, control_buttons
from ._save_cookies import save_all_cookies
from ._health import HealthCheck

__all__ = [
"admins_only",
Expand All @@ -46,4 +47,5 @@
"PlatformTracks",
"SupportButton",
"Filter",
"HealthCheck"
]
1 change: 1 addition & 0 deletions TgMusic/core/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def __init__(self):
self.LOGGER_ID: int = self._get_env_int("LOGGER_ID", -1002166934878)

# Optional Settings
self.PORT: int = self._get_env_int("PORT", 5068)
self.PROXY: Optional[str] = os.getenv("PROXY")
self.DEFAULT_SERVICE: str = os.getenv("DEFAULT_SERVICE", "youtube").lower()
self.MIN_MEMBER_COUNT: int = self._get_env_int("MIN_MEMBER_COUNT", 50)
Expand Down
62 changes: 62 additions & 0 deletions TgMusic/core/_health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from aiohttp import web
import asyncio

from typing import TYPE_CHECKING
if TYPE_CHECKING:
from TgMusic import Bot

class HealthCheck:
def __init__(self, client: 'Bot', port, host='0.0.0.0'):
self.client = client
self.port = port
self.host = host
self.app = web.Application()
self.runner = None
self.site = None

async def home(self, _: web.Request):
bot_name = self.client.me.first_name
return web.json_response({
'Bot': bot_name,
'version': getattr(self.client, '_version', 'unknown'),
'uptime': getattr(self.client, '_get_uptime', lambda: 0)()
})

async def health_check(self, _: web.Request):
import logging

if not self.client:
logging.error("HealthCheck failed: Client not initialized")
raise web.HTTPServiceUnavailable(text="Service temporarily unavailable")

if not getattr(self.client, 'is_running', False):
logging.error("HealthCheck failed: Client not running")
raise web.HTTPServiceUnavailable(text="Service temporarily unavailable")

try:
await self.client.call.health_check()
except RuntimeError as e:
self.client.logger.error(f"Health check failed: {e}")
raise web.HTTPServiceUnavailable(text="Pyrogram client not running")
Comment thread
AshokShau marked this conversation as resolved.

return web.json_response({
'status': 'healthy',
'version': getattr(self.client, '_version', 'unknown'),
'uptime': getattr(self.client, '_get_uptime', lambda: 0)(),
'timestamp': asyncio.get_event_loop().time(),
Comment thread
AshokShau marked this conversation as resolved.
})

async def start(self):
self.app.router.add_get('/', self.home)
self.app.router.add_get('/health', self.health_check)
self.runner = web.AppRunner(self.app)
await self.runner.setup()
self.site = web.TCPSite(self.runner, self.host, self.port)
await self.site.start()
self.client.logger.info(f"Health check server started on http://{self.host}:{self.port}")

async def stop(self):
if self.site:
await self.site.stop()
if self.runner:
await self.runner.cleanup()
22 changes: 18 additions & 4 deletions TgMusic/core/_tgcalls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
from pathlib import Path
from typing import Optional, Union

from ntgcalls import TelegramServerError, ConnectionNotFound
import ntgcalls
from ntgcalls import ConnectionNotFound
from pyrogram import Client as PyroClient
from pyrogram import errors
from pytdbot import Client, types
Expand Down Expand Up @@ -152,7 +153,8 @@ async def health_check(self) -> None:
for name, client in self.pyrogram_clients.items():
try:
await client.get_me()
await client.send_message("me", "Health check")
if not client.is_connected:
raise RuntimeError("Client not connected")
LOGGER.debug("Client %s is healthy", name)
except (errors.Flood, errors.FloodWait):
LOGGER.warning("Flood error while checking health of client %s", name)
Expand Down Expand Up @@ -180,6 +182,12 @@ async def general_handler(_, update: Update, _call=_call):
"Cleaning up chat %s after leaving", update.chat_id
)
chat_cache.clear_chat(update.chat_id)
elif isinstance(update, ChatUpdate) and update.status.CLOSED_VOICE_CHAT:
LOGGER.debug(
"Cleaning up chat %s after leaving", update.chat_id
)
chat_cache.clear_chat(update.chat_id)
await self.end(update.chat_id)
except Exception as e:
LOGGER.error("Error in general handler: %s", e, exc_info=True)

Expand Down Expand Up @@ -247,13 +255,19 @@ async def play_media(
)

return types.Ok()
except (exceptions.NoActiveGroupCall, ConnectionNotFound):
except (exceptions.NoActiveGroupCall, ntgcalls.ConnectionNotFound):
return types.Error(
code=404,
message="No active voice chat found.\n\n"
"Please start a voice chat and try again.",
)
except TelegramServerError:
except ntgcalls.ConnectionError as e:
LOGGER.error("Connection error during playback: %s", e)
return types.Error(
code=502,
message="Connection error detected. Please try again later.\nDid you just close the voice chat?",
)
except ntgcalls.TelegramServerError:
LOGGER.warning("Telegram server error during playback")
return types.Error(
code=502,
Expand Down
1 change: 1 addition & 0 deletions TgMusic/modules/watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ async def new_message(client: Client, update: types.UpdateNewMessage) -> None:

if isinstance(content, types.MessageVideoChatStarted):
LOGGER.info("Video chat started in %s", chat_id)
await call.end(chat_id)
chat_cache.clear_chat(chat_id)
await client.sendTextMessage(
chat_id, "Video chat started!\nUse /play song name to play a song"
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ dependencies = [
"pycryptodome~=3.23.0",
"pydantic~=2.11.7",
"pymongo~=4.14.1",
"py-tgcalls~=2.2.7",
"py-tgcalls~=2.2.8",
"pytgcrypto~=1.2.11",
"pytdbot~=0.9.6.dev1",
"pytdbot~=0.9.6",
"tdjson~=1.8.52",
"ujson~=5.11.0",
"yt-dlp~=2025.8.27",
Expand Down
18 changes: 0 additions & 18 deletions requirements.txt

This file was deleted.

14 changes: 7 additions & 7 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.