Implemented the full clean rewrite for Gitea
All checks were successful
Test blackout notifier / test (push) Successful in 3m41s
Hourly blackout check / check (push) Successful in 8s

This commit is contained in:
Meghdad
2026-07-17 04:33:16 +03:30
parent d7f53ba234
commit 4218e4ac36
30 changed files with 1437 additions and 384 deletions

View File

@@ -0,0 +1,3 @@
"""Planned electricity outage notifier."""
__version__ = "1.0.0"

View File

@@ -0,0 +1,4 @@
from blackout_notifier.cli import main
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,68 @@
from __future__ import annotations
import argparse
import logging
import sys
from blackout_notifier.clients import EitaaClient, SaapaClient
from blackout_notifier.config import Config, ConfigurationError
from blackout_notifier.models import tehran_now
from blackout_notifier.service import OutageService
from blackout_notifier.state import StateError, StateRepository
class ConsoleNotifier:
def send(self, text: str) -> None:
print("\n--- notification preview ---")
print(text)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Check and announce planned power outages")
subparsers = parser.add_subparsers(dest="command", required=True)
check_parser = subparsers.add_parser("check", help="fetch and process planned outages")
check_parser.add_argument(
"--dry-run",
action="store_true",
help="print notifications without sending them or changing state",
)
return parser
def main(argv: list[str] | None = None) -> int:
arguments = build_parser().parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
try:
config = Config.from_env()
state_repository = StateRepository(config.state_file)
notifier = (
ConsoleNotifier()
if arguments.dry_run
else EitaaClient(
config.eitaayar_token,
config.chat_id,
timeout=config.request_timeout_seconds,
)
)
service = OutageService(
bill_ids=config.bill_ids,
lookahead_days=config.lookahead_days,
saapa=SaapaClient(
config.bargheman_token,
timeout=config.request_timeout_seconds,
max_events_per_bill=config.max_events_per_bill,
),
notifier=notifier,
state_repository=state_repository,
)
result = service.run(tehran_now(), persist=not arguments.dry_run)
except (ConfigurationError, StateError) as error:
print(f"Configuration/state error: {error}", file=sys.stderr)
return 2
print(
f"Check complete: sent={result.sent}, state_changed={result.state_changed}, "
f"errors={len(result.errors)}"
)
return 1 if result.errors else 0

View File

@@ -0,0 +1,136 @@
from __future__ import annotations
from typing import Protocol
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from blackout_notifier.models import Outage, OutageDataError
SAAPA_URL = "https://uiapi2.saapa.ir/api/ebills/PlannedBlackoutsReport"
EITAAYAR_BASE_URL = "https://eitaayar.ir/api"
class ExternalServiceError(RuntimeError):
"""Raised when an external API fails or returns unsafe data."""
class Notifier(Protocol):
def send(self, text: str) -> None: ...
class SaapaClient:
def __init__(
self,
token: str,
*,
timeout: float,
max_events_per_bill: int,
session: requests.Session | None = None,
) -> None:
self._token = token
self._timeout = timeout
self._max_events_per_bill = max_events_per_bill
self._session = session or _retrying_session({"POST"})
def fetch(self, bill_id: str, from_date: str, to_date: str) -> list[Outage]:
try:
response = self._session.post(
SAAPA_URL,
headers={
"Accept": "application/json",
"Authorization": f"Bearer {self._token}",
},
json={"bill_id": bill_id, "from_date": from_date, "to_date": to_date},
timeout=self._timeout,
)
except requests.RequestException as error:
raise ExternalServiceError(
f"SAAPA request failed for bill {bill_id}: {error}"
) from error
if not response.ok:
raise ExternalServiceError(
f"SAAPA returned HTTP {response.status_code} for bill {bill_id}"
)
try:
body = response.json()
except requests.JSONDecodeError as error:
raise ExternalServiceError(f"SAAPA returned invalid JSON for bill {bill_id}") from error
if not isinstance(body, dict) or not isinstance(body.get("data"), list):
raise ExternalServiceError(f"SAAPA response schema is invalid for bill {bill_id}")
raw_events = body["data"]
if len(raw_events) > self._max_events_per_bill:
raise ExternalServiceError(
f"SAAPA returned {len(raw_events)} events for bill {bill_id}; "
f"limit is {self._max_events_per_bill}"
)
events: list[Outage] = []
seen_numbers: set[str] = set()
try:
for raw_event in raw_events:
event = Outage.from_api(raw_event)
if event.outage_number in seen_numbers:
raise OutageDataError(
f"duplicate outage_number {event.outage_number} in one snapshot"
)
seen_numbers.add(event.outage_number)
events.append(event)
except OutageDataError as error:
raise ExternalServiceError(f"Invalid outage for bill {bill_id}: {error}") from error
return events
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:
try:
response = self._session.get(
self._url,
params={"chat_id": self._chat_id, "text": text},
timeout=self._timeout,
)
except requests.RequestException as error:
raise ExternalServiceError(f"Eitaa request failed: {error}") from error
if not response.ok:
raise ExternalServiceError(f"Eitaa returned HTTP {response.status_code}")
try:
body = response.json()
except requests.JSONDecodeError as error:
raise ExternalServiceError("Eitaa returned invalid JSON") 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'}"
)
def _retrying_session(methods: set[str]) -> requests.Session:
retry = Retry(
total=3,
connect=3,
read=3,
status=3,
backoff_factor=0.5,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset(methods),
raise_on_status=False,
)
session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=retry))
return session

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
class ConfigurationError(ValueError):
"""Raised when application configuration is missing or invalid."""
@dataclass(frozen=True, slots=True)
class Config:
bargheman_token: str
eitaayar_token: str
chat_id: str
bill_ids: tuple[str, ...]
state_file: Path
lookahead_days: int = 5
request_timeout_seconds: float = 15.0
max_events_per_bill: int = 25
@classmethod
def from_env(cls) -> Config:
load_dotenv(override=False)
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)}"
)
if missing:
raise ConfigurationError(
f"Missing required environment variables: {', '.join(missing)}"
)
return cls(
bargheman_token=values["BARGHEMAN_TOKEN"],
eitaayar_token=values["EITAAYAR_TOKEN"],
chat_id=values["CHAT_ID"],
bill_ids=bill_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(
"REQUEST_TIMEOUT_SECONDS", 15.0, minimum=1.0, maximum=120.0
),
max_events_per_bill=_integer_setting("MAX_EVENTS_PER_BILL", 25, minimum=1, maximum=100),
)
def _integer_setting(name: str, default: int, *, minimum: int, maximum: int) -> int:
raw_value = os.getenv(name, str(default)).strip()
try:
value = int(raw_value)
except ValueError as error:
raise ConfigurationError(f"{name} must be an integer") from error
if not minimum <= value <= maximum:
raise ConfigurationError(f"{name} must be between {minimum} and {maximum}")
return value
def _float_setting(name: str, default: float, *, minimum: float, maximum: float) -> float:
raw_value = os.getenv(name, str(default)).strip()
try:
value = float(raw_value)
except ValueError as error:
raise ConfigurationError(f"{name} must be a number") from error
if not minimum <= value <= maximum:
raise ConfigurationError(f"{name} must be between {minimum:g} and {maximum:g}")
return value

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
from datetime import datetime
import jdatetime
from blackout_notifier.models import TEHRAN, Outage
RTL = "\u200f"
def new_outage_message(bill_id: str, outage: Outage, now: datetime) -> str:
return _join(
"⚡ اطلاعیه خاموشی جدید",
*_outage_lines(bill_id, outage),
_sent_at(now),
)
def updated_outage_message(bill_id: str, previous: Outage, current: Outage, now: datetime) -> str:
labels = {
"outage_date": "تاریخ",
"start_time": "ساعت شروع",
"stop_time": "ساعت پایان",
"address": "محل",
"reason": "دلیل",
"is_planned": "وضعیت برنامه‌ریزی",
}
changes = []
for field, label in labels.items():
old_value = _display_value(getattr(previous, field))
new_value = _display_value(getattr(current, field))
if old_value != new_value:
changes.append(f"✏️ {label}: {old_value}{new_value}")
return _join(
"🔄 تغییر در برنامه خاموشی",
f"🆔 شناسه قبض: {bill_id}",
f"🔖 شماره خاموشی: {current.outage_number}",
*changes,
_sent_at(now),
)
def cancelled_outage_message(bill_id: str, outage: Outage, now: datetime) -> str:
return _join(
"✅ لغو احتمالی برنامه خاموشی",
"این خاموشی در دو بررسی پیاپی مشاهده نشد.",
*_outage_lines(bill_id, outage),
_sent_at(now),
)
def reactivated_outage_message(bill_id: str, outage: Outage, now: datetime) -> str:
return _join(
"⚠️ بازگشت برنامه خاموشی",
*_outage_lines(bill_id, outage),
_sent_at(now),
)
def _outage_lines(bill_id: str, outage: Outage) -> tuple[str, ...]:
return (
f"🆔 شناسه قبض: {bill_id}",
f"🔖 شماره خاموشی: {outage.outage_number}",
f"📅 تاریخ: {outage.outage_date}",
f"⏰ ساعت: {outage.start_time} تا {outage.stop_time}",
f"📍 محل: {outage.address or 'نامشخص'}",
f"📝 دلیل: {outage.reason or 'اعلام نشده'}",
)
def _sent_at(now: datetime) -> str:
local_now = now.astimezone(TEHRAN)
jalali_now = jdatetime.datetime.fromgregorian(datetime=local_now.replace(tzinfo=None))
return f"📥 ارسال در {jalali_now.strftime('%Y/%m/%d %H:%M:%S')}"
def _display_value(value: str | bool) -> str:
if isinstance(value, bool):
return "برنامه‌ریزی‌شده" if value else "برنامه‌ریزی‌نشده"
return value or "نامشخص"
def _join(*lines: str) -> str:
return f"{RTL}\n{RTL}".join(lines)

View File

@@ -0,0 +1,125 @@
from __future__ import annotations
import hashlib
import json
from dataclasses import asdict, dataclass
from datetime import date, datetime, time, timedelta
from typing import Any
from zoneinfo import ZoneInfo
import jdatetime
TEHRAN = ZoneInfo("Asia/Tehran")
class OutageDataError(ValueError):
"""Raised when an outage payload cannot be normalized safely."""
@dataclass(frozen=True, slots=True)
class Outage:
outage_number: str
outage_date: str
start_time: str
stop_time: str
address: str
reason: str
is_planned: bool
@classmethod
def from_api(cls, payload: Any) -> Outage:
if not isinstance(payload, dict):
raise OutageDataError("outage entry must be an object")
outage_number = _required_text(payload, "outage_number")
outage_date = _required_text(payload, "outage_date")
start_time = _required_text(payload, "outage_start_time")
stop_time = _required_text(payload, "outage_stop_time")
address = str(payload.get("outage_address") or payload.get("address") or "").strip()
reason = str(payload.get("reason_outage") or "").strip()
is_planned = payload.get("is_planned", True)
if not isinstance(is_planned, bool):
raise OutageDataError("is_planned must be a boolean")
outage = cls(
outage_number=outage_number,
outage_date=outage_date,
start_time=start_time,
stop_time=stop_time,
address=address,
reason=reason,
is_planned=is_planned,
)
outage.start_datetime()
outage.stop_datetime()
return outage
@classmethod
def from_dict(cls, payload: Any) -> Outage:
if not isinstance(payload, dict):
raise OutageDataError("stored outage snapshot must be an object")
try:
outage = cls(
outage_number=str(payload["outage_number"]),
outage_date=str(payload["outage_date"]),
start_time=str(payload["start_time"]),
stop_time=str(payload["stop_time"]),
address=str(payload["address"]),
reason=str(payload["reason"]),
is_planned=payload["is_planned"],
)
except (KeyError, TypeError) as error:
raise OutageDataError("stored outage snapshot has an invalid schema") from error
if not isinstance(outage.is_planned, bool):
raise OutageDataError("stored is_planned must be a boolean")
outage.start_datetime()
outage.stop_datetime()
return outage
def to_dict(self) -> dict[str, str | bool]:
return asdict(self)
def fingerprint(self) -> str:
serialized = json.dumps(
self.to_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
def start_datetime(self) -> datetime:
return _jalali_datetime(self.outage_date, self.start_time)
def stop_datetime(self) -> datetime:
start = self.start_datetime()
stop = _jalali_datetime(self.outage_date, self.stop_time)
if stop <= start:
stop += timedelta(days=1)
return stop
def jalali_query_window(now: datetime, lookahead_days: int) -> tuple[str, str]:
local_now = now.astimezone(TEHRAN)
start = jdatetime.date.fromgregorian(date=local_now.date())
end = jdatetime.date.fromgregorian(date=local_now.date() + timedelta(days=lookahead_days))
return start.strftime("%Y/%m/%d"), end.strftime("%Y/%m/%d")
def tehran_now() -> datetime:
return datetime.now(TEHRAN)
def _required_text(payload: dict[str, Any], field: str) -> str:
value = payload.get(field)
if value is None or not str(value).strip():
raise OutageDataError(f"{field} is required")
return str(value).strip()
def _jalali_datetime(date_text: str, time_text: str) -> datetime:
try:
year, month, day = (int(part) for part in date_text.split("/"))
hour, minute = (int(part) for part in time_text.split(":"))
gregorian_date: date = jdatetime.date(year, month, day).togregorian()
parsed_time = time(hour, minute)
except (TypeError, ValueError) as error:
raise OutageDataError(f"invalid Jalali date/time: {date_text} {time_text}") from error
return datetime.combine(gregorian_date, parsed_time, tzinfo=TEHRAN)

View File

@@ -0,0 +1,165 @@
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Any
from blackout_notifier.clients import ExternalServiceError, Notifier, SaapaClient
from blackout_notifier.messages import (
cancelled_outage_message,
new_outage_message,
reactivated_outage_message,
updated_outage_message,
)
from blackout_notifier.models import Outage, OutageDataError, jalali_query_window
from blackout_notifier.state import StateRepository
LOGGER = logging.getLogger(__name__)
@dataclass(slots=True)
class RunResult:
sent: int = 0
state_changed: bool = False
errors: list[str] = field(default_factory=list)
class OutageService:
def __init__(
self,
*,
bill_ids: tuple[str, ...],
lookahead_days: int,
saapa: SaapaClient,
notifier: Notifier,
state_repository: StateRepository,
) -> None:
self._bill_ids = bill_ids
self._lookahead_days = lookahead_days
self._saapa = saapa
self._notifier = notifier
self._state_repository = state_repository
def run(self, now: datetime, *, persist: bool = True) -> RunResult:
state = self._state_repository.load()
result = RunResult()
from_date, to_date = jalali_query_window(now, self._lookahead_days)
for bill_id in self._bill_ids:
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)
if self._prune_expired(state, now):
result.state_changed = True
if result.state_changed and persist:
state["updated_at"] = now.isoformat()
self._state_repository.save(state)
return result
def _process_bill(
self,
state: dict[str, Any],
bill_id: str,
outages: list[Outage],
now: datetime,
result: RunResult,
) -> None:
bills = state["bills"]
bill_state = bills.get(bill_id)
if bill_state is None:
bill_state = {"events": {}}
records: dict[str, dict[str, Any]] = bill_state["events"]
current = {
outage.outage_number: outage for outage in outages if outage.stop_datetime() > now
}
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):
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):
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):
records[event_id] = _record(outage, now)
result.state_changed = True
continue
if record["missing_checks"]:
record["missing_checks"] = 0
record["last_seen_at"] = now.isoformat()
result.state_changed = True
for event_id, record in list(records.items()):
if event_id in current or record["status"] != "active":
continue
try:
previous = Outage.from_dict(record["snapshot"])
except OutageDataError as error:
result.errors.append(f"Invalid stored outage for bill {bill_id}: {error}")
continue
if previous.start_datetime() <= now:
continue
if record["missing_checks"] == 0:
record["missing_checks"] = 1
result.state_changed = True
elif self._notify(cancelled_outage_message(bill_id, previous, now), result):
record["status"] = "cancelled"
record["missing_checks"] = 2
record["notified_at"] = now.isoformat()
result.state_changed = True
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
@staticmethod
def _prune_expired(state: dict[str, Any], now: datetime) -> bool:
changed = False
cutoff = now - timedelta(days=30)
for bill_id, bill_state in list(state["bills"].items()):
records = bill_state["events"]
for event_id, record in list(records.items()):
outage = Outage.from_dict(record["snapshot"])
if outage.stop_datetime() < cutoff:
del records[event_id]
changed = True
if not records:
del state["bills"][bill_id]
changed = True
return changed
def _record(outage: Outage, now: datetime) -> dict[str, Any]:
return {
"snapshot": outage.to_dict(),
"notified_hash": outage.fingerprint(),
"status": "active",
"missing_checks": 0,
"last_seen_at": now.isoformat(),
"notified_at": now.isoformat(),
}

View File

@@ -0,0 +1,102 @@
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from typing import Any
from blackout_notifier.models import Outage, OutageDataError
SCHEMA_VERSION = 1
class StateError(RuntimeError):
"""Raised when persisted state is unreadable or unsafe to overwrite."""
def empty_state() -> dict[str, Any]:
return {"schema_version": SCHEMA_VERSION, "updated_at": None, "bills": {}}
class StateRepository:
def __init__(self, path: Path) -> None:
self.path = path
def load(self) -> dict[str, Any]:
if not self.path.exists():
return empty_state()
try:
payload = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise StateError(f"Cannot read state file {self.path}: {error}") from error
self._validate(payload)
return payload
def save(self, state: dict[str, Any]) -> None:
self._validate(state)
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
"w",
encoding="utf-8",
dir=self.path.parent,
prefix=f".{self.path.name}.",
suffix=".tmp",
delete=False,
) as temporary_file:
temporary_path = Path(temporary_file.name)
json.dump(state, temporary_file, ensure_ascii=False, indent=2, sort_keys=True)
temporary_file.write("\n")
temporary_file.flush()
os.fsync(temporary_file.fileno())
os.replace(temporary_path, self.path)
except OSError as error:
raise StateError(f"Cannot save state file {self.path}: {error}") from error
finally:
if temporary_path and temporary_path.exists():
temporary_path.unlink()
@staticmethod
def _validate(state: Any) -> None:
if not isinstance(state, dict) or state.get("schema_version") != SCHEMA_VERSION:
raise StateError(f"State must use schema_version {SCHEMA_VERSION}")
if state.get("updated_at") is not None and not isinstance(state.get("updated_at"), str):
raise StateError("State updated_at must be a string or null")
bills = state.get("bills")
if not isinstance(bills, dict):
raise StateError("State bills must be an object")
for bill_id, bill_state in bills.items():
if not isinstance(bill_id, str) or not isinstance(bill_state, dict):
raise StateError("Invalid bill state")
events = bill_state.get("events")
if not isinstance(events, dict):
raise StateError(f"State events for bill {bill_id} must be an object")
for event_id, record in events.items():
_validate_record(bill_id, event_id, record)
def _validate_record(bill_id: str, event_id: Any, record: Any) -> None:
if not isinstance(event_id, str) or not isinstance(record, dict):
raise StateError(f"Invalid event record for bill {bill_id}")
try:
outage = Outage.from_dict(record.get("snapshot"))
except OutageDataError as error:
raise StateError(
f"Invalid snapshot for bill {bill_id}, event {event_id}: {error}"
) from error
if outage.outage_number != event_id:
raise StateError(f"Event key does not match outage_number for bill {bill_id}")
if record.get("status") not in {"active", "cancelled"}:
raise StateError(f"Invalid event status for bill {bill_id}, event {event_id}")
missing_checks = record.get("missing_checks")
if (
not isinstance(missing_checks, int)
or isinstance(missing_checks, bool)
or missing_checks < 0
):
raise StateError(f"Invalid missing_checks for bill {bill_id}, event {event_id}")
for name in ("notified_hash", "last_seen_at", "notified_at"):
if not isinstance(record.get(name), str):
raise StateError(f"Invalid {name} for bill {bill_id}, event {event_id}")