Compare commits
2 Commits
f08dbc34ba
...
42687ee651
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42687ee651 | ||
|
|
6c3e146234 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
59
README.md
59
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:
|
||||
|
||||
BIN
avatar.png
Normal file
BIN
avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
80
main.sh
80
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,6 +816,14 @@ render_dashboard() {
|
||||
return
|
||||
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"
|
||||
if [[ "$phase" == "ONGOING" ]]; then
|
||||
remaining=$((stop_epoch - now_epoch))
|
||||
@@ -792,13 +831,15 @@ render_dashboard() {
|
||||
remaining=$((start_epoch - now_epoch))
|
||||
fi
|
||||
banner="$(banner_duration "$remaining")"
|
||||
rule="$(repeat_character '=' "$columns")"
|
||||
|
||||
printf '%s%s%s\n' "$CYAN" "$rule" "$RESET"
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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_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:
|
||||
|
||||
@@ -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:
|
||||
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(message)
|
||||
self._notifier.send(chat_id, message)
|
||||
except ExternalServiceError as error:
|
||||
result.errors.append(str(error))
|
||||
LOGGER.error("%s", error)
|
||||
return False
|
||||
delivered_to_all = False
|
||||
continue
|
||||
result.sent += 1
|
||||
return True
|
||||
return delivered_to_all
|
||||
|
||||
@staticmethod
|
||||
def _prune_expired(state: dict[str, Any], now: datetime) -> bool:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user