diff --git a/.env.example b/.env.example index 8ebaaa0..6302c14 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml new file mode 100644 index 0000000..7f5c728 --- /dev/null +++ b/.gitea/workflows/check.yml @@ -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" diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..ba5fb66 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -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 diff --git a/.github/workflows/run-script.yml b/.github/workflows/run-script.yml deleted file mode 100644 index 76158e9..0000000 --- a/.github/workflows/run-script.yml +++ /dev/null @@ -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 diff --git a/.gitignore b/.gitignore index 7b6caf3..eb9135a 100644 --- a/.gitignore +++ b/.gitignore @@ -124,6 +124,7 @@ celerybeat.pid # Environments .env .venv +blackouts.json env/ venv/ ENV/ diff --git a/README.md b/README.md index 2d93b33..8a7e585 100644 --- a/README.md +++ b/README.md @@ -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 - ```sh - python -m venv .venv - source .venv/bin/activate - pip install -r requirements.txt - ``` +- 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. -2. Copy the example configuration and replace its placeholder values: +## Local setup - ```sh - cp .env.example .env - ``` +Python 3.11 or newer is required. CI uses Python 3.13. -3. Run the checker: +```sh +python -m venv .venv +source .venv/bin/activate +python -m pip install -e '.[test]' +cp .env.example .env +``` - ```sh - python main.py - ``` +Fill in the required values in `.env`, then preview the next run without sending +messages or changing state: -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`. +```sh +python -m blackout_notifier check --dry-run +``` -`BILL_IDS` accepts one or more comma-separated electricity bill IDs. +Run the live checker with: + +```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. diff --git a/blackouts-1404.json b/archive/blackouts-1404.json similarity index 100% rename from blackouts-1404.json rename to archive/blackouts-1404.json diff --git a/blockout.py b/blockout.py deleted file mode 100644 index 10c844a..0000000 --- a/blockout.py +++ /dev/null @@ -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!') diff --git a/database/bills.json b/database/bills.json deleted file mode 100644 index be9d751..0000000 --- a/database/bills.json +++ /dev/null @@ -1,8 +0,0 @@ -[ - { - "id": 7356609804429 - }, - { - "id": 7619314604429 - } -] \ No newline at end of file diff --git a/main.py b/main.py deleted file mode 100644 index 0d705be..0000000 --- a/main.py +++ /dev/null @@ -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\"}^" -""" diff --git a/models.py b/models.py deleted file mode 100644 index fc6ea17..0000000 --- a/models.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b7248b3 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 7a43e0b..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -requests -jdatetime -beautifulsoup4 -python-dotenv diff --git a/site-version.py b/site-version.py deleted file mode 100644 index 0e2d3fd..0000000 --- a/site-version.py +++ /dev/null @@ -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() diff --git a/src/blackout_notifier/__init__.py b/src/blackout_notifier/__init__.py new file mode 100644 index 0000000..ff6ebdc --- /dev/null +++ b/src/blackout_notifier/__init__.py @@ -0,0 +1,3 @@ +"""Planned electricity outage notifier.""" + +__version__ = "1.0.0" diff --git a/src/blackout_notifier/__main__.py b/src/blackout_notifier/__main__.py new file mode 100644 index 0000000..5600f6d --- /dev/null +++ b/src/blackout_notifier/__main__.py @@ -0,0 +1,4 @@ +from blackout_notifier.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/blackout_notifier/cli.py b/src/blackout_notifier/cli.py new file mode 100644 index 0000000..eaad7b7 --- /dev/null +++ b/src/blackout_notifier/cli.py @@ -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 diff --git a/src/blackout_notifier/clients.py b/src/blackout_notifier/clients.py new file mode 100644 index 0000000..f4cbae0 --- /dev/null +++ b/src/blackout_notifier/clients.py @@ -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 diff --git a/src/blackout_notifier/config.py b/src/blackout_notifier/config.py new file mode 100644 index 0000000..7aef673 --- /dev/null +++ b/src/blackout_notifier/config.py @@ -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 diff --git a/src/blackout_notifier/messages.py b/src/blackout_notifier/messages.py new file mode 100644 index 0000000..d1bae42 --- /dev/null +++ b/src/blackout_notifier/messages.py @@ -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) diff --git a/src/blackout_notifier/models.py b/src/blackout_notifier/models.py new file mode 100644 index 0000000..d523269 --- /dev/null +++ b/src/blackout_notifier/models.py @@ -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) diff --git a/src/blackout_notifier/service.py b/src/blackout_notifier/service.py new file mode 100644 index 0000000..dd78010 --- /dev/null +++ b/src/blackout_notifier/service.py @@ -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(), + } diff --git a/src/blackout_notifier/state.py b/src/blackout_notifier/state.py new file mode 100644 index 0000000..9c724f1 --- /dev/null +++ b/src/blackout_notifier/state.py @@ -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}") diff --git a/state/outages.json b/state/outages.json new file mode 100644 index 0000000..81a4277 --- /dev/null +++ b/state/outages.json @@ -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" +} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7851c57 --- /dev/null +++ b/tests/conftest.py @@ -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, + ) diff --git a/tests/test_clients.py b/tests/test_clients.py new file mode 100644 index 0000000..98cebf9 --- /dev/null +++ b/tests/test_clients.py @@ -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"} diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..2981e9d --- /dev/null +++ b/tests/test_config.py @@ -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() diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..3d81275 --- /dev/null +++ b/tests/test_models.py @@ -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", + } + ) diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..5a78f1b --- /dev/null +++ b/tests/test_service.py @@ -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() diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..a165006 --- /dev/null +++ b/tests/test_state.py @@ -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"