From 42687ee6512c98800f3a397080074e8a86fd16dd Mon Sep 17 00:00:00 2001 From: Meghdad <61308304+MeghdadFadaee@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:14:45 +0330 Subject: [PATCH] feat: add multi-chat routing and bill-specific dashboard countdown --- .env.example | 5 +- .gitea/workflows/check.yml | 3 +- README.md | 59 +++++++++++++++----- main.sh | 96 +++++++++++++++++++++++++------- src/blackout_notifier/cli.py | 7 +-- src/blackout_notifier/clients.py | 23 +++++--- src/blackout_notifier/config.py | 69 ++++++++++++++++++----- src/blackout_notifier/service.py | 47 ++++++++++------ tests/test_clients.py | 4 +- tests/test_config.py | 40 +++++++++++-- tests/test_main.sh | 28 ++++++++++ tests/test_service.py | 91 ++++++++++++++++++++++++++---- 12 files changed, 372 insertions(+), 100 deletions(-) diff --git a/.env.example b/.env.example index 6302c14..6c9cc97 100644 --- a/.env.example +++ b/.env.example @@ -3,10 +3,9 @@ BARGHEMAN_TOKEN=replace-with-bargheman-token # Eitaa bot configuration EITAAYAR_TOKEN=replace-with-eitaayar-token -CHAT_ID=replace-with-chat-id -# One or more comma-separated bill IDs -BILL_IDS=7356609804429,7619314604429 +# Repeat a bill ID to send its notifications to multiple chats. +BILL_CHAT_MAP=BILL_ID:FIRST_CHAT_ID,BILL_ID:SECOND_CHAT_ID,OTHER_BILL_ID:THIRD_CHAT_ID # Optional settings and their defaults STATE_FILE=state/outages.json diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 393a95b..8eeea53 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -25,8 +25,7 @@ jobs: env: BARGHEMAN_TOKEN: ${{ secrets.BARGHEMAN_TOKEN }} EITAAYAR_TOKEN: ${{ secrets.EITAAYAR_TOKEN }} - CHAT_ID: ${{ secrets.CHAT_ID }} - BILL_IDS: ${{ secrets.BILL_IDS }} + BILL_CHAT_MAP: ${{ secrets.BILL_CHAT_MAP }} STATE_FILE: state/outages.json run: | set +e diff --git a/README.md b/README.md index 1cbb382..f031912 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # Daily Blackout Check Checks the official SAAPA planned-outage API and sends Persian notifications to -an Eitaa chat when an outage is added, changed, cancelled, or restored. +the Eitaa chats configured for each bill when an outage is added, changed, +cancelled, or restored. 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. @@ -9,14 +10,16 @@ state is stored in `state/outages.json` and committed back to this repository. ## Behavior - Queries each configured bill ID from today through five days ahead. +- Routes each bill's notifications to all of its configured Eitaa chats. - 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. +- Records a notification only after every configured chat accepts it. This + favors delivery over perfect deduplication: if one chat fails, successful + destinations may receive a retry on the next run. - Rejects malformed responses and snapshots larger than the configured safety limit without changing that bill's state. @@ -50,8 +53,7 @@ python -m blackout_notifier check | --- | --- | --- | --- | | `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 | +| `BILL_CHAT_MAP` | Yes | — | Comma-separated `BILL_ID:CHAT_ID` routes | | `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 | @@ -59,6 +61,18 @@ python -m blackout_notifier check Shell and Gitea-provided variables take precedence over `.env`. +For example, this sends bill `7356609804429` to chats `10251670` and `20873456`, +while bill `7619314604429` is sent only to chat `30984567`: + +```dotenv +BILL_CHAT_MAP=7356609804429:10251670,7356609804429:20873456,7619314604429:30984567 +``` + +Repeat a bill ID with another chat ID to add destinations; duplicate identical +routes are ignored. The old `CHAT_ID` plus `BILL_IDS` configuration is still +accepted when `BILL_CHAT_MAP` is absent, but it sends every bill to the same chat +and is intended only for backward compatibility. + ## Gitea deployment This repository targets Gitea 1.25.x and a Docker-based runner advertising the @@ -91,8 +105,7 @@ set to **Read and Write**. Add these repository Actions secrets: | `REGISTRY_TOKEN` | Personal access token with package Read and Write permission | | `BARGHEMAN_TOKEN` | SAAPA bearer token | | `EITAAYAR_TOKEN` | EitaaYar bot token | -| `CHAT_ID` | Destination chat ID | -| `BILL_IDS` | Comma-separated bill IDs | +| `BILL_CHAT_MAP` | Comma-separated `BILL_ID:CHAT_ID` routes | The registry token is used to publish the image and as `container.credentials` when Gitea Runner pulls the private image before starting a job. @@ -183,6 +196,16 @@ It requires Bash, `curl`, `jq`, and GNU `date`: ./main.sh ``` +On macOS, install the modern Bash and GNU `date` implementations with Homebrew: + +```sh +brew install bash coreutils +``` + +`main.sh` automatically switches from Apple's Bash 3.2 to Homebrew Bash and +adds Homebrew's GNU utilities to `PATH`, so both `./main.sh` and +`bash ./main.sh` work after installation. + The display uses a flicker-free alternate terminal screen and runs until `q` is pressed (or it is interrupted with `Ctrl+C`). It updates the countdown every second and refetches the committed state every 10 minutes. Set a different whole @@ -192,12 +215,22 @@ minute interval with `-i`/`--interval`: ./main.sh --interval 5 ``` -The dashboard uses Tehran time to show a large `HH : mm : ss` countdown for the outage -currently in progress or the nearest upcoming outage. A compact schedule below it -lists all active current and future events, including emergency outages. The -terminal output uses English-only labels and omits the stored address and reason -text. When there are no qualifying events it prints an `ALL CLEAR` screen and exits -successfully. +Select one bill for the large countdown with `-b`/`--bill-id`: + +```sh +./main.sh --bill-id 7356609804429 +``` + +The dashboard uses Tehran time to show a large `HH : mm : ss` countdown for the +selected bill's ongoing or nearest upcoming outage. The compact schedule below +continues to list active current and future events for every bill, including +emergency outages. If the selected bill has no qualifying outage, the countdown +area says so while the full schedule remains visible. Without `--bill-id`, the +countdown retains the original behavior and selects across all bills. + +The terminal output uses English-only labels and omits the stored address and +reason text. When there are no qualifying events for any bill it prints an `ALL +CLEAR` screen and exits successfully. Colors are enabled only when output is attached to a terminal. Set `NO_COLOR` to disable them explicitly, or set `COLUMNS` to control the rendered width: diff --git a/main.sh b/main.sh index 3e1d443..9c12fb3 100755 --- a/main.sh +++ b/main.sh @@ -2,6 +2,31 @@ set -euo pipefail +# macOS ships Bash 3.2 and BSD date. Prefer Homebrew's compatible runtime when +# it is installed, even when this script was started explicitly with /bin/bash. +for coreutils_path in \ + /opt/homebrew/opt/coreutils/libexec/gnubin \ + /usr/local/opt/coreutils/libexec/gnubin; do + if [[ -d "$coreutils_path" ]]; then + PATH="$coreutils_path:$PATH" + export PATH + break + fi +done +unset coreutils_path + +if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3))); then + for modern_bash in /opt/homebrew/bin/bash /usr/local/bin/bash; do + if [[ -x "$modern_bash" ]]; then + exec "$modern_bash" "$0" "$@" + fi + done + printf 'Error: Bash 4.3 or newer is required (found %s).\n' "$BASH_VERSION" >&2 + printf 'On macOS, install it with: brew install bash coreutils\n' >&2 + exit 2 +fi +unset modern_bash + readonly STATE_URL="https://mahgit.ir/MeghdadFadaee/daily-blackout-check/raw/branch/main/state/outages.json" readonly TEHRAN_TZ="Asia/Tehran" @@ -36,20 +61,21 @@ trap cleanup EXIT usage() { cat <<'EOF' -Usage: ./main.sh [-i MINUTES] +Usage: ./main.sh [-i MINUTES] [-b BILL_ID] Continuously display current and upcoming electricity outages from the public repository state. The countdown updates every second. Options: -i, --interval MINUTES Refetch state at this interval (default: 10) + -b, --bill-id BILL_ID Use only this bill for the large countdown -h, --help Show this help Environment: NO_COLOR Disable ANSI colors when set COLUMNS Override the detected output width -Required commands: bash, curl, jq, and GNU date +Required commands: Bash 4.3+, curl, jq, and GNU date EOF } @@ -299,6 +325,7 @@ record_fields() { build_schedule() { local path="$1" local now_epoch="$2" + local countdown_bill_id="${3:-}" local record start_epoch stop_epoch phase phase_order sort_key line hero_key="" local -a fields sortable=() @@ -324,7 +351,8 @@ build_schedule() { phase="ONGOING" phase_order=0 sort_key="$(printf '0:%020d:%s:%s' "$start_epoch" "${fields[0]}" "${fields[1]}")" - if [[ -z "$hero_key" || "$stop_epoch" -lt "$hero_key" ]]; then + if [[ (-z "$countdown_bill_id" || "${fields[0]}" == "$countdown_bill_id") \ + && (-z "$hero_key" || "$stop_epoch" -lt "$hero_key") ]]; then hero_key="$stop_epoch" HERO="$phase"$'\t'"$start_epoch"$'\t'"$stop_epoch"$'\t'"$record" fi @@ -332,9 +360,11 @@ build_schedule() { phase="UPCOMING" phase_order=1 sort_key="$(printf '1:%020d:%s:%s' "$start_epoch" "${fields[0]}" "${fields[1]}")" - if [[ -z "$hero_key" && -z "$HERO" ]]; then + if [[ (-z "$countdown_bill_id" || "${fields[0]}" == "$countdown_bill_id") \ + && -z "$hero_key" && -z "$HERO" ]]; then HERO="$phase"$'\t'"$start_epoch"$'\t'"$stop_epoch"$'\t'"$record" - elif [[ "${HERO%%$'\t'*}" == "UPCOMING" ]]; then + elif [[ (-z "$countdown_bill_id" || "${fields[0]}" == "$countdown_bill_id") \ + && "${HERO%%$'\t'*}" == "UPCOMING" ]]; then local current_hero_start current_hero_start="${HERO#*$'\t'}" current_hero_start="${current_hero_start%%$'\t'*}" @@ -772,6 +802,7 @@ schedule_next_transition() { render_dashboard() { local now_epoch="$1" local columns="${2:-}" + local countdown_bill_id="${3:-}" local rule phase start_epoch stop_epoch record remaining banner local schedule_line ignored @@ -785,20 +816,30 @@ render_dashboard() { return fi - IFS=$'\t' read -r phase start_epoch stop_epoch record <<<"$HERO" - if [[ "$phase" == "ONGOING" ]]; then - remaining=$((stop_epoch - now_epoch)) - else - remaining=$((start_epoch - now_epoch)) - fi - banner="$(banner_duration "$remaining")" rule="$(repeat_character '=' "$columns")" - printf '%s%s%s\n' "$CYAN" "$rule" "$RESET" printf '\n' - printf '%s%s' "$BOLD" "$YELLOW" - print_large_countdown "$banner" "$columns" - printf '%s' "$RESET" + + if [[ -z "$HERO" ]]; then + center_line "NO CURRENT OR UPCOMING OUTAGE" "$columns" + center_line "Countdown bill $countdown_bill_id" "$columns" + else + IFS=$'\t' read -r phase start_epoch stop_epoch record <<<"$HERO" + if [[ "$phase" == "ONGOING" ]]; then + remaining=$((stop_epoch - now_epoch)) + else + remaining=$((start_epoch - now_epoch)) + fi + banner="$(banner_duration "$remaining")" + if [[ -n "$countdown_bill_id" ]]; then + center_line "COUNTDOWN FOR BILL $countdown_bill_id" "$columns" + printf '\n' + fi + printf '%s%s' "$BOLD" "$YELLOW" + print_large_countdown "$banner" "$columns" + printf '%s' "$RESET" + fi + printf '\n%s%s%s\n' "$CYAN" "$rule" "$RESET" printf '\n%sOUTAGE SCHEDULE (%d)%s\n\n' "$BOLD" "${#SCHEDULE[@]}" "$RESET" @@ -812,7 +853,7 @@ render_dashboard() { main() { local now_epoch next_refresh=0 refresh_minutes=10 refresh_seconds state_ready=0 local last_rendered=-1 next_transition=0 rebuild_schedule=0 frame key input_fd=0 - local display_columns + local display_columns countdown_bill_id="" while (($#)); do case "$1" in @@ -833,6 +874,19 @@ main() { refresh_minutes="${1#*=}" shift ;; + -b|--bill-id) + if (($# < 2)); then + printf 'Error: %s requires a value\n' "$1" >&2 + usage >&2 + return 2 + fi + countdown_bill_id="$2" + shift 2 + ;; + --bill-id=*) + countdown_bill_id="${1#*=}" + shift + ;; *) printf 'Error: unknown option: %s\n' "$1" >&2 usage >&2 @@ -845,6 +899,10 @@ main() { printf 'Error: interval must be a whole number from 1 to 1440 minutes\n' >&2 return 2 fi + if [[ -n "$countdown_bill_id" && ! "$countdown_bill_id" =~ ^[0-9]+$ ]]; then + printf 'Error: bill ID must contain only decimal digits\n' >&2 + return 2 + fi refresh_seconds=$((10#$refresh_minutes * 60)) require_command curl @@ -890,7 +948,7 @@ main() { if ((state_ready)); then if ((rebuild_schedule || (next_transition > 0 && now_epoch >= next_transition))); then - if build_schedule "$STATE_PATH" "$now_epoch"; then + if build_schedule "$STATE_PATH" "$now_epoch" "$countdown_bill_id"; then next_transition="$(schedule_next_transition "$now_epoch")" rebuild_schedule=0 else @@ -903,7 +961,7 @@ main() { display_columns="$(terminal_columns)" frame="$( if ((state_ready)); then - render_dashboard "$now_epoch" "$display_columns" + render_dashboard "$now_epoch" "$display_columns" "$countdown_bill_id" else render_unavailable "$display_columns" fi diff --git a/src/blackout_notifier/cli.py b/src/blackout_notifier/cli.py index eaad7b7..6801efd 100644 --- a/src/blackout_notifier/cli.py +++ b/src/blackout_notifier/cli.py @@ -12,8 +12,8 @@ from blackout_notifier.state import StateError, StateRepository class ConsoleNotifier: - def send(self, text: str) -> None: - print("\n--- notification preview ---") + def send(self, chat_id: str, text: str) -> None: + print(f"\n--- notification preview for chat {chat_id} ---") print(text) @@ -41,12 +41,11 @@ def main(argv: list[str] | None = None) -> int: if arguments.dry_run else EitaaClient( config.eitaayar_token, - config.chat_id, timeout=config.request_timeout_seconds, ) ) service = OutageService( - bill_ids=config.bill_ids, + bill_chat_ids=config.bill_chat_ids, lookahead_days=config.lookahead_days, saapa=SaapaClient( config.bargheman_token, diff --git a/src/blackout_notifier/clients.py b/src/blackout_notifier/clients.py index f4cbae0..be49896 100644 --- a/src/blackout_notifier/clients.py +++ b/src/blackout_notifier/clients.py @@ -17,7 +17,7 @@ class ExternalServiceError(RuntimeError): class Notifier(Protocol): - def send(self, text: str) -> None: ... + def send(self, chat_id: str, text: str) -> None: ... class SaapaClient: @@ -88,35 +88,40 @@ class EitaaClient: def __init__( self, token: str, - chat_id: str, *, timeout: float, session: requests.Session | None = None, ) -> None: self._url = f"{EITAAYAR_BASE_URL}/{token}/sendMessage" - self._chat_id = chat_id self._timeout = timeout self._session = session or _retrying_session({"GET"}) - def send(self, text: str) -> None: + def send(self, chat_id: str, text: str) -> None: try: response = self._session.get( self._url, - params={"chat_id": self._chat_id, "text": text}, + params={"chat_id": chat_id, "text": text}, timeout=self._timeout, ) except requests.RequestException as error: - raise ExternalServiceError(f"Eitaa request failed: {error}") from error + raise ExternalServiceError( + f"Eitaa request failed for chat {chat_id}: {error}" + ) from error if not response.ok: - raise ExternalServiceError(f"Eitaa returned HTTP {response.status_code}") + raise ExternalServiceError( + f"Eitaa returned HTTP {response.status_code} for chat {chat_id}" + ) try: body = response.json() except requests.JSONDecodeError as error: - raise ExternalServiceError("Eitaa returned invalid JSON") from error + raise ExternalServiceError( + f"Eitaa returned invalid JSON for chat {chat_id}" + ) from error if not isinstance(body, dict) or body.get("ok") is not True: description = body.get("description") if isinstance(body, dict) else None raise ExternalServiceError( - f"Eitaa rejected the message: {description or 'unknown error'}" + f"Eitaa rejected the message for chat {chat_id}: " + f"{description or 'unknown error'}" ) diff --git a/src/blackout_notifier/config.py b/src/blackout_notifier/config.py index 7aef673..0b03e72 100644 --- a/src/blackout_notifier/config.py +++ b/src/blackout_notifier/config.py @@ -1,8 +1,10 @@ from __future__ import annotations import os +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path +from types import MappingProxyType from dotenv import load_dotenv @@ -15,13 +17,16 @@ class ConfigurationError(ValueError): class Config: bargheman_token: str eitaayar_token: str - chat_id: str - bill_ids: tuple[str, ...] + bill_chat_ids: Mapping[str, tuple[str, ...]] state_file: Path lookahead_days: int = 5 request_timeout_seconds: float = 15.0 max_events_per_bill: int = 25 + @property + def bill_ids(self) -> tuple[str, ...]: + return tuple(self.bill_chat_ids) + @classmethod def from_env(cls) -> Config: load_dotenv(override=False) @@ -29,20 +34,27 @@ class Config: values = { "BARGHEMAN_TOKEN": os.getenv("BARGHEMAN_TOKEN", "").strip(), "EITAAYAR_TOKEN": os.getenv("EITAAYAR_TOKEN", "").strip(), - "CHAT_ID": os.getenv("CHAT_ID", "").strip(), } missing = [name for name, value in values.items() if not value] - raw_bill_ids = os.getenv("BILL_IDS", "") - bill_ids = tuple( - dict.fromkeys(part.strip() for part in raw_bill_ids.split(",") if part.strip()) - ) - if not bill_ids: - missing.append("BILL_IDS") - elif invalid := [bill_id for bill_id in bill_ids if not bill_id.isdecimal()]: - raise ConfigurationError( - f"BILL_IDS must contain only decimal IDs: {', '.join(invalid)}" + raw_routes = os.getenv("BILL_CHAT_MAP", "").strip() + if raw_routes: + bill_chat_ids = _bill_chat_map(raw_routes) + else: + chat_id = os.getenv("CHAT_ID", "").strip() + raw_bill_ids = os.getenv("BILL_IDS", "") + bill_ids = tuple( + dict.fromkeys(part.strip() for part in raw_bill_ids.split(",") if part.strip()) ) + if not chat_id: + missing.append("CHAT_ID") + if not bill_ids: + missing.append("BILL_IDS") + elif invalid := [bill_id for bill_id in bill_ids if not bill_id.isdecimal()]: + raise ConfigurationError( + f"BILL_IDS must contain only decimal IDs: {', '.join(invalid)}" + ) + bill_chat_ids = {bill_id: (chat_id,) for bill_id in bill_ids} if missing: raise ConfigurationError( @@ -52,8 +64,7 @@ class Config: return cls( bargheman_token=values["BARGHEMAN_TOKEN"], eitaayar_token=values["EITAAYAR_TOKEN"], - chat_id=values["CHAT_ID"], - bill_ids=bill_ids, + bill_chat_ids=MappingProxyType(bill_chat_ids), state_file=Path(os.getenv("STATE_FILE", "state/outages.json").strip()), lookahead_days=_integer_setting("LOOKAHEAD_DAYS", 5, minimum=1, maximum=30), request_timeout_seconds=_float_setting( @@ -63,6 +74,36 @@ class Config: ) +def _bill_chat_map(raw_routes: str) -> dict[str, tuple[str, ...]]: + routes: dict[str, list[str]] = {} + malformed: list[str] = [] + for raw_route in raw_routes.split(","): + route = raw_route.strip() + if not route: + continue + bill_id, separator, chat_id = route.partition(":") + bill_id = bill_id.strip() + chat_id = chat_id.strip() + if not separator or not bill_id or not chat_id: + malformed.append(route) + continue + if not bill_id.isdecimal(): + raise ConfigurationError( + f"BILL_CHAT_MAP bill IDs must be decimal: {bill_id}" + ) + chat_ids = routes.setdefault(bill_id, []) + if chat_id not in chat_ids: + chat_ids.append(chat_id) + if malformed: + raise ConfigurationError( + "BILL_CHAT_MAP entries must use BILL_ID:CHAT_ID format: " + + ", ".join(malformed) + ) + if not routes: + raise ConfigurationError("BILL_CHAT_MAP must contain at least one route") + return {bill_id: tuple(chat_ids) for bill_id, chat_ids in routes.items()} + + def _integer_setting(name: str, default: int, *, minimum: int, maximum: int) -> int: raw_value = os.getenv(name, str(default)).strip() try: diff --git a/src/blackout_notifier/service.py b/src/blackout_notifier/service.py index dd78010..829e5bf 100644 --- a/src/blackout_notifier/service.py +++ b/src/blackout_notifier/service.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from collections.abc import Mapping from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import Any @@ -29,13 +30,13 @@ class OutageService: def __init__( self, *, - bill_ids: tuple[str, ...], + bill_chat_ids: Mapping[str, tuple[str, ...]], lookahead_days: int, saapa: SaapaClient, notifier: Notifier, state_repository: StateRepository, ) -> None: - self._bill_ids = bill_ids + self._bill_chat_ids = dict(bill_chat_ids) self._lookahead_days = lookahead_days self._saapa = saapa self._notifier = notifier @@ -46,14 +47,14 @@ class OutageService: result = RunResult() from_date, to_date = jalali_query_window(now, self._lookahead_days) - for bill_id in self._bill_ids: + for bill_id, chat_ids in self._bill_chat_ids.items(): try: outages = self._saapa.fetch(bill_id, from_date, to_date) except ExternalServiceError as error: result.errors.append(str(error)) LOGGER.error("%s", error) continue - self._process_bill(state, bill_id, outages, now, result) + self._process_bill(state, bill_id, chat_ids, outages, now, result) if self._prune_expired(state, now): result.state_changed = True @@ -66,6 +67,7 @@ class OutageService: self, state: dict[str, Any], bill_id: str, + chat_ids: tuple[str, ...], outages: list[Outage], now: datetime, result: RunResult, @@ -82,20 +84,26 @@ class OutageService: for event_id, outage in current.items(): record = records.get(event_id) if record is None: - if self._notify(new_outage_message(bill_id, outage, now), result): + if self._notify(chat_ids, new_outage_message(bill_id, outage, now), result): records[event_id] = _record(outage, now) result.state_changed = True continue previous = Outage.from_dict(record["snapshot"]) if record["status"] == "cancelled": - if self._notify(reactivated_outage_message(bill_id, outage, now), result): + if self._notify( + chat_ids, reactivated_outage_message(bill_id, outage, now), result + ): records[event_id] = _record(outage, now) result.state_changed = True continue if outage.fingerprint() != record["notified_hash"]: - if self._notify(updated_outage_message(bill_id, previous, outage, now), result): + if self._notify( + chat_ids, + updated_outage_message(bill_id, previous, outage, now), + result, + ): records[event_id] = _record(outage, now) result.state_changed = True continue @@ -118,7 +126,9 @@ class OutageService: if record["missing_checks"] == 0: record["missing_checks"] = 1 result.state_changed = True - elif self._notify(cancelled_outage_message(bill_id, previous, now), result): + elif self._notify( + chat_ids, cancelled_outage_message(bill_id, previous, now), result + ): record["status"] = "cancelled" record["missing_checks"] = 2 record["notified_at"] = now.isoformat() @@ -127,15 +137,18 @@ class OutageService: if records and bill_id not in bills: bills[bill_id] = bill_state - def _notify(self, message: str, result: RunResult) -> bool: - try: - self._notifier.send(message) - except ExternalServiceError as error: - result.errors.append(str(error)) - LOGGER.error("%s", error) - return False - result.sent += 1 - return True + def _notify(self, chat_ids: tuple[str, ...], message: str, result: RunResult) -> bool: + delivered_to_all = True + for chat_id in chat_ids: + try: + self._notifier.send(chat_id, message) + except ExternalServiceError as error: + result.errors.append(str(error)) + LOGGER.error("%s", error) + delivered_to_all = False + continue + result.sent += 1 + return delivered_to_all @staticmethod def _prune_expired(state: dict[str, Any], now: datetime) -> bool: diff --git a/tests/test_clients.py b/tests/test_clients.py index 98cebf9..037cb5c 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -70,10 +70,10 @@ def test_saapa_rejects_oversized_or_duplicate_snapshot() -> None: 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] + client = EitaaClient("token", timeout=2, session=session) # type: ignore[arg-type] with pytest.raises(ExternalServiceError, match="denied"): - client.send("message") + client.send("chat", "message") _, url, kwargs = session.calls[0] assert url.endswith("/token/sendMessage") diff --git a/tests/test_config.py b/tests/test_config.py index 2981e9d..5d70dd5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,8 +7,7 @@ from blackout_notifier.config import Config, ConfigurationError REQUIRED = { "BARGHEMAN_TOKEN": "bargheman", "EITAAYAR_TOKEN": "eitaayar", - "CHAT_ID": "chat", - "BILL_IDS": " 123,456,123 ,, ", + "BILL_CHAT_MAP": " 123:chat-a,456:chat-b,123:chat-c,123:chat-a ,, ", } @@ -17,13 +16,17 @@ 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: +def test_config_parses_and_deduplicates_bill_routes(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 dict(config.bill_chat_ids) == { + "123": ("chat-a", "chat-c"), + "456": ("chat-b",), + } assert str(config.state_file) == "state/outages.json" assert config.lookahead_days == 5 @@ -40,12 +43,39 @@ def test_config_reports_all_missing_required_values(monkeypatch: pytest.MonkeyPa 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") + monkeypatch.setenv("BILL_CHAT_MAP", "123:chat-a,761/pe9314604429:chat-b") - with pytest.raises(ConfigurationError, match="decimal IDs"): + with pytest.raises(ConfigurationError, match="bill IDs must be decimal"): Config.from_env() +def test_config_rejects_malformed_routes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + for name, value in REQUIRED.items(): + monkeypatch.setenv(name, value) + + monkeypatch.setenv("BILL_CHAT_MAP", "123") + with pytest.raises(ConfigurationError, match="BILL_ID:CHAT_ID"): + Config.from_env() + +def test_legacy_single_chat_configuration_is_still_supported( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("BARGHEMAN_TOKEN", "bargheman") + monkeypatch.setenv("EITAAYAR_TOKEN", "eitaayar") + monkeypatch.setenv("CHAT_ID", "legacy-chat") + monkeypatch.setenv("BILL_IDS", "123,456,123") + monkeypatch.delenv("BILL_CHAT_MAP", raising=False) + + config = Config.from_env() + + assert dict(config.bill_chat_ids) == { + "123": ("legacy-chat",), + "456": ("legacy-chat",), + } + + def test_config_validates_numeric_ranges(monkeypatch: pytest.MonkeyPatch) -> None: for name, value in REQUIRED.items(): monkeypatch.setenv(name, value) diff --git a/tests/test_main.sh b/tests/test_main.sh index 3d076e7..8b0079f 100755 --- a/tests/test_main.sh +++ b/tests/test_main.sh @@ -142,6 +142,29 @@ assert_contains "${SCHEDULE[1]}" '"outage_number":"2"' \ assert_contains "${SCHEDULE[2]}" '"outage_number":"5"' \ "later events should remain in the schedule" +build_schedule "$FIXTURE" "$NOW_EPOCH" "222" +assert_equal "3" "${#SCHEDULE[@]}" \ + "selecting a countdown bill should not filter the schedule" +assert_equal "UPCOMING" "${HERO%%$'\t'*}" \ + "the countdown should ignore another bill's ongoing outage" +assert_contains "$HERO" '"bill_id":"222"' \ + "the countdown should use only the selected bill" +SELECTED_OUTPUT="$(NO_COLOR=1 COLUMNS=80 render_dashboard "$NOW_EPOCH" 80 "222")" +assert_contains "$SELECTED_OUTPUT" "COUNTDOWN FOR BILL 222" \ + "the selected countdown bill should be labeled" +assert_contains "$SELECTED_OUTPUT" "OUTAGE SCHEDULE (3)" \ + "the selected countdown bill should not hide other bills from the list" + +build_schedule "$FIXTURE" "$NOW_EPOCH" "333" +assert_equal "" "$HERO" \ + "a bill without upcoming outages should not borrow another bill's countdown" +NO_HERO_OUTPUT="$(NO_COLOR=1 COLUMNS=80 render_dashboard "$NOW_EPOCH" 80 "333")" +assert_contains "$NO_HERO_OUTPUT" "NO CURRENT OR UPCOMING OUTAGE" \ + "a selected bill without outages should have a clear countdown status" +assert_contains "$NO_HERO_OUTPUT" "OUTAGE SCHEDULE (3)" \ + "all bills should remain listed when the selected bill has no outage" + +build_schedule "$FIXTURE" "$NOW_EPOCH" OUTPUT="$(NO_COLOR=1 COLUMNS=80 render_dashboard "$NOW_EPOCH")" if [[ "$OUTPUT" == *"POWER RETURNS IN"* || "$OUTPUT" == *"NEXT OUTAGE IN"* ]]; then printf 'FAIL: countdown output contains a redundant heading\n' >&2 @@ -219,4 +242,9 @@ if ( exit 1 fi +if main --bill-id invalid >/dev/null 2>&1; then + printf 'FAIL: a non-decimal countdown bill ID was accepted\n' >&2 + exit 1 +fi + printf 'Bash CLI tests passed\n' diff --git a/tests/test_service.py b/tests/test_service.py index 5a78f1b..4029d3a 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -26,26 +26,26 @@ class FakeSaapa: class FakeNotifier: def __init__(self, *, fail: bool = False, fail_on_call: int | None = None) -> None: - self.messages: list[str] = [] + self.messages: list[tuple[str, str]] = [] self.fail = fail self.fail_on_call = fail_on_call self.calls = 0 - def send(self, text: str) -> None: + def send(self, chat_id: str, text: str) -> None: self.calls += 1 if self.fail or self.calls == self.fail_on_call: raise ExternalServiceError("Eitaa unavailable") - self.messages.append(text) + self.messages.append((chat_id, text)) def _service( path: Path, saapa: FakeSaapa, notifier: FakeNotifier, - bill_ids: tuple[str, ...] = ("123",), + bill_chat_ids: Mapping[str, tuple[str, ...]] | None = None, ) -> OutageService: return OutageService( - bill_ids=bill_ids, + bill_chat_ids=bill_chat_ids or {"123": ("chat-a",)}, lookahead_days=5, saapa=saapa, # type: ignore[arg-type] notifier=notifier, @@ -65,7 +65,7 @@ def test_first_run_sends_upcoming_once(tmp_path: Path, now: datetime, outage: Ou assert first.sent == 1 assert first.state_changed is True - assert "خاموشی جدید" in notifier.messages[0] + assert "خاموشی جدید" in notifier.messages[0][1] assert second.sent == 0 assert second.state_changed is False assert path.read_text(encoding="utf-8") == first_state @@ -82,9 +82,9 @@ def test_changed_outage_sends_field_diff(tmp_path: Path, now: datetime, outage: 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] + assert "تغییر در برنامه" in notifier.messages[-1][1] + assert "09:00" in notifier.messages[-1][1] + assert "10:00" in notifier.messages[-1][1] def test_cancellation_requires_two_successful_omissions_and_can_reactivate( @@ -103,12 +103,12 @@ def test_cancellation_requires_two_successful_omissions_and_can_reactivate( assert first_missing.sent == 0 assert second_missing.sent == 1 - assert "دو بررسی پیاپی" in notifier.messages[-1] + assert "دو بررسی پیاپی" in notifier.messages[-1][1] saapa.snapshots["123"] = [outage] restored = service.run(now) assert restored.sent == 1 - assert "بازگشت برنامه" in notifier.messages[-1] + assert "بازگشت برنامه" in notifier.messages[-1][1] def test_failed_notification_does_not_advance_state( @@ -130,13 +130,80 @@ def test_fetch_failure_for_one_bill_does_not_block_another( ) -> None: saapa = FakeSaapa({"123": ExternalServiceError("SAAPA failed for 123"), "456": [outage]}) notifier = FakeNotifier() - service = _service(tmp_path / "state.json", saapa, notifier, ("123", "456")) + service = _service( + tmp_path / "state.json", + saapa, + notifier, + {"123": ("chat-a",), "456": ("chat-b",)}, + ) result = service.run(now) assert result.sent == 1 assert result.errors == ["SAAPA failed for 123"] assert len(notifier.messages) == 1 + assert notifier.messages[0][0] == "chat-b" + + +def test_each_bill_is_sent_to_its_configured_chat( + tmp_path: Path, now: datetime, outage: Outage +) -> None: + second_outage = replace(outage, outage_number="404992999002") + saapa = FakeSaapa({"123": [outage], "456": [second_outage]}) + notifier = FakeNotifier() + service = _service( + tmp_path / "state.json", + saapa, + notifier, + {"123": ("chat-a",), "456": ("chat-b",)}, + ) + + result = service.run(now) + + assert result.sent == 2 + assert [chat_id for chat_id, _ in notifier.messages] == ["chat-a", "chat-b"] + assert "شناسه قبض: 123" in notifier.messages[0][1] + assert "شناسه قبض: 456" in notifier.messages[1][1] + + +def test_one_bill_is_sent_to_all_its_configured_chats( + tmp_path: Path, now: datetime, outage: Outage +) -> None: + saapa = FakeSaapa({"123": [outage]}) + notifier = FakeNotifier() + service = _service( + tmp_path / "state.json", + saapa, + notifier, + {"123": ("chat-a", "chat-b")}, + ) + + result = service.run(now) + + assert result.sent == 2 + assert len(saapa.calls) == 1 + assert [chat_id for chat_id, _ in notifier.messages] == ["chat-a", "chat-b"] + + +def test_state_advances_only_after_all_chats_accept_notification( + tmp_path: Path, now: datetime, outage: Outage +) -> None: + path = tmp_path / "state.json" + notifier = FakeNotifier(fail_on_call=2) + service = _service( + path, + FakeSaapa({"123": [outage]}), + notifier, + {"123": ("chat-a", "chat-b", "chat-c")}, + ) + + result = service.run(now) + + assert result.sent == 2 + assert result.errors == ["Eitaa unavailable"] + assert [chat_id for chat_id, _ in notifier.messages] == ["chat-a", "chat-c"] + assert result.state_changed is False + assert not path.exists() def test_partial_delivery_persists_only_successful_events(