feat: add multi-chat routing and bill-specific dashboard countdown
This commit is contained in:
@@ -12,8 +12,8 @@ from blackout_notifier.state import StateError, StateRepository
|
||||
|
||||
|
||||
class ConsoleNotifier:
|
||||
def send(self, text: str) -> None:
|
||||
print("\n--- notification preview ---")
|
||||
def send(self, chat_id: str, text: str) -> None:
|
||||
print(f"\n--- notification preview for chat {chat_id} ---")
|
||||
print(text)
|
||||
|
||||
|
||||
@@ -41,12 +41,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if arguments.dry_run
|
||||
else EitaaClient(
|
||||
config.eitaayar_token,
|
||||
config.chat_id,
|
||||
timeout=config.request_timeout_seconds,
|
||||
)
|
||||
)
|
||||
service = OutageService(
|
||||
bill_ids=config.bill_ids,
|
||||
bill_chat_ids=config.bill_chat_ids,
|
||||
lookahead_days=config.lookahead_days,
|
||||
saapa=SaapaClient(
|
||||
config.bargheman_token,
|
||||
|
||||
@@ -17,7 +17,7 @@ class ExternalServiceError(RuntimeError):
|
||||
|
||||
|
||||
class Notifier(Protocol):
|
||||
def send(self, text: str) -> None: ...
|
||||
def send(self, chat_id: str, text: str) -> None: ...
|
||||
|
||||
|
||||
class SaapaClient:
|
||||
@@ -88,35 +88,40 @@ class EitaaClient:
|
||||
def __init__(
|
||||
self,
|
||||
token: str,
|
||||
chat_id: str,
|
||||
*,
|
||||
timeout: float,
|
||||
session: requests.Session | None = None,
|
||||
) -> None:
|
||||
self._url = f"{EITAAYAR_BASE_URL}/{token}/sendMessage"
|
||||
self._chat_id = chat_id
|
||||
self._timeout = timeout
|
||||
self._session = session or _retrying_session({"GET"})
|
||||
|
||||
def send(self, text: str) -> None:
|
||||
def send(self, chat_id: str, text: str) -> None:
|
||||
try:
|
||||
response = self._session.get(
|
||||
self._url,
|
||||
params={"chat_id": self._chat_id, "text": text},
|
||||
params={"chat_id": chat_id, "text": text},
|
||||
timeout=self._timeout,
|
||||
)
|
||||
except requests.RequestException as error:
|
||||
raise ExternalServiceError(f"Eitaa request failed: {error}") from error
|
||||
raise ExternalServiceError(
|
||||
f"Eitaa request failed for chat {chat_id}: {error}"
|
||||
) from error
|
||||
if not response.ok:
|
||||
raise ExternalServiceError(f"Eitaa returned HTTP {response.status_code}")
|
||||
raise ExternalServiceError(
|
||||
f"Eitaa returned HTTP {response.status_code} for chat {chat_id}"
|
||||
)
|
||||
try:
|
||||
body = response.json()
|
||||
except requests.JSONDecodeError as error:
|
||||
raise ExternalServiceError("Eitaa returned invalid JSON") from error
|
||||
raise ExternalServiceError(
|
||||
f"Eitaa returned invalid JSON for chat {chat_id}"
|
||||
) from error
|
||||
if not isinstance(body, dict) or body.get("ok") is not True:
|
||||
description = body.get("description") if isinstance(body, dict) else None
|
||||
raise ExternalServiceError(
|
||||
f"Eitaa rejected the message: {description or 'unknown error'}"
|
||||
f"Eitaa rejected the message for chat {chat_id}: "
|
||||
f"{description or 'unknown error'}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@@ -15,13 +17,16 @@ class ConfigurationError(ValueError):
|
||||
class Config:
|
||||
bargheman_token: str
|
||||
eitaayar_token: str
|
||||
chat_id: str
|
||||
bill_ids: tuple[str, ...]
|
||||
bill_chat_ids: Mapping[str, tuple[str, ...]]
|
||||
state_file: Path
|
||||
lookahead_days: int = 5
|
||||
request_timeout_seconds: float = 15.0
|
||||
max_events_per_bill: int = 25
|
||||
|
||||
@property
|
||||
def bill_ids(self) -> tuple[str, ...]:
|
||||
return tuple(self.bill_chat_ids)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> Config:
|
||||
load_dotenv(override=False)
|
||||
@@ -29,20 +34,27 @@ class Config:
|
||||
values = {
|
||||
"BARGHEMAN_TOKEN": os.getenv("BARGHEMAN_TOKEN", "").strip(),
|
||||
"EITAAYAR_TOKEN": os.getenv("EITAAYAR_TOKEN", "").strip(),
|
||||
"CHAT_ID": os.getenv("CHAT_ID", "").strip(),
|
||||
}
|
||||
missing = [name for name, value in values.items() if not value]
|
||||
|
||||
raw_bill_ids = os.getenv("BILL_IDS", "")
|
||||
bill_ids = tuple(
|
||||
dict.fromkeys(part.strip() for part in raw_bill_ids.split(",") if part.strip())
|
||||
)
|
||||
if not bill_ids:
|
||||
missing.append("BILL_IDS")
|
||||
elif invalid := [bill_id for bill_id in bill_ids if not bill_id.isdecimal()]:
|
||||
raise ConfigurationError(
|
||||
f"BILL_IDS must contain only decimal IDs: {', '.join(invalid)}"
|
||||
raw_routes = os.getenv("BILL_CHAT_MAP", "").strip()
|
||||
if raw_routes:
|
||||
bill_chat_ids = _bill_chat_map(raw_routes)
|
||||
else:
|
||||
chat_id = os.getenv("CHAT_ID", "").strip()
|
||||
raw_bill_ids = os.getenv("BILL_IDS", "")
|
||||
bill_ids = tuple(
|
||||
dict.fromkeys(part.strip() for part in raw_bill_ids.split(",") if part.strip())
|
||||
)
|
||||
if not chat_id:
|
||||
missing.append("CHAT_ID")
|
||||
if not bill_ids:
|
||||
missing.append("BILL_IDS")
|
||||
elif invalid := [bill_id for bill_id in bill_ids if not bill_id.isdecimal()]:
|
||||
raise ConfigurationError(
|
||||
f"BILL_IDS must contain only decimal IDs: {', '.join(invalid)}"
|
||||
)
|
||||
bill_chat_ids = {bill_id: (chat_id,) for bill_id in bill_ids}
|
||||
|
||||
if missing:
|
||||
raise ConfigurationError(
|
||||
@@ -52,8 +64,7 @@ class Config:
|
||||
return cls(
|
||||
bargheman_token=values["BARGHEMAN_TOKEN"],
|
||||
eitaayar_token=values["EITAAYAR_TOKEN"],
|
||||
chat_id=values["CHAT_ID"],
|
||||
bill_ids=bill_ids,
|
||||
bill_chat_ids=MappingProxyType(bill_chat_ids),
|
||||
state_file=Path(os.getenv("STATE_FILE", "state/outages.json").strip()),
|
||||
lookahead_days=_integer_setting("LOOKAHEAD_DAYS", 5, minimum=1, maximum=30),
|
||||
request_timeout_seconds=_float_setting(
|
||||
@@ -63,6 +74,36 @@ class Config:
|
||||
)
|
||||
|
||||
|
||||
def _bill_chat_map(raw_routes: str) -> dict[str, tuple[str, ...]]:
|
||||
routes: dict[str, list[str]] = {}
|
||||
malformed: list[str] = []
|
||||
for raw_route in raw_routes.split(","):
|
||||
route = raw_route.strip()
|
||||
if not route:
|
||||
continue
|
||||
bill_id, separator, chat_id = route.partition(":")
|
||||
bill_id = bill_id.strip()
|
||||
chat_id = chat_id.strip()
|
||||
if not separator or not bill_id or not chat_id:
|
||||
malformed.append(route)
|
||||
continue
|
||||
if not bill_id.isdecimal():
|
||||
raise ConfigurationError(
|
||||
f"BILL_CHAT_MAP bill IDs must be decimal: {bill_id}"
|
||||
)
|
||||
chat_ids = routes.setdefault(bill_id, [])
|
||||
if chat_id not in chat_ids:
|
||||
chat_ids.append(chat_id)
|
||||
if malformed:
|
||||
raise ConfigurationError(
|
||||
"BILL_CHAT_MAP entries must use BILL_ID:CHAT_ID format: "
|
||||
+ ", ".join(malformed)
|
||||
)
|
||||
if not routes:
|
||||
raise ConfigurationError("BILL_CHAT_MAP must contain at least one route")
|
||||
return {bill_id: tuple(chat_ids) for bill_id, chat_ids in routes.items()}
|
||||
|
||||
|
||||
def _integer_setting(name: str, default: int, *, minimum: int, maximum: int) -> int:
|
||||
raw_value = os.getenv(name, str(default)).strip()
|
||||
try:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
@@ -29,13 +30,13 @@ class OutageService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bill_ids: tuple[str, ...],
|
||||
bill_chat_ids: Mapping[str, tuple[str, ...]],
|
||||
lookahead_days: int,
|
||||
saapa: SaapaClient,
|
||||
notifier: Notifier,
|
||||
state_repository: StateRepository,
|
||||
) -> None:
|
||||
self._bill_ids = bill_ids
|
||||
self._bill_chat_ids = dict(bill_chat_ids)
|
||||
self._lookahead_days = lookahead_days
|
||||
self._saapa = saapa
|
||||
self._notifier = notifier
|
||||
@@ -46,14 +47,14 @@ class OutageService:
|
||||
result = RunResult()
|
||||
from_date, to_date = jalali_query_window(now, self._lookahead_days)
|
||||
|
||||
for bill_id in self._bill_ids:
|
||||
for bill_id, chat_ids in self._bill_chat_ids.items():
|
||||
try:
|
||||
outages = self._saapa.fetch(bill_id, from_date, to_date)
|
||||
except ExternalServiceError as error:
|
||||
result.errors.append(str(error))
|
||||
LOGGER.error("%s", error)
|
||||
continue
|
||||
self._process_bill(state, bill_id, outages, now, result)
|
||||
self._process_bill(state, bill_id, chat_ids, outages, now, result)
|
||||
|
||||
if self._prune_expired(state, now):
|
||||
result.state_changed = True
|
||||
@@ -66,6 +67,7 @@ class OutageService:
|
||||
self,
|
||||
state: dict[str, Any],
|
||||
bill_id: str,
|
||||
chat_ids: tuple[str, ...],
|
||||
outages: list[Outage],
|
||||
now: datetime,
|
||||
result: RunResult,
|
||||
@@ -82,20 +84,26 @@ class OutageService:
|
||||
for event_id, outage in current.items():
|
||||
record = records.get(event_id)
|
||||
if record is None:
|
||||
if self._notify(new_outage_message(bill_id, outage, now), result):
|
||||
if self._notify(chat_ids, new_outage_message(bill_id, outage, now), result):
|
||||
records[event_id] = _record(outage, now)
|
||||
result.state_changed = True
|
||||
continue
|
||||
|
||||
previous = Outage.from_dict(record["snapshot"])
|
||||
if record["status"] == "cancelled":
|
||||
if self._notify(reactivated_outage_message(bill_id, outage, now), result):
|
||||
if self._notify(
|
||||
chat_ids, reactivated_outage_message(bill_id, outage, now), result
|
||||
):
|
||||
records[event_id] = _record(outage, now)
|
||||
result.state_changed = True
|
||||
continue
|
||||
|
||||
if outage.fingerprint() != record["notified_hash"]:
|
||||
if self._notify(updated_outage_message(bill_id, previous, outage, now), result):
|
||||
if self._notify(
|
||||
chat_ids,
|
||||
updated_outage_message(bill_id, previous, outage, now),
|
||||
result,
|
||||
):
|
||||
records[event_id] = _record(outage, now)
|
||||
result.state_changed = True
|
||||
continue
|
||||
@@ -118,7 +126,9 @@ class OutageService:
|
||||
if record["missing_checks"] == 0:
|
||||
record["missing_checks"] = 1
|
||||
result.state_changed = True
|
||||
elif self._notify(cancelled_outage_message(bill_id, previous, now), result):
|
||||
elif self._notify(
|
||||
chat_ids, cancelled_outage_message(bill_id, previous, now), result
|
||||
):
|
||||
record["status"] = "cancelled"
|
||||
record["missing_checks"] = 2
|
||||
record["notified_at"] = now.isoformat()
|
||||
@@ -127,15 +137,18 @@ class OutageService:
|
||||
if records and bill_id not in bills:
|
||||
bills[bill_id] = bill_state
|
||||
|
||||
def _notify(self, message: str, result: RunResult) -> bool:
|
||||
try:
|
||||
self._notifier.send(message)
|
||||
except ExternalServiceError as error:
|
||||
result.errors.append(str(error))
|
||||
LOGGER.error("%s", error)
|
||||
return False
|
||||
result.sent += 1
|
||||
return True
|
||||
def _notify(self, chat_ids: tuple[str, ...], message: str, result: RunResult) -> bool:
|
||||
delivered_to_all = True
|
||||
for chat_id in chat_ids:
|
||||
try:
|
||||
self._notifier.send(chat_id, message)
|
||||
except ExternalServiceError as error:
|
||||
result.errors.append(str(error))
|
||||
LOGGER.error("%s", error)
|
||||
delivered_to_all = False
|
||||
continue
|
||||
result.sent += 1
|
||||
return delivered_to_all
|
||||
|
||||
@staticmethod
|
||||
def _prune_expired(state: dict[str, Any], now: datetime) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user