feat: add multi-chat routing and bill-specific dashboard countdown
Some checks failed
Test blackout notifier / test (push) Failing after 4s
Hourly blackout check / check (push) Successful in 3s

This commit is contained in:
Meghdad
2026-07-22 02:14:45 +03:30
parent 6c3e146234
commit 42687ee651
12 changed files with 372 additions and 100 deletions

View File

@@ -3,10 +3,9 @@ BARGHEMAN_TOKEN=replace-with-bargheman-token
# Eitaa bot configuration # Eitaa bot configuration
EITAAYAR_TOKEN=replace-with-eitaayar-token EITAAYAR_TOKEN=replace-with-eitaayar-token
CHAT_ID=replace-with-chat-id
# One or more comma-separated bill IDs # Repeat a bill ID to send its notifications to multiple chats.
BILL_IDS=7356609804429,7619314604429 BILL_CHAT_MAP=BILL_ID:FIRST_CHAT_ID,BILL_ID:SECOND_CHAT_ID,OTHER_BILL_ID:THIRD_CHAT_ID
# Optional settings and their defaults # Optional settings and their defaults
STATE_FILE=state/outages.json STATE_FILE=state/outages.json

View File

@@ -25,8 +25,7 @@ jobs:
env: env:
BARGHEMAN_TOKEN: ${{ secrets.BARGHEMAN_TOKEN }} BARGHEMAN_TOKEN: ${{ secrets.BARGHEMAN_TOKEN }}
EITAAYAR_TOKEN: ${{ secrets.EITAAYAR_TOKEN }} EITAAYAR_TOKEN: ${{ secrets.EITAAYAR_TOKEN }}
CHAT_ID: ${{ secrets.CHAT_ID }} BILL_CHAT_MAP: ${{ secrets.BILL_CHAT_MAP }}
BILL_IDS: ${{ secrets.BILL_IDS }}
STATE_FILE: state/outages.json STATE_FILE: state/outages.json
run: | run: |
set +e set +e

View File

@@ -1,7 +1,8 @@
# Daily Blackout Check # Daily Blackout Check
Checks the official SAAPA planned-outage API and sends Persian notifications to 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 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. 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 ## Behavior
- Queries each configured bill ID from today through five days ahead. - 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. - Uses Tehran time regardless of the runner container's timezone.
- Sends all upcoming outages when starting with empty state. - Sends all upcoming outages when starting with empty state.
- Treats an existing outage number with changed time, address, date, reason, or - Treats an existing outage number with changed time, address, date, reason, or
planned status as an update. planned status as an update.
- Reports a future outage as cancelled only after two consecutive successful - Reports a future outage as cancelled only after two consecutive successful
checks omit it. checks omit it.
- Records a notification only after Eitaa accepts it. This favors delivery over - Records a notification only after every configured chat accepts it. This
perfect deduplication: a failed state push can cause a duplicate next hour. 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 - Rejects malformed responses and snapshots larger than the configured safety
limit without changing that bill's state. limit without changing that bill's state.
@@ -50,8 +53,7 @@ python -m blackout_notifier check
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `BARGHEMAN_TOKEN` | Yes | — | SAAPA bearer token | | `BARGHEMAN_TOKEN` | Yes | — | SAAPA bearer token |
| `EITAAYAR_TOKEN` | Yes | — | EitaaYar bot token | | `EITAAYAR_TOKEN` | Yes | — | EitaaYar bot token |
| `CHAT_ID` | Yes | — | Destination Eitaa chat | | `BILL_CHAT_MAP` | Yes | — | Comma-separated `BILL_ID:CHAT_ID` routes |
| `BILL_IDS` | Yes | — | Comma-separated decimal bill IDs |
| `STATE_FILE` | No | `state/outages.json` | Durable notification state | | `STATE_FILE` | No | `state/outages.json` | Durable notification state |
| `LOOKAHEAD_DAYS` | No | `5` | Query horizon, from 1 to 30 days | | `LOOKAHEAD_DAYS` | No | `5` | Query horizon, from 1 to 30 days |
| `REQUEST_TIMEOUT_SECONDS` | No | `15` | HTTP timeout, from 1 to 120 seconds | | `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`. 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 ## Gitea deployment
This repository targets Gitea 1.25.x and a Docker-based runner advertising the 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 | | `REGISTRY_TOKEN` | Personal access token with package Read and Write permission |
| `BARGHEMAN_TOKEN` | SAAPA bearer token | | `BARGHEMAN_TOKEN` | SAAPA bearer token |
| `EITAAYAR_TOKEN` | EitaaYar bot token | | `EITAAYAR_TOKEN` | EitaaYar bot token |
| `CHAT_ID` | Destination chat ID | | `BILL_CHAT_MAP` | Comma-separated `BILL_ID:CHAT_ID` routes |
| `BILL_IDS` | Comma-separated bill IDs |
The registry token is used to publish the image and as `container.credentials` The registry token is used to publish the image and as `container.credentials`
when Gitea Runner pulls the private image before starting a job. 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 ./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 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 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 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 ./main.sh --interval 5
``` ```
The dashboard uses Tehran time to show a large `HH : mm : ss` countdown for the outage Select one bill for the large countdown with `-b`/`--bill-id`:
currently in progress or the nearest upcoming outage. A compact schedule below it
lists all active current and future events, including emergency outages. The ```sh
terminal output uses English-only labels and omits the stored address and reason ./main.sh --bill-id 7356609804429
text. When there are no qualifying events it prints an `ALL CLEAR` screen and exits ```
successfully.
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 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: disable them explicitly, or set `COLUMNS` to control the rendered width:

80
main.sh
View File

@@ -2,6 +2,31 @@
set -euo pipefail 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 STATE_URL="https://mahgit.ir/MeghdadFadaee/daily-blackout-check/raw/branch/main/state/outages.json"
readonly TEHRAN_TZ="Asia/Tehran" readonly TEHRAN_TZ="Asia/Tehran"
@@ -36,20 +61,21 @@ trap cleanup EXIT
usage() { usage() {
cat <<'EOF' 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 Continuously display current and upcoming electricity outages from the public
repository state. The countdown updates every second. repository state. The countdown updates every second.
Options: Options:
-i, --interval MINUTES Refetch state at this interval (default: 10) -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 -h, --help Show this help
Environment: Environment:
NO_COLOR Disable ANSI colors when set NO_COLOR Disable ANSI colors when set
COLUMNS Override the detected output width 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 EOF
} }
@@ -299,6 +325,7 @@ record_fields() {
build_schedule() { build_schedule() {
local path="$1" local path="$1"
local now_epoch="$2" local now_epoch="$2"
local countdown_bill_id="${3:-}"
local record start_epoch stop_epoch phase phase_order sort_key line hero_key="" local record start_epoch stop_epoch phase phase_order sort_key line hero_key=""
local -a fields sortable=() local -a fields sortable=()
@@ -324,7 +351,8 @@ build_schedule() {
phase="ONGOING" phase="ONGOING"
phase_order=0 phase_order=0
sort_key="$(printf '0:%020d:%s:%s' "$start_epoch" "${fields[0]}" "${fields[1]}")" 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_key="$stop_epoch"
HERO="$phase"$'\t'"$start_epoch"$'\t'"$stop_epoch"$'\t'"$record" HERO="$phase"$'\t'"$start_epoch"$'\t'"$stop_epoch"$'\t'"$record"
fi fi
@@ -332,9 +360,11 @@ build_schedule() {
phase="UPCOMING" phase="UPCOMING"
phase_order=1 phase_order=1
sort_key="$(printf '1:%020d:%s:%s' "$start_epoch" "${fields[0]}" "${fields[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" 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 local current_hero_start
current_hero_start="${HERO#*$'\t'}" current_hero_start="${HERO#*$'\t'}"
current_hero_start="${current_hero_start%%$'\t'*}" current_hero_start="${current_hero_start%%$'\t'*}"
@@ -772,6 +802,7 @@ schedule_next_transition() {
render_dashboard() { render_dashboard() {
local now_epoch="$1" local now_epoch="$1"
local columns="${2:-}" local columns="${2:-}"
local countdown_bill_id="${3:-}"
local rule phase start_epoch stop_epoch record remaining banner local rule phase start_epoch stop_epoch record remaining banner
local schedule_line ignored local schedule_line ignored
@@ -785,6 +816,14 @@ render_dashboard() {
return return
fi fi
rule="$(repeat_character '=' "$columns")"
printf '%s%s%s\n' "$CYAN" "$rule" "$RESET"
printf '\n'
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" IFS=$'\t' read -r phase start_epoch stop_epoch record <<<"$HERO"
if [[ "$phase" == "ONGOING" ]]; then if [[ "$phase" == "ONGOING" ]]; then
remaining=$((stop_epoch - now_epoch)) remaining=$((stop_epoch - now_epoch))
@@ -792,13 +831,15 @@ render_dashboard() {
remaining=$((start_epoch - now_epoch)) remaining=$((start_epoch - now_epoch))
fi fi
banner="$(banner_duration "$remaining")" banner="$(banner_duration "$remaining")"
rule="$(repeat_character '=' "$columns")" if [[ -n "$countdown_bill_id" ]]; then
center_line "COUNTDOWN FOR BILL $countdown_bill_id" "$columns"
printf '%s%s%s\n' "$CYAN" "$rule" "$RESET"
printf '\n' printf '\n'
fi
printf '%s%s' "$BOLD" "$YELLOW" printf '%s%s' "$BOLD" "$YELLOW"
print_large_countdown "$banner" "$columns" print_large_countdown "$banner" "$columns"
printf '%s' "$RESET" printf '%s' "$RESET"
fi
printf '\n%s%s%s\n' "$CYAN" "$rule" "$RESET" printf '\n%s%s%s\n' "$CYAN" "$rule" "$RESET"
printf '\n%sOUTAGE SCHEDULE (%d)%s\n\n' "$BOLD" "${#SCHEDULE[@]}" "$RESET" printf '\n%sOUTAGE SCHEDULE (%d)%s\n\n' "$BOLD" "${#SCHEDULE[@]}" "$RESET"
@@ -812,7 +853,7 @@ render_dashboard() {
main() { main() {
local now_epoch next_refresh=0 refresh_minutes=10 refresh_seconds state_ready=0 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 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 while (($#)); do
case "$1" in case "$1" in
@@ -833,6 +874,19 @@ main() {
refresh_minutes="${1#*=}" refresh_minutes="${1#*=}"
shift 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 printf 'Error: unknown option: %s\n' "$1" >&2
usage >&2 usage >&2
@@ -845,6 +899,10 @@ main() {
printf 'Error: interval must be a whole number from 1 to 1440 minutes\n' >&2 printf 'Error: interval must be a whole number from 1 to 1440 minutes\n' >&2
return 2 return 2
fi 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)) refresh_seconds=$((10#$refresh_minutes * 60))
require_command curl require_command curl
@@ -890,7 +948,7 @@ main() {
if ((state_ready)); then if ((state_ready)); then
if ((rebuild_schedule || (next_transition > 0 && now_epoch >= next_transition))); 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")" next_transition="$(schedule_next_transition "$now_epoch")"
rebuild_schedule=0 rebuild_schedule=0
else else
@@ -903,7 +961,7 @@ main() {
display_columns="$(terminal_columns)" display_columns="$(terminal_columns)"
frame="$( frame="$(
if ((state_ready)); then if ((state_ready)); then
render_dashboard "$now_epoch" "$display_columns" render_dashboard "$now_epoch" "$display_columns" "$countdown_bill_id"
else else
render_unavailable "$display_columns" render_unavailable "$display_columns"
fi fi

View File

@@ -12,8 +12,8 @@ from blackout_notifier.state import StateError, StateRepository
class ConsoleNotifier: class ConsoleNotifier:
def send(self, text: str) -> None: def send(self, chat_id: str, text: str) -> None:
print("\n--- notification preview ---") print(f"\n--- notification preview for chat {chat_id} ---")
print(text) print(text)
@@ -41,12 +41,11 @@ def main(argv: list[str] | None = None) -> int:
if arguments.dry_run if arguments.dry_run
else EitaaClient( else EitaaClient(
config.eitaayar_token, config.eitaayar_token,
config.chat_id,
timeout=config.request_timeout_seconds, timeout=config.request_timeout_seconds,
) )
) )
service = OutageService( service = OutageService(
bill_ids=config.bill_ids, bill_chat_ids=config.bill_chat_ids,
lookahead_days=config.lookahead_days, lookahead_days=config.lookahead_days,
saapa=SaapaClient( saapa=SaapaClient(
config.bargheman_token, config.bargheman_token,

View File

@@ -17,7 +17,7 @@ class ExternalServiceError(RuntimeError):
class Notifier(Protocol): class Notifier(Protocol):
def send(self, text: str) -> None: ... def send(self, chat_id: str, text: str) -> None: ...
class SaapaClient: class SaapaClient:
@@ -88,35 +88,40 @@ class EitaaClient:
def __init__( def __init__(
self, self,
token: str, token: str,
chat_id: str,
*, *,
timeout: float, timeout: float,
session: requests.Session | None = None, session: requests.Session | None = None,
) -> None: ) -> None:
self._url = f"{EITAAYAR_BASE_URL}/{token}/sendMessage" self._url = f"{EITAAYAR_BASE_URL}/{token}/sendMessage"
self._chat_id = chat_id
self._timeout = timeout self._timeout = timeout
self._session = session or _retrying_session({"GET"}) self._session = session or _retrying_session({"GET"})
def send(self, text: str) -> None: def send(self, chat_id: str, text: str) -> None:
try: try:
response = self._session.get( response = self._session.get(
self._url, self._url,
params={"chat_id": self._chat_id, "text": text}, params={"chat_id": chat_id, "text": text},
timeout=self._timeout, timeout=self._timeout,
) )
except requests.RequestException as error: 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: 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: try:
body = response.json() body = response.json()
except requests.JSONDecodeError as error: 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: if not isinstance(body, dict) or body.get("ok") is not True:
description = body.get("description") if isinstance(body, dict) else None description = body.get("description") if isinstance(body, dict) else None
raise ExternalServiceError( 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'}"
) )

View File

@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import os import os
from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from types import MappingProxyType
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -15,13 +17,16 @@ class ConfigurationError(ValueError):
class Config: class Config:
bargheman_token: str bargheman_token: str
eitaayar_token: str eitaayar_token: str
chat_id: str bill_chat_ids: Mapping[str, tuple[str, ...]]
bill_ids: tuple[str, ...]
state_file: Path state_file: Path
lookahead_days: int = 5 lookahead_days: int = 5
request_timeout_seconds: float = 15.0 request_timeout_seconds: float = 15.0
max_events_per_bill: int = 25 max_events_per_bill: int = 25
@property
def bill_ids(self) -> tuple[str, ...]:
return tuple(self.bill_chat_ids)
@classmethod @classmethod
def from_env(cls) -> Config: def from_env(cls) -> Config:
load_dotenv(override=False) load_dotenv(override=False)
@@ -29,20 +34,27 @@ class Config:
values = { values = {
"BARGHEMAN_TOKEN": os.getenv("BARGHEMAN_TOKEN", "").strip(), "BARGHEMAN_TOKEN": os.getenv("BARGHEMAN_TOKEN", "").strip(),
"EITAAYAR_TOKEN": os.getenv("EITAAYAR_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] missing = [name for name, value in values.items() if not value]
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", "") raw_bill_ids = os.getenv("BILL_IDS", "")
bill_ids = tuple( bill_ids = tuple(
dict.fromkeys(part.strip() for part in raw_bill_ids.split(",") if part.strip()) 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: if not bill_ids:
missing.append("BILL_IDS") missing.append("BILL_IDS")
elif invalid := [bill_id for bill_id in bill_ids if not bill_id.isdecimal()]: elif invalid := [bill_id for bill_id in bill_ids if not bill_id.isdecimal()]:
raise ConfigurationError( raise ConfigurationError(
f"BILL_IDS must contain only decimal IDs: {', '.join(invalid)}" 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: if missing:
raise ConfigurationError( raise ConfigurationError(
@@ -52,8 +64,7 @@ class Config:
return cls( return cls(
bargheman_token=values["BARGHEMAN_TOKEN"], bargheman_token=values["BARGHEMAN_TOKEN"],
eitaayar_token=values["EITAAYAR_TOKEN"], eitaayar_token=values["EITAAYAR_TOKEN"],
chat_id=values["CHAT_ID"], bill_chat_ids=MappingProxyType(bill_chat_ids),
bill_ids=bill_ids,
state_file=Path(os.getenv("STATE_FILE", "state/outages.json").strip()), state_file=Path(os.getenv("STATE_FILE", "state/outages.json").strip()),
lookahead_days=_integer_setting("LOOKAHEAD_DAYS", 5, minimum=1, maximum=30), lookahead_days=_integer_setting("LOOKAHEAD_DAYS", 5, minimum=1, maximum=30),
request_timeout_seconds=_float_setting( 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: def _integer_setting(name: str, default: int, *, minimum: int, maximum: int) -> int:
raw_value = os.getenv(name, str(default)).strip() raw_value = os.getenv(name, str(default)).strip()
try: try:

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections.abc import Mapping
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any from typing import Any
@@ -29,13 +30,13 @@ class OutageService:
def __init__( def __init__(
self, self,
*, *,
bill_ids: tuple[str, ...], bill_chat_ids: Mapping[str, tuple[str, ...]],
lookahead_days: int, lookahead_days: int,
saapa: SaapaClient, saapa: SaapaClient,
notifier: Notifier, notifier: Notifier,
state_repository: StateRepository, state_repository: StateRepository,
) -> None: ) -> None:
self._bill_ids = bill_ids self._bill_chat_ids = dict(bill_chat_ids)
self._lookahead_days = lookahead_days self._lookahead_days = lookahead_days
self._saapa = saapa self._saapa = saapa
self._notifier = notifier self._notifier = notifier
@@ -46,14 +47,14 @@ class OutageService:
result = RunResult() result = RunResult()
from_date, to_date = jalali_query_window(now, self._lookahead_days) 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: try:
outages = self._saapa.fetch(bill_id, from_date, to_date) outages = self._saapa.fetch(bill_id, from_date, to_date)
except ExternalServiceError as error: except ExternalServiceError as error:
result.errors.append(str(error)) result.errors.append(str(error))
LOGGER.error("%s", error) LOGGER.error("%s", error)
continue 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): if self._prune_expired(state, now):
result.state_changed = True result.state_changed = True
@@ -66,6 +67,7 @@ class OutageService:
self, self,
state: dict[str, Any], state: dict[str, Any],
bill_id: str, bill_id: str,
chat_ids: tuple[str, ...],
outages: list[Outage], outages: list[Outage],
now: datetime, now: datetime,
result: RunResult, result: RunResult,
@@ -82,20 +84,26 @@ class OutageService:
for event_id, outage in current.items(): for event_id, outage in current.items():
record = records.get(event_id) record = records.get(event_id)
if record is None: 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) records[event_id] = _record(outage, now)
result.state_changed = True result.state_changed = True
continue continue
previous = Outage.from_dict(record["snapshot"]) previous = Outage.from_dict(record["snapshot"])
if record["status"] == "cancelled": 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) records[event_id] = _record(outage, now)
result.state_changed = True result.state_changed = True
continue continue
if outage.fingerprint() != record["notified_hash"]: 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) records[event_id] = _record(outage, now)
result.state_changed = True result.state_changed = True
continue continue
@@ -118,7 +126,9 @@ class OutageService:
if record["missing_checks"] == 0: if record["missing_checks"] == 0:
record["missing_checks"] = 1 record["missing_checks"] = 1
result.state_changed = True 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["status"] = "cancelled"
record["missing_checks"] = 2 record["missing_checks"] = 2
record["notified_at"] = now.isoformat() record["notified_at"] = now.isoformat()
@@ -127,15 +137,18 @@ class OutageService:
if records and bill_id not in bills: if records and bill_id not in bills:
bills[bill_id] = bill_state bills[bill_id] = bill_state
def _notify(self, message: str, result: RunResult) -> bool: def _notify(self, chat_ids: tuple[str, ...], message: str, result: RunResult) -> bool:
delivered_to_all = True
for chat_id in chat_ids:
try: try:
self._notifier.send(message) self._notifier.send(chat_id, message)
except ExternalServiceError as error: except ExternalServiceError as error:
result.errors.append(str(error)) result.errors.append(str(error))
LOGGER.error("%s", error) LOGGER.error("%s", error)
return False delivered_to_all = False
continue
result.sent += 1 result.sent += 1
return True return delivered_to_all
@staticmethod @staticmethod
def _prune_expired(state: dict[str, Any], now: datetime) -> bool: def _prune_expired(state: dict[str, Any], now: datetime) -> bool:

View File

@@ -70,10 +70,10 @@ def test_saapa_rejects_oversized_or_duplicate_snapshot() -> None:
def test_eitaa_requires_ok_response() -> None: def test_eitaa_requires_ok_response() -> None:
session = FakeSession(FakeResponse({"ok": False, "description": "denied"})) 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"): with pytest.raises(ExternalServiceError, match="denied"):
client.send("message") client.send("chat", "message")
_, url, kwargs = session.calls[0] _, url, kwargs = session.calls[0]
assert url.endswith("/token/sendMessage") assert url.endswith("/token/sendMessage")

View File

@@ -7,8 +7,7 @@ from blackout_notifier.config import Config, ConfigurationError
REQUIRED = { REQUIRED = {
"BARGHEMAN_TOKEN": "bargheman", "BARGHEMAN_TOKEN": "bargheman",
"EITAAYAR_TOKEN": "eitaayar", "EITAAYAR_TOKEN": "eitaayar",
"CHAT_ID": "chat", "BILL_CHAT_MAP": " 123:chat-a,456:chat-b,123:chat-c,123:chat-a ,, ",
"BILL_IDS": " 123,456,123 ,, ",
} }
@@ -17,13 +16,17 @@ def do_not_load_project_dotenv(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("blackout_notifier.config.load_dotenv", lambda **_: 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(): for name, value in REQUIRED.items():
monkeypatch.setenv(name, value) monkeypatch.setenv(name, value)
config = Config.from_env() config = Config.from_env()
assert config.bill_ids == ("123", "456") 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 str(config.state_file) == "state/outages.json"
assert config.lookahead_days == 5 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: def test_config_rejects_non_decimal_bill_ids(monkeypatch: pytest.MonkeyPatch) -> None:
for name, value in REQUIRED.items(): for name, value in REQUIRED.items():
monkeypatch.setenv(name, value) 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() 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: def test_config_validates_numeric_ranges(monkeypatch: pytest.MonkeyPatch) -> None:
for name, value in REQUIRED.items(): for name, value in REQUIRED.items():
monkeypatch.setenv(name, value) monkeypatch.setenv(name, value)

View File

@@ -142,6 +142,29 @@ assert_contains "${SCHEDULE[1]}" '"outage_number":"2"' \
assert_contains "${SCHEDULE[2]}" '"outage_number":"5"' \ assert_contains "${SCHEDULE[2]}" '"outage_number":"5"' \
"later events should remain in the schedule" "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")" OUTPUT="$(NO_COLOR=1 COLUMNS=80 render_dashboard "$NOW_EPOCH")"
if [[ "$OUTPUT" == *"POWER RETURNS IN"* || "$OUTPUT" == *"NEXT OUTAGE IN"* ]]; then if [[ "$OUTPUT" == *"POWER RETURNS IN"* || "$OUTPUT" == *"NEXT OUTAGE IN"* ]]; then
printf 'FAIL: countdown output contains a redundant heading\n' >&2 printf 'FAIL: countdown output contains a redundant heading\n' >&2
@@ -219,4 +242,9 @@ if (
exit 1 exit 1
fi 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' printf 'Bash CLI tests passed\n'

View File

@@ -26,26 +26,26 @@ class FakeSaapa:
class FakeNotifier: class FakeNotifier:
def __init__(self, *, fail: bool = False, fail_on_call: int | None = None) -> None: 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 = fail
self.fail_on_call = fail_on_call self.fail_on_call = fail_on_call
self.calls = 0 self.calls = 0
def send(self, text: str) -> None: def send(self, chat_id: str, text: str) -> None:
self.calls += 1 self.calls += 1
if self.fail or self.calls == self.fail_on_call: if self.fail or self.calls == self.fail_on_call:
raise ExternalServiceError("Eitaa unavailable") raise ExternalServiceError("Eitaa unavailable")
self.messages.append(text) self.messages.append((chat_id, text))
def _service( def _service(
path: Path, path: Path,
saapa: FakeSaapa, saapa: FakeSaapa,
notifier: FakeNotifier, notifier: FakeNotifier,
bill_ids: tuple[str, ...] = ("123",), bill_chat_ids: Mapping[str, tuple[str, ...]] | None = None,
) -> OutageService: ) -> OutageService:
return OutageService( return OutageService(
bill_ids=bill_ids, bill_chat_ids=bill_chat_ids or {"123": ("chat-a",)},
lookahead_days=5, lookahead_days=5,
saapa=saapa, # type: ignore[arg-type] saapa=saapa, # type: ignore[arg-type]
notifier=notifier, 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.sent == 1
assert first.state_changed is True assert first.state_changed is True
assert "خاموشی جدید" in notifier.messages[0] assert "خاموشی جدید" in notifier.messages[0][1]
assert second.sent == 0 assert second.sent == 0
assert second.state_changed is False assert second.state_changed is False
assert path.read_text(encoding="utf-8") == first_state 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) result = service.run(now)
assert result.sent == 1 assert result.sent == 1
assert "تغییر در برنامه" in notifier.messages[-1] assert "تغییر در برنامه" in notifier.messages[-1][1]
assert "09:00" in notifier.messages[-1] assert "09:00" in notifier.messages[-1][1]
assert "10:00" in notifier.messages[-1] assert "10:00" in notifier.messages[-1][1]
def test_cancellation_requires_two_successful_omissions_and_can_reactivate( 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 first_missing.sent == 0
assert second_missing.sent == 1 assert second_missing.sent == 1
assert "دو بررسی پیاپی" in notifier.messages[-1] assert "دو بررسی پیاپی" in notifier.messages[-1][1]
saapa.snapshots["123"] = [outage] saapa.snapshots["123"] = [outage]
restored = service.run(now) restored = service.run(now)
assert restored.sent == 1 assert restored.sent == 1
assert "بازگشت برنامه" in notifier.messages[-1] assert "بازگشت برنامه" in notifier.messages[-1][1]
def test_failed_notification_does_not_advance_state( def test_failed_notification_does_not_advance_state(
@@ -130,13 +130,80 @@ def test_fetch_failure_for_one_bill_does_not_block_another(
) -> None: ) -> None:
saapa = FakeSaapa({"123": ExternalServiceError("SAAPA failed for 123"), "456": [outage]}) saapa = FakeSaapa({"123": ExternalServiceError("SAAPA failed for 123"), "456": [outage]})
notifier = FakeNotifier() 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) result = service.run(now)
assert result.sent == 1 assert result.sent == 1
assert result.errors == ["SAAPA failed for 123"] assert result.errors == ["SAAPA failed for 123"]
assert len(notifier.messages) == 1 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( def test_partial_delivery_persists_only_successful_events(