Skip to content

Commit

Permalink
feat: Remove Sockets and clean some code (#85)
Browse files Browse the repository at this point in the history
* feat: Remove Sockets and clean some code

* feat: reorganized command handlers and filters

* chore: refactor

* flake8

* typo

---------

Co-authored-by: andruten <andruten@users.noreply.github.com>
  • Loading branch information
andruten and andruten authored Jan 31, 2025
1 parent 22a5c4c commit 6a28055
Show file tree
Hide file tree
Showing 23 changed files with 231 additions and 262 deletions.
8 changes: 3 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Health check bot

Welcome to Health check bot 👋!! I'm a python bot for telegram which intends to implement a (very basic) healthcheck
system. I can make HTTP requests or open Sockets.
system. I perform HTTP requests and I'll let you know if your service is healthy :).

## Available commands

Expand All @@ -14,12 +14,10 @@ List all polling services
### add
Add new service to the polling list
```
/add <service_type> <name> <domain> <port>
/add <name> <url>
```
- `service_type` must be "socket" or "request"
- `name` is the name that will be stored
- `domain` is the domain that will be reached by the service
- `port` is the port number
- `url` is the url that will be reached by the service

### remove
Unsubscribe a service from the polling list by name
Expand Down
60 changes: 0 additions & 60 deletions backends.py

This file was deleted.

5 changes: 5 additions & 0 deletions backends/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .request_backend import RequestBackend

__all__ = [
'RequestBackend',
]
16 changes: 16 additions & 0 deletions backends/base_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Tuple, Optional


class BaseBackend(ABC):
def __init__(self, service) -> None:
self.service = service

@abstractmethod
async def check(
self,
*args,
**kwargs,
) -> Tuple[bool, Optional[float], Optional[datetime], Optional[int]]: # pragma: no cover
pass
33 changes: 33 additions & 0 deletions backends/request_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import logging
from datetime import datetime
from typing import Tuple, Optional

import httpx
import ssl

from .base_backend import BaseBackend

logger = logging.getLogger(__name__)


class RequestBackend(BaseBackend):
async def check(self, session) -> Tuple[bool, Optional[float], Optional[datetime], Optional[int]]:
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36'
}
try:
logger.debug(f"Fetching {self.service.url}")
response = await session.request(method='GET', url=self.service.url, headers=headers)
except (httpx.HTTPError, ssl.SSLCertVerificationError,) as exc:
logger.warning(f'"{self.service.url}" request failed {exc}')
return False, None, None, None
else:
raw_stream = response.extensions['network_stream']
ssl_object = raw_stream.get_extra_info('ssl_object')
cert = ssl_object.getpeercert()
expire_date = datetime.strptime(cert['notAfter'], '%b %d %H:%M:%S %Y %Z')
elapsed_total_seconds = response.elapsed.total_seconds()
logger.debug(f'{self.service.url} fetched in {elapsed_total_seconds}')
service_is_healthy = (400 <= response.status_code <= 511)
return not service_is_healthy, elapsed_total_seconds, expire_date, response.status_code
19 changes: 5 additions & 14 deletions commands/add_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,20 @@
from telegram import Update
from telegram.ext import ContextTypes

from command_handlers import add_service_command_handler
from models import HEALTHCHECK_BACKENDS
from commands.handlers import add_service_command_handler


logger = logging.getLogger(__name__)


async def add_service(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
# Validate arguments
if len(context.args) != 4:
await update.message.reply_text('Please, use /add <service_type> <name> <domain> <port>')
if len(context.args) != 2:
await update.message.reply_text('Please, use /add <name> <url>')
return

service_type, name, domain, port = context.args
if service_type.lower() not in HEALTHCHECK_BACKENDS.keys():
await update.message.reply_text(f'<service_type> must be {", ".join(HEALTHCHECK_BACKENDS.keys())}')
return
try:
port = int(port)
except ValueError:
await update.message.reply_text('<port> must be a number')
return
name, url = context.args

service = add_service_command_handler(update.effective_chat.id, service_type, name, domain, port)
service = add_service_command_handler(update.effective_chat.id, name, url)

await update.message.reply_text(f'ok! I\'ve added {service}')
12 changes: 9 additions & 3 deletions commands/check_all_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from telegram.constants import ParseMode
from telegram.ext import ContextTypes

from command_handlers import chat_services_checker_command_handler
from commands.handlers import chat_services_checker_command_handler
from models import Service

logger = logging.getLogger(__name__)
Expand All @@ -17,7 +17,10 @@ async def check_all_services(context: ContextTypes.DEFAULT_TYPE):
fetched_services: Dict[str, List[Service]] = chat_fetched_services[chat_id]
unhealthy_service: Service
for unhealthy_service in fetched_services['unhealthy']:
text = f'{unhealthy_service.name} is down 🤕!'
text = (
f'{unhealthy_service.name} is down 🤕! '
f'\n `HTTP_STATUS_CODE={unhealthy_service.last_http_response_status_code}`'
)
await context.bot.send_message(chat_id=chat_id, text=text)
healthy_service: Service
for healthy_service in fetched_services['healthy']:
Expand All @@ -27,5 +30,8 @@ async def check_all_services(context: ContextTypes.DEFAULT_TYPE):
except (KeyError, TypeError) as e:
logger.debug(f'Exception occurred: {e}')
suffix = ''
text = f'{healthy_service.name} is fixed now{suffix} 😅!'
text = (
f'{healthy_service.name} is fixed now{suffix} 😅!'
f'\n `HTTP_STATUS_CODE={healthy_service.last_http_response_status_code}`'
)
await context.bot.send_message(chat_id=chat_id, text=text, parse_mode=ParseMode.MARKDOWN)
18 changes: 11 additions & 7 deletions command_handlers.py → commands/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

import httpx

from models import Service, ServiceManager, ServiceStatus
from models import Service, ServiceStatus
from repositories import ServiceRepository
from persistence import LocalJsonRepository

logger = logging.getLogger(__name__)
Expand All @@ -17,7 +18,7 @@

async def chat_service_checker_command_handler(chat_id: str) -> dict[str, dict[str, Any]]:
persistence = LocalJsonRepository.create(chat_id)
service_manager = ServiceManager(persistence)
service_manager = ServiceRepository(persistence)
active_services = service_manager.fetch_active()
unhealthy_services = []
healthy_services = []
Expand All @@ -29,8 +30,9 @@ async def chat_service_checker_command_handler(chat_id: str) -> dict[str, dict[s
backend_checks.append(service.healthcheck_backend.check(session))
responses = await asyncio.gather(*backend_checks)
services = []
for service, (service_is_healthy, time_to_first_byte, expire_date) in zip(active_services, responses):
for service, (service_is_healthy, time_to_first_byte, expire_date, http_status) in zip(active_services, responses):
initial_service_status = service.status
service.last_http_response_status_code = http_status
if service_is_healthy is False:
service.status = ServiceStatus.UNHEALTHY
if initial_service_status != ServiceStatus.UNHEALTHY:
Expand All @@ -50,6 +52,7 @@ async def chat_service_checker_command_handler(chat_id: str) -> dict[str, dict[s
service.last_time_healthy = now_utc
service.time_to_first_byte = time_to_first_byte
service.expire_date = expire_date
service.last_http_response_status_code = http_status
services.append(service.to_dict())
service_manager.update(services)

Expand All @@ -75,25 +78,26 @@ async def chat_services_checker_command_handler() -> dict[str, dict]:
return all_chats_fetched_services


def add_service_command_handler(chat_id, service_type, name, domain, port) -> Service:
def add_service_command_handler(chat_id, name, url) -> Service:
persistence = LocalJsonRepository.create(chat_id)
return ServiceManager(persistence).add(service_type, name, domain, port)
return ServiceRepository(persistence).add(name, url)


def remove_services_command_handler(name, chat_id: str) -> None:
persistence = LocalJsonRepository.create(chat_id)
ServiceManager(persistence).remove(name)
ServiceRepository(persistence).remove(name)


def list_services_command_handler(chat_id: str) -> str:
persistence = LocalJsonRepository.create(chat_id)
all_services = ServiceManager(persistence).fetch_all()
all_services = ServiceRepository(persistence).fetch_all()
if not all_services:
return 'There is nothing to see here'
result = ''
for service in all_services:
result += '\n\n'
result += f'`{service.name}` is {service.status.value.upper()}'
result += f'\nHTTP status code: `{service.last_http_response_status_code}`'
if service.status == ServiceStatus.HEALTHY and service.time_to_first_byte is not None:
result += f'\nttfb: `{service.time_to_first_byte}`'
elif service.status == ServiceStatus.UNHEALTHY and service.last_time_healthy is not None:
Expand Down
2 changes: 1 addition & 1 deletion commands/list_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from telegram.constants import ParseMode
from telegram.ext import ContextTypes

from command_handlers import list_services_command_handler
from commands.handlers import list_services_command_handler


async def list_services(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
Expand Down
2 changes: 1 addition & 1 deletion commands/remove_service.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from telegram import Update
from telegram.ext import ContextTypes

from command_handlers import remove_services_command_handler
from commands.handlers import remove_services_command_handler


async def remove_service(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
Expand Down
Empty file added filters/__init__.py
Empty file.
2 changes: 1 addition & 1 deletion filter_allowed_chats.py → filters/allowed_chats.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
logger = logging.getLogger(__name__)


class FilterAllowedChats(MessageFilter):
class AllowedChatsMessageFilter(MessageFilter):

def __init__(self, allowed_chat_ids: List[str]):
super().__init__()
Expand Down
8 changes: 6 additions & 2 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from telegram.ext import ApplicationBuilder, CommandHandler

from commands import check_all_services, list_services, remove_service, add_service, error
from filter_allowed_chats import FilterAllowedChats
from filters.allowed_chats import AllowedChatsMessageFilter

abspath = os.path.abspath(__file__)
directory_name = os.path.dirname(abspath)
Expand All @@ -21,13 +21,17 @@

logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=LOG_LEVEL)

logging.getLogger('telegram').setLevel(logging.WARNING)
logging.getLogger('httpx').setLevel(logging.WARNING)
logging.getLogger('apscheduler').setLevel(logging.WARNING)

logger = logging.getLogger(__name__)


def main() -> None:
app = ApplicationBuilder().token(BOT_TOKEN).build()

filter_allowed_chats = FilterAllowedChats(ALLOWED_CHAT_IDS)
filter_allowed_chats = AllowedChatsMessageFilter(ALLOWED_CHAT_IDS)

job_queue = app.job_queue
job_queue.run_repeating(check_all_services, POLLING_INTERVAL, first=1)
Expand Down
6 changes: 6 additions & 0 deletions models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from .service import Service, ServiceStatus

__all__ = [
'Service',
'ServiceStatus',
]
48 changes: 48 additions & 0 deletions models/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import enum
from dataclasses import dataclass, field, asdict
from datetime import datetime
from typing import Optional, Dict

from backends import RequestBackend


class ServiceStatus(enum.Enum):
UNKNOWN = 'unknown'
HEALTHY = 'healthy'
UNHEALTHY = 'unhealthy'


def service_asdict_factory(data):
def convert_value(obj):
if isinstance(obj, ServiceStatus):
return obj.value
elif isinstance(obj, datetime):
return obj.strftime('%Y-%m-%dT%H:%M:%S.%f')
return obj

return dict((k, convert_value(v)) for k, v in data)


@dataclass
class Service:
name: str = field()
url: str = field()
enabled: bool = field(default=True)
last_time_healthy: Optional[datetime] = field(default=None)
last_http_response_status_code: Optional[int] = field(default=None)
time_to_first_byte: float = field(default=0.0)
status: ServiceStatus = field(init=True, default=ServiceStatus.UNKNOWN)
expire_date: Optional[datetime] = field(default=None)

@property
def healthcheck_backend(self) -> RequestBackend:
return RequestBackend(self)

def __repr__(self) -> str: # pragma: no cover
return f'{self.name} <{self.url}>'

def __str__(self) -> str: # pragma: no cover
return f'{self.name} <{self.url}>'

def to_dict(self) -> Dict:
return asdict(self, dict_factory=service_asdict_factory)
7 changes: 7 additions & 0 deletions persistence/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from .base_persistence import BaseRepository
from .local_json_repository import LocalJsonRepository

__all__ = [
'BaseRepository',
'LocalJsonRepository',
]
Loading

0 comments on commit 6a28055

Please sign in to comment.