Compare commits
13 Commits
f08dbc34ba
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14f54dd09f | ||
|
|
b75f44f0a1 | ||
| bf09d4d99c | |||
| 0b24d544d3 | |||
|
|
b44d6dd3cb | ||
|
|
cfd19fe4a8 | ||
|
|
5f5329530c | ||
|
|
88e11f6594 | ||
|
|
ec54c552c2 | ||
|
|
e9947a14cb | ||
|
|
ea03d84901 | ||
|
|
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
|
||||
|
||||
72
README.md
72
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,18 +215,33 @@ 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.
|
||||
|
||||
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:
|
||||
Select one bill for the countdown and outage schedule with `-b`/`--bill-id`:
|
||||
|
||||
```sh
|
||||
NO_COLOR=1 COLUMNS=80 ./main.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
|
||||
is also limited to that bill's active current and future events, including
|
||||
emergency outages. If the selected bill has no qualifying outage, the dashboard
|
||||
shows `ALL CLEAR`. Without `--bill-id`, the countdown and schedule include 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.
|
||||
|
||||
Schedule entries use one compact row each. When every outage cannot fit below
|
||||
the countdown, the dashboard keeps the nearest entries visible and reports how
|
||||
many additional outages were omitted. Resizing the terminal reveals more rows.
|
||||
|
||||
Colors are enabled only when output is attached to a terminal. Set `NO_COLOR` to
|
||||
disable them explicitly, or set `COLUMNS` and `LINES` to control the rendered
|
||||
size:
|
||||
|
||||
```sh
|
||||
NO_COLOR=1 COLUMNS=80 LINES=24 ./main.sh
|
||||
```
|
||||
|
||||
When `pyfiglet` or `figlet` provides the `univers` font, the countdown uses it
|
||||
|
||||
BIN
avatar.png
Normal file
BIN
avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
248
main.sh
248
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,22 @@ 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 Show the countdown and schedule for this bill only
|
||||
-h, --help Show this help
|
||||
|
||||
Environment:
|
||||
NO_COLOR Disable ANSI colors when set
|
||||
COLUMNS Override the detected output width
|
||||
LINES Override the detected output height
|
||||
|
||||
Required commands: bash, curl, jq, and GNU date
|
||||
Required commands: Bash 4.3+, curl, jq, and GNU date
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -299,6 +326,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=()
|
||||
|
||||
@@ -307,6 +335,9 @@ build_schedule() {
|
||||
|
||||
while IFS= read -r record; do
|
||||
record_fields "$record" fields
|
||||
if [[ -n "$countdown_bill_id" && "${fields[0]}" != "$countdown_bill_id" ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ "${fields[2]}" != "active" ]]; then
|
||||
continue
|
||||
fi
|
||||
@@ -324,7 +355,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 +364,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'*}"
|
||||
@@ -382,6 +416,38 @@ terminal_columns() {
|
||||
printf '%s\n' "$columns"
|
||||
}
|
||||
|
||||
terminal_lines() {
|
||||
local lines="${LINES:-}"
|
||||
local terminal_size="" detected_lines=""
|
||||
|
||||
if [[ -z "$lines" ]] && command -v stty >/dev/null 2>&1; then
|
||||
if [[ -r /dev/tty ]]; then
|
||||
terminal_size="$(stty size 2>/dev/null </dev/tty || true)"
|
||||
fi
|
||||
if [[ -z "$terminal_size" && -t 2 ]]; then
|
||||
terminal_size="$(stty size <&2 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -z "$terminal_size" && -t 0 ]]; then
|
||||
terminal_size="$(stty size <&0 2>/dev/null || true)"
|
||||
fi
|
||||
read -r detected_lines _ <<<"$terminal_size"
|
||||
if [[ "$detected_lines" =~ ^[0-9]+$ ]]; then
|
||||
lines="$detected_lines"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$lines" ]] && command -v tput >/dev/null 2>&1; then
|
||||
lines="$(tput lines 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [[ ! "$lines" =~ ^[0-9]+$ ]]; then
|
||||
lines=24
|
||||
elif ((lines < 10)); then
|
||||
lines=10
|
||||
fi
|
||||
printf '%s\n' "$lines"
|
||||
}
|
||||
|
||||
color_setup() {
|
||||
RESET=""
|
||||
BOLD=""
|
||||
@@ -681,10 +747,29 @@ human_duration() {
|
||||
printf '%s\n' "$output"
|
||||
}
|
||||
|
||||
compact_reference() {
|
||||
local bill_id="$1"
|
||||
local outage_number="$2"
|
||||
local width="$3"
|
||||
local reference="$bill_id/$outage_number"
|
||||
local bill_width
|
||||
|
||||
if ((${#reference} <= width)); then
|
||||
printf '%s' "$reference"
|
||||
elif ((width > ${#outage_number} + 2)); then
|
||||
bill_width=$((width - ${#outage_number} - 2))
|
||||
printf '~%s/%s' "${bill_id: -bill_width}" "$outage_number"
|
||||
elif ((width > 1)); then
|
||||
printf '%s~' "${reference:0:width-1}"
|
||||
else
|
||||
printf '~'
|
||||
fi
|
||||
}
|
||||
|
||||
render_event() {
|
||||
local line="$1"
|
||||
local now_epoch="$2"
|
||||
local ignored phase start_epoch stop_epoch record type_label timing
|
||||
local columns="$2"
|
||||
local ignored phase start_epoch stop_epoch record type_label reference reference_width
|
||||
local -a fields
|
||||
|
||||
IFS=$'\t' read -r ignored phase start_epoch stop_epoch record <<<"$line"
|
||||
@@ -692,18 +777,24 @@ render_event() {
|
||||
type_label="EMERGENCY"
|
||||
[[ "${fields[8]}" == "true" ]] && type_label="PLANNED"
|
||||
|
||||
if [[ "$phase" == "ONGOING" ]]; then
|
||||
timing="ends in $(human_duration "$((stop_epoch - now_epoch))")"
|
||||
if ((columns >= 60)); then
|
||||
reference_width=$((columns - 47))
|
||||
reference="$(compact_reference "${fields[0]}" "${fields[1]}" "$reference_width")"
|
||||
printf '%s%-9s%s %s%-9s%s %-10s %-11s %s\n' \
|
||||
"$([[ "$phase" == "ONGOING" ]] && printf '%s' "$RED" || printf '%s' "$CYAN")" \
|
||||
"$phase" "$RESET" \
|
||||
"$([[ "$type_label" == "PLANNED" ]] && printf '%s' "$GREEN" || printf '%s' "$YELLOW")" \
|
||||
"$type_label" "$RESET" "${fields[3]}" "${fields[4]}-${fields[5]}" "$reference"
|
||||
else
|
||||
timing="starts in $(human_duration "$((start_epoch - now_epoch))")"
|
||||
reference_width=$((columns - 34))
|
||||
((reference_width < 1)) && reference_width=1
|
||||
reference="$(compact_reference "${fields[0]}" "${fields[1]}" "$reference_width")"
|
||||
printf '%s%-4s%s %-5s %-10s %-11s %s\n' \
|
||||
"$([[ "$phase" == "ONGOING" ]] && printf '%s' "$RED" || printf '%s' "$CYAN")" \
|
||||
"$([[ "$phase" == "ONGOING" ]] && printf 'ON' || printf 'UP')" "$RESET" \
|
||||
"$([[ "$type_label" == "PLANNED" ]] && printf 'PLAN' || printf 'EMERG')" \
|
||||
"${fields[3]}" "${fields[4]}-${fields[5]}" "$reference"
|
||||
fi
|
||||
|
||||
printf '%s%s%-10s%s %s%-9s%s %s\n' \
|
||||
"$BOLD" "$([[ "$phase" == "ONGOING" ]] && printf '%s' "$RED" || printf '%s' "$CYAN")" \
|
||||
"$phase" "$RESET" "$([[ "$type_label" == "PLANNED" ]] && printf '%s' "$GREEN" || printf '%s' "$YELLOW")" \
|
||||
"$type_label" "$RESET" "$timing"
|
||||
printf ' Date %s %s - %s\n' "${fields[3]}" "${fields[4]}" "${fields[5]}"
|
||||
printf ' Reference bill %s / outage %s\n' "${fields[0]}" "${fields[1]}"
|
||||
}
|
||||
|
||||
render_empty() {
|
||||
@@ -747,7 +838,7 @@ draw_frame() {
|
||||
|
||||
if ((TERMINAL_ACTIVE)); then
|
||||
cleared_frame="${frame//$'\n'/$'\033[K\n'}"
|
||||
printf '\033[H%s\033[K\n\033[J' "$cleared_frame"
|
||||
printf '\033[H%s\033[K\033[J' "$cleared_frame"
|
||||
else
|
||||
printf '%s\n' "$frame"
|
||||
fi
|
||||
@@ -772,47 +863,102 @@ schedule_next_transition() {
|
||||
render_dashboard() {
|
||||
local now_epoch="$1"
|
||||
local columns="${2:-}"
|
||||
local rule phase start_epoch stop_epoch record remaining banner
|
||||
local schedule_line ignored
|
||||
local countdown_bill_id="${3:-}"
|
||||
local lines="${4:-}"
|
||||
local rule phase start_epoch stop_epoch record remaining banner banner_art
|
||||
local banner_rows fixed_rows available_rows visible_count hidden_count index show_bill_heading=0
|
||||
|
||||
color_setup
|
||||
if [[ -z "$columns" ]]; then
|
||||
columns="$(terminal_columns)"
|
||||
fi
|
||||
if [[ -z "$lines" ]]; then
|
||||
lines="$(terminal_lines)"
|
||||
fi
|
||||
|
||||
if ((${#SCHEDULE[@]} == 0)); then
|
||||
render_empty "$columns"
|
||||
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"
|
||||
printf '\n%s%s%s\n' "$CYAN" "$rule" "$RESET"
|
||||
printf '\n%sOUTAGE SCHEDULE (%d)%s\n\n' "$BOLD" "${#SCHEDULE[@]}" "$RESET"
|
||||
|
||||
for schedule_line in "${SCHEDULE[@]}"; do
|
||||
IFS=$'\t' read -r ignored phase start_epoch stop_epoch record <<<"$schedule_line"
|
||||
render_event "$schedule_line" "$now_epoch"
|
||||
printf '%s\n' "$DIM$(repeat_character '-' "$columns")$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")"
|
||||
banner_art="$(print_large_countdown "$banner" "$columns")"
|
||||
banner_rows="$(awk 'END { print NR }' <<<"$banner_art")"
|
||||
[[ -n "$countdown_bill_id" ]] && show_bill_heading=1
|
||||
fixed_rows=$((8 + banner_rows))
|
||||
((show_bill_heading)) && fixed_rows=$((fixed_rows + 2))
|
||||
if ((fixed_rows + 2 > lines)); then
|
||||
banner_art="$banner"
|
||||
banner_rows=1
|
||||
fixed_rows=$((8 + banner_rows))
|
||||
((show_bill_heading)) && fixed_rows=$((fixed_rows + 2))
|
||||
if ((fixed_rows + 1 > lines)); then
|
||||
show_bill_heading=0
|
||||
fi
|
||||
fi
|
||||
if ((show_bill_heading)); then
|
||||
center_line "COUNTDOWN FOR BILL $countdown_bill_id" "$columns"
|
||||
printf '\n'
|
||||
fi
|
||||
printf '%s%s' "$BOLD" "$YELLOW"
|
||||
printf '%s\n' "$banner_art"
|
||||
printf '%s' "$RESET"
|
||||
fi
|
||||
|
||||
printf '\n%s%s%s\n' "$CYAN" "$rule" "$RESET"
|
||||
printf '\n%sOUTAGE SCHEDULE (%d)%s\n' "$BOLD" "${#SCHEDULE[@]}" "$RESET"
|
||||
if ((columns >= 60)); then
|
||||
printf '%s%-9s %-9s %-10s %-11s %s%s\n' \
|
||||
"$BOLD" "STATUS" "TYPE" "DATE" "TIME" "BILL/OUTAGE" "$RESET"
|
||||
else
|
||||
printf '%s%-4s %-5s %-10s %-11s %s%s\n' \
|
||||
"$BOLD" "ST" "TYPE" "DATE" "TIME" "REF" "$RESET"
|
||||
fi
|
||||
printf '%s%s%s\n' "$DIM" "$(repeat_character '-' "$columns")" "$RESET"
|
||||
|
||||
fixed_rows=$((8 + ${banner_rows:-1}))
|
||||
((show_bill_heading)) && fixed_rows=$((fixed_rows + 2))
|
||||
available_rows=$((lines - fixed_rows - 1))
|
||||
((available_rows < 0)) && available_rows=0
|
||||
visible_count="${#SCHEDULE[@]}"
|
||||
hidden_count=0
|
||||
if ((visible_count > available_rows)); then
|
||||
if ((available_rows > 0)); then
|
||||
visible_count=$((available_rows - 1))
|
||||
else
|
||||
visible_count=0
|
||||
fi
|
||||
hidden_count=$((${#SCHEDULE[@]} - visible_count))
|
||||
fi
|
||||
|
||||
for ((index = 0; index < visible_count; index++)); do
|
||||
render_event "${SCHEDULE[index]}" "$columns"
|
||||
done
|
||||
if ((hidden_count && available_rows > 0)); then
|
||||
printf '%s... %d more outage%s%s\n' \
|
||||
"$DIM" "$hidden_count" "$([[ "$hidden_count" == 1 ]] || printf 's')" "$RESET"
|
||||
fi
|
||||
printf '%s%s%s\n' "$CYAN" "$rule" "$RESET"
|
||||
}
|
||||
|
||||
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 display_lines render_lines countdown_bill_id=""
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
@@ -833,6 +979,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 +1004,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 +1053,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
|
||||
@@ -901,9 +1064,14 @@ main() {
|
||||
fi
|
||||
|
||||
display_columns="$(terminal_columns)"
|
||||
display_lines="$(terminal_lines)"
|
||||
render_lines="$display_lines"
|
||||
if [[ -n "$REFRESH_ERROR" ]] && ((render_lines > 12)); then
|
||||
render_lines=$((render_lines - 2))
|
||||
fi
|
||||
frame="$(
|
||||
if ((state_ready)); then
|
||||
render_dashboard "$now_epoch" "$display_columns"
|
||||
render_dashboard "$now_epoch" "$display_columns" "$countdown_bill_id" "$render_lines"
|
||||
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,37 @@ 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}: {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_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,33 @@ 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,24 @@ 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 +124,7 @@ 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 +133,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:
|
||||
|
||||
@@ -52,8 +52,8 @@
|
||||
},
|
||||
"159719": {
|
||||
"last_seen_at": "2026-07-21T18:30:24.266070+03:30",
|
||||
"missing_checks": 0,
|
||||
"notified_at": "2026-07-21T18:30:24.266070+03:30",
|
||||
"missing_checks": 2,
|
||||
"notified_at": "2026-07-22T17:30:23.869053+03:30",
|
||||
"notified_hash": "40590e173639e2c8ecdb3682c092adfe5a0a9897535df835e4e1ce44cc6afa01",
|
||||
"snapshot": {
|
||||
"address": "باغ قلعه 8متری سوم ج ترانسفورماتور",
|
||||
@@ -64,11 +64,191 @@
|
||||
"start_time": "18:00",
|
||||
"stop_time": "20:00"
|
||||
},
|
||||
"status": "cancelled"
|
||||
},
|
||||
"159977": {
|
||||
"last_seen_at": "2026-07-22T16:30:24.078355+03:30",
|
||||
"missing_checks": 0,
|
||||
"notified_at": "2026-07-22T16:30:24.078355+03:30",
|
||||
"notified_hash": "9e082e2d029e51980d1178903a346f6d7a0ff9c5036bbc62fd9c256912865e40",
|
||||
"snapshot": {
|
||||
"address": "باغ قلعه 8متری سوم ج ترانسفورماتور",
|
||||
"is_planned": false,
|
||||
"outage_date": "1405/04/31",
|
||||
"outage_number": "159977",
|
||||
"reason": "مدیریت اضطراری بار",
|
||||
"start_time": "18:15",
|
||||
"stop_time": "20:00"
|
||||
},
|
||||
"status": "active"
|
||||
},
|
||||
"160375": {
|
||||
"last_seen_at": "2026-07-22T16:30:24.078355+03:30",
|
||||
"missing_checks": 0,
|
||||
"notified_at": "2026-07-22T16:30:24.078355+03:30",
|
||||
"notified_hash": "1be423abd3e35086d9956597041568a284a8f21ef9a0c67d17cd0e2d83f73cf2",
|
||||
"snapshot": {
|
||||
"address": "باغ قلعه 8متری سوم ج ترانسفورماتور",
|
||||
"is_planned": false,
|
||||
"outage_date": "1405/05/03",
|
||||
"outage_number": "160375",
|
||||
"reason": "مدیریت اضطراری بار",
|
||||
"start_time": "20:00",
|
||||
"stop_time": "22:00"
|
||||
},
|
||||
"status": "active"
|
||||
},
|
||||
"160621": {
|
||||
"last_seen_at": "2026-07-26T16:30:57.897178+03:30",
|
||||
"missing_checks": 0,
|
||||
"notified_at": "2026-07-26T16:30:57.897178+03:30",
|
||||
"notified_hash": "a1089904ac9932cd248aba8187fa44adabebf926be04e1ae382ef3f93d2a4f4b",
|
||||
"snapshot": {
|
||||
"address": "باغ قلعه 8متری سوم ج ترانسفورماتور",
|
||||
"is_planned": false,
|
||||
"outage_date": "1405/05/05",
|
||||
"outage_number": "160621",
|
||||
"reason": "مدیریت اضطراری بار",
|
||||
"start_time": "12:00",
|
||||
"stop_time": "14:00"
|
||||
},
|
||||
"status": "active"
|
||||
},
|
||||
"160869": {
|
||||
"last_seen_at": "2026-07-27T15:30:59.279795+03:30",
|
||||
"missing_checks": 0,
|
||||
"notified_at": "2026-07-27T15:30:59.279795+03:30",
|
||||
"notified_hash": "b78a1c2bde240ab2a99bf92e2f51678394c40557d2cf6feb86290cf4b416bf81",
|
||||
"snapshot": {
|
||||
"address": "باغ قلعه 8متری سوم ج ترانسفورماتور",
|
||||
"is_planned": false,
|
||||
"outage_date": "1405/05/06",
|
||||
"outage_number": "160869",
|
||||
"reason": "مدیریت اضطراری بار",
|
||||
"start_time": "16:00",
|
||||
"stop_time": "18:00"
|
||||
},
|
||||
"status": "active"
|
||||
},
|
||||
"161111": {
|
||||
"last_seen_at": "2026-07-29T17:31:00.393243+03:30",
|
||||
"missing_checks": 0,
|
||||
"notified_at": "2026-07-29T17:31:00.393243+03:30",
|
||||
"notified_hash": "4a19b3095a786c5ce1da5cb41e820ac91bdf59f124c1aad0bc76edc8cfff9b74",
|
||||
"snapshot": {
|
||||
"address": "باغ قلعه 8متری سوم ج ترانسفورماتور",
|
||||
"is_planned": false,
|
||||
"outage_date": "1405/05/08",
|
||||
"outage_number": "161111",
|
||||
"reason": "مدیریت اضطراری بار",
|
||||
"start_time": "10:00",
|
||||
"stop_time": "12:00"
|
||||
},
|
||||
"status": "active"
|
||||
},
|
||||
"161389": {
|
||||
"last_seen_at": "2026-07-29T19:30:59.469939+03:30",
|
||||
"missing_checks": 0,
|
||||
"notified_at": "2026-07-29T19:30:59.469939+03:30",
|
||||
"notified_hash": "698ad7a1d28d9f1fd42a350926da771a11abd9d5c14d63dbf156f8ca789b80da",
|
||||
"snapshot": {
|
||||
"address": "باغ قلعه 8متری سوم ج ترانسفورماتور",
|
||||
"is_planned": false,
|
||||
"outage_date": "1405/05/10",
|
||||
"outage_number": "161389",
|
||||
"reason": "مدیریت اضطراری بار",
|
||||
"start_time": "20:00",
|
||||
"stop_time": "22:00"
|
||||
},
|
||||
"status": "active"
|
||||
}
|
||||
}
|
||||
},
|
||||
"7359439104426": {
|
||||
"events": {
|
||||
"160167": {
|
||||
"last_seen_at": "2026-07-22T16:30:24.078355+03:30",
|
||||
"missing_checks": 0,
|
||||
"notified_at": "2026-07-22T16:30:24.078355+03:30",
|
||||
"notified_hash": "b156c680bc077693453f33be3ea11bead797f4793503d151434501b38a552d2d",
|
||||
"snapshot": {
|
||||
"address": "بلوار 15 خرداد ک 27 بن بست گلستان پ 38",
|
||||
"is_planned": false,
|
||||
"outage_date": "1405/05/01",
|
||||
"outage_number": "160167",
|
||||
"reason": "مدیریت اضطراری بار",
|
||||
"start_time": "16:00",
|
||||
"stop_time": "18:00"
|
||||
},
|
||||
"status": "active"
|
||||
},
|
||||
"160499": {
|
||||
"last_seen_at": "2026-07-25T16:30:57.756664+03:30",
|
||||
"missing_checks": 0,
|
||||
"notified_at": "2026-07-25T16:30:57.756664+03:30",
|
||||
"notified_hash": "ade1146f40d4ed0bbe8a182afe6f40f0f256a4accd84155fc4ae097e66309734",
|
||||
"snapshot": {
|
||||
"address": "بلوار 15 خرداد ک 27 بن بست گلستان پ 38",
|
||||
"is_planned": false,
|
||||
"outage_date": "1405/05/04",
|
||||
"outage_number": "160499",
|
||||
"reason": "مدیریت اضطراری بار",
|
||||
"start_time": "16:00",
|
||||
"stop_time": "18:00"
|
||||
},
|
||||
"status": "active"
|
||||
},
|
||||
"160727": {
|
||||
"last_seen_at": "2026-07-26T16:30:57.897178+03:30",
|
||||
"missing_checks": 0,
|
||||
"notified_at": "2026-07-26T16:30:57.897178+03:30",
|
||||
"notified_hash": "36501deedcf55f8a43dfc42b3ce265097a3a2202a20183f69ff6bc0aaf8f8b58",
|
||||
"snapshot": {
|
||||
"address": "بلوار 15 خرداد ک 27 بن بست گلستان پ 38",
|
||||
"is_planned": false,
|
||||
"outage_date": "1405/05/05",
|
||||
"outage_number": "160727",
|
||||
"reason": "مدیریت اضطراری بار",
|
||||
"start_time": "22:00",
|
||||
"stop_time": "00:00"
|
||||
},
|
||||
"status": "active"
|
||||
},
|
||||
"160785": {
|
||||
"last_seen_at": "2026-07-27T14:34:00.319029+03:30",
|
||||
"missing_checks": 0,
|
||||
"notified_at": "2026-07-27T14:34:00.319029+03:30",
|
||||
"notified_hash": "7dbebe0f429ee9591442b43bd91066da407e106f5863d57bfe7e385aaa4ae73e",
|
||||
"snapshot": {
|
||||
"address": "بلوار 15 خرداد ک 27 بن بست گلستان پ 38",
|
||||
"is_planned": false,
|
||||
"outage_date": "1405/05/06",
|
||||
"outage_number": "160785",
|
||||
"reason": "مدیریت اضطراری بار",
|
||||
"start_time": "10:00",
|
||||
"stop_time": "12:00"
|
||||
},
|
||||
"status": "active"
|
||||
},
|
||||
"161230": {
|
||||
"last_seen_at": "2026-07-29T17:31:00.393243+03:30",
|
||||
"missing_checks": 0,
|
||||
"notified_at": "2026-07-29T17:31:00.393243+03:30",
|
||||
"notified_hash": "5951e4742eb57490e879f9b7b924460929e750a39dffc9c8fbb00f08fb81a0ae",
|
||||
"snapshot": {
|
||||
"address": "بلوار 15 خرداد ک 27 بن بست گلستان پ 38",
|
||||
"is_planned": false,
|
||||
"outage_date": "1405/05/08",
|
||||
"outage_number": "161230",
|
||||
"reason": "مدیریت اضطراری بار",
|
||||
"start_time": "20:00",
|
||||
"stop_time": "22:00"
|
||||
},
|
||||
"status": "active"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema_version": 1,
|
||||
"updated_at": "2026-07-21T18:30:24.266070+03:30"
|
||||
"updated_at": "2026-07-29T19:30:59.469939+03:30"
|
||||
}
|
||||
|
||||
@@ -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,40 @@ 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)
|
||||
|
||||
@@ -120,6 +120,8 @@ assert_equal "01 : 01 : 01" "$(banner_duration 3661)" \
|
||||
"countdowns should include hours, minutes, and seconds"
|
||||
assert_equal "49 : 30 : 00" "$(banner_duration 178200)" \
|
||||
"countdowns should use total hours beyond one day"
|
||||
assert_equal "17" "$(LINES=17 terminal_lines)" \
|
||||
"the terminal height override should be respected"
|
||||
FONT_OUTPUT="$(print_large_countdown "00 : 00 : 00" 150)"
|
||||
assert_contains "$FONT_OUTPUT" ',a8888a,' \
|
||||
"the countdown should consistently use the univers-style font"
|
||||
@@ -142,6 +144,33 @@ 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 "1" "${#SCHEDULE[@]}" \
|
||||
"selecting a bill should 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 (1)" \
|
||||
"the schedule should contain only the selected bill"
|
||||
if [[ "$SELECTED_OUTPUT" == *"Bill 111"* ]]; then
|
||||
printf 'FAIL: selected bill output contains another bill\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build_schedule "$FIXTURE" "$NOW_EPOCH" "333"
|
||||
assert_equal "" "$HERO" \
|
||||
"a bill without upcoming outages should not borrow another bill's countdown"
|
||||
assert_equal "0" "${#SCHEDULE[@]}" \
|
||||
"a bill without upcoming outages should have an empty schedule"
|
||||
NO_HERO_OUTPUT="$(NO_COLOR=1 COLUMNS=80 render_dashboard "$NOW_EPOCH" 80 "333")"
|
||||
assert_contains "$NO_HERO_OUTPUT" "ALL CLEAR" \
|
||||
"a selected bill without outages should render the all-clear screen"
|
||||
|
||||
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
|
||||
@@ -162,6 +191,17 @@ if [[ "$OUTPUT" == *$'\033['* ]]; then
|
||||
printf 'FAIL: NO_COLOR output contains ANSI escapes\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
SHORT_OUTPUT="$(NO_COLOR=1 COLUMNS=80 LINES=12 render_dashboard "$NOW_EPOCH" 80 "" 12)"
|
||||
SHORT_LINE_COUNT="$(awk 'END { print NR }' <<<"$SHORT_OUTPUT")"
|
||||
assert_equal "12" "$SHORT_LINE_COUNT" \
|
||||
"a dashboard with extra outages should fit the terminal height"
|
||||
assert_contains "$SHORT_OUTPUT" '01 : 00 : 00' \
|
||||
"the countdown should remain visible when schedule rows are limited"
|
||||
assert_contains "$SHORT_OUTPUT" "... 2 more outages" \
|
||||
"the dashboard should report schedule rows that do not fit"
|
||||
SHORT_BOTTOM_LINE="${SHORT_OUTPUT##*$'\n'}"
|
||||
assert_equal "$(repeat_character '=' 80)" "$SHORT_BOTTOM_LINE" \
|
||||
"the compact schedule should end with a full-width rule"
|
||||
WIDE_OUTPUT="$(NO_COLOR=1 COLUMNS=180 render_dashboard "$NOW_EPOCH")"
|
||||
WIDE_TOP_LINE="${WIDE_OUTPUT%%$'\n'*}"
|
||||
assert_equal "180" "${#WIDE_TOP_LINE}" \
|
||||
@@ -169,6 +209,8 @@ assert_equal "180" "${#WIDE_TOP_LINE}" \
|
||||
WIDE_BOTTOM_LINE="${WIDE_OUTPUT##*$'\n'}"
|
||||
assert_equal "180" "${#WIDE_BOTTOM_LINE}" \
|
||||
"the dashboard bottom divider should span the full terminal width"
|
||||
assert_equal "$(repeat_character '=' 180)" "$WIDE_BOTTOM_LINE" \
|
||||
"the dashboard should end with the same rule as the countdown section"
|
||||
|
||||
FUTURE_NOW="$(TZ=Asia/Tehran date -d '2026-07-18 15:00:00' +%s)"
|
||||
build_schedule "$FIXTURE" "$FUTURE_NOW"
|
||||
@@ -219,4 +261,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