Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,22 +316,31 @@ stan kanałów bramki, „Połącz WhatsApp” (QR z WAHA odświeżany co 20 s d
`BRAMKA_KLUCZ_ADMIN` (wysyłka powiadomień idzie osobnym `BRAMKA_KLUCZ`);
przeglądarka nie zna żadnego z nich.

Odbiorcy (najwyżej 5): numer z kierunkowym, etykieta (≤ 40 znaków) i
przełącznik „aktywny”; „+ Dodaj odbiorcę”, „Usuń”, „Cofnij zmiany”. Zmiany
Odbiorcy (najwyżej 5): numer z kierunkowym, etykieta (≤ 40 znaków), kolumna
**„Dostaje”** i przełącznik „aktywny”; „+ Dodaj odbiorcę”, „Usuń”, „Cofnij zmiany”. Zmiany
są lokalne, dopóki nie klikniesz „Zapisz listę” — zapis **całej** listy wymaga
hasła albo kodu 2FA (lista jest wspólna dla wszystkich aplikacji RenaCode,
sama sesja nie wystarcza). Błędne potwierdzenie liczy się do blokady konta;
błąd w liście (zły numer, powtórka) odpada wcześniej, z 400, bez sprawdzania
hasła. „Wyślij test do aktywnych” wysyła próbę tylko WhatsAppem i pokazuje,
do ilu doszła (np. „doszło do 1 z 2”, z zamaskowanym numerem tego, do kogo
hasła. „Wyślij test (N)” wysyła próbę tylko WhatsAppem do aktywnych
z „Wszystko” i pokazuje, do ilu doszła (np. „doszło do 1 z 2”, z zamaskowanym numerem tego, do kogo
nie doszło).

Bramka wysyła do wszystkich aktywnych naraz; gdy dojdzie do części, to
**„Dostaje”** to trasy w bramce (`charts/bramka/README.md` w renacode-infra,
„Trasy”): „Wszystko” — każda aplikacja RenaCode (kidwatch, trader,
monitoring…), alarmy techniczne czujki kidwatch i wiadomość próbna;
„Tylko kidwatch (dzieci)” — zwykłe powiadomienia kidwatch, **bez** alarmów
czujki (kidwatch wysyła je z `kategoria: czujka`). Nowy wiersz ma domyślnie
„Tylko kidwatch”. Alarm, którego nikt nie dostaje WhatsAppem, idzie e-mailem
na adres właściciela bramki. Przy bramce sprzed tras kolumna pokazuje
„wszystko”, a zapis nie wysyła pola tras.

Bramka wysyła do wszystkich aktywnych z daną trasą naraz; gdy dojdzie do części, to
sukces bez maila, a e-mail idzie dopiero, gdy nie doszło do nikogo. Numery
widzi tylko zalogowany właściciel konta w Profilu; logi panelu i bramki mają
wyłącznie trzy ostatnie cyfry (`...200`). Endpoint:
`POST /api/profile/whatsapp/recipients`
`{"recipients": [{"number", "label", "active"}], "confirm": "<hasło albo kod>"}`
`{"recipients": [{"number", "label", "active", "sources"?: ["*"] | ["kidwatch"]}], "confirm": "<hasło albo kod>"}`
(stary `POST /api/profile/whatsapp/recipient` z jednym numerem zostaje).
Wymaga bramki z `POST /v1/whatsapp/odbiorcy` — przy starszej bramce Profil
pokazuje jej jedynego odbiorcę, ale zapis listy kończy się błędem.
Expand Down
32 changes: 28 additions & 4 deletions src/kidwatch/bramka_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@
#: odrzuca wczesniej, z komunikatem po polsku, ale decyduje bramka.
MAX_RECIPIENTS = 5
MAX_LABEL = 40
#: Trasy odbiorcy (kanaly.zrodla_z_wpisu w bramce): "*" = wszystko, inaczej
#: nazwy aplikacji z opcjonalnym ":kategoria". Te same granice co w bramce.
ALL_SOURCES = "*"
MAX_SOURCES = 10
_SOURCE = re.compile(r"[a-z0-9][a-z0-9-]{0,39}(:[a-z0-9][a-z0-9-]{0,39})?", re.ASCII)


class BramkaError(RuntimeError):
Expand Down Expand Up @@ -60,10 +65,26 @@ def mask(number: str) -> str:
return f"...{number[-3:]}"


def normalize_sources(raw: object, i: int) -> list[str]:
"""["kidwatch"] / ["*"] -> lista bez powtorzen; "*" pochlania reszte."""
if not isinstance(raw, list) or not raw:
raise ValueError(f"Odbiorca {i}: wybierz, co ma dostawać")
if len(raw) > MAX_SOURCES:
raise ValueError(f"Odbiorca {i}: najwyżej {MAX_SOURCES} źródeł")
result: list[str] = []
for s in raw:
if not isinstance(s, str) or not (s == ALL_SOURCES or _SOURCE.fullmatch(s)):
raise ValueError(f"Odbiorca {i}: nieznane źródło powiadomień")
if s not in result:
result.append(s)
return [ALL_SOURCES] if ALL_SOURCES in result else result


def normalize_recipients(raw: object) -> list[dict]:
"""Lista z przegladarki [{number, label, active}] -> lista dla bramki
[{numer, etykieta, aktywny}]. ValueError z komunikatem dla czlowieka -
pozycja na liscie, nigdy sam numer."""
"""Lista z przegladarki [{number, label, active, sources?}] -> lista dla
bramki [{numer, etykieta, aktywny, zrodla?}]. Bez `sources` pole `zrodla`
nie idzie, a bramka zostawia trasy numeru bez zmian. ValueError
z komunikatem dla czlowieka - pozycja na liscie, nigdy sam numer."""
if not isinstance(raw, list):
raise ValueError("Lista odbiorców ma zły format")
if len(raw) > MAX_RECIPIENTS:
Expand All @@ -87,7 +108,10 @@ def normalize_recipients(raw: object) -> list[dict]:
raise ValueError(f"Odbiorca {i}: zła wartość „aktywny”")
if any(r["numer"] == number for r in result):
raise ValueError(f"Odbiorca {i}: ten numer jest już na liście")
result.append({"numer": number, "etykieta": label, "aktywny": active})
entry = {"numer": number, "etykieta": label, "aktywny": active}
if "sources" in item:
entry["zrodla"] = normalize_sources(item["sources"], i)
result.append(entry)
return result


Expand Down
13 changes: 13 additions & 0 deletions src/kidwatch/notifiers/bramka.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
`priorytet`, `rodzaj` i `format` steruja tylko wygladem maila HTML (etykieta
alarm / wazne / informacja, podpis w naglowku, listy i sekcje z tekstu).
Starsza bramka ich nie czyta i wysyla jak dotad - pola sa zgodne wstecz.

`kategoria` steruje TRASA w bramce: alarmy czujki ida jako "kidwatch:czujka",
a odbiorca z trasa "kidwatch" (rodzina) dostaje tylko zwykle powiadomienia
o dzieciach. Starsza bramka pole ignoruje - wtedy czujka idzie do wszystkich,
jak dotad.
"""

from __future__ import annotations
Expand Down Expand Up @@ -45,6 +50,12 @@
NotifyKind.GAME: "czas gry",
}

#: Rodzaje, ktore nie sa powiadomieniem o dzieciach, tylko alarmem
#: technicznym (cisza DNS, odczyt iPada nie dziala, kolejka porzucila push).
#: Bramka kieruje je tylko do odbiorcow z "*" - patrz charts/bramka/README.md,
#: "Trasy".
KATEGORIE = {NotifyKind.WATCHDOG: "czujka"}


class BramkaNotifier:
name = "bramka"
Expand All @@ -67,6 +78,7 @@ def url(self) -> str:
return self.cfg.url.rstrip("/") + "/v1/wyslij"

def payload(self, note: Notification) -> dict:
extra = {"kategoria": KATEGORIE[note.kind]} if note.kind in KATEGORIE else {}
return {
"temat": note.title,
"tresc": note.text,
Expand All @@ -76,6 +88,7 @@ def payload(self, note: Notification) -> dict:
"rodzaj": RODZAJE.get(note.kind, ""),
# Tresc z formatting.py: punkty "•", naglowki sekcji w *gwiazdkach*.
"format": "markdown-lite",
**extra,
}

async def send(self, note: Notification) -> bool:
Expand Down
4 changes: 3 additions & 1 deletion src/kidwatch/panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -1152,7 +1152,9 @@ def _profile_post(self, path: str) -> None:
"panel: %s zapisal odbiorcow WhatsApp (%d, aktywni %d): %s",
session.login, len(recipients),
sum(r["aktywny"] for r in recipients),
", ".join(mask(r["numer"]) + ("" if r["aktywny"] else " (wyl.)")
", ".join(mask(r["numer"])
+ (f" [{','.join(r['zrodla'])}]" if "zrodla" in r else "")
+ ("" if r["aktywny"] else " (wyl.)")
for r in recipients) or "-",
)
elif path == "/api/profile/test":
Expand Down
17 changes: 17 additions & 0 deletions tests/test_notifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,23 @@ def handler(request: httpx.Request) -> httpx.Response:
await client.aclose()


async def test_bramka_czujka_idzie_z_kategoria_a_zwykle_bez():
"""Trasy w bramce: alarm techniczny (WATCHDOG) to "kidwatch:czujka", ktorej
rodzina z trasa "kidwatch" nie dostaje. Zwykle powiadomienia bez kategorii."""
from kidwatch.config import BramkaConfig
from kidwatch.notifiers.bramka import BramkaNotifier

n = BramkaNotifier(BramkaConfig(url="http://bramka.test/"), key="k1",
client=httpx.AsyncClient())
ts = datetime.now(UTC)
assert n.payload(Notification(kind=NotifyKind.WATCHDOG, title="Cisza DNS", text="x",
dedup_key="w", ts=ts))["kategoria"] == "czujka"
for kind in set(NotifyKind) - {NotifyKind.WATCHDOG}:
body = n.payload(Notification(kind=kind, title="t", text="x", dedup_key="d", ts=ts))
assert "kategoria" not in body, kind
await n.aclose()


def test_bramka_kazdy_rodzaj_ma_podpis_w_mailu():
from kidwatch.notifiers.bramka import RODZAJE

Expand Down
36 changes: 36 additions & 0 deletions tests/test_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,34 @@ def test_lista_odbiorcow_dla_bramki():
assert normalize_recipients([]) == []


def test_trasy_odbiorcow_dla_bramki():
"""`sources` z panelu -> `zrodla` dla bramki; bez pola bramka zostawia
trasy numeru (stary panel nie poszerza nikomu tras)."""
assert normalize_recipients([
{"number": A, "label": "Ja", "active": True, "sources": ["*", "kidwatch"]},
{"number": B, "label": "Rodzina", "active": True, "sources": ["kidwatch", "kidwatch"]},
{"number": C, "active": True, "sources": ["kidwatch", "kidwatch:czujka"]},
]) == [
{"numer": A, "etykieta": "Ja", "aktywny": True, "zrodla": ["*"]},
{"numer": B, "etykieta": "Rodzina", "aktywny": True, "zrodla": ["kidwatch"]},
{"numer": C, "etykieta": "", "aktywny": True, "zrodla": ["kidwatch", "kidwatch:czujka"]},
]
assert "zrodla" not in normalize_recipients([{"number": A}])[0]


@pytest.mark.parametrize("sources, fragment", [
([], "co ma dostawać"),
("*", "co ma dostawać"),
(["Trader"], "nieznane"),
([1], "nieznane"),
([f"a{i}" for i in range(11)], "najwyżej 10"),
])
def test_walidacja_tras(sources, fragment):
with pytest.raises(ValueError, match=fragment) as e:
normalize_recipients([{"number": A, "sources": sources}])
assert "Odbiorca 1" in str(e.value) and A not in str(e.value)


@pytest.mark.parametrize("lista, fragment", [
(None, "format"),
([{"number": f"4850010030{i}"} for i in range(6)], "Najwyżej 5"),
Expand Down Expand Up @@ -430,6 +458,14 @@ def test_zapis_listy_odbiorcow_wymaga_hasla(served, caplog):
status, data = call(port, "GET", "/api/profile/notify", headers={"Cookie": h["Cookie"]})
assert status == 200 and len(data["odbiorcy"]) == 3

# Trasy z Profilu ida do bramki jako `zrodla`, a log pokazuje, kto co dostaje.
z_trasami = [{**LISTA[0], "sources": ["*"]}, {**LISTA[1], "sources": ["kidwatch"]}]
with caplog.at_level("INFO", logger=panel_mod.log.name):
status, data = call(port, "POST", url, {"recipients": z_trasami, "confirm": HASLO}, h)
assert status == 200, data
assert [o["zrodla"] for o in calls[-1][2]["odbiorcy"]] == [["*"], ["kidwatch"]]
assert "...200 [*], ...300 [kidwatch]" in " ".join(r.getMessage() for r in caplog.records)


def test_zapis_listy_odbiorcow_kodem_2FA(served):
import pyotp # noqa: PLC0415
Expand Down
50 changes: 43 additions & 7 deletions web/src/components/Profile.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
import React, { useCallback, useEffect, useState } from 'react';
import { get, post } from '../utils/api';
import {
MAX_LABEL, MAX_RECIPIENTS, partialFailure, recipientsFromState, recipientsPayload,
sameRecipients, testSummary, validateRecipients,
MAX_LABEL, MAX_RECIPIENTS, SOURCES_ALL, SOURCES_FAMILY, getsEverything, newRecipient,
partialFailure, recipientsFromState, recipientsPayload, sameRecipients, sourcesKey,
sourcesLabel, testSummary, validateRecipients,
} from '../utils/recipients';
import TwoFactor from './TwoFactor';

Expand Down Expand Up @@ -78,6 +79,27 @@ function PasswordForm() {
);
}

/* Co dostaje odbiorca (trasy w bramce). Dwa gotowe wybory; trasy ustawione
inaczej (API bramki) widac jako trzecia pozycje i zostaja bez zmian. */
function SourcesSelect({ i, sources, apps, onChange }) {
if (!Array.isArray(sources)) {
return <span className="dim" title="Bramka bez tras wysyła wszystko do wszystkich">wszystko</span>;
}
const others = apps.filter((a) => a !== 'kidwatch');
const key = sourcesKey(sources);
const presets = [sourcesKey(SOURCES_ALL), sourcesKey(SOURCES_FAMILY)];
return (
<select className="input-field" aria-label={`Co dostaje odbiorca ${i + 1}`} value={key}
onChange={(e) => onChange(e.target.value.split(','))}>
<option value={sourcesKey(SOURCES_ALL)}>
Wszystko{others.length ? ` (kidwatch, ${others.join(', ')}, testy)` : ''}
</option>
<option value={sourcesKey(SOURCES_FAMILY)}>Tylko kidwatch (dzieci)</option>
{!presets.includes(key) && <option value={key}>Inne: {sourcesLabel(sources)}</option>}
</select>
);
}

/* Odbiorcy WhatsApp: cala lista edytowana lokalnie i zapisywana jednym
POST. Lista jest wspolna dla wszystkich aplikacji RenaCode - serwer zada
hasla albo kodu 2FA (sama sesja nie wystarcza). */
Expand All @@ -91,6 +113,8 @@ function Recipients({ state, onSaved, busy, setBusy }) {
const dirty = !sameRecipients(draft, saved);
const problem = validateRecipients(draft, max, maxLabel);
const activeSaved = saved.filter((r) => r.active).length;
const testTargets = saved.filter((r) => r.active && getsEverything(r)).length;
const apps = Array.isArray(state.aplikacje) ? state.aplikacje : [];

// Odswiezenie stanu (np. co 20 s przy QR) nie nadpisuje edycji w toku.
const savedKey = JSON.stringify(recipientsPayload(saved));
Expand Down Expand Up @@ -147,7 +171,7 @@ function Recipients({ state, onSaved, busy, setBusy }) {
<div className="table-scroll" style={{ marginBottom: 10 }}>
<table className="table">
<thead>
<tr><th>Numer (z kierunkowym)</th><th>Etykieta</th><th>Aktywny</th><th aria-label="Usuń" /></tr>
<tr><th>Numer (z kierunkowym)</th><th>Etykieta</th><th>Dostaje</th><th>Aktywny</th><th aria-label="Usuń" /></tr>
</thead>
<tbody>
{draft.map((r, i) => (
Expand All @@ -162,6 +186,10 @@ function Recipients({ state, onSaved, busy, setBusy }) {
aria-label={`Etykieta odbiorcy ${i + 1}`} value={r.label}
onChange={(e) => edit(i, { label: e.target.value })} />
</td>
<td>
<SourcesSelect i={i} sources={r.sources} apps={apps}
onChange={(sources) => edit(i, { sources })} />
</td>
<td>
<label className="switch" title={r.active ? 'dostaje powiadomienia' : 'wyłączony'}>
<input type="checkbox" checked={r.active} aria-label={`Odbiorca ${i + 1} aktywny`}
Expand All @@ -179,19 +207,27 @@ function Recipients({ state, onSaved, busy, setBusy }) {
</table>
</div>
)}
{draft.some((r) => Array.isArray(r.sources)) && (
<p className="dim" style={{ fontSize: '0.76rem', margin: '0 0 10px' }}>
„Wszystko” — kidwatch, alarmy techniczne czujki, trader, monitoring, wiadomość
próbna i każda nowa aplikacja. „Tylko kidwatch” — powiadomienia o dzieciach.
Alarm, którego nikt nie dostaje WhatsAppem, idzie e-mailem do właściciela bramki.
</p>
)}

<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
<button type="button" className="btn-ghost" disabled={busy || draft.length >= max}
onClick={() => setDraft((d) => [...d, { number: '', label: '', active: true }])}>
onClick={() => setDraft((d) => [...d, newRecipient(state)])}>
+ Dodaj odbiorcę
</button>
{dirty && (
<button type="button" className="btn-link" disabled={busy}
onClick={() => { setDraft(saved); setMsg(null); }}>Cofnij zmiany</button>
)}
<button type="button" className="btn-ghost" disabled={busy || dirty || activeSaved === 0}
title={dirty ? 'Najpierw zapisz listę' : undefined} onClick={sendTest}>
Wyślij test do aktywnych
<button type="button" className="btn-ghost" disabled={busy || dirty || testTargets === 0}
title={dirty ? 'Najpierw zapisz listę' : 'Test dostają tylko aktywni z „Wszystko”'}
onClick={sendTest}>
Wyślij test ({testTargets})
</button>
</div>

Expand Down
38 changes: 35 additions & 3 deletions web/src/utils/recipients.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@
export const MAX_RECIPIENTS = 5;
export const MAX_LABEL = 40;

/* Trasy odbiorcy w bramce (`zrodla`): "*" to wszystko - kidwatch, trader,
monitoring, wiadomosc probna i aplikacje dopisane pozniej; "kidwatch" to
tylko powiadomienia o dzieciach (bez alarmow technicznych czujki). */
export const ALL = '*';
export const SOURCES_ALL = [ALL];
export const SOURCES_FAMILY = ['kidwatch'];

export const sourcesKey = (s) => (Array.isArray(s) ? s.join(',') : '');

/* Opis tras dla czlowieka. `null` = bramka sprzed tras (wysyla wszystko). */
export function sourcesLabel(sources) {
if (!Array.isArray(sources) || sources.includes(ALL)) return 'wszystko';
if (sourcesKey(sources) === sourcesKey(SOURCES_FAMILY)) return 'tylko kidwatch (dzieci)';
return sources.join(', ');
}

/* Czy odbiorca dostaje wiadomosc probna i alarmy spoza kidwatch. */
export const getsEverything = (r) => !Array.isArray(r.sources) || r.sources.includes(ALL);

/* Jak bramka_admin.normalize_number: tylko cyfry ASCII, + i separatory;
"00" na poczatku (bez +) to prefiks miedzynarodowy. */
export const validNumberInput = (s) => /^\+?[0-9 ().-]+$/.test(String(s ?? '').trim());
Expand All @@ -23,9 +42,11 @@ export function recipientsFromState(state) {
number: String(o.numer ?? ''),
label: String(o.etykieta ?? ''),
active: o.aktywny !== false,
// null: bramka sprzed tras - pole nie idzie w zapisie.
sources: Array.isArray(o.zrodla) ? o.zrodla.map(String) : null,
}));
}
return state?.odbiorca ? [{ number: state.odbiorca, label: '', active: true }] : [];
return state?.odbiorca ? [{ number: state.odbiorca, label: '', active: true, sources: null }] : [];
}

/* Pierwszy problem z lista albo null. Komunikat wskazuje pozycje, nie numer. */
Expand All @@ -43,9 +64,20 @@ export function validateRecipients(list, max = MAX_RECIPIENTS, maxLabel = MAX_LA
return null;
}

/* Cialo POST /api/profile/whatsapp/recipients (bez `confirm`). */
/* Nowy wiersz: waskie trasy (rodzina). Bramka sprzed tras ich nie zna. */
export const newRecipient = (state) => ({
number: '', label: '', active: true,
sources: Array.isArray(state?.odbiorcy?.[0]?.zrodla) || Array.isArray(state?.zrodla_rodziny)
? [...(state.zrodla_rodziny || SOURCES_FAMILY)] : null,
});

/* Cialo POST /api/profile/whatsapp/recipients (bez `confirm`). `sources`
tylko, gdy bramka zna trasy - bez pola bramka zostawia trasy numeru. */
export const recipientsPayload = (list) =>
list.map((r) => ({ number: digits(r.number), label: r.label.trim(), active: !!r.active }));
list.map((r) => ({
number: digits(r.number), label: r.label.trim(), active: !!r.active,
...(Array.isArray(r.sources) ? { sources: [...r.sources] } : {}),
}));

export function sameRecipients(a, b) {
return JSON.stringify(recipientsPayload(a)) === JSON.stringify(recipientsPayload(b));
Expand Down
Loading
Loading