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

@@ -1,4 +1,4 @@
# API token used to retrieve planned outages
# API token used to retrieve planned outages from SAAPA
BARGHEMAN_TOKEN=replace-with-bargheman-token
# Eitaa bot configuration
@@ -6,7 +6,10 @@ EITAAYAR_TOKEN=replace-with-eitaayar-token
CHAT_ID=replace-with-chat-id
# One or more comma-separated bill IDs
BILL_IDS=7356609804429,761/pe9314604429
BILL_IDS=7356609804429,7619314604429
# JSON file used to remember outages that have already been announced
BLACKOUTS_FILE=blackouts.json
# Optional settings and their defaults
STATE_FILE=state/outages.json
LOOKAHEAD_DAYS=5
REQUEST_TIMEOUT_SECONDS=15
MAX_EVENTS_PER_BILL=25

View File

@@ -0,0 +1,48 @@
name: Hourly blackout check
on:
schedule:
- cron: "@hourly"
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: https://gitea.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: https://gitea.com/actions/setup-python@v5
with:
python-version: "3.13"
- name: Install project
run: python -m pip install .
- name: Check outages and persist state
env:
BARGHEMAN_TOKEN: ${{ secrets.BARGHEMAN_TOKEN }}
EITAAYAR_TOKEN: ${{ secrets.EITAAYAR_TOKEN }}
CHAT_ID: ${{ secrets.CHAT_ID }}
BILL_IDS: ${{ secrets.BILL_IDS }}
STATE_FILE: state/outages.json
run: |
set +e
python -m blackout_notifier check
checker_status=$?
set -e
if ! git diff --quiet -- state/outages.json; then
git config user.name "gitea-blackout-bot"
git config user.email "blackout-bot@mahgit.ir"
git add state/outages.json
git commit -m "chore(state): update outage notification state [skip ci]"
branch="$(git branch --show-current)"
git pull --rebase origin "$branch"
git push origin "$branch"
fi
exit "$checker_status"

32
.gitea/workflows/test.yml Normal file
View File

@@ -0,0 +1,32 @@
name: Test blackout notifier
on:
push:
paths-ignore:
- state/outages.json
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: https://gitea.com/actions/checkout@v4
- name: Set up Python
uses: https://gitea.com/actions/setup-python@v5
with:
python-version: "3.13"
- name: Install project
run: python -m pip install -e '.[test]'
- name: Lint
run: ruff check .
- name: Check formatting
run: ruff format --check .
- name: Test
run: pytest

View File

@@ -1,49 +0,0 @@
name: Run Script
on:
# schedule:
# - cron: "30 * * * *"
workflow_dispatch:
permissions:
contents: write
jobs:
run-script:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run Python Script
env:
BARGHEMAN_TOKEN: ${{ secrets.BARGHEMAN_TOKEN }}
EITAAYAR_TOKEN: ${{ secrets.EITAAYAR_TOKEN }}
BLACKOUTS_FILE: "blackouts.json"
BILL_IDS: ${{ secrets.BILL_IDS }}
CHAT_ID: ${{ secrets.CHAT_ID }}
run: |
python main.py
- name: Commit & Push updated data
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config --global user.name "github-actions"
git config --global user.email "github-actions@github.com"
git add blackouts.json
git commit -m "Update outage data" || echo "Nothing to commit"
git push

1
.gitignore vendored
View File

@@ -124,6 +124,7 @@ celerybeat.pid
# Environments
.env
.venv
blackouts.json
env/
venv/
ENV/

View File

@@ -1,30 +1,94 @@
# daily-blackout-check
# Daily Blackout Check
Checks planned power outages and sends new notices to an Eitaa chat.
Checks the official SAAPA planned-outage API and sends Persian notifications to
an Eitaa chat when an outage is added, changed, cancelled, or restored.
## Setup
The checker is designed for an hourly Gitea Actions workflow. Its deduplication
state is stored in `state/outages.json` and committed back to this repository.
1. Create and activate a Python virtual environment, then install dependencies:
## Behavior
- Queries each configured bill ID from today through five days ahead.
- Uses Tehran time regardless of the runner container's timezone.
- Sends all upcoming outages when starting with empty state.
- Treats an existing outage number with changed time, address, date, reason, or
planned status as an update.
- Reports a future outage as cancelled only after two consecutive successful
checks omit it.
- Records a notification only after Eitaa accepts it. This favors delivery over
perfect deduplication: a failed state push can cause a duplicate next hour.
- Rejects malformed responses and snapshots larger than the configured safety
limit without changing that bill's state.
## Local setup
Python 3.11 or newer is required. CI uses Python 3.13.
```sh
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
2. Copy the example configuration and replace its placeholder values:
```sh
python -m pip install -e '.[test]'
cp .env.example .env
```
3. Run the checker:
Fill in the required values in `.env`, then preview the next run without sending
messages or changing state:
```sh
python main.py
python -m blackout_notifier check --dry-run
```
The `.env` file is ignored by Git so tokens are not committed. Environment
variables supplied by the shell or GitHub Actions take precedence over `.env`.
Run the live checker with:
`BILL_IDS` accepts one or more comma-separated electricity bill IDs.
```sh
python -m blackout_notifier check
```
## Configuration
| Variable | Required | Default | Purpose |
| --- | --- | --- | --- |
| `BARGHEMAN_TOKEN` | Yes | — | SAAPA bearer token |
| `EITAAYAR_TOKEN` | Yes | — | EitaaYar bot token |
| `CHAT_ID` | Yes | — | Destination Eitaa chat |
| `BILL_IDS` | Yes | — | Comma-separated decimal bill IDs |
| `STATE_FILE` | No | `state/outages.json` | Durable notification state |
| `LOOKAHEAD_DAYS` | No | `5` | Query horizon, from 1 to 30 days |
| `REQUEST_TIMEOUT_SECONDS` | No | `15` | HTTP timeout, from 1 to 120 seconds |
| `MAX_EVENTS_PER_BILL` | No | `25` | Response safety limit, from 1 to 100 |
Shell and Gitea-provided variables take precedence over `.env`.
## Gitea deployment
This repository targets Gitea 1.25.x and a Docker-based runner advertising the
`ubuntu-latest` label. The runner needs outbound access to the Gitea instance,
`gitea.com`, PyPI, SAAPA, and EitaaYar.
1. Enable Actions in the repository settings.
2. Confirm an online runner advertises `ubuntu-latest`.
3. Add these repository Actions secrets:
`BARGHEMAN_TOKEN`, `EITAAYAR_TOKEN`, `CHAT_ID`, and `BILL_IDS`.
4. Run the test workflow manually.
5. Run **Hourly blackout check** manually once and inspect the notification and
resulting state commit.
6. Leave the `@hourly` schedule enabled.
The workflow uses fully qualified `https://gitea.com/actions/...` actions rather
than resolving actions through GitHub. Gitea 1.25 ignores workflow `permissions`
and `concurrency`, so the notifier workflow deliberately does not rely on them.
Its checkout credential must retain the default ability to push to the current
repository. Scheduled jobs should not be manually overlapped; in the rare event
of a push race, the job fails and unsaved notices may be repeated on the next run.
## Development
```sh
ruff check .
pytest
```
Tests mock both external services and never send real messages.
Historical state from the previous implementation is retained at
`archive/blackouts-1404.json` for reference only and is not imported.

View File

@@ -1,20 +0,0 @@
import requests
from bs4 import BeautifulSoup
def get_main_page() -> str:
return requests.get('https://www.qepd.co.ir/').text
def find_blockout_link(html: str) -> None | str:
soup = BeautifulSoup(html, 'html.parser')
a_tags = soup.find_all('a', href=True)
for a_tag in filter(lambda tag: 'اطلاعات برنامه خاموشی ها' in tag.text, a_tags):
return a_tag.get('href')
return None
page = get_main_page()
link = find_blockout_link(page)
if not link:
raise Exception('unable to find blockout link!')

View File

@@ -1,8 +0,0 @@
[
{
"id": 7356609804429
},
{
"id": 7619314604429
}
]

126
main.py
View File

@@ -1,126 +0,0 @@
import os
import json
import requests
from jdatetime import datetime, timedelta
from dotenv import load_dotenv
# Load local configuration without overriding variables supplied by the shell or CI.
load_dotenv()
BARGHEMAN_TOKEN = os.getenv("BARGHEMAN_TOKEN", "").strip()
EITAAYAR_TOKEN = os.getenv("EITAAYAR_TOKEN", "").strip()
CHAT_ID = os.getenv("CHAT_ID", "").strip()
BILL_IDS = [bill_id.strip() for bill_id in os.getenv("BILL_IDS", "").split(",") if bill_id.strip()]
BLACKOUTS_FILE = os.getenv("BLACKOUTS_FILE", "").strip()
required_settings = {
"BARGHEMAN_TOKEN": BARGHEMAN_TOKEN,
"EITAAYAR_TOKEN": EITAAYAR_TOKEN,
"CHAT_ID": CHAT_ID,
"BILL_IDS": BILL_IDS,
"BLACKOUTS_FILE": BLACKOUTS_FILE,
}
missing_settings = [name for name, value in required_settings.items() if not value]
if missing_settings:
raise EnvironmentError(f"Missing required environment variables: {', '.join(missing_settings)}")
today = datetime.now()
to_date = today + timedelta(days=5)
def get_blackouts(bill_id) -> list:
url = "https://uiapi2.saapa.ir/api/ebills/PlannedBlackoutsReport"
headers = {
'accept': 'application/json',
"Authorization": f"Bearer {BARGHEMAN_TOKEN}",
}
payload = {
"bill_id": bill_id,
"from_date": today.strftime("%Y/%m/%d"),
"to_date": to_date.strftime("%Y/%m/%d"),
}
response = requests.post(url, headers=headers, json=payload)
if response.ok:
return response.json().get("data", [])
else:
print(f"❌ Failed to get blackouts: {response.text}")
return []
def load_blackouts() -> dict:
if os.path.exists(BLACKOUTS_FILE):
with open(BLACKOUTS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
return {}
def save_blackouts(data) -> None:
with open(BLACKOUTS_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def format_message(bill_id, entry):
RTLM = "\u200f"
return f"{RTLM}\n{RTLM}".join([
f"⚡️ اطلاعیه خاموشی برق",
f"🆔 شناسه قبض: {bill_id}",
f"📅 تاریخ: {entry['outage_date']}",
f"⏰ ساعت: {entry['outage_start_time']} تا {entry['outage_stop_time']}",
f"📍 محل: {entry['outage_address']}",
f"📝 دلیل: {entry['reason_outage']}",
f"📥 ارسال شده در {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
f".",
])
def send_message(text):
url = f"https://eitaayar.ir/api/{EITAAYAR_TOKEN}/sendMessage"
payload = {
"chat_id": CHAT_ID,
"text": text,
"parse_mode": "HTML"
}
response = requests.get(url, params=payload)
if not response.ok:
print(f"❌ Failed to send message: {response.text}")
def is_entries_valid(entries: list) -> bool:
return 0 < len(entries) < 10
def main() -> None:
previous_data = load_blackouts()
updated_data = previous_data.copy()
for bill_id in BILL_IDS:
new_blackouts = get_blackouts(bill_id)
old_blackouts = previous_data.get(bill_id, [])
new_entries = [entry for entry in new_blackouts if entry not in old_blackouts]
if not is_entries_valid(new_entries):
print('unusual entries was received. count:', len(new_entries))
print(json.dumps(new_entries, ensure_ascii=False, indent=2))
continue
for entry in new_entries:
message = format_message(bill_id, entry)
send_message(message)
if new_entries:
updated_data[bill_id] = old_blackouts + new_entries
save_blackouts(updated_data)
if __name__ == '__main__':
main()
"""
curl ^"https://uiapi2.saapa.ir/api/ebills/PlannedBlackoutsReport^" ^
--compressed ^
-X POST ^
-H ^"Authorization: Bearer ...^" ^
--data-raw ^"{\"bill_id\":\"7619314604429\",\"from_date\":\"1404/03/11\",\"to_date\":\"1404/03/16\"}^"
"""

View File

@@ -1,59 +0,0 @@
import json
from typing import Any
class Model:
drive: str = 'json'
attributes: dict[str, Any] = {}
def __init__(self, attributes: None | dict[str, Any] = None):
if attributes is None:
attributes = {}
for key, value in attributes.items():
self.__setattr__(key, value)
@staticmethod
def all() -> list['Model']:
model = Model()
with open(f'database/{model.get_table()}.json', 'r', encoding='utf-8') as f:
data = json.load(f)
return list(map(lambda attr: Model(attr), data))
def save(self) -> bool:
models = self.all()
models.append(self)
data = list(map(lambda model: dict(model), models))
with open(f'database/{self.get_table()}.json', 'w+', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return True
def get_table(self) -> str:
return 'bills'
def __dict__(self):
return self.attributes
def __getattr__(self, item):
return self.attributes.get(item)
def __setattr__(self, key, value):
self.attributes[key] = value
def __eq__(self, other):
return self.attributes == other.attributes
class Bill(Model):
...
bills = Bill.all()
print(Bill.all()[0].attributes)

38
pyproject.toml Normal file
View File

@@ -0,0 +1,38 @@
[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"
[project]
name = "daily-blackout-check"
version = "1.0.0"
description = "Notify an Eitaa chat about planned electricity outages"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"jdatetime>=5,<7",
"python-dotenv>=1,<2",
"requests>=2.32,<3",
]
[project.optional-dependencies]
test = [
"pytest>=8,<10",
"ruff>=0.12,<1",
]
[project.scripts]
blackout-notifier = "blackout_notifier.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
addopts = "-q"
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]

View File

@@ -1,4 +0,0 @@
requests
jdatetime
beautifulsoup4
python-dotenv

View File

@@ -1,94 +0,0 @@
import os
import re
import time
import requests
from bs4 import BeautifulSoup
token = os.getenv("EITAAYAR_TOKEN")
chat_id = os.getenv("CHAT_ID")
if not token:
raise EnvironmentError("EITAAYAR_TOKEN is not set!")
if not chat_id:
raise EnvironmentError("CHAT_ID is not set!")
def send_message(text: str) -> dict:
sent_at: int = int(time.time())
url = f'https://eitaayar.ir/api/{token}/sendMessage?chat_id={chat_id}&text={text}&title={sent_at=}'
return requests.get(url).json()
def get_blockout_page() -> str:
blockout_link = 'https://qepd.co.ir/fa-IR/DouranPortal/6423'
return requests.get(blockout_link).text
def convert_to_english_numbers(text):
persian_to_english = str.maketrans("۰۱۲۳۴۵۶۷۸۹", "0123456789")
return text.translate(persian_to_english)
def find_times(text: str) -> list[str]:
text = convert_to_english_numbers(text)
pattern = r"(\d{1,2}:\d{2}|\d{1,2})"
return re.findall(pattern, text)
def is_time_past(time_str: str) -> bool:
try:
current_time = datetime.now(iran_tz).time()
if ":" in time_str:
time_obj = datetime.strptime(time_str.strip(), "%H:%M").time()
else:
time_obj = datetime.strptime(time_str.strip(), "%H").time()
return time_obj < current_time
except Exception:
return false
def make_message(html: str) -> str|None:
return None
if len(html) == 0:
return "خطا در خواندن صفحه!"
soup = BeautifulSoup(html, 'html.parser')
# table = soup.find('table', id='ctl01_ctl00_myDataList')
block_needle = 'بلوک A4'
try:
paragraphs = soup.find_all('p')
block = soup.find('p', string=lambda text: isinstance(text, str) and block_needle in text)
time_index = paragraphs.index(block) - 1
text = paragraphs[time_index].get_text(separator='\n', strip=True)
start, end, *_ = find_times(text)
if is_time_past(start):
return None
return text
except:
return f"اطلاعات {block_needle} یافت نشد!"
def main() -> None:
try:
page = get_blockout_page()
message = make_message(page)
except requests.exceptions.Timeout:
message = 'درخواست ارسال نشد!'
if not message:
return None
response = send_message(message)
if not response.get('ok'):
if response.get('description'):
raise Exception(response.get('description'))
else:
raise Exception('something sent wrong!')
if __name__ == '__main__':
main()

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}")

26
state/outages.json Normal file
View File

@@ -0,0 +1,26 @@
{
"bills": {
"7356609804429": {
"events": {
"158818": {
"last_seen_at": "2026-07-17T04:32:29.526959+03:30",
"missing_checks": 0,
"notified_at": "2026-07-17T04:32:29.526959+03:30",
"notified_hash": "66abd8a5158281f5954a081a48a4d7986d34ad6bb0f257a9dd0629d00f2d0f49",
"snapshot": {
"address": "باغ قلعه 8متری سوم ج ترانسفورماتور",
"is_planned": false,
"outage_date": "1405/04/27",
"outage_number": "158818",
"reason": "مدیریت اضطراری بار",
"start_time": "18:00",
"stop_time": "20:00"
},
"status": "active"
}
}
}
},
"schema_version": 1,
"updated_at": "2026-07-17T04:32:29.526959+03:30"
}

25
tests/conftest.py Normal file
View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from datetime import datetime
import pytest
from blackout_notifier.models import TEHRAN, Outage
@pytest.fixture
def now() -> datetime:
return datetime(2026, 7, 17, 8, 0, tzinfo=TEHRAN)
@pytest.fixture
def outage() -> Outage:
return Outage(
outage_number="404992999001",
outage_date="1405/04/27",
start_time="09:00",
stop_time="11:00",
address="فیدر آزمایشی",
reason="مدیریت بار",
is_planned=True,
)

80
tests/test_clients.py Normal file
View File

@@ -0,0 +1,80 @@
from __future__ import annotations
from typing import Any
import pytest
from blackout_notifier.clients import EitaaClient, ExternalServiceError, SaapaClient
class FakeResponse:
def __init__(self, body: Any, *, status_code: int = 200) -> None:
self._body = body
self.status_code = status_code
self.ok = 200 <= status_code < 400
def json(self) -> Any:
return self._body
class FakeSession:
def __init__(self, response: FakeResponse) -> None:
self.response = response
self.calls: list[tuple[str, str, dict[str, Any]]] = []
def post(self, url: str, **kwargs: Any) -> FakeResponse:
self.calls.append(("POST", url, kwargs))
return self.response
def get(self, url: str, **kwargs: Any) -> FakeResponse:
self.calls.append(("GET", url, kwargs))
return self.response
def _payload(number: int = 1) -> dict[str, Any]:
return {
"outage_number": number,
"outage_date": "1405/04/27",
"outage_start_time": "09:00",
"outage_stop_time": "11:00",
"outage_address": "آدرس",
"reason_outage": "مدیریت بار",
"is_planned": True,
}
def test_saapa_client_sends_expected_request() -> None:
session = FakeSession(FakeResponse({"data": [_payload()]}))
client = SaapaClient("secret", timeout=7, max_events_per_bill=25, session=session) # type: ignore[arg-type]
events = client.fetch("123", "1405/04/26", "1405/04/31")
assert len(events) == 1
_, _, kwargs = session.calls[0]
assert kwargs["headers"]["Authorization"] == "Bearer secret"
assert kwargs["json"]["bill_id"] == "123"
assert kwargs["timeout"] == 7
def test_saapa_rejects_oversized_or_duplicate_snapshot() -> None:
oversized = FakeSession(FakeResponse({"data": [_payload(1), _payload(2)]}))
client = SaapaClient("token", timeout=1, max_events_per_bill=1, session=oversized) # type: ignore[arg-type]
with pytest.raises(ExternalServiceError, match="limit is 1"):
client.fetch("123", "from", "to")
duplicate = FakeSession(FakeResponse({"data": [_payload(1), _payload(1)]}))
client = SaapaClient("token", timeout=1, max_events_per_bill=5, session=duplicate) # type: ignore[arg-type]
with pytest.raises(ExternalServiceError, match="duplicate outage_number"):
client.fetch("123", "from", "to")
def test_eitaa_requires_ok_response() -> None:
session = FakeSession(FakeResponse({"ok": False, "description": "denied"}))
client = EitaaClient("token", "chat", timeout=2, session=session) # type: ignore[arg-type]
with pytest.raises(ExternalServiceError, match="denied"):
client.send("message")
_, url, kwargs = session.calls[0]
assert url.endswith("/token/sendMessage")
assert kwargs["params"] == {"chat_id": "chat", "text": "message"}

55
tests/test_config.py Normal file
View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import pytest
from blackout_notifier.config import Config, ConfigurationError
REQUIRED = {
"BARGHEMAN_TOKEN": "bargheman",
"EITAAYAR_TOKEN": "eitaayar",
"CHAT_ID": "chat",
"BILL_IDS": " 123,456,123 ,, ",
}
@pytest.fixture(autouse=True)
def do_not_load_project_dotenv(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("blackout_notifier.config.load_dotenv", lambda **_: None)
def test_config_parses_and_deduplicates_bill_ids(monkeypatch: pytest.MonkeyPatch) -> None:
for name, value in REQUIRED.items():
monkeypatch.setenv(name, value)
config = Config.from_env()
assert config.bill_ids == ("123", "456")
assert str(config.state_file) == "state/outages.json"
assert config.lookahead_days == 5
def test_config_reports_all_missing_required_values(monkeypatch: pytest.MonkeyPatch) -> None:
for name in REQUIRED:
monkeypatch.delenv(name, raising=False)
expected = "BARGHEMAN_TOKEN.*EITAAYAR_TOKEN.*CHAT_ID.*BILL_IDS"
with pytest.raises(ConfigurationError, match=expected):
Config.from_env()
def test_config_rejects_non_decimal_bill_ids(monkeypatch: pytest.MonkeyPatch) -> None:
for name, value in REQUIRED.items():
monkeypatch.setenv(name, value)
monkeypatch.setenv("BILL_IDS", "123,761/pe9314604429")
with pytest.raises(ConfigurationError, match="decimal IDs"):
Config.from_env()
def test_config_validates_numeric_ranges(monkeypatch: pytest.MonkeyPatch) -> None:
for name, value in REQUIRED.items():
monkeypatch.setenv(name, value)
monkeypatch.setenv("LOOKAHEAD_DAYS", "0")
with pytest.raises(ConfigurationError, match="between 1 and 30"):
Config.from_env()

57
tests/test_models.py Normal file
View File

@@ -0,0 +1,57 @@
from __future__ import annotations
from datetime import datetime, timedelta
import pytest
from blackout_notifier.models import TEHRAN, Outage, OutageDataError, jalali_query_window
def test_jalali_query_window_uses_tehran_date() -> None:
utc_near_midnight = datetime.fromisoformat("2026-07-16T20:31:00+00:00")
assert jalali_query_window(utc_near_midnight, 5) == ("1405/04/26", "1405/04/31")
def test_outage_parses_api_payload() -> None:
outage = Outage.from_api(
{
"outage_number": 404992999001,
"outage_date": "1405/04/27",
"outage_start_time": "09:00",
"outage_stop_time": "11:00",
"outage_address": " فیدر آزمایشی ",
"reason_outage": "مدیریت بار",
"is_planned": True,
}
)
assert outage.outage_number == "404992999001"
assert outage.address == "فیدر آزمایشی"
assert outage.start_datetime() == datetime(2026, 7, 18, 9, 0, tzinfo=TEHRAN)
def test_outage_supports_cross_midnight_window() -> None:
outage = Outage(
outage_number="1",
outage_date="1405/04/27",
start_time="23:00",
stop_time="01:00",
address="",
reason="",
is_planned=True,
)
assert outage.stop_datetime() - outage.start_datetime() == timedelta(hours=2)
def test_outage_rejects_invalid_date() -> None:
with pytest.raises(OutageDataError, match="invalid Jalali"):
Outage.from_api(
{
"outage_number": "1",
"outage_date": "not-a-date",
"outage_start_time": "09:00",
"outage_stop_time": "11:00",
}
)

183
tests/test_service.py Normal file
View File

@@ -0,0 +1,183 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import replace
from datetime import datetime
from pathlib import Path
from blackout_notifier.clients import ExternalServiceError
from blackout_notifier.models import Outage
from blackout_notifier.service import OutageService
from blackout_notifier.state import StateRepository
class FakeSaapa:
def __init__(self, snapshots: Mapping[str, list[Outage] | Exception]) -> None:
self.snapshots = dict(snapshots)
self.calls: list[tuple[str, str, str]] = []
def fetch(self, bill_id: str, from_date: str, to_date: str) -> list[Outage]:
self.calls.append((bill_id, from_date, to_date))
result = self.snapshots[bill_id]
if isinstance(result, Exception):
raise result
return result
class FakeNotifier:
def __init__(self, *, fail: bool = False, fail_on_call: int | None = None) -> None:
self.messages: list[str] = []
self.fail = fail
self.fail_on_call = fail_on_call
self.calls = 0
def send(self, text: str) -> None:
self.calls += 1
if self.fail or self.calls == self.fail_on_call:
raise ExternalServiceError("Eitaa unavailable")
self.messages.append(text)
def _service(
path: Path,
saapa: FakeSaapa,
notifier: FakeNotifier,
bill_ids: tuple[str, ...] = ("123",),
) -> OutageService:
return OutageService(
bill_ids=bill_ids,
lookahead_days=5,
saapa=saapa, # type: ignore[arg-type]
notifier=notifier,
state_repository=StateRepository(path),
)
def test_first_run_sends_upcoming_once(tmp_path: Path, now: datetime, outage: Outage) -> None:
path = tmp_path / "state.json"
saapa = FakeSaapa({"123": [outage]})
notifier = FakeNotifier()
service = _service(path, saapa, notifier)
first = service.run(now)
first_state = path.read_text(encoding="utf-8")
second = service.run(now)
assert first.sent == 1
assert first.state_changed is True
assert "خاموشی جدید" in notifier.messages[0]
assert second.sent == 0
assert second.state_changed is False
assert path.read_text(encoding="utf-8") == first_state
def test_changed_outage_sends_field_diff(tmp_path: Path, now: datetime, outage: Outage) -> None:
path = tmp_path / "state.json"
notifier = FakeNotifier()
saapa = FakeSaapa({"123": [outage]})
service = _service(path, saapa, notifier)
service.run(now)
saapa.snapshots["123"] = [replace(outage, start_time="10:00")]
result = service.run(now)
assert result.sent == 1
assert "تغییر در برنامه" in notifier.messages[-1]
assert "09:00" in notifier.messages[-1]
assert "10:00" in notifier.messages[-1]
def test_cancellation_requires_two_successful_omissions_and_can_reactivate(
tmp_path: Path, now: datetime, outage: Outage
) -> None:
path = tmp_path / "state.json"
notifier = FakeNotifier()
saapa = FakeSaapa({"123": [outage]})
service = _service(path, saapa, notifier)
service.run(now)
notifier.messages.clear()
saapa.snapshots["123"] = []
first_missing = service.run(now)
second_missing = service.run(now)
assert first_missing.sent == 0
assert second_missing.sent == 1
assert "دو بررسی پیاپی" in notifier.messages[-1]
saapa.snapshots["123"] = [outage]
restored = service.run(now)
assert restored.sent == 1
assert "بازگشت برنامه" in notifier.messages[-1]
def test_failed_notification_does_not_advance_state(
tmp_path: Path, now: datetime, outage: Outage
) -> None:
path = tmp_path / "state.json"
service = _service(path, FakeSaapa({"123": [outage]}), FakeNotifier(fail=True))
result = service.run(now)
assert result.sent == 0
assert result.errors == ["Eitaa unavailable"]
assert result.state_changed is False
assert not path.exists()
def test_fetch_failure_for_one_bill_does_not_block_another(
tmp_path: Path, now: datetime, outage: Outage
) -> None:
saapa = FakeSaapa({"123": ExternalServiceError("SAAPA failed for 123"), "456": [outage]})
notifier = FakeNotifier()
service = _service(tmp_path / "state.json", saapa, notifier, ("123", "456"))
result = service.run(now)
assert result.sent == 1
assert result.errors == ["SAAPA failed for 123"]
assert len(notifier.messages) == 1
def test_partial_delivery_persists_only_successful_events(
tmp_path: Path, now: datetime, outage: Outage
) -> None:
second_outage = replace(outage, outage_number="404992999002", start_time="12:00")
path = tmp_path / "state.json"
notifier = FakeNotifier(fail_on_call=2)
service = _service(path, FakeSaapa({"123": [outage, second_outage]}), notifier)
result = service.run(now)
records = StateRepository(path).load()["bills"]["123"]["events"]
assert result.sent == 1
assert result.errors == ["Eitaa unavailable"]
assert set(records) == {outage.outage_number}
def test_failed_fetch_never_advances_cancellation_counter(
tmp_path: Path, now: datetime, outage: Outage
) -> None:
path = tmp_path / "state.json"
saapa = FakeSaapa({"123": [outage]})
service = _service(path, saapa, FakeNotifier())
service.run(now)
original_state = path.read_text(encoding="utf-8")
saapa.snapshots["123"] = ExternalServiceError("SAAPA unavailable")
result = service.run(now)
assert result.errors == ["SAAPA unavailable"]
assert result.state_changed is False
assert path.read_text(encoding="utf-8") == original_state
def test_dry_run_never_writes_state(tmp_path: Path, now: datetime, outage: Outage) -> None:
path = tmp_path / "state.json"
service = _service(path, FakeSaapa({"123": [outage]}), FakeNotifier())
result = service.run(now, persist=False)
assert result.sent == 1
assert result.state_changed is True
assert not path.exists()

28
tests/test_state.py Normal file
View File

@@ -0,0 +1,28 @@
from __future__ import annotations
from pathlib import Path
import pytest
from blackout_notifier.state import StateError, StateRepository, empty_state
def test_state_round_trip_is_deterministic(tmp_path: Path) -> None:
repository = StateRepository(tmp_path / "nested" / "outages.json")
repository.save(empty_state())
first = repository.path.read_text(encoding="utf-8")
repository.save(repository.load())
assert repository.load() == empty_state()
assert repository.path.read_text(encoding="utf-8") == first
def test_corrupt_state_fails_closed(tmp_path: Path) -> None:
path = tmp_path / "outages.json"
path.write_text("not json", encoding="utf-8")
with pytest.raises(StateError, match="Cannot read state"):
StateRepository(path).load()
assert path.read_text(encoding="utf-8") == "not json"