From 25a5beb16e523da652ffc76f18340870134a5c3b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 09:23:44 +0000 Subject: [PATCH 01/19] Alert menu: no Sub button when every type is on; Sub, Unsub and Settings are just their emojis Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/menu.py | 10 +++++++--- tests/test_alert_menu.py | 33 +++++++++++++++++---------------- tests/test_irrigation.py | 3 +-- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index 1825e14..142515b 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -74,7 +74,8 @@ def keyboard(muted: set[str], available: list[str], open: str | None = None, par snd_on, snd_off, or snd_on_o / snd_off_o, the Other list of a sound menu) with its options under it. A ▸ after the text marks a button that opens a menu, ▾ (in the same place) the open one; the rest do something. `parent` is the type of the alert the buttons sit under (carried in every button): its lists put that type first (●) and the rest under "Other". `loud`: the types whose alerts make a notification sound; None (a - group) leaves out Settings, which holds two menus, Enable and Disable notification sounds, over the subscribed types.""" + group) leaves out Settings, which holds two menus, Enable and Disable notification sounds, over the subscribed types. Sub is left out + when every type is on.""" tail = f":{parent}" if parent else "" subscribed = options(muted, available, "unsub") if open == "other" and not (parent in subscribed and len(subscribed) > 1): @@ -82,9 +83,12 @@ def keyboard(muted: set[str], available: list[str], open: str | None = None, par unsub_open = open in ("unsub", "other") def head(is_open: bool, text: str, section: str) -> InlineKeyboardButton: return _button(menu_text(text, is_open), f"al:close{tail}" if is_open else f"al:open:{section}{tail}") - rows = [[head(open == "sub", "➕ Sub", "sub"), head(unsub_open, "➖ Unsub", "unsub")]] + everything = not options(muted, available, "sub") # subscribed to every type: nothing for Sub to list, so no Sub button + if everything and open == "sub": + open = None + rows = [[*([] if everything else [head(open == "sub", "➕", "sub")]), head(unsub_open, "➖", "unsub")]] if loud is not None: - rows[0].append(head(open in SETTINGS, "⚙️ Settings", "set")) + rows[0].append(head(open in SETTINGS, "⚙️", "set")) mark = lambda k: f"● {label(k)}" if k == parent else label(k) # the alert these buttons are under if open in SETTINGS and loud is not None: for section, heading, items in (("snd_on", "🔔 Enable notification sounds", [k for k in subscribed if k not in loud]), diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 582e483..5e28c00 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -15,18 +15,20 @@ def rows(markup): return [[(b.text, b.callback_data) for b in row] for row in markup.inline_keyboard] -def test_the_collapsed_menu_is_subscribe_and_unsubscribe(): - assert rows(keyboard(set(), KINDS)) == [[("➕ Sub ▸", "al:open:sub"), ("➖ Unsub ▸", "al:open:unsub")]] +def test_the_collapsed_menu_is_subscribe_and_unsubscribe_and_has_no_subscribe_when_everything_is_on(): + assert rows(keyboard({"rain"}, KINDS)) == [[("➕ ▸", "al:open:sub"), ("➖ ▸", "al:open:unsub")]] + assert rows(keyboard(set(), KINDS)) == [[("➖ ▸", "al:open:unsub")]] # nothing for Sub to list + assert rows(keyboard(set(), KINDS, "sub")) == rows(keyboard(set(), KINDS)) # and it cannot be left open def test_unsubscribe_opens_the_types_that_are_on_and_subscribe_the_types_that_are_off(): got = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "unsub")) - assert got[0] == [("➕ Sub ▸", "al:open:sub"), ("➖ Unsub ▾", "al:close")] + assert got[0] == [("➕ ▸", "al:open:sub"), ("➖ ▾", "al:close")] assert got[1] == [("🌧️ rain", "al:off:rain:unsub"), ("💨 gusts", "al:off:gusts:unsub")] assert [text for row in got[1:-1] for text, _ in row] == ["🌧️ rain", "💨 gusts", "🧴 UV", "🌡️ temperature crossing", "😷 particulates"] assert got[-1] == [("🔕 Unsubscribe from all", "al:off:all:unsub")] got = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "sub")) - assert got[0][0] == ("➕ Sub ▾", "al:close") + assert got[0][0] == ("➕ ▾", "al:close") assert got[1:] == [[("🌼 pollen & asthma", "al:on:pollen_and_asthma:sub"), ("🔄 forecast changes", "al:on:forecast:sub")], [("🔔 Subscribe to all", "al:on:all:sub")]] assert not any(data == "al:noop" for row in got for _, data in row) # no button that does nothing assert options({"pollen_and_asthma"}, KINDS, "sub") == ["pollen_and_asthma"] and options({"pollen_and_asthma"}, ["rain", "pollen_and_asthma"], "unsub") == ["rain"] @@ -85,8 +87,7 @@ async def test_an_alert_goes_to_the_chats_subscribed_to_its_type_and_carries_the await notifier("Gusts", kind="gusts") assert [(c, t.split("\n")[0]) for c, t, _ in bot.sent] == [(1, "It's raining"), (1, "Gusts"), (2, "Gusts")] markup = bot.sent[0][2]["reply_markup"] - assert rows(markup) == [[("➕ Sub ▸", "al:open:sub:rain"), ("➖ Unsub ▸", "al:open:unsub:rain"), - ("⚙️ Settings ▸", "al:open:set:rain")]] # the alert's own type rides along + assert rows(markup) == [[("➖ ▸", "al:open:unsub:rain"), ("⚙️ ▸", "al:open:set:rain")]] # the alert's own type rides along assert bot.sent[0][1] == "It's raining" # no footer: the buttons say how to change it assert await notifier.to_chat(2, "x", kind="rain") is False and await notifier.to_chat(2, "x", kind="uv") is True @@ -137,7 +138,7 @@ async def test_pressing_unsubscribe_then_a_type_updates_the_state_and_redraws_th await press(bot, q) assert state.muted(1) == {"rain"} and q.toast == "Rain alerts off in this chat" drawn = rows(q.edits[0][1]) - assert drawn[0][1] == ("➖ Unsub ▾", "al:close") and "🌧️ rain" not in [t for row in drawn[1:] for t, _ in row] + assert drawn[0][1] == ("➖ ▾", "al:close") and "🌧️ rain" not in [t for row in drawn[1:] for t, _ in row] q = Query("al:on:rain:sub") await press(bot, q) assert state.muted(1) == set() and q.toast == "Rain alerts on in this chat" @@ -205,7 +206,7 @@ async def reply_text(body, **kw): update = NS(effective_message=NS(reply_text=reply_text, chat_id=1), effective_chat=chat, effective_user=NS(full_name="Rob", username="rob")) await bot.on_alerts(update, NS(args=[])) assert replies[0][0].startswith(TITLE) and "✅ On: rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes" in replies[0][0] - assert rows(replies[0][1]["reply_markup"])[0] == [("➕ Sub ▸", "al:open:sub"), ("➖ Unsub ▸", "al:open:unsub"), ("⚙️ Settings ▸", "al:open:set")] + assert rows(replies[0][1]["reply_markup"])[0] == [("➖ ▸", "al:open:unsub"), ("⚙️ ▸", "al:open:set")] await bot.on_alerts(update, NS(args=["off"])) assert not state.alerts_on(1) and "✅ On: nothing" in replies[1][0] await bot.on_alerts(update, NS(args=["on"])) @@ -258,7 +259,7 @@ async def spy(chat_id, text, link=None, kind=None): def test_under_an_alert_the_unsubscribe_list_is_its_type_then_other_which_holds_the_rest_and_all(): got = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "unsub", parent="uv")) - assert got == [[("➕ Sub ▸", "al:open:sub:uv"), ("➖ Unsub ▾", "al:close:uv")], + assert got == [[("➕ ▸", "al:open:sub:uv"), ("➖ ▾", "al:close:uv")], [("● 🧴 UV", "al:off:uv:unsub:uv")], [("Other ▸", "al:open:other:uv")]] other = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "other", parent="uv")) assert other[1:3] == [[("● 🧴 UV", "al:off:uv:other:uv")], [("Other ▾", "al:open:unsub:uv")]] @@ -289,7 +290,7 @@ async def test_the_alerts_own_type_survives_opening_unsubscribing_and_closing_th q = Query("al:open:unsub:uv", text="☀️ UV 10") await press(bot, q) drawn = rows(q.edits[0][1]) - assert drawn[0][1] == ("➖ Unsub ▾", "al:close:uv") and drawn[1:] == [ + assert drawn[0][0] == ("➖ ▾", "al:close:uv") and drawn[1:] == [ [("● 🧴 UV", "al:off:uv:unsub:uv")], [("Other ▸", "al:open:other:uv")]] q = Query("al:open:other:uv", text="☀️ UV 10") await press(bot, q) @@ -300,8 +301,8 @@ async def test_the_alerts_own_type_survives_opening_unsubscribing_and_closing_th assert "Other" not in str(rows(q.edits[0][1])) and rows(q.edits[0][1])[-1] == [("🔕 Unsubscribe from all", "al:off:all:unsub:uv")] q = Query("al:off:all:unsub:uv", text="☀️ UV 10") # and "all" turns the rest off, closing the empty section await press(bot, q) - assert rows(q.edits[0][1]) == [[("➕ Sub ▸", "al:open:sub:uv"), ("➖ Unsub ▸", "al:open:unsub:uv"), - ("⚙️ Settings ▸", "al:open:set:uv")]] + assert rows(q.edits[0][1]) == [[("➕ ▸", "al:open:sub:uv"), ("➖ ▸", "al:open:unsub:uv"), + ("⚙️ ▸", "al:open:set:uv")]] q = Query("al:open:unsub:uv", text="☀️ UV 10") # nothing is on now: a toast, no menu await press(bot, q) assert q.toast == "No alerts are on" and not q.edits @@ -313,7 +314,7 @@ async def test_an_unknown_parent_in_a_button_is_ignored(tmp_path): await press(bot, q) drawn = rows(q.edits[0][1]) assert [t for row in drawn[1:] for t, _ in row][0] == "🌧️ rain" and not any(t.startswith("●") for row in drawn for t, _ in row) - assert drawn[0][0][1] == "al:open:sub" # and it is not carried on + assert drawn[0][0][1] == "al:close" # and it is not carried on def test_every_alert_type_has_its_emoji_and_every_button_in_the_lists_starts_with_it(): @@ -336,7 +337,7 @@ async def test_settings_hold_enable_and_disable_sub_menus_of_notification_sounds q = Query("al:open:set") # /alerts: Settings opens the two sub-menus (▸ marks a menu) await press(bot, q) drawn = rows(q.edits[0][1]) - assert drawn[0][2] == ("⚙️ Settings ▾", "al:close") and drawn[1:] == [[("🔔 Enable notification sounds ▸", "al:open:snd_on")]] + assert drawn[0][1] == ("⚙️ ▾", "al:close") and drawn[1:] == [[("🔔 Enable notification sounds ▸", "al:open:snd_on")]] q = Query("al:open:snd_on") # only the types with the sound off, as buttons await press(bot, q) drawn = rows(q.edits[0][1]) @@ -389,7 +390,7 @@ async def test_an_alert_has_a_sound_only_in_a_chat_that_turned_it_on_and_groups_ flags = {(c, t.split("\n")[0]): kw["disable_notification"] for c, t, kw in bot.sent} assert flags[(1, "It's raining")] is False and flags[(1, "UV")] is True and flags[(-5, "It's raining")] is True group = next(kw for c, _, kw in bot.sent if c == -5) - assert [b[0] for b in rows(group["reply_markup"])[0]] == ["➕ Sub ▸", "➖ Unsub ▸"] + assert [b[0] for b in rows(group["reply_markup"])[0]] == ["➖ ▸"] async def test_a_chat_that_became_a_supergroup_is_followed_not_a_crash(tmp_path): @@ -486,7 +487,7 @@ async def test_an_alert_with_a_picture_is_the_picture_with_the_text_as_its_capti assert bot.sent == [] and len(bot.photos) == 1 chat, photo, kw = bot.photos[0] assert (chat, photo, kw["caption"]) == (1, b"png", "It's warmer outside") and kw["disable_notification"] is True - assert rows(kw["reply_markup"])[0][0] == ("➕ Sub ▸", "al:open:sub:temps") # the alert's own buttons ride along + assert rows(kw["reply_markup"])[0][0] == ("➖ ▸", "al:open:unsub:temps") # the alert's own buttons ride along async def test_an_alerts_picture_is_uploaded_once_and_the_other_chats_are_sent_telegrams_copy(tmp_path): diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index 0acd6c7..23bb331 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -708,8 +708,7 @@ async def send_message(self, chat_id, text, **kw): await m.check(NOW) assert sorted(c for c, _, _ in delivered) == [-7, 1, 3] and all(text.startswith("☔ Irrigation paused") for _, text, _ in delivered) markup = delivered[0][2]["reply_markup"] - assert rows(markup) == [[("➕ Sub ▸", "al:open:sub:irrigation_pause"), ("➖ Unsub ▸", "al:open:unsub:irrigation_pause"), - ("⚙️ Settings ▸", "al:open:set:irrigation_pause")]] + assert rows(markup) == [[("➖ ▸", "al:open:unsub:irrigation_pause"), ("⚙️ ▸", "al:open:set:irrigation_pause")]] from lib.alerts.menu import keyboard kinds = available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast", "Irrigation"}) for section in (None, "sub", "unsub"): # every button's data fits Telegram's 64 bytes, for the new type too From 6ae6e7d6ef5815ffc370aa5d465d2d0cbdf1f207 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 09:50:15 +0000 Subject: [PATCH 02/19] Alert menu: Sub and Unsub are back, with a bell and a crossed-out bell Settings stays the gear alone. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/menu.py | 2 +- tests/test_alert_menu.py | 24 ++++++++++++------------ tests/test_irrigation.py | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index 142515b..298f2c2 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -86,7 +86,7 @@ def head(is_open: bool, text: str, section: str) -> InlineKeyboardButton: everything = not options(muted, available, "sub") # subscribed to every type: nothing for Sub to list, so no Sub button if everything and open == "sub": open = None - rows = [[*([] if everything else [head(open == "sub", "➕", "sub")]), head(unsub_open, "➖", "unsub")]] + rows = [[*([] if everything else [head(open == "sub", "🔔 Sub", "sub")]), head(unsub_open, "🔕 Unsub", "unsub")]] if loud is not None: rows[0].append(head(open in SETTINGS, "⚙️", "set")) mark = lambda k: f"● {label(k)}" if k == parent else label(k) # the alert these buttons are under diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 5e28c00..a82d353 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -16,19 +16,19 @@ def rows(markup): def test_the_collapsed_menu_is_subscribe_and_unsubscribe_and_has_no_subscribe_when_everything_is_on(): - assert rows(keyboard({"rain"}, KINDS)) == [[("➕ ▸", "al:open:sub"), ("➖ ▸", "al:open:unsub")]] - assert rows(keyboard(set(), KINDS)) == [[("➖ ▸", "al:open:unsub")]] # nothing for Sub to list + assert rows(keyboard({"rain"}, KINDS)) == [[("🔔 Sub ▸", "al:open:sub"), ("🔕 Unsub ▸", "al:open:unsub")]] + assert rows(keyboard(set(), KINDS)) == [[("🔕 Unsub ▸", "al:open:unsub")]] # nothing for Sub to list assert rows(keyboard(set(), KINDS, "sub")) == rows(keyboard(set(), KINDS)) # and it cannot be left open def test_unsubscribe_opens_the_types_that_are_on_and_subscribe_the_types_that_are_off(): got = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "unsub")) - assert got[0] == [("➕ ▸", "al:open:sub"), ("➖ ▾", "al:close")] + assert got[0] == [("🔔 Sub ▸", "al:open:sub"), ("🔕 Unsub ▾", "al:close")] assert got[1] == [("🌧️ rain", "al:off:rain:unsub"), ("💨 gusts", "al:off:gusts:unsub")] assert [text for row in got[1:-1] for text, _ in row] == ["🌧️ rain", "💨 gusts", "🧴 UV", "🌡️ temperature crossing", "😷 particulates"] assert got[-1] == [("🔕 Unsubscribe from all", "al:off:all:unsub")] got = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "sub")) - assert got[0][0] == ("➕ ▾", "al:close") + assert got[0][0] == ("🔔 Sub ▾", "al:close") assert got[1:] == [[("🌼 pollen & asthma", "al:on:pollen_and_asthma:sub"), ("🔄 forecast changes", "al:on:forecast:sub")], [("🔔 Subscribe to all", "al:on:all:sub")]] assert not any(data == "al:noop" for row in got for _, data in row) # no button that does nothing assert options({"pollen_and_asthma"}, KINDS, "sub") == ["pollen_and_asthma"] and options({"pollen_and_asthma"}, ["rain", "pollen_and_asthma"], "unsub") == ["rain"] @@ -87,7 +87,7 @@ async def test_an_alert_goes_to_the_chats_subscribed_to_its_type_and_carries_the await notifier("Gusts", kind="gusts") assert [(c, t.split("\n")[0]) for c, t, _ in bot.sent] == [(1, "It's raining"), (1, "Gusts"), (2, "Gusts")] markup = bot.sent[0][2]["reply_markup"] - assert rows(markup) == [[("➖ ▸", "al:open:unsub:rain"), ("⚙️ ▸", "al:open:set:rain")]] # the alert's own type rides along + assert rows(markup) == [[("🔕 Unsub ▸", "al:open:unsub:rain"), ("⚙️ ▸", "al:open:set:rain")]] # the alert's own type rides along assert bot.sent[0][1] == "It's raining" # no footer: the buttons say how to change it assert await notifier.to_chat(2, "x", kind="rain") is False and await notifier.to_chat(2, "x", kind="uv") is True @@ -138,7 +138,7 @@ async def test_pressing_unsubscribe_then_a_type_updates_the_state_and_redraws_th await press(bot, q) assert state.muted(1) == {"rain"} and q.toast == "Rain alerts off in this chat" drawn = rows(q.edits[0][1]) - assert drawn[0][1] == ("➖ ▾", "al:close") and "🌧️ rain" not in [t for row in drawn[1:] for t, _ in row] + assert drawn[0][1] == ("🔕 Unsub ▾", "al:close") and "🌧️ rain" not in [t for row in drawn[1:] for t, _ in row] q = Query("al:on:rain:sub") await press(bot, q) assert state.muted(1) == set() and q.toast == "Rain alerts on in this chat" @@ -206,7 +206,7 @@ async def reply_text(body, **kw): update = NS(effective_message=NS(reply_text=reply_text, chat_id=1), effective_chat=chat, effective_user=NS(full_name="Rob", username="rob")) await bot.on_alerts(update, NS(args=[])) assert replies[0][0].startswith(TITLE) and "✅ On: rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes" in replies[0][0] - assert rows(replies[0][1]["reply_markup"])[0] == [("➖ ▸", "al:open:unsub"), ("⚙️ ▸", "al:open:set")] + assert rows(replies[0][1]["reply_markup"])[0] == [("🔕 Unsub ▸", "al:open:unsub"), ("⚙️ ▸", "al:open:set")] await bot.on_alerts(update, NS(args=["off"])) assert not state.alerts_on(1) and "✅ On: nothing" in replies[1][0] await bot.on_alerts(update, NS(args=["on"])) @@ -259,7 +259,7 @@ async def spy(chat_id, text, link=None, kind=None): def test_under_an_alert_the_unsubscribe_list_is_its_type_then_other_which_holds_the_rest_and_all(): got = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "unsub", parent="uv")) - assert got == [[("➕ ▸", "al:open:sub:uv"), ("➖ ▾", "al:close:uv")], + assert got == [[("🔔 Sub ▸", "al:open:sub:uv"), ("🔕 Unsub ▾", "al:close:uv")], [("● 🧴 UV", "al:off:uv:unsub:uv")], [("Other ▸", "al:open:other:uv")]] other = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "other", parent="uv")) assert other[1:3] == [[("● 🧴 UV", "al:off:uv:other:uv")], [("Other ▾", "al:open:unsub:uv")]] @@ -290,7 +290,7 @@ async def test_the_alerts_own_type_survives_opening_unsubscribing_and_closing_th q = Query("al:open:unsub:uv", text="☀️ UV 10") await press(bot, q) drawn = rows(q.edits[0][1]) - assert drawn[0][0] == ("➖ ▾", "al:close:uv") and drawn[1:] == [ + assert drawn[0][0] == ("🔕 Unsub ▾", "al:close:uv") and drawn[1:] == [ [("● 🧴 UV", "al:off:uv:unsub:uv")], [("Other ▸", "al:open:other:uv")]] q = Query("al:open:other:uv", text="☀️ UV 10") await press(bot, q) @@ -301,7 +301,7 @@ async def test_the_alerts_own_type_survives_opening_unsubscribing_and_closing_th assert "Other" not in str(rows(q.edits[0][1])) and rows(q.edits[0][1])[-1] == [("🔕 Unsubscribe from all", "al:off:all:unsub:uv")] q = Query("al:off:all:unsub:uv", text="☀️ UV 10") # and "all" turns the rest off, closing the empty section await press(bot, q) - assert rows(q.edits[0][1]) == [[("➕ ▸", "al:open:sub:uv"), ("➖ ▸", "al:open:unsub:uv"), + assert rows(q.edits[0][1]) == [[("🔔 Sub ▸", "al:open:sub:uv"), ("🔕 Unsub ▸", "al:open:unsub:uv"), ("⚙️ ▸", "al:open:set:uv")]] q = Query("al:open:unsub:uv", text="☀️ UV 10") # nothing is on now: a toast, no menu await press(bot, q) @@ -390,7 +390,7 @@ async def test_an_alert_has_a_sound_only_in_a_chat_that_turned_it_on_and_groups_ flags = {(c, t.split("\n")[0]): kw["disable_notification"] for c, t, kw in bot.sent} assert flags[(1, "It's raining")] is False and flags[(1, "UV")] is True and flags[(-5, "It's raining")] is True group = next(kw for c, _, kw in bot.sent if c == -5) - assert [b[0] for b in rows(group["reply_markup"])[0]] == ["➖ ▸"] + assert [b[0] for b in rows(group["reply_markup"])[0]] == ["🔕 Unsub ▸"] async def test_a_chat_that_became_a_supergroup_is_followed_not_a_crash(tmp_path): @@ -487,7 +487,7 @@ async def test_an_alert_with_a_picture_is_the_picture_with_the_text_as_its_capti assert bot.sent == [] and len(bot.photos) == 1 chat, photo, kw = bot.photos[0] assert (chat, photo, kw["caption"]) == (1, b"png", "It's warmer outside") and kw["disable_notification"] is True - assert rows(kw["reply_markup"])[0][0] == ("➖ ▸", "al:open:unsub:temps") # the alert's own buttons ride along + assert rows(kw["reply_markup"])[0][0] == ("🔕 Unsub ▸", "al:open:unsub:temps") # the alert's own buttons ride along async def test_an_alerts_picture_is_uploaded_once_and_the_other_chats_are_sent_telegrams_copy(tmp_path): diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index 23bb331..22276e6 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -708,7 +708,7 @@ async def send_message(self, chat_id, text, **kw): await m.check(NOW) assert sorted(c for c, _, _ in delivered) == [-7, 1, 3] and all(text.startswith("☔ Irrigation paused") for _, text, _ in delivered) markup = delivered[0][2]["reply_markup"] - assert rows(markup) == [[("➖ ▸", "al:open:unsub:irrigation_pause"), ("⚙️ ▸", "al:open:set:irrigation_pause")]] + assert rows(markup) == [[("🔕 Unsub ▸", "al:open:unsub:irrigation_pause"), ("⚙️ ▸", "al:open:set:irrigation_pause")]] from lib.alerts.menu import keyboard kinds = available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast", "Irrigation"}) for section in (None, "sub", "unsub"): # every button's data fits Telegram's 64 bytes, for the new type too From 157be074b0035194c8acfa74d60b9a282857a67f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 09:51:25 +0000 Subject: [PATCH 03/19] Alert menu: Sub and Unsub are just the bell and the crossed-out bell Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/menu.py | 2 +- tests/test_alert_menu.py | 24 ++++++++++++------------ tests/test_irrigation.py | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index 298f2c2..a081707 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -86,7 +86,7 @@ def head(is_open: bool, text: str, section: str) -> InlineKeyboardButton: everything = not options(muted, available, "sub") # subscribed to every type: nothing for Sub to list, so no Sub button if everything and open == "sub": open = None - rows = [[*([] if everything else [head(open == "sub", "🔔 Sub", "sub")]), head(unsub_open, "🔕 Unsub", "unsub")]] + rows = [[*([] if everything else [head(open == "sub", "🔔", "sub")]), head(unsub_open, "🔕", "unsub")]] if loud is not None: rows[0].append(head(open in SETTINGS, "⚙️", "set")) mark = lambda k: f"● {label(k)}" if k == parent else label(k) # the alert these buttons are under diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index a82d353..b62ba61 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -16,19 +16,19 @@ def rows(markup): def test_the_collapsed_menu_is_subscribe_and_unsubscribe_and_has_no_subscribe_when_everything_is_on(): - assert rows(keyboard({"rain"}, KINDS)) == [[("🔔 Sub ▸", "al:open:sub"), ("🔕 Unsub ▸", "al:open:unsub")]] - assert rows(keyboard(set(), KINDS)) == [[("🔕 Unsub ▸", "al:open:unsub")]] # nothing for Sub to list + assert rows(keyboard({"rain"}, KINDS)) == [[("🔔 ▸", "al:open:sub"), ("🔕 ▸", "al:open:unsub")]] + assert rows(keyboard(set(), KINDS)) == [[("🔕 ▸", "al:open:unsub")]] # nothing for Sub to list assert rows(keyboard(set(), KINDS, "sub")) == rows(keyboard(set(), KINDS)) # and it cannot be left open def test_unsubscribe_opens_the_types_that_are_on_and_subscribe_the_types_that_are_off(): got = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "unsub")) - assert got[0] == [("🔔 Sub ▸", "al:open:sub"), ("🔕 Unsub ▾", "al:close")] + assert got[0] == [("🔔 ▸", "al:open:sub"), ("🔕 ▾", "al:close")] assert got[1] == [("🌧️ rain", "al:off:rain:unsub"), ("💨 gusts", "al:off:gusts:unsub")] assert [text for row in got[1:-1] for text, _ in row] == ["🌧️ rain", "💨 gusts", "🧴 UV", "🌡️ temperature crossing", "😷 particulates"] assert got[-1] == [("🔕 Unsubscribe from all", "al:off:all:unsub")] got = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "sub")) - assert got[0][0] == ("🔔 Sub ▾", "al:close") + assert got[0][0] == ("🔔 ▾", "al:close") assert got[1:] == [[("🌼 pollen & asthma", "al:on:pollen_and_asthma:sub"), ("🔄 forecast changes", "al:on:forecast:sub")], [("🔔 Subscribe to all", "al:on:all:sub")]] assert not any(data == "al:noop" for row in got for _, data in row) # no button that does nothing assert options({"pollen_and_asthma"}, KINDS, "sub") == ["pollen_and_asthma"] and options({"pollen_and_asthma"}, ["rain", "pollen_and_asthma"], "unsub") == ["rain"] @@ -87,7 +87,7 @@ async def test_an_alert_goes_to_the_chats_subscribed_to_its_type_and_carries_the await notifier("Gusts", kind="gusts") assert [(c, t.split("\n")[0]) for c, t, _ in bot.sent] == [(1, "It's raining"), (1, "Gusts"), (2, "Gusts")] markup = bot.sent[0][2]["reply_markup"] - assert rows(markup) == [[("🔕 Unsub ▸", "al:open:unsub:rain"), ("⚙️ ▸", "al:open:set:rain")]] # the alert's own type rides along + assert rows(markup) == [[("🔕 ▸", "al:open:unsub:rain"), ("⚙️ ▸", "al:open:set:rain")]] # the alert's own type rides along assert bot.sent[0][1] == "It's raining" # no footer: the buttons say how to change it assert await notifier.to_chat(2, "x", kind="rain") is False and await notifier.to_chat(2, "x", kind="uv") is True @@ -138,7 +138,7 @@ async def test_pressing_unsubscribe_then_a_type_updates_the_state_and_redraws_th await press(bot, q) assert state.muted(1) == {"rain"} and q.toast == "Rain alerts off in this chat" drawn = rows(q.edits[0][1]) - assert drawn[0][1] == ("🔕 Unsub ▾", "al:close") and "🌧️ rain" not in [t for row in drawn[1:] for t, _ in row] + assert drawn[0][1] == ("🔕 ▾", "al:close") and "🌧️ rain" not in [t for row in drawn[1:] for t, _ in row] q = Query("al:on:rain:sub") await press(bot, q) assert state.muted(1) == set() and q.toast == "Rain alerts on in this chat" @@ -206,7 +206,7 @@ async def reply_text(body, **kw): update = NS(effective_message=NS(reply_text=reply_text, chat_id=1), effective_chat=chat, effective_user=NS(full_name="Rob", username="rob")) await bot.on_alerts(update, NS(args=[])) assert replies[0][0].startswith(TITLE) and "✅ On: rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes" in replies[0][0] - assert rows(replies[0][1]["reply_markup"])[0] == [("🔕 Unsub ▸", "al:open:unsub"), ("⚙️ ▸", "al:open:set")] + assert rows(replies[0][1]["reply_markup"])[0] == [("🔕 ▸", "al:open:unsub"), ("⚙️ ▸", "al:open:set")] await bot.on_alerts(update, NS(args=["off"])) assert not state.alerts_on(1) and "✅ On: nothing" in replies[1][0] await bot.on_alerts(update, NS(args=["on"])) @@ -259,7 +259,7 @@ async def spy(chat_id, text, link=None, kind=None): def test_under_an_alert_the_unsubscribe_list_is_its_type_then_other_which_holds_the_rest_and_all(): got = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "unsub", parent="uv")) - assert got == [[("🔔 Sub ▸", "al:open:sub:uv"), ("🔕 Unsub ▾", "al:close:uv")], + assert got == [[("🔔 ▸", "al:open:sub:uv"), ("🔕 ▾", "al:close:uv")], [("● 🧴 UV", "al:off:uv:unsub:uv")], [("Other ▸", "al:open:other:uv")]] other = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "other", parent="uv")) assert other[1:3] == [[("● 🧴 UV", "al:off:uv:other:uv")], [("Other ▾", "al:open:unsub:uv")]] @@ -290,7 +290,7 @@ async def test_the_alerts_own_type_survives_opening_unsubscribing_and_closing_th q = Query("al:open:unsub:uv", text="☀️ UV 10") await press(bot, q) drawn = rows(q.edits[0][1]) - assert drawn[0][0] == ("🔕 Unsub ▾", "al:close:uv") and drawn[1:] == [ + assert drawn[0][0] == ("🔕 ▾", "al:close:uv") and drawn[1:] == [ [("● 🧴 UV", "al:off:uv:unsub:uv")], [("Other ▸", "al:open:other:uv")]] q = Query("al:open:other:uv", text="☀️ UV 10") await press(bot, q) @@ -301,7 +301,7 @@ async def test_the_alerts_own_type_survives_opening_unsubscribing_and_closing_th assert "Other" not in str(rows(q.edits[0][1])) and rows(q.edits[0][1])[-1] == [("🔕 Unsubscribe from all", "al:off:all:unsub:uv")] q = Query("al:off:all:unsub:uv", text="☀️ UV 10") # and "all" turns the rest off, closing the empty section await press(bot, q) - assert rows(q.edits[0][1]) == [[("🔔 Sub ▸", "al:open:sub:uv"), ("🔕 Unsub ▸", "al:open:unsub:uv"), + assert rows(q.edits[0][1]) == [[("🔔 ▸", "al:open:sub:uv"), ("🔕 ▸", "al:open:unsub:uv"), ("⚙️ ▸", "al:open:set:uv")]] q = Query("al:open:unsub:uv", text="☀️ UV 10") # nothing is on now: a toast, no menu await press(bot, q) @@ -390,7 +390,7 @@ async def test_an_alert_has_a_sound_only_in_a_chat_that_turned_it_on_and_groups_ flags = {(c, t.split("\n")[0]): kw["disable_notification"] for c, t, kw in bot.sent} assert flags[(1, "It's raining")] is False and flags[(1, "UV")] is True and flags[(-5, "It's raining")] is True group = next(kw for c, _, kw in bot.sent if c == -5) - assert [b[0] for b in rows(group["reply_markup"])[0]] == ["🔕 Unsub ▸"] + assert [b[0] for b in rows(group["reply_markup"])[0]] == ["🔕 ▸"] async def test_a_chat_that_became_a_supergroup_is_followed_not_a_crash(tmp_path): @@ -487,7 +487,7 @@ async def test_an_alert_with_a_picture_is_the_picture_with_the_text_as_its_capti assert bot.sent == [] and len(bot.photos) == 1 chat, photo, kw = bot.photos[0] assert (chat, photo, kw["caption"]) == (1, b"png", "It's warmer outside") and kw["disable_notification"] is True - assert rows(kw["reply_markup"])[0][0] == ("🔕 Unsub ▸", "al:open:unsub:temps") # the alert's own buttons ride along + assert rows(kw["reply_markup"])[0][0] == ("🔕 ▸", "al:open:unsub:temps") # the alert's own buttons ride along async def test_an_alerts_picture_is_uploaded_once_and_the_other_chats_are_sent_telegrams_copy(tmp_path): diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index 22276e6..ac09150 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -708,7 +708,7 @@ async def send_message(self, chat_id, text, **kw): await m.check(NOW) assert sorted(c for c, _, _ in delivered) == [-7, 1, 3] and all(text.startswith("☔ Irrigation paused") for _, text, _ in delivered) markup = delivered[0][2]["reply_markup"] - assert rows(markup) == [[("🔕 Unsub ▸", "al:open:unsub:irrigation_pause"), ("⚙️ ▸", "al:open:set:irrigation_pause")]] + assert rows(markup) == [[("🔕 ▸", "al:open:unsub:irrigation_pause"), ("⚙️ ▸", "al:open:set:irrigation_pause")]] from lib.alerts.menu import keyboard kinds = available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast", "Irrigation"}) for section in (None, "sub", "unsub"): # every button's data fits Telegram's 64 bytes, for the new type too From bf26beb08d0c41a85bc2d62fb365dfe8751efc22 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 09:54:07 +0000 Subject: [PATCH 04/19] Alert menu: no Sub under an alert; its bell opens the alert's own type and all The crossed-out bell under an alert lists just that type and "Unsubscribe from all" (no Other menu). The gear stays. The /alerts message keeps both menus. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/menu.py | 14 +++++++++----- tests/test_alert_menu.py | 26 ++++++++++---------------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index a081707..cea2574 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -78,15 +78,15 @@ def keyboard(muted: set[str], available: list[str], open: str | None = None, par when every type is on.""" tail = f":{parent}" if parent else "" subscribed = options(muted, available, "unsub") - if open == "other" and not (parent in subscribed and len(subscribed) > 1): + if open == "other" and (parent or not (parent in subscribed and len(subscribed) > 1)): # under an alert there is no Other open = "unsub" unsub_open = open in ("unsub", "other") def head(is_open: bool, text: str, section: str) -> InlineKeyboardButton: return _button(menu_text(text, is_open), f"al:close{tail}" if is_open else f"al:open:{section}{tail}") - everything = not options(muted, available, "sub") # subscribed to every type: nothing for Sub to list, so no Sub button - if everything and open == "sub": + no_sub = bool(parent) or not options(muted, available, "sub") # under an alert, or subscribed to every type: no Sub button + if no_sub and open == "sub": open = None - rows = [[*([] if everything else [head(open == "sub", "🔔", "sub")]), head(unsub_open, "🔕", "unsub")]] + rows = [[*([] if no_sub else [head(open == "sub", "🔔", "sub")]), head(unsub_open, "🔕", "unsub")]] if loud is not None: rows[0].append(head(open in SETTINGS, "⚙️", "set")) mark = lambda k: f"● {label(k)}" if k == parent else label(k) # the alert these buttons are under @@ -102,7 +102,11 @@ def head(is_open: bool, text: str, section: str) -> InlineKeyboardButton: rows += _type_rows(items, parent, open == f"{section}_o", lambda k: _button(mark(k), f"al:snd:{k}:{verb}{tail}"), (f"al:open:{section}_o{tail}", f"al:open:{section}{tail}"), _button("🔔 Enable all" if verb == "on" else "🔕 Disable all", f"al:snd:{ALL}:{verb}{tail}")) - if open in ("sub", "unsub", "other"): + if open == "unsub" and parent in subscribed: # under an alert: its own type and all, nothing else + rows.append([_button(mark(parent), f"al:off:{parent}:unsub{tail}")]) + if len(subscribed) > 1: + rows.append([_button("🔕 Unsubscribe from all", f"al:off:{ALL}:unsub{tail}")]) + elif open in ("sub", "unsub", "other"): verb = "on" if open == "sub" else "off" rows += _type_rows(options(muted, available, "sub" if open == "sub" else "unsub"), parent if open != "sub" else None, open == "other", lambda k: _button(mark(k), f"al:{verb}:{k}:{open}{tail}"), (f"al:open:other{tail}", f"al:open:unsub{tail}"), diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index b62ba61..b0791ba 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -257,22 +257,17 @@ async def spy(chat_id, text, link=None, kind=None): assert seen == ["forecast"] -def test_under_an_alert_the_unsubscribe_list_is_its_type_then_other_which_holds_the_rest_and_all(): +def test_under_an_alert_the_unsubscribe_list_is_just_its_type_and_all_and_there_is_no_subscribe(): got = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "unsub", parent="uv")) - assert got == [[("🔔 ▸", "al:open:sub:uv"), ("🔕 ▾", "al:close:uv")], - [("● 🧴 UV", "al:off:uv:unsub:uv")], [("Other ▸", "al:open:other:uv")]] - other = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "other", parent="uv")) - assert other[1:3] == [[("● 🧴 UV", "al:off:uv:other:uv")], [("Other ▾", "al:open:unsub:uv")]] - listed = [d for r in other[3:-1] for _, d in r] - assert listed and all(d.startswith("al:off:") and d.endswith(":other:uv") and ":uv:" not in d for d in listed) # the rest, not its own type - assert other[-1] == [("🔕 Unsubscribe from all", "al:off:all:other:uv")] - only = rows(keyboard(set(KINDS) - {"uv"}, KINDS, "unsub", parent="uv")) # the alert's type is the only one on: no Other + assert got == [[("🔕 ▾", "al:close:uv")], [("● 🧴 UV", "al:off:uv:unsub:uv")], [("🔕 Unsubscribe from all", "al:off:all:unsub:uv")]] + assert rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "other", parent="uv")) == got # there is no Other + assert rows(keyboard({"pollen_and_asthma"}, KINDS, "sub", parent="uv")) == rows(keyboard({"pollen_and_asthma"}, KINDS, None, parent="uv")) + only = rows(keyboard(set(KINDS) - {"uv"}, KINDS, "unsub", parent="uv")) # the alert's type is the only one on: no "all" assert only[1:] == [[("● 🧴 UV", "al:off:uv:unsub:uv")]] - assert rows(keyboard(set(KINDS) - {"uv"}, KINDS, "other", parent="uv")) == only # and an open Other degrades to the plain list gone = rows(keyboard({"uv", "gusts"}, KINDS, "unsub", parent="uv")) # its type is already off: a plain list of the rest, with "all" assert "Other" not in str(gone) and gone[-1] == [("🔕 Unsubscribe from all", "al:off:all:unsub:uv")] assert len(rows(keyboard(set(KINDS), KINDS, "unsub", parent="uv"))) == 1 # nothing is on at all: nothing listed - sub = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "sub", parent="uv")) # Subscribe is the types that are off, as ever + sub = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "sub")) # Subscribe (the /alerts message) is the types that are off assert [t for row in sub[1:-1] for t, _ in row] == ["🌼 pollen & asthma", "🔄 forecast changes"] and sub[-1][0][0] == "🔔 Subscribe to all" assert rows(keyboard(set(), KINDS, "unsub"))[1][0][0] == "🌧️ rain" # the /alerts message has no parent: every type that is on @@ -291,17 +286,14 @@ async def test_the_alerts_own_type_survives_opening_unsubscribing_and_closing_th await press(bot, q) drawn = rows(q.edits[0][1]) assert drawn[0][0] == ("🔕 ▾", "al:close:uv") and drawn[1:] == [ - [("● 🧴 UV", "al:off:uv:unsub:uv")], [("Other ▸", "al:open:other:uv")]] - q = Query("al:open:other:uv", text="☀️ UV 10") - await press(bot, q) - assert rows(q.edits[0][1])[-1] == [("🔕 Unsubscribe from all", "al:off:all:other:uv")] + [("● 🧴 UV", "al:off:uv:unsub:uv")], [("🔕 Unsubscribe from all", "al:off:all:unsub:uv")]] q = Query("al:off:uv:unsub:uv", text="☀️ UV 10") # unsubscribing from this type leaves a plain list of the rest await press(bot, q) assert "uv" in state.muted(1) and q.toast == "UV alerts off in this chat" assert "Other" not in str(rows(q.edits[0][1])) and rows(q.edits[0][1])[-1] == [("🔕 Unsubscribe from all", "al:off:all:unsub:uv")] q = Query("al:off:all:unsub:uv", text="☀️ UV 10") # and "all" turns the rest off, closing the empty section await press(bot, q) - assert rows(q.edits[0][1]) == [[("🔔 ▸", "al:open:sub:uv"), ("🔕 ▸", "al:open:unsub:uv"), + assert rows(q.edits[0][1]) == [[("🔕 ▸", "al:open:unsub:uv"), ("⚙️ ▸", "al:open:set:uv")]] q = Query("al:open:unsub:uv", text="☀️ UV 10") # nothing is on now: a toast, no menu await press(bot, q) @@ -325,6 +317,8 @@ def test_every_alert_type_has_its_emoji_and_every_button_in_the_lists_starts_wit everything = list(LABELS) for section in ("sub", "unsub"): for parent in (None, "uv"): + if parent and section == "sub": + continue # no Subscribe under an alert drawn = rows(keyboard(set(LABELS) if section == "sub" else set(), everything, section, parent)) texts = [t for row in drawn[1:] for t, _ in row] texts = [t for t in texts if "Other" not in t] # (the Other menu is a word, not an alert type) From fc43cc6f8e92105dbfe5a1785cae720bfb26512d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 09:58:28 +0000 Subject: [PATCH 05/19] Log the "Keeping warm:" and "Alerts:" lists on one line, never cut Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- ecobot.py | 23 +++-------------------- lib/logs.py | 5 +++-- tests/test_startup.py | 19 ++++++++----------- 4 files changed, 15 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 4f4ef1c..7203142 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ The state and caches go in `/data` (its `DATA_DIR`), here a named volume. A host | `CHART_ALL_VPD=on` | optional: "weather all week" also draws the vapour pressure deficit panel; off by default, as it is temperature and humidity combined (the station reports it for the outdoor sensor only). Naming it ("plot vpd") always works | | `TELEGRAM_PRELOAD_CHAT` | optional: a chat id (negative for a group or channel) the bot silently uploads charts to ahead of need. With it set, each chart sent with period buttons has its other periods drawn and uploaded there in the background, each on its own the moment it is drawn, starting as soon as the chart is sent and whatever else is being answered, so pressing a period button edits the message with Telegram's copy and uploads nothing. A chart uploaded this way is used for 30 minutes, then drawn afresh. The bot must be able to post in that chat; the uploaded messages are left there. If Telegram refuses an upload (the bot can't post there) none is tried again until a restart; a flood limit or a timeout only skips that photo. Unset: off | | `ANTISPIKE_REQUIRE_CONSECUTIVE` | optional: how many readings in a row a rise of the particles (PM1, PM2.5, PM10) needs to count as the air rather than a glitch of the sensor. Default `2`: a single-reading spike (at least 2.5 times, and 5 µg/m³ above, the readings either side) is left out of PM charts and of every air figure (highs, lows, averages, days over a limit), and the unhealthy-air alert counts a check only when the sensor's last 2 readings are all over the mask level. `3` also drops two-reading spikes, and so on; `1` or `off` turns the rule off. The live "air now" reading and the "safe again" check are unchanged | -| `LOG_WIDTH` | optional: the most characters of a log line, cut with an ellipsis (…); `0` cuts none. Default 71. Every INFO line obeys it; a `WARN` or `ERROR` entry is never cut (a warning is printed as `WARN`); a request, chat message, reply or alert is also put on one line, and the startup "Alerts:" list wraps at it (at 90 when `0`) | +| `LOG_WIDTH` | optional: the most characters of a log line, cut with an ellipsis (…); `0` cuts none. Default 71. Every INFO line obeys it; a `WARN` or `ERROR` entry is never cut (a warning is printed as `WARN`); a request, chat message, reply or alert is also put on one line; the startup "Keeping warm:" and "Alerts:" lists are never cut or wrapped | | `LOG_LEVEL` | optional: `DEBUG`, `INFO` (default), `WARNING` or `ERROR`. `DEBUG` adds the bot's own debug lines (the chart timings, charts it couldn't draw ahead); the libraries stay at their usual level. | ## Layout diff --git a/ecobot.py b/ecobot.py index 90132ac..5494441 100644 --- a/ecobot.py +++ b/ecobot.py @@ -32,7 +32,7 @@ from lib.irrigation import Tuya from lib.irrigation.watchdog import CHECK_SECONDS as WATCHDOG_SECONDS from lib.llm import Agent -from lib.logs import LogFormat +from lib.logs import WHOLE, LogFormat from lib.pollen import Pollen from lib.compose import Composer from lib.tools import Tools @@ -95,27 +95,10 @@ def commit_in(git_dir) -> str: return "unknown" -INDENT = 13 # the continuation lines of a wrapped log line - - -def wrapped(head: str, items: list[str], tail: str = "", width: int = 90) -> str: - """head + the items joined by ", " + tail, over several lines when it is longer than `width`: a line breaks only between items, - and the next one is indented.""" - lines, line = [], head - for i, item in enumerate(items): - piece = item + (", " if i < len(items) - 1 else tail) - if line.strip() != head.strip() and len(line + piece.rstrip()) > width: - lines.append(line.rstrip()) - line = " " * INDENT - line += piece - return "\n".join([*lines, line]) - - async def main(): log.info("Starting ecobot %s", version()) cfg = Config.from_env() _handler.formatter.width = cfg.log_width # every log line is cut to it from here on - WRAP = max(cfg.log_width - len("INFO "), 30) if cfg.log_width else 90 # where the lists in the log wrap air_metrics.REQUIRE_CONSECUTIVE = cfg.antispike_require_consecutive intent.FEELS_LIKE_IN_ALL = cfg.chart_all_feels_like intent.VPD_IN_ALL = cfg.chart_all_vpd @@ -186,7 +169,7 @@ async def main(): # Keeping warm: everything questions need, refreshed before they arrive tasks.extend(asyncio.create_task(s.warmer.run()) for s in sources) - log.info("%s", wrapped("Keeping warm: ", [f"{s.name} {s.warmer.interval:.0f}s" for s in sources], "", WRAP)) + log.info("Keeping warm: %s", ", ".join(f"{s.name} {s.warmer.interval:.0f}s" for s in sources), extra=WHOLE) async def startup_warmup(): """Fetch everything once, together, and log one summary line.""" @@ -226,7 +209,7 @@ async def air_backfill(): time.monotonic() - started) tasks.append(asyncio.create_task(every(PREDRAW_SECONDS, bot.predraw))) # the button charts, ready before they are pressed on = [k for k in LABELS if k in running] # the line says what started, not what is configured - log.info("%s", wrapped("Alerts: ", [LABELS[k] for k in on] or ["none"], f": {len(state.alert_chats())} chats", WRAP)) + log.info("Alerts: %s: %d chats", ", ".join(LABELS[k] for k in on) or "none", len(state.alert_chats()), extra=WHOLE) if missing := [LABELS[k] for k in kinds if k not in running]: log.warning("Alerts NOT running: %s (see the error above)", ", ".join(missing)) diff --git a/lib/logs.py b/lib/logs.py index 4b09aee..f3ebf19 100644 --- a/lib/logs.py +++ b/lib/logs.py @@ -1,9 +1,10 @@ """How the bot's log lines look. Every line of an INFO or DEBUG entry is cut to the log width (LOG_WIDTH), an ellipsis (…) marking the -cut; a WARN or an ERROR is never cut. A line that quotes a request or a chat message (the quote last on the line) also has the quote +cut; a WARN or an ERROR is never cut, nor a line marked WHOLE. A line that quotes a request or a chat message (the quote last on the line) also has the quote put on one line first.""" import logging +WHOLE = {"whole": True} # extra= for a line that is never cut (a list of what started) QUOTES = {"quotes": True} # extra= for a line whose last argument is a request or a chat message @@ -33,6 +34,6 @@ def format(self, record): quote = str(record.args[-1]) if getattr(record, "quotes", False) and isinstance(record.args, tuple) and record.args else None if quote and line.endswith(quote): # (not when a traceback follows it) line = line[:len(line) - len(quote)] + " ".join(quote.split()) # the quote on one line - if not self.width or record.levelno >= logging.WARNING: + if not self.width or record.levelno >= logging.WARNING or getattr(record, "whole", False): return line return "\n".join(fit(part, self.width) for part in line.split("\n")) diff --git a/tests/test_startup.py b/tests/test_startup.py index c47ca0e..18898d6 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -252,13 +252,6 @@ async def stop_when_archived(): assert len(app.handlers) == 10 and closed == [1] # the same handlers (buttons share one entry point), and the client closed at shutdown -def test_a_long_alerts_line_continues_indented_at_90_characters(): - from lib.alerts.menu import LABELS - lines = ecobot.wrapped("Alerts: ", list(LABELS.values()), ": 3 chats").splitlines() - assert len(lines) > 1 and all(len(line) <= 90 for line in lines) - assert all(line.startswith(" " * 13) and not line.startswith(" " * 14) for line in lines[1:]) - - def test_log_lines_do_not_name_their_module(): fmt = ecobot.LogFormat("%(levelname)s %(message)s") record = lambda name: logging.LogRecord(name, logging.INFO, __file__, 1, "hello", None, None) @@ -319,7 +312,11 @@ def test_the_http_clients_lines_do_not_start_with_http_request(): assert record.msg.startswith("HTTP Request: ") # the record itself is left as it was -def test_the_alerts_list_wraps_at_the_log_width(): - from lib.alerts.menu import LABELS - lines = ecobot.wrapped("Alerts: ", list(LABELS.values()), ": 3 chats", 40).splitlines() - assert len(lines) > 1 and all(len(line) <= 40 for line in lines) +def test_a_line_marked_whole_is_never_cut_to_the_log_width(): + import logging + from lib.logs import WHOLE, LogFormat + record = logging.LogRecord("x", logging.INFO, "", 0, "Alerts: %s", ("rain, gusts, UV, temperature crossing, particulates",), None) + assert LogFormat("%(levelname)s %(message)s", 30).format(record).endswith("…") + for key in WHOLE: + setattr(record, key, True) + assert LogFormat("%(levelname)s %(message)s", 30).format(record) == "INFO Alerts: rain, gusts, UV, temperature crossing, particulates" From 31f5b897f050bb3e1060d10d50bdcb41174222be Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:01:43 +0000 Subject: [PATCH 06/19] LOG_WIDTH applies to every log line, whatever its level A WARN or an ERROR is cut too, and so are the startup "Keeping warm:" and "Alerts:" lists (the WHOLE marker is gone). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- ecobot.py | 6 +++--- lib/logs.py | 8 +++----- tests/test_startup.py | 14 ++------------ 4 files changed, 9 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 7203142..20a8c38 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ The state and caches go in `/data` (its `DATA_DIR`), here a named volume. A host | `CHART_ALL_VPD=on` | optional: "weather all week" also draws the vapour pressure deficit panel; off by default, as it is temperature and humidity combined (the station reports it for the outdoor sensor only). Naming it ("plot vpd") always works | | `TELEGRAM_PRELOAD_CHAT` | optional: a chat id (negative for a group or channel) the bot silently uploads charts to ahead of need. With it set, each chart sent with period buttons has its other periods drawn and uploaded there in the background, each on its own the moment it is drawn, starting as soon as the chart is sent and whatever else is being answered, so pressing a period button edits the message with Telegram's copy and uploads nothing. A chart uploaded this way is used for 30 minutes, then drawn afresh. The bot must be able to post in that chat; the uploaded messages are left there. If Telegram refuses an upload (the bot can't post there) none is tried again until a restart; a flood limit or a timeout only skips that photo. Unset: off | | `ANTISPIKE_REQUIRE_CONSECUTIVE` | optional: how many readings in a row a rise of the particles (PM1, PM2.5, PM10) needs to count as the air rather than a glitch of the sensor. Default `2`: a single-reading spike (at least 2.5 times, and 5 µg/m³ above, the readings either side) is left out of PM charts and of every air figure (highs, lows, averages, days over a limit), and the unhealthy-air alert counts a check only when the sensor's last 2 readings are all over the mask level. `3` also drops two-reading spikes, and so on; `1` or `off` turns the rule off. The live "air now" reading and the "safe again" check are unchanged | -| `LOG_WIDTH` | optional: the most characters of a log line, cut with an ellipsis (…); `0` cuts none. Default 71. Every INFO line obeys it; a `WARN` or `ERROR` entry is never cut (a warning is printed as `WARN`); a request, chat message, reply or alert is also put on one line; the startup "Keeping warm:" and "Alerts:" lists are never cut or wrapped | +| `LOG_WIDTH` | optional: the most characters of a log line, cut with an ellipsis (…); `0` cuts none. Default 71. Every log line obeys it, whatever its level (a warning is printed as `WARN`); a request, chat message, reply or alert is also put on one line | | `LOG_LEVEL` | optional: `DEBUG`, `INFO` (default), `WARNING` or `ERROR`. `DEBUG` adds the bot's own debug lines (the chart timings, charts it couldn't draw ahead); the libraries stay at their usual level. | ## Layout diff --git a/ecobot.py b/ecobot.py index 5494441..f246e16 100644 --- a/ecobot.py +++ b/ecobot.py @@ -32,7 +32,7 @@ from lib.irrigation import Tuya from lib.irrigation.watchdog import CHECK_SECONDS as WATCHDOG_SECONDS from lib.llm import Agent -from lib.logs import WHOLE, LogFormat +from lib.logs import LogFormat from lib.pollen import Pollen from lib.compose import Composer from lib.tools import Tools @@ -169,7 +169,7 @@ async def main(): # Keeping warm: everything questions need, refreshed before they arrive tasks.extend(asyncio.create_task(s.warmer.run()) for s in sources) - log.info("Keeping warm: %s", ", ".join(f"{s.name} {s.warmer.interval:.0f}s" for s in sources), extra=WHOLE) + log.info("Keeping warm: %s", ", ".join(f"{s.name} {s.warmer.interval:.0f}s" for s in sources)) async def startup_warmup(): """Fetch everything once, together, and log one summary line.""" @@ -209,7 +209,7 @@ async def air_backfill(): time.monotonic() - started) tasks.append(asyncio.create_task(every(PREDRAW_SECONDS, bot.predraw))) # the button charts, ready before they are pressed on = [k for k in LABELS if k in running] # the line says what started, not what is configured - log.info("Alerts: %s: %d chats", ", ".join(LABELS[k] for k in on) or "none", len(state.alert_chats()), extra=WHOLE) + log.info("Alerts: %s: %d chats", ", ".join(LABELS[k] for k in on) or "none", len(state.alert_chats())) if missing := [LABELS[k] for k in kinds if k not in running]: log.warning("Alerts NOT running: %s (see the error above)", ", ".join(missing)) diff --git a/lib/logs.py b/lib/logs.py index f3ebf19..3144521 100644 --- a/lib/logs.py +++ b/lib/logs.py @@ -1,10 +1,9 @@ """How the bot's log lines look. Every line of an INFO or DEBUG entry is cut to the log width (LOG_WIDTH), an ellipsis (…) marking the -cut; a WARN or an ERROR is never cut, nor a line marked WHOLE. A line that quotes a request or a chat message (the quote last on the line) also has the quote +cut, whatever its level. A line that quotes a request or a chat message (the quote last on the line) also has the quote put on one line first.""" import logging -WHOLE = {"whole": True} # extra= for a line that is never cut (a list of what started) QUOTES = {"quotes": True} # extra= for a line whose last argument is a request or a chat message @@ -14,8 +13,7 @@ def fit(text: str, room: int) -> str: class LogFormat(logging.Formatter): - """"INFO message": the level (WARNING is shown as WARN) and the message, with no module name. Cuts each line to the width, - except for a WARN or an ERROR.""" + """"INFO message": the level (WARNING is shown as WARN) and the message, with no module name. Cuts each line to the width.""" def __init__(self, fmt: str, width: int = 0): """width: the most characters of a line of the log; 0 cuts none.""" @@ -34,6 +32,6 @@ def format(self, record): quote = str(record.args[-1]) if getattr(record, "quotes", False) and isinstance(record.args, tuple) and record.args else None if quote and line.endswith(quote): # (not when a traceback follows it) line = line[:len(line) - len(quote)] + " ".join(quote.split()) # the quote on one line - if not self.width or record.levelno >= logging.WARNING or getattr(record, "whole", False): + if not self.width: return line return "\n".join(fit(part, self.width) for part in line.split("\n")) diff --git a/tests/test_startup.py b/tests/test_startup.py index 18898d6..b66ad67 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -273,9 +273,9 @@ def line(msg, *args, quotes=True): timings = "Chart ms: data 12, calc 6, draw 112, PNG 41, send 9, more words" assert line(timings, quotes=False) == "INFO Chart ms: data 12, calc 6, draw 11…" # a line that quotes nothing is cut too assert line("a\n b " + "x" * 50, quotes=False).split("\n")[1] == " b " + "x" * 33 + "…" # each line of a long message - for level, name in ((logging.WARNING, "WARN"), (logging.ERROR, "ERROR")): # a warning or an error is shown whole + for level, name in ((logging.WARNING, "WARN"), (logging.ERROR, "ERROR")): # a warning or an error is cut too record = logging.LogRecord("lib.bot", level, __file__, 1, timings, None, None) - assert fmt.format(record) == f"{name} " + timings and record.levelname == logging.getLevelName(level) + assert fmt.format(record) == f"{name} " + timings[:40 - len(name) - 2] + "…" and record.levelname == logging.getLevelName(level) fmt.width = 0 assert line("[@rob] %s", asked) == "INFO [@rob] " + asked # 0 cuts none assert line(timings, quotes=False) == "INFO " + timings @@ -310,13 +310,3 @@ def test_the_http_clients_lines_do_not_start_with_http_request(): ("POST", "https://api.x.ai/v1/chat/completions", "HTTP/1.1", 200, "OK"), None) assert fmt.format(record) == 'INFO POST https://api.x.ai/v1/chat/completions "HTTP/1.1 200 OK"' assert record.msg.startswith("HTTP Request: ") # the record itself is left as it was - - -def test_a_line_marked_whole_is_never_cut_to_the_log_width(): - import logging - from lib.logs import WHOLE, LogFormat - record = logging.LogRecord("x", logging.INFO, "", 0, "Alerts: %s", ("rain, gusts, UV, temperature crossing, particulates",), None) - assert LogFormat("%(levelname)s %(message)s", 30).format(record).endswith("…") - for key in WHOLE: - setattr(record, key, True) - assert LogFormat("%(levelname)s %(message)s", 30).format(record) == "INFO Alerts: rain, gusts, UV, temperature crossing, particulates" From 8bf0918ebf7319080cd5c0253dac857940c523e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:07:31 +0000 Subject: [PATCH 07/19] Alerts carry a sound toggle instead of the gear; /alerts keeps the full menus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under an alert (private chats) a 🔊 / 🔇 button turns that type's notification sound on or off. The gear and its sound menus are only in /alerts. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- lib/alerts/menu.py | 6 +++++- lib/bot.py | 2 ++ tests/test_alert_menu.py | 32 +++++++++----------------------- tests/test_irrigation.py | 2 +- 5 files changed, 18 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 20a8c38..70f186b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A Telegram bot for a personal Ecowitt weather station and AirGradient air-quality sensor. Ask it about the weather or air quality (text or charts); it also sends silent alerts to every chat it is in: rain starting/stopping, gusts over 40 km/h (with the last day's wind chart), UV index of 10 or more in two readings in a row (with the last day's UV chart), indoor/outdoor temperatures crossing by more than 1°C (held back for the cooldown, then announced if it still stands, with the last day's temperature chart), -and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries Subscribe / Unsubscribe buttons that expand into the alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes): Subscribe lists the types that are off, Unsubscribe the ones that are on; `/alerts` opens the same settings (in a private chat, a ⚙️ Settings button there and under each alert has Enable and Disable notification sounds sub-menus that list the alert types; alerts are silent unless you do; and `/alerts on|off` still subscribes or unsubscribes every type); `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). +and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries a 🔕 button that opens the alert's own type and "Unsubscribe from all", and in a private chat a 🔊 / 🔇 button that turns that type's notification sound on or off (alerts are silent unless you do); `/alerts` has the full settings: 🔔 Sub lists the alert types that are off (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes), 🔕 Unsub the ones that are on, and in a private chat ⚙️ has Enable and Disable notification sounds sub-menus that list the alert types; and `/alerts on|off` still subscribes or unsubscribes every type); `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). ## Run diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index cea2574..ca5f9a0 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -80,6 +80,8 @@ def keyboard(muted: set[str], available: list[str], open: str | None = None, par subscribed = options(muted, available, "unsub") if open == "other" and (parent or not (parent in subscribed and len(subscribed) > 1)): # under an alert there is no Other open = "unsub" + if parent and open in SETTINGS: # under an alert there is no Settings: the sound is one toggle + open = None unsub_open = open in ("unsub", "other") def head(is_open: bool, text: str, section: str) -> InlineKeyboardButton: return _button(menu_text(text, is_open), f"al:close{tail}" if is_open else f"al:open:{section}{tail}") @@ -87,8 +89,10 @@ def head(is_open: bool, text: str, section: str) -> InlineKeyboardButton: if no_sub and open == "sub": open = None rows = [[*([] if no_sub else [head(open == "sub", "🔔", "sub")]), head(unsub_open, "🔕", "unsub")]] - if loud is not None: + if loud is not None and not parent: rows[0].append(head(open in SETTINGS, "⚙️", "set")) + elif loud is not None and parent in subscribed: # the alert's sound: 🔊 turns it on, 🔇 off + rows[0].append(_button("🔇" if parent in loud else "🔊", f"al:snd:{parent}:{'off' if parent in loud else 'on'}{tail}")) mark = lambda k: f"● {label(k)}" if k == parent else label(k) # the alert these buttons are under if open in SETTINGS and loud is not None: for section, heading, items in (("snd_on", "🔔 Enable notification sounds", [k for k in subscribed if k not in loud]), diff --git a/lib/bot.py b/lib/bot.py index d1e16cc..3129926 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -444,6 +444,8 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY opened = "snd_on" if section == "on" else "snd_off" # stay in the list (under an alert: in "Other" for another type) if parent and arg != parent: opened += "_o" + elif parent: + opened = None # the sound toggle under an alert: nothing opens toast = f"{self._alerts_named(arg)} {'now make' if section == 'on' else 'no longer make'} a sound" elif action in ("on", "off") and (arg == menu.ALL or arg in kinds) and chat.id in self.state.chats: self.state.set_kind(chat.id, arg, action == "on") diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index b0791ba..61177f0 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -87,7 +87,7 @@ async def test_an_alert_goes_to_the_chats_subscribed_to_its_type_and_carries_the await notifier("Gusts", kind="gusts") assert [(c, t.split("\n")[0]) for c, t, _ in bot.sent] == [(1, "It's raining"), (1, "Gusts"), (2, "Gusts")] markup = bot.sent[0][2]["reply_markup"] - assert rows(markup) == [[("🔕 ▸", "al:open:unsub:rain"), ("⚙️ ▸", "al:open:set:rain")]] # the alert's own type rides along + assert rows(markup) == [[("🔕 ▸", "al:open:unsub:rain"), ("🔊", "al:snd:rain:on:rain")]] # the alert's own type rides along assert bot.sent[0][1] == "It's raining" # no footer: the buttons say how to change it assert await notifier.to_chat(2, "x", kind="rain") is False and await notifier.to_chat(2, "x", kind="uv") is True @@ -293,8 +293,7 @@ async def test_the_alerts_own_type_survives_opening_unsubscribing_and_closing_th assert "Other" not in str(rows(q.edits[0][1])) and rows(q.edits[0][1])[-1] == [("🔕 Unsubscribe from all", "al:off:all:unsub:uv")] q = Query("al:off:all:unsub:uv", text="☀️ UV 10") # and "all" turns the rest off, closing the empty section await press(bot, q) - assert rows(q.edits[0][1]) == [[("🔕 ▸", "al:open:unsub:uv"), - ("⚙️ ▸", "al:open:set:uv")]] + assert rows(q.edits[0][1]) == [[("🔕 ▸", "al:open:unsub:uv")]] q = Query("al:open:unsub:uv", text="☀️ UV 10") # nothing is on now: a toast, no menu await press(bot, q) assert q.toast == "No alerts are on" and not q.edits @@ -346,24 +345,16 @@ async def test_settings_hold_enable_and_disable_sub_menus_of_notification_sounds assert [b for r in rows(q.edits[0][1]) for b in r if b[1].startswith("al:snd")] == [("🌧️ rain", "al:snd:rain:off")] await press(bot, Query("al:snd:rain:off")) assert state.loud(1) == set() - q = Query("al:open:snd_on:uv", text="☀️ UV 10") # under an alert: its own type, then Other + q = Query("al:open:snd_on:uv", text="☀️ UV 10") # under an alert there is no Settings: the sound is one toggle await press(bot, q) - drawn = rows(q.edits[0][1]) - assert drawn[2:] == [[("● 🧴 UV", "al:snd:uv:on:uv")], [("Other ▸", "al:open:snd_on_o:uv")]] - q = Query("al:open:snd_on_o:uv", text="☀️ UV 10") # Other holds the rest - await press(bot, q) - drawn = rows(q.edits[0][1]) - assert drawn[3] == [("Other ▾", "al:open:snd_on:uv")] and ("🌧️ rain", "al:snd:rain:on:uv") in [b for r in drawn for b in r] - assert "al:snd:uv:on:uv" not in [d for r in drawn[4:] for _, d in r] - q = Query("al:snd:rain:on:uv", text="☀️ UV 10") # a press in Other stays in Other + assert rows(q.edits[0][1]) == [[("🔕 ▸", "al:open:unsub:uv"), ("🔊", "al:snd:uv:on:uv")]] + q = Query("al:snd:uv:on:uv", text="☀️ UV 10") # 🔊 turns this type's sound on, and the button becomes 🔇 await press(bot, q) - assert state.loud(1) == {"rain"} and ("Other ▾", "al:open:snd_on:uv") in [b for r in rows(q.edits[0][1]) for b in r] - q = Query("al:snd:uv:on:uv", text="☀️ UV 10") # a press on its own type stays on its level + assert state.loud(1) == {"uv"} and q.toast == "UV alerts now make a sound" + assert rows(q.edits[0][1]) == [[("🔕 ▸", "al:open:unsub:uv"), ("🔇", "al:snd:uv:off:uv")]] + q = Query("al:snd:uv:off:uv", text="☀️ UV 10") await press(bot, q) - assert state.loud(1) == {"rain", "uv"} and ("🔕 Disable notification sounds ▸", "al:open:snd_off:uv") in [b for r in rows(q.edits[0][1]) for b in r] - await press(bot, Query("al:snd:rain:off:uv", text="☀️ UV 10")) - await press(bot, Query("al:snd:uv:off:uv", text="☀️ UV 10")) - assert state.loud(1) == set() + assert state.loud(1) == set() and q.toast == "UV alerts no longer make a sound" and rows(q.edits[0][1])[0][1][0] == "🔊" for data in ("al:snd:bogus:on", "al:snd:rain:maybe", "al:snd:rain"): # nothing from the data reaches the state await press(bot, Query(data)) assert state.loud(1) == set() @@ -445,11 +436,6 @@ async def test_the_sound_lists_have_an_all_button(tmp_path): assert rows(q.edits[0][1])[-1] == [("🔕 Disable all", "al:snd:all:off")] await press(bot, Query("al:snd:all:off")) assert state.loud(1) == set() - q = Query("al:open:snd_on_o:uv", text="☀️ UV 10") # under an alert, "all" sits in Other - await press(bot, q) - assert rows(q.edits[0][1])[-1] == [("🔔 Enable all", "al:snd:all:on:uv")] - await press(bot, Query("al:snd:all:on:uv", text="☀️ UV 10")) - assert "uv" in state.loud(1) and "rain" in state.loud(1) and "gusts" not in state.loud(1) def test_every_alert_type_sent_is_registered_and_each_monitor_declares_what_it_sends(): diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index ac09150..3c447ab 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -708,7 +708,7 @@ async def send_message(self, chat_id, text, **kw): await m.check(NOW) assert sorted(c for c, _, _ in delivered) == [-7, 1, 3] and all(text.startswith("☔ Irrigation paused") for _, text, _ in delivered) markup = delivered[0][2]["reply_markup"] - assert rows(markup) == [[("🔕 ▸", "al:open:unsub:irrigation_pause"), ("⚙️ ▸", "al:open:set:irrigation_pause")]] + assert rows(markup) == [[("🔕 ▸", "al:open:unsub:irrigation_pause"), ("🔊", "al:snd:irrigation_pause:on:irrigation_pause")]] from lib.alerts.menu import keyboard kinds = available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast", "Irrigation"}) for section in (None, "sub", "unsub"): # every button's data fits Telegram's 64 bytes, for the new type too From f207595caa6475fb5f3bb15b114858f4c789f166 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 11:20:01 +0000 Subject: [PATCH 08/19] Individual alert menu redesigned: one gear opening an Alert and a Sound toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under an alert, a single ⚙️ opens 🔔 Alert and, while the alert is on and in a private chat, 🔊 Sound. Each is struck through while off, and the menu stays open after a toggle. /alerts keeps its full menus; the Other lists are gone. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- lib/alerts/menu.py | 115 ++++++++++++++++++++------------------- lib/bot.py | 12 ++-- tests/test_admin_only.py | 5 +- tests/test_alert_menu.py | 73 +++++++++++-------------- tests/test_irrigation.py | 2 +- 6 files changed, 101 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index 70f186b..4ff1a6d 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A Telegram bot for a personal Ecowitt weather station and AirGradient air-quality sensor. Ask it about the weather or air quality (text or charts); it also sends silent alerts to every chat it is in: rain starting/stopping, gusts over 40 km/h (with the last day's wind chart), UV index of 10 or more in two readings in a row (with the last day's UV chart), indoor/outdoor temperatures crossing by more than 1°C (held back for the cooldown, then announced if it still stands, with the last day's temperature chart), -and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries a 🔕 button that opens the alert's own type and "Unsubscribe from all", and in a private chat a 🔊 / 🔇 button that turns that type's notification sound on or off (alerts are silent unless you do); `/alerts` has the full settings: 🔔 Sub lists the alert types that are off (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes), 🔕 Unsub the ones that are on, and in a private chat ⚙️ has Enable and Disable notification sounds sub-menus that list the alert types; and `/alerts on|off` still subscribes or unsubscribes every type); `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). +and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one ⚙️ button that opens up to two toggles for that alert type: 🔔 Alert (on or off in this chat) and, while that is on and in a private chat, 🔊 Sound (whether it makes a notification sound; alerts are silent unless you turn it on). A toggle is struck through while it is off; `/alerts` has the full settings: 🔔 Sub lists the alert types that are off (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes), 🔕 Unsub the ones that are on, and in a private chat ⚙️ has Enable and Disable notification sounds sub-menus that list the alert types; and `/alerts on|off` still subscribes or unsubscribes every type); `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). ## Run diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index ca5f9a0..402939a 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -1,13 +1,13 @@ -"""The alert settings as buttons: Subscribe | Unsubscribe, each opening its options below: Subscribe lists the types that are off, -Unsubscribe the types that are on, so each list says by itself whether its alerts are on. Pure: it only builds the keyboard and -the title from what a chat has muted (the same menu goes under every alert and under /alerts). +"""The alert settings as buttons. /alerts has the full menu: Sub | Unsub (and Settings in private chats), each opening its options below: Sub +lists the types that are off, Unsub the types that are on, so each list says by itself whether its alerts are on. Under an alert there +is one gear that opens that type's two toggles, Alert and Sound. Pure: it only builds the keyboard and the title from what a chat has muted. Callback data (self-contained and short, so an old message's buttons still work after a restart): - al:open:sub | al:open:unsub | al:open:other | al:open:set | al:open:snd_on | al:open:snd_off | al:close | al:noop (the heading rows of messages sent by an earlier version) - al:snd::[:parent] (notification sound for one type; Settings is in private chats only) + al:open:sub | al:open:unsub | al:open:set | al:open:snd_on | al:open:snd_off | al:close | al:noop (the heading rows of messages sent by an earlier version) + al:snd:: (notification sound for one type; Settings is in private chats only) al:on::
| al:off::
(section: sub or unsub, the part left open) -An alert's buttons end with the alert's own type (parent): under an alert each list is its own type (marked ●) and "Other", which holds the rest and "all": al:open:unsub:rain, al:close:rain, -al:off:uv:unsub:rain (the sound menus' Other is al:open:snd_on_o:rain, al:open:snd_off_o:rain). A chat's /alerts message has no parent and no such field. +An alert's buttons end with the alert's own type: al:open:gear:rain, al:close:rain, al:off:rain:gear:rain, al:snd:rain:on:rain. A chat's +/alerts message has no such field. """ from itertools import batched @@ -26,7 +26,7 @@ ALL = "all" TITLE = "🔔 Alerts in this chat" PER_ROW = 2 -SETTINGS = ("set", "snd_on", "snd_off", "snd_on_o", "snd_off_o") # (_o: the "Other" list inside a sound menu) +SETTINGS = ("set", "snd_on", "snd_off", "snd_on_o", "snd_off_o") # (_o: the lists of an earlier version) def available_kinds(sources: set[str]) -> list[str]: @@ -51,76 +51,81 @@ def _button(text: str, data: str) -> InlineKeyboardButton: return InlineKeyboardButton(text, callback_data=data) -def _type_rows(items: list[str], parent: str | None, more: bool, button, other_data: tuple[str, str], every: InlineKeyboardButton) -> list[list]: - """A list of alert types as button rows, the same under Unsubscribe, Subscribe and both sound menus. Under an alert that is in the - list (with others), the alert's own type comes first, then an "Other" menu (`other_data` = the data that opens it, the data that - closes it; `more`: it is open) that holds the rest and `every` ("all"). Otherwise a flat list, with `every` when it has more than - one type. `button(kind)` draws one type.""" - rows: list[list] = [] - if parent in items and len(items) > 1: - rows += [[button(parent)], [_button(menu_text("Other", more), other_data[1] if more else other_data[0])]] - items = [k for k in items if k != parent] if more else [] - elif more: - more = False # nothing to put under Other - rows += map(list, batched(map(button, items), PER_ROW)) - if len(items) > 1 or (more and items): +def _type_rows(items: list[str], button, every: InlineKeyboardButton) -> list[list]: + """A list of alert types as button rows, the same under Unsubscribe, Subscribe and both sound menus: `every` ("all") last when it has + more than one type. `button(kind)` draws one type.""" + rows = list(map(list, batched(map(button, items), PER_ROW))) + if len(items) > 1: rows.append([every]) return rows +def struck(text: str) -> str: + """The text with a line through it (a button cannot be styled, so each letter gets the combining long stroke).""" + return "".join(f"{c}\u0336" for c in text) + + +def _toggle(emoji: str, word: str, on: bool, data: str) -> InlineKeyboardButton: + """A button that flips a setting: its emoji and word, the word struck through while the setting is off.""" + return _button(f"{emoji} {word if on else struck(word)}", data) + + +def alert_keyboard(kind: str, muted: set[str], loud: set[str] | None, open: str | None = None) -> InlineKeyboardMarkup: + """The buttons under an alert of type `kind`: one gear (⚙️ ▸, ▾ once open) that opens up to two toggles, 🔔 Alert (this type's alerts + in this chat) and, only while that is on and in a private chat (`loud` is not None), 🔊 Sound (whether they make a notification sound). + A toggle is struck through while it is off. Every button carries the type: al:open:gear:, al:close:, + al:on|off::gear:, al:snd::on|off:.""" + is_open = open == "gear" + rows = [[_button(menu_text("⚙️", is_open), f"al:close:{kind}" if is_open else f"al:open:gear:{kind}")]] + if is_open: + on = kind not in muted + rows.append([_toggle("🔔", "Alert", on, f"al:{'off' if on else 'on'}:{kind}:gear:{kind}")]) + if on and loud is not None: + sound = kind in loud + rows.append([_toggle("🔊", "Sound", sound, f"al:snd:{kind}:{'off' if sound else 'on'}:{kind}")]) + return InlineKeyboardMarkup(rows) + + def keyboard(muted: set[str], available: list[str], open: str | None = None, parent: str | None = None, loud: set[str] | None = None) -> InlineKeyboardMarkup: - """The menu: Subscribe, Unsubscribe and (private chats) Settings, one section open at a time (`open`: sub, unsub, other, set, - snd_on, snd_off, or snd_on_o / snd_off_o, the Other list of a sound menu) with its options under it. A ▸ after the text marks a button that opens - a menu, ▾ (in the same place) the open one; the rest do something. `parent` is the type of the alert the buttons sit under (carried in every button): - its lists put that type first (●) and the rest under "Other". `loud`: the types whose alerts make a notification sound; None (a - group) leaves out Settings, which holds two menus, Enable and Disable notification sounds, over the subscribed types. Sub is left out - when every type is on.""" - tail = f":{parent}" if parent else "" + """The /alerts menu: Sub, Unsub and (private chats) Settings, one section open at a time (`open`: sub, unsub, set, snd_on, snd_off) with + its options under it. A ▸ after the text marks a button that opens a menu, ▾ (in the same place) the open one; the rest do something. + `loud`: the types whose alerts make a notification sound; None (a group) leaves out Settings, which holds two menus, Enable and Disable + notification sounds, over the subscribed types. Sub is left out when every type is on. `parent`: the type of the alert these buttons + sit under, which has a menu of its own (alert_keyboard).""" + if parent: + return alert_keyboard(parent, muted, loud, open) + open = {"other": "unsub", "snd_on_o": "snd_on", "snd_off_o": "snd_off"}.get(open, open) # (lists of messages from an earlier version) subscribed = options(muted, available, "unsub") - if open == "other" and (parent or not (parent in subscribed and len(subscribed) > 1)): # under an alert there is no Other - open = "unsub" - if parent and open in SETTINGS: # under an alert there is no Settings: the sound is one toggle - open = None - unsub_open = open in ("unsub", "other") def head(is_open: bool, text: str, section: str) -> InlineKeyboardButton: - return _button(menu_text(text, is_open), f"al:close{tail}" if is_open else f"al:open:{section}{tail}") - no_sub = bool(parent) or not options(muted, available, "sub") # under an alert, or subscribed to every type: no Sub button + return _button(menu_text(text, is_open), "al:close" if is_open else f"al:open:{section}") + no_sub = not options(muted, available, "sub") # subscribed to every type: no Sub button if no_sub and open == "sub": open = None - rows = [[*([] if no_sub else [head(open == "sub", "🔔", "sub")]), head(unsub_open, "🔕", "unsub")]] - if loud is not None and not parent: + rows = [[*([] if no_sub else [head(open == "sub", "🔔", "sub")]), head(open == "unsub", "🔕", "unsub")]] + if loud is not None: rows[0].append(head(open in SETTINGS, "⚙️", "set")) - elif loud is not None and parent in subscribed: # the alert's sound: 🔊 turns it on, 🔇 off - rows[0].append(_button("🔇" if parent in loud else "🔊", f"al:snd:{parent}:{'off' if parent in loud else 'on'}{tail}")) - mark = lambda k: f"● {label(k)}" if k == parent else label(k) # the alert these buttons are under if open in SETTINGS and loud is not None: for section, heading, items in (("snd_on", "🔔 Enable notification sounds", [k for k in subscribed if k not in loud]), ("snd_off", "🔕 Disable notification sounds", [k for k in subscribed if k in loud])): - here = open in (section, f"{section}_o") if not items: continue - rows.append([_button(menu_text(heading, here), f"al:open:set{tail}" if here else f"al:open:{section}{tail}")]) + here = open == section + rows.append([_button(menu_text(heading, here), "al:open:set" if here else f"al:open:{section}")]) if here: verb = "on" if section == "snd_on" else "off" - rows += _type_rows(items, parent, open == f"{section}_o", lambda k: _button(mark(k), f"al:snd:{k}:{verb}{tail}"), - (f"al:open:{section}_o{tail}", f"al:open:{section}{tail}"), - _button("🔔 Enable all" if verb == "on" else "🔕 Disable all", f"al:snd:{ALL}:{verb}{tail}")) - if open == "unsub" and parent in subscribed: # under an alert: its own type and all, nothing else - rows.append([_button(mark(parent), f"al:off:{parent}:unsub{tail}")]) - if len(subscribed) > 1: - rows.append([_button("🔕 Unsubscribe from all", f"al:off:{ALL}:unsub{tail}")]) - elif open in ("sub", "unsub", "other"): + rows += _type_rows(items, lambda k: _button(label(k), f"al:snd:{k}:{verb}"), + _button("🔔 Enable all" if verb == "on" else "🔕 Disable all", f"al:snd:{ALL}:{verb}")) + if open in ("sub", "unsub"): verb = "on" if open == "sub" else "off" - rows += _type_rows(options(muted, available, "sub" if open == "sub" else "unsub"), parent if open != "sub" else None, open == "other", - lambda k: _button(mark(k), f"al:{verb}:{k}:{open}{tail}"), (f"al:open:other{tail}", f"al:open:unsub{tail}"), - _button("🔔 Subscribe to all" if verb == "on" else "🔕 Unsubscribe from all", f"al:{verb}:{ALL}:{open}{tail}")) + rows += _type_rows(options(muted, available, open), lambda k: _button(label(k), f"al:{verb}:{k}:{open}"), + _button("🔔 Subscribe to all" if verb == "on" else "🔕 Unsubscribe from all", f"al:{verb}:{ALL}:{open}")) return InlineKeyboardMarkup(rows) -def has_options(muted: set[str], available: list[str], section: str, parent: str | None = None) -> bool: +def has_options(muted: set[str], available: list[str], section: str) -> bool: """Would this section show any button under its heading?""" - return len(keyboard(muted, available, section, parent).inline_keyboard) > 1 + return len(keyboard(muted, available, section).inline_keyboard) > 1 def options(muted: set[str], available: list[str], section: str) -> list[str]: diff --git a/lib/bot.py b/lib/bot.py index 3129926..3a87c7f 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -434,18 +434,14 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY if opened == "set" and not parent and not menu.options(self.state.muted(chat.id), kinds, "unsub"): await query.answer("No alerts are on") # no subscribed type to set a sound for return - if opened in ("sub", "unsub", "other") and not menu.has_options(self.state.muted(chat.id), kinds, opened, parent): + if opened in ("sub", "unsub", "other") and not menu.has_options(self.state.muted(chat.id), kinds, opened): await query.answer("You're subscribed to everything" if opened == "sub" else "No alerts are on") # nothing to list return elif action == "snd" and chat.type == ChatType.PRIVATE and (arg in kinds or arg == menu.ALL) and section in ("on", "off") and chat.id in self.state.chats: for k in ([k for k in kinds if k not in self.state.muted(chat.id)] if arg == menu.ALL else [arg]): # "all": the types offered here self.state.set_sound(chat.id, k, section == "on") parent = tail if tail in kinds else None - opened = "snd_on" if section == "on" else "snd_off" # stay in the list (under an alert: in "Other" for another type) - if parent and arg != parent: - opened += "_o" - elif parent: - opened = None # the sound toggle under an alert: nothing opens + opened = "gear" if parent else "snd_on" if section == "on" else "snd_off" # stay in the list (under an alert: its gear stays open) toast = f"{self._alerts_named(arg)} {'now make' if section == 'on' else 'no longer make'} a sound" elif action in ("on", "off") and (arg == menu.ALL or arg in kinds) and chat.id in self.state.chats: self.state.set_kind(chat.id, arg, action == "on") @@ -454,10 +450,10 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY await query.answer() return muted = self.state.muted(chat.id) - if opened in ("sub", "unsub", "other") and not menu.has_options(muted, kinds, opened, parent): + if opened in ("sub", "unsub", "other") and not menu.has_options(muted, kinds, opened): opened = None # the last one was just turned on or off: close the section private = chat.type == ChatType.PRIVATE - markup = menu.keyboard(muted, kinds, opened if opened in ("sub", "unsub", "other", *menu.SETTINGS) else None, parent, + markup = menu.keyboard(muted, kinds, opened if opened in ("sub", "unsub", "other", "gear", *menu.SETTINGS) else None, parent, self.state.loud(chat.id) if private else None) await query.answer(toast) try: diff --git a/tests/test_admin_only.py b/tests/test_admin_only.py index 7806e5c..d600dc3 100644 --- a/tests/test_admin_only.py +++ b/tests/test_admin_only.py @@ -321,9 +321,10 @@ def every_keyboard(bot): kinds = menu.available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast", "Irrigation"}) muted_sets = [set(), {"rain"}, {"rain", "uv", "particulates"}, set(menu.LABELS)] for muted in muted_sets: - for opened in (None, "sub", "unsub"): + for opened in (None, "sub", "unsub", "gear", "set", "snd_on", "snd_off"): for parent in (None, *kinds): - yield menu.keyboard(muted, kinds, opened, parent) + for loud in (None, set(), set(kinds)): + yield menu.keyboard(muted, kinds, opened, parent, loud) for battery in (True, False): for pause in (True, False): for opened in (None, *irrigation_menu.SECTIONS, "bogus"): diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 61177f0..5ca449b 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -87,7 +87,7 @@ async def test_an_alert_goes_to_the_chats_subscribed_to_its_type_and_carries_the await notifier("Gusts", kind="gusts") assert [(c, t.split("\n")[0]) for c, t, _ in bot.sent] == [(1, "It's raining"), (1, "Gusts"), (2, "Gusts")] markup = bot.sent[0][2]["reply_markup"] - assert rows(markup) == [[("🔕 ▸", "al:open:unsub:rain"), ("🔊", "al:snd:rain:on:rain")]] # the alert's own type rides along + assert rows(markup) == [[("⚙️ ▸", "al:open:gear:rain")]] # the alert's own type rides along assert bot.sent[0][1] == "It's raining" # no footer: the buttons say how to change it assert await notifier.to_chat(2, "x", kind="rain") is False and await notifier.to_chat(2, "x", kind="uv") is True @@ -257,16 +257,19 @@ async def spy(chat_id, text, link=None, kind=None): assert seen == ["forecast"] -def test_under_an_alert_the_unsubscribe_list_is_just_its_type_and_all_and_there_is_no_subscribe(): - got = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "unsub", parent="uv")) - assert got == [[("🔕 ▾", "al:close:uv")], [("● 🧴 UV", "al:off:uv:unsub:uv")], [("🔕 Unsubscribe from all", "al:off:all:unsub:uv")]] - assert rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "other", parent="uv")) == got # there is no Other - assert rows(keyboard({"pollen_and_asthma"}, KINDS, "sub", parent="uv")) == rows(keyboard({"pollen_and_asthma"}, KINDS, None, parent="uv")) - only = rows(keyboard(set(KINDS) - {"uv"}, KINDS, "unsub", parent="uv")) # the alert's type is the only one on: no "all" - assert only[1:] == [[("● 🧴 UV", "al:off:uv:unsub:uv")]] - gone = rows(keyboard({"uv", "gusts"}, KINDS, "unsub", parent="uv")) # its type is already off: a plain list of the rest, with "all" - assert "Other" not in str(gone) and gone[-1] == [("🔕 Unsubscribe from all", "al:off:all:unsub:uv")] - assert len(rows(keyboard(set(KINDS), KINDS, "unsub", parent="uv"))) == 1 # nothing is on at all: nothing listed +def test_under_an_alert_one_gear_opens_the_alert_toggle_and_then_the_sound_toggle_struck_through_when_off(): + strike = lambda word: "".join(c + "\u0336" for c in word) + shut = rows(keyboard(set(), KINDS, None, parent="uv", loud=set())) + assert shut == [[("⚙️ ▸", "al:open:gear:uv")]] + on = rows(keyboard(set(), KINDS, "gear", parent="uv", loud=set())) # both on... the sound is off + assert on == [[("⚙️ ▾", "al:close:uv")], [("🔔 Alert", "al:off:uv:gear:uv")], [("🔊 " + strike("Sound"), "al:snd:uv:on:uv")]] + both = rows(keyboard(set(), KINDS, "gear", parent="uv", loud={"uv"})) + assert both[2] == [("🔊 Sound", "al:snd:uv:off:uv")] + off = rows(keyboard({"uv"}, KINDS, "gear", parent="uv", loud={"uv"})) # alert off: struck through, and no sound toggle + assert off == [[("⚙️ ▾", "al:close:uv")], [("🔔 " + strike("Alert"), "al:on:uv:gear:uv")]] + group = rows(keyboard(set(), KINDS, "gear", parent="uv", loud=None)) # a group has no sound + assert group == [[("⚙️ ▾", "al:close:uv")], [("🔔 Alert", "al:off:uv:gear:uv")]] + assert rows(keyboard(set(), KINDS, "unsub", parent="uv", loud=set())) == shut # (an earlier version's list: just the gear) sub = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "sub")) # Subscribe (the /alerts message) is the types that are off assert [t for row in sub[1:-1] for t, _ in row] == ["🌼 pollen & asthma", "🔄 forecast changes"] and sub[-1][0][0] == "🔔 Subscribe to all" assert rows(keyboard(set(), KINDS, "unsub"))[1][0][0] == "🌧️ rain" # the /alerts message has no parent: every type that is on @@ -280,23 +283,25 @@ def test_the_all_button_is_only_there_when_the_list_has_more_than_one_item(): assert rows(keyboard(set(), KINDS, "unsub"))[-1][0][0] == "🔕 Unsubscribe from all" -async def test_the_alerts_own_type_survives_opening_unsubscribing_and_closing_the_menu_under_it(tmp_path): +async def test_the_gear_under_an_alert_toggles_the_alert_and_its_sound_and_stays_open(tmp_path): bot, state = make_bot(tmp_path) - q = Query("al:open:unsub:uv", text="☀️ UV 10") + strike = lambda word: "".join(c + "\u0336" for c in word) + q = Query("al:open:gear:uv", text="☀️ UV 10") await press(bot, q) - drawn = rows(q.edits[0][1]) - assert drawn[0][0] == ("🔕 ▾", "al:close:uv") and drawn[1:] == [ - [("● 🧴 UV", "al:off:uv:unsub:uv")], [("🔕 Unsubscribe from all", "al:off:all:unsub:uv")]] - q = Query("al:off:uv:unsub:uv", text="☀️ UV 10") # unsubscribing from this type leaves a plain list of the rest + assert rows(q.edits[0][1]) == [[("⚙️ ▾", "al:close:uv")], [("🔔 Alert", "al:off:uv:gear:uv")], [("🔊 " + strike("Sound"), "al:snd:uv:on:uv")]] + q = Query("al:snd:uv:on:uv", text="☀️ UV 10") + await press(bot, q) + assert state.loud(1) == {"uv"} and q.toast == "UV alerts now make a sound" and rows(q.edits[0][1])[2] == [("🔊 Sound", "al:snd:uv:off:uv")] + q = Query("al:off:uv:gear:uv", text="☀️ UV 10") # alert off: the sound toggle goes, the struck alert stays to turn it back on await press(bot, q) assert "uv" in state.muted(1) and q.toast == "UV alerts off in this chat" - assert "Other" not in str(rows(q.edits[0][1])) and rows(q.edits[0][1])[-1] == [("🔕 Unsubscribe from all", "al:off:all:unsub:uv")] - q = Query("al:off:all:unsub:uv", text="☀️ UV 10") # and "all" turns the rest off, closing the empty section + assert rows(q.edits[0][1]) == [[("⚙️ ▾", "al:close:uv")], [("🔔 " + strike("Alert"), "al:on:uv:gear:uv")]] + q = Query("al:on:uv:gear:uv", text="☀️ UV 10") await press(bot, q) - assert rows(q.edits[0][1]) == [[("🔕 ▸", "al:open:unsub:uv")]] - q = Query("al:open:unsub:uv", text="☀️ UV 10") # nothing is on now: a toast, no menu + assert "uv" not in state.muted(1) and rows(q.edits[0][1])[2] == [("🔊 Sound", "al:snd:uv:off:uv")] # the sound setting was kept + q = Query("al:close:uv", text="☀️ UV 10") await press(bot, q) - assert q.toast == "No alerts are on" and not q.edits + assert rows(q.edits[0][1]) == [[("⚙️ ▸", "al:open:gear:uv")]] async def test_an_unknown_parent_in_a_button_is_ignored(tmp_path): @@ -315,13 +320,9 @@ def test_every_alert_type_has_its_emoji_and_every_button_in_the_lists_starts_wit assert label("rain") == "🌧️ rain" and label("irrigation_battery") == "🪫 irrigation battery" and label("irrigation_pause") == "☔ irrigation pause" everything = list(LABELS) for section in ("sub", "unsub"): - for parent in (None, "uv"): - if parent and section == "sub": - continue # no Subscribe under an alert - drawn = rows(keyboard(set(LABELS) if section == "sub" else set(), everything, section, parent)) - texts = [t for row in drawn[1:] for t, _ in row] - texts = [t for t in texts if "Other" not in t] # (the Other menu is a word, not an alert type) - assert texts and all(not t.removeprefix("● ")[0].isascii() for t in texts), texts # every button under it starts with an emoji + drawn = rows(keyboard(set(LABELS) if section == "sub" else set(), everything, section)) + texts = [t for row in drawn[1:] for t, _ in row] + assert texts and all(not t[0].isascii() for t in texts), texts # every button under it starts with an emoji assert title(set(), ["rain"]) == f"{TITLE}\n✅ On: rain\n🔕 Off: nothing" # the on/off summary and the toasts stay plain words @@ -345,16 +346,6 @@ async def test_settings_hold_enable_and_disable_sub_menus_of_notification_sounds assert [b for r in rows(q.edits[0][1]) for b in r if b[1].startswith("al:snd")] == [("🌧️ rain", "al:snd:rain:off")] await press(bot, Query("al:snd:rain:off")) assert state.loud(1) == set() - q = Query("al:open:snd_on:uv", text="☀️ UV 10") # under an alert there is no Settings: the sound is one toggle - await press(bot, q) - assert rows(q.edits[0][1]) == [[("🔕 ▸", "al:open:unsub:uv"), ("🔊", "al:snd:uv:on:uv")]] - q = Query("al:snd:uv:on:uv", text="☀️ UV 10") # 🔊 turns this type's sound on, and the button becomes 🔇 - await press(bot, q) - assert state.loud(1) == {"uv"} and q.toast == "UV alerts now make a sound" - assert rows(q.edits[0][1]) == [[("🔕 ▸", "al:open:unsub:uv"), ("🔇", "al:snd:uv:off:uv")]] - q = Query("al:snd:uv:off:uv", text="☀️ UV 10") - await press(bot, q) - assert state.loud(1) == set() and q.toast == "UV alerts no longer make a sound" and rows(q.edits[0][1])[0][1][0] == "🔊" for data in ("al:snd:bogus:on", "al:snd:rain:maybe", "al:snd:rain"): # nothing from the data reaches the state await press(bot, Query(data)) assert state.loud(1) == set() @@ -375,7 +366,7 @@ async def test_an_alert_has_a_sound_only_in_a_chat_that_turned_it_on_and_groups_ flags = {(c, t.split("\n")[0]): kw["disable_notification"] for c, t, kw in bot.sent} assert flags[(1, "It's raining")] is False and flags[(1, "UV")] is True and flags[(-5, "It's raining")] is True group = next(kw for c, _, kw in bot.sent if c == -5) - assert [b[0] for b in rows(group["reply_markup"])[0]] == ["🔕 ▸"] + assert [b[0] for b in rows(group["reply_markup"])[0]] == ["⚙️ ▸"] async def test_a_chat_that_became_a_supergroup_is_followed_not_a_crash(tmp_path): @@ -467,7 +458,7 @@ async def test_an_alert_with_a_picture_is_the_picture_with_the_text_as_its_capti assert bot.sent == [] and len(bot.photos) == 1 chat, photo, kw = bot.photos[0] assert (chat, photo, kw["caption"]) == (1, b"png", "It's warmer outside") and kw["disable_notification"] is True - assert rows(kw["reply_markup"])[0][0] == ("🔕 ▸", "al:open:unsub:temps") # the alert's own buttons ride along + assert rows(kw["reply_markup"])[0][0] == ("⚙️ ▸", "al:open:gear:temps") # the alert's own buttons ride along async def test_an_alerts_picture_is_uploaded_once_and_the_other_chats_are_sent_telegrams_copy(tmp_path): diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index 3c447ab..ebdc3eb 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -708,7 +708,7 @@ async def send_message(self, chat_id, text, **kw): await m.check(NOW) assert sorted(c for c, _, _ in delivered) == [-7, 1, 3] and all(text.startswith("☔ Irrigation paused") for _, text, _ in delivered) markup = delivered[0][2]["reply_markup"] - assert rows(markup) == [[("🔕 ▸", "al:open:unsub:irrigation_pause"), ("🔊", "al:snd:irrigation_pause:on:irrigation_pause")]] + assert rows(markup) == [[("⚙️ ▸", "al:open:gear:irrigation_pause")]] from lib.alerts.menu import keyboard kinds = available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast", "Irrigation"}) for section in (None, "sub", "unsub"): # every button's data fits Telegram's 64 bytes, for the new type too From ac150129ef7d53692888d393967af6f18e73ee90 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 11:39:41 +0000 Subject: [PATCH 09/19] /alerts: two buttons, Alerts and Sound, each a full list of struck-through toggles with Back Each opens the full list of alert types (alerts on or off in the chat; notification sound on or off, private chats only) with a Back button, and the other is hidden meanwhile. The strikethrough toggles, here and in the individual alert's gear menu, no longer carry emojis. Sub, Unsub and Settings are gone. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- lib/alerts/menu.py | 104 +++++++------------- lib/bot.py | 17 +--- tests/test_admin_only.py | 14 +-- tests/test_alert_menu.py | 202 ++++++++++++++------------------------- tests/test_irrigation.py | 8 +- 6 files changed, 121 insertions(+), 226 deletions(-) diff --git a/README.md b/README.md index 4ff1a6d..37728d2 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A Telegram bot for a personal Ecowitt weather station and AirGradient air-quality sensor. Ask it about the weather or air quality (text or charts); it also sends silent alerts to every chat it is in: rain starting/stopping, gusts over 40 km/h (with the last day's wind chart), UV index of 10 or more in two readings in a row (with the last day's UV chart), indoor/outdoor temperatures crossing by more than 1°C (held back for the cooldown, then announced if it still stands, with the last day's temperature chart), -and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one ⚙️ button that opens up to two toggles for that alert type: 🔔 Alert (on or off in this chat) and, while that is on and in a private chat, 🔊 Sound (whether it makes a notification sound; alerts are silent unless you turn it on). A toggle is struck through while it is off; `/alerts` has the full settings: 🔔 Sub lists the alert types that are off (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes), 🔕 Unsub the ones that are on, and in a private chat ⚙️ has Enable and Disable notification sounds sub-menus that list the alert types; and `/alerts on|off` still subscribes or unsubscribes every type); `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). +and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one ⚙️ button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). A toggle is struck through while it is off. `/alerts` has two buttons, 🔔 Alerts and (in a private chat) 🔊 Sound; each opens the full list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes) as toggles, struck through while off, with a ⬅️ Back button, and while one is open the other is hidden until you go back; `/alerts on|off` still subscribes or unsubscribes every type. `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). ## Run diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index 402939a..466d9b7 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -1,11 +1,11 @@ -"""The alert settings as buttons. /alerts has the full menu: Sub | Unsub (and Settings in private chats), each opening its options below: Sub -lists the types that are off, Unsub the types that are on, so each list says by itself whether its alerts are on. Under an alert there -is one gear that opens that type's two toggles, Alert and Sound. Pure: it only builds the keyboard and the title from what a chat has muted. +"""The alert settings as buttons. /alerts has two: 🔔 Alerts and (private chats) 🔊 Sound, each opening the full list of alert types as +toggles (struck through while off) with a Back button, the other hidden meanwhile. Under an alert there is one gear that opens that type's +two toggles, Alert and Sound. Pure: it only builds the keyboard and the title from what a chat has muted. Callback data (self-contained and short, so an old message's buttons still work after a restart): - al:open:sub | al:open:unsub | al:open:set | al:open:snd_on | al:open:snd_off | al:close | al:noop (the heading rows of messages sent by an earlier version) - al:snd:: (notification sound for one type; Settings is in private chats only) - al:on::
| al:off::
(section: sub or unsub, the part left open) + al:open:alerts | al:open:sound | al:close (Back) | al:noop (the heading rows of messages sent by an earlier version) + al:on::alerts | al:off::alerts (alerts of one type on or off in the chat) + al:snd:: (notification sound for one type; private chats only) An alert's buttons end with the alert's own type: al:open:gear:rain, al:close:rain, al:off:rain:gear:rain, al:snd:rain:on:rain. A chat's /alerts message has no such field. """ @@ -20,9 +20,6 @@ LABELS = {"rain": "rain", "gusts": "gusts", "uv": "UV", "temps": "temperature crossing", "particulates": "particulates", "pollen_and_asthma": "pollen & asthma", "forecast": "forecast changes", "irrigation_battery": "irrigation battery", "irrigation_pause": "irrigation pause"} -# the emoji on each type's button: the one its own alerts start with (every type has one: a test holds the two lists together) -EMOJI = {"rain": "\U0001f327️", "gusts": "\U0001f4a8", "uv": "\U0001f9f4", "temps": "\U0001f321️", - "particulates": "\U0001f637", "pollen_and_asthma": "\U0001f33c", "forecast": "\U0001f504", "irrigation_battery": "\U0001faab", "irrigation_pause": "☔"} ALL = "all" TITLE = "🔔 Alerts in this chat" PER_ROW = 2 @@ -42,95 +39,60 @@ def kinds_for(source_names, irrigation: bool) -> list[str]: return available_kinds(set(source_names) | ({"Irrigation"} if irrigation else set())) -def label(kind: str) -> str: - """The name of an alert type on its button: its emoji, then its name.""" - return f"{EMOJI[kind]} {LABELS[kind]}" - - def _button(text: str, data: str) -> InlineKeyboardButton: return InlineKeyboardButton(text, callback_data=data) -def _type_rows(items: list[str], button, every: InlineKeyboardButton) -> list[list]: - """A list of alert types as button rows, the same under Unsubscribe, Subscribe and both sound menus: `every` ("all") last when it has - more than one type. `button(kind)` draws one type.""" - rows = list(map(list, batched(map(button, items), PER_ROW))) - if len(items) > 1: - rows.append([every]) - return rows - - def struck(text: str) -> str: """The text with a line through it (a button cannot be styled, so each letter gets the combining long stroke).""" return "".join(f"{c}\u0336" for c in text) -def _toggle(emoji: str, word: str, on: bool, data: str) -> InlineKeyboardButton: - """A button that flips a setting: its emoji and word, the word struck through while the setting is off.""" - return _button(f"{emoji} {word if on else struck(word)}", data) +def _toggle(word: str, on: bool, data: str) -> InlineKeyboardButton: + """A button that flips a setting: just its word, struck through while the setting is off.""" + return _button(word if on else struck(word), data) def alert_keyboard(kind: str, muted: set[str], loud: set[str] | None, open: str | None = None) -> InlineKeyboardMarkup: - """The buttons under an alert of type `kind`: one gear (⚙️ ▸, ▾ once open) that opens up to two toggles, 🔔 Alert (this type's alerts - in this chat) and, only while that is on and in a private chat (`loud` is not None), 🔊 Sound (whether they make a notification sound). + """The buttons under an alert of type `kind`: one gear (⚙️ ▸, ▾ once open) that opens up to two toggles, Alert (this type's alerts + in this chat) and, only while that is on and in a private chat (`loud` is not None), Sound (whether they make a notification sound). A toggle is struck through while it is off. Every button carries the type: al:open:gear:, al:close:, al:on|off::gear:, al:snd::on|off:.""" is_open = open == "gear" rows = [[_button(menu_text("⚙️", is_open), f"al:close:{kind}" if is_open else f"al:open:gear:{kind}")]] if is_open: on = kind not in muted - rows.append([_toggle("🔔", "Alert", on, f"al:{'off' if on else 'on'}:{kind}:gear:{kind}")]) + rows.append([_toggle("Alert", on, f"al:{'off' if on else 'on'}:{kind}:gear:{kind}")]) if on and loud is not None: sound = kind in loud - rows.append([_toggle("🔊", "Sound", sound, f"al:snd:{kind}:{'off' if sound else 'on'}:{kind}")]) + rows.append([_toggle("Sound", sound, f"al:snd:{kind}:{'off' if sound else 'on'}:{kind}")]) return InlineKeyboardMarkup(rows) +def _list(kinds: list[str], is_on, data) -> list[list]: + """A toggle for each of the alert types, PER_ROW to a row, a type struck through while it is off. is_on(kind): its state; + data(kind): the callback data that flips it.""" + return list(map(list, batched((_toggle(LABELS[k], is_on(k), data(k)) for k in kinds), PER_ROW))) + + def keyboard(muted: set[str], available: list[str], open: str | None = None, parent: str | None = None, loud: set[str] | None = None) -> InlineKeyboardMarkup: - """The /alerts menu: Sub, Unsub and (private chats) Settings, one section open at a time (`open`: sub, unsub, set, snd_on, snd_off) with - its options under it. A ▸ after the text marks a button that opens a menu, ▾ (in the same place) the open one; the rest do something. - `loud`: the types whose alerts make a notification sound; None (a group) leaves out Settings, which holds two menus, Enable and Disable - notification sounds, over the subscribed types. Sub is left out when every type is on. `parent`: the type of the alert these buttons - sit under, which has a menu of its own (alert_keyboard).""" + """The /alerts menu: two buttons, 🔔 Alerts and (private chats) 🔊 Sound. Each opens a menu with the full list of alert types, each a + toggle struck through while it is off (alerts on or off in this chat; notification sound on or off), and ⬅️ Back. While one is open + the other is hidden, its button included, until Back. `open`: alerts, sound (or one of an earlier version's sections). `loud`: the + types whose alerts make a notification sound; None (a group) has no Sound. `parent`: the type of the alert these buttons sit under, + which has a menu of its own (alert_keyboard).""" if parent: return alert_keyboard(parent, muted, loud, open) - open = {"other": "unsub", "snd_on_o": "snd_on", "snd_off_o": "snd_off"}.get(open, open) # (lists of messages from an earlier version) - subscribed = options(muted, available, "unsub") - def head(is_open: bool, text: str, section: str) -> InlineKeyboardButton: - return _button(menu_text(text, is_open), "al:close" if is_open else f"al:open:{section}") - no_sub = not options(muted, available, "sub") # subscribed to every type: no Sub button - if no_sub and open == "sub": - open = None - rows = [[*([] if no_sub else [head(open == "sub", "🔔", "sub")]), head(open == "unsub", "🔕", "unsub")]] - if loud is not None: - rows[0].append(head(open in SETTINGS, "⚙️", "set")) - if open in SETTINGS and loud is not None: - for section, heading, items in (("snd_on", "🔔 Enable notification sounds", [k for k in subscribed if k not in loud]), - ("snd_off", "🔕 Disable notification sounds", [k for k in subscribed if k in loud])): - if not items: - continue - here = open == section - rows.append([_button(menu_text(heading, here), "al:open:set" if here else f"al:open:{section}")]) - if here: - verb = "on" if section == "snd_on" else "off" - rows += _type_rows(items, lambda k: _button(label(k), f"al:snd:{k}:{verb}"), - _button("🔔 Enable all" if verb == "on" else "🔕 Disable all", f"al:snd:{ALL}:{verb}")) - if open in ("sub", "unsub"): - verb = "on" if open == "sub" else "off" - rows += _type_rows(options(muted, available, open), lambda k: _button(label(k), f"al:{verb}:{k}:{open}"), - _button("🔔 Subscribe to all" if verb == "on" else "🔕 Unsubscribe from all", f"al:{verb}:{ALL}:{open}")) - return InlineKeyboardMarkup(rows) - - -def has_options(muted: set[str], available: list[str], section: str) -> bool: - """Would this section show any button under its heading?""" - return len(keyboard(muted, available, section).inline_keyboard) > 1 - - -def options(muted: set[str], available: list[str], section: str) -> list[str]: - """What a section lists: Subscribe the types that are off, Unsubscribe the types that are on.""" - return [k for k in available if (k in muted) == (section == "sub")] + open = {"sub": "alerts", "unsub": "alerts", "other": "alerts", **dict.fromkeys(SETTINGS, "sound")}.get(open, open) + if open == "alerts": + rows = _list(available, lambda k: k not in muted, lambda k: f"al:{'off' if k not in muted else 'on'}:{k}:alerts") + elif open == "sound" and loud is not None: + rows = _list(available, lambda k: k in loud, lambda k: f"al:snd:{k}:{'off' if k in loud else 'on'}") + else: + return InlineKeyboardMarkup([[_button(menu_text("🔔 Alerts", False), "al:open:alerts"), + *([_button(menu_text("🔊 Sound", False), "al:open:sound")] if loud is not None else [])]]) + return InlineKeyboardMarkup([*rows, [_button("⬅️ Back", "al:close")]]) def title(muted: set[str], available: list[str]) -> str: diff --git a/lib/bot.py b/lib/bot.py index 3a87c7f..0473b05 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -431,17 +431,10 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY toast, opened = None, section or None if action in ("open", "close"): opened = arg if action == "open" else None - if opened == "set" and not parent and not menu.options(self.state.muted(chat.id), kinds, "unsub"): - await query.answer("No alerts are on") # no subscribed type to set a sound for - return - if opened in ("sub", "unsub", "other") and not menu.has_options(self.state.muted(chat.id), kinds, opened): - await query.answer("You're subscribed to everything" if opened == "sub" else "No alerts are on") # nothing to list - return - elif action == "snd" and chat.type == ChatType.PRIVATE and (arg in kinds or arg == menu.ALL) and section in ("on", "off") and chat.id in self.state.chats: - for k in ([k for k in kinds if k not in self.state.muted(chat.id)] if arg == menu.ALL else [arg]): # "all": the types offered here - self.state.set_sound(chat.id, k, section == "on") + elif action == "snd" and chat.type == ChatType.PRIVATE and arg in kinds and section in ("on", "off") and chat.id in self.state.chats: + self.state.set_sound(chat.id, arg, section == "on") parent = tail if tail in kinds else None - opened = "gear" if parent else "snd_on" if section == "on" else "snd_off" # stay in the list (under an alert: its gear stays open) + opened = "gear" if parent else "sound" # stay in the list (under an alert: its gear stays open) toast = f"{self._alerts_named(arg)} {'now make' if section == 'on' else 'no longer make'} a sound" elif action in ("on", "off") and (arg == menu.ALL or arg in kinds) and chat.id in self.state.chats: self.state.set_kind(chat.id, arg, action == "on") @@ -450,10 +443,8 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY await query.answer() return muted = self.state.muted(chat.id) - if opened in ("sub", "unsub", "other") and not menu.has_options(muted, kinds, opened): - opened = None # the last one was just turned on or off: close the section private = chat.type == ChatType.PRIVATE - markup = menu.keyboard(muted, kinds, opened if opened in ("sub", "unsub", "other", "gear", *menu.SETTINGS) else None, parent, + markup = menu.keyboard(muted, kinds, opened if opened in ("alerts", "sound", "gear", "sub", "unsub", "other", *menu.SETTINGS) else None, parent, self.state.loud(chat.id) if private else None) await query.answer(toast) try: diff --git a/tests/test_admin_only.py b/tests/test_admin_only.py index d600dc3..41cea63 100644 --- a/tests/test_admin_only.py +++ b/tests/test_admin_only.py @@ -282,14 +282,14 @@ async def test_usage_is_the_help_and_alerts_button(tmp_path, kind): # ---------- every button comes in through one place ---------- -def alert_keyboard(opened="unsub", parent=None): - return menu.keyboard(set(), menu.available_kinds({"Ecowitt"}), opened, parent) +def alert_keyboard(opened="alerts", parent=None): + return menu.keyboard(set(), menu.available_kinds({"Ecowitt"}), opened, parent, set()) async def test_a_stranger_is_refused_on_every_family_of_buttons_and_an_admin_is_let_through(tmp_path): bot, state, tg = make(tmp_path, irrigation=Remote()) state.add_chat(ADMIN, "Rob") - cases = [("al:off:rain:unsub", alert_keyboard()), (f"{periods.PREFIX}7", periods.keyboard(1)), + cases = [("al:off:rain:alerts", alert_keyboard()), (f"{periods.PREFIX}7", periods.keyboard(1)), ("ir:open:water", irrigation_menu.keyboard(True, True))] for data, markup in cases: press = Press(data, markup, user=STRANGER) @@ -307,10 +307,10 @@ async def test_a_stranger_is_refused_on_every_family_of_buttons_and_an_admin_is_ async def test_alert_buttons_in_a_group_stay_admin_only_even_when_admin_only_is_off(tmp_path): bot, state, tg = make(tmp_path, admin_only=False) - press = Press("al:off:rain:unsub", alert_keyboard(), user=STRANGER, kind="supergroup") + press = Press("al:off:rain:alerts", alert_keyboard(), user=STRANGER, kind="supergroup") await bot.on_button(press.update(), ctx(tg)) assert press.toast == "Only group admins can change alerts" and state.muted(GROUP) == set() - press = Press("al:off:rain:unsub", alert_keyboard(), user=ADMIN, kind="supergroup") + press = Press("al:off:rain:alerts", alert_keyboard(), user=ADMIN, kind="supergroup") await bot.on_button(press.update(), ctx(tg)) assert state.muted(GROUP) == {"rain"} @@ -321,7 +321,7 @@ def every_keyboard(bot): kinds = menu.available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast", "Irrigation"}) muted_sets = [set(), {"rain"}, {"rain", "uv", "particulates"}, set(menu.LABELS)] for muted in muted_sets: - for opened in (None, "sub", "unsub", "gear", "set", "snd_on", "snd_off"): + for opened in (None, "alerts", "sound", "gear", "sub", "unsub", "set", "snd_on", "snd_off"): for parent in (None, *kinds): for loud in (None, set(), set(kinds)): yield menu.keyboard(muted, kinds, opened, parent, loud) @@ -421,7 +421,7 @@ async def test_a_prefix_nobody_handles_is_refused_even_when_the_message_really_h async def test_a_real_press_on_a_button_the_message_carries_still_works(tmp_path): bot, state, tg = make(tmp_path, irrigation=Remote()) state.add_chat(ADMIN, "Rob") - for data, markup in (("ir:refresh", irrigation_menu.keyboard(True, True)), ("al:off:rain:unsub", alert_keyboard()), + for data, markup in (("ir:refresh", irrigation_menu.keyboard(True, True)), ("al:off:rain:alerts", alert_keyboard()), ("ir:water:10", irrigation_menu.keyboard(True, True, "water"))): press = await press_through(bot, tg, data, markup) assert press.toast not in (STALE_BUTTON, NOT_ADMIN_TOAST) and press.edits, data diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 5ca449b..9432028 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -4,7 +4,7 @@ from telegram.error import BadRequest from lib.alerts import AlertState, Notifier -from lib.alerts.menu import ALL, LABELS, TITLE, available_kinds, keyboard, options, title +from lib.alerts.menu import ALL, LABELS, TITLE, available_kinds, keyboard, title from lib.bot import Bot from tests.fakes import TZ @@ -15,31 +15,35 @@ def rows(markup): return [[(b.text, b.callback_data) for b in row] for row in markup.inline_keyboard] -def test_the_collapsed_menu_is_subscribe_and_unsubscribe_and_has_no_subscribe_when_everything_is_on(): - assert rows(keyboard({"rain"}, KINDS)) == [[("🔔 ▸", "al:open:sub"), ("🔕 ▸", "al:open:unsub")]] - assert rows(keyboard(set(), KINDS)) == [[("🔕 ▸", "al:open:unsub")]] # nothing for Sub to list - assert rows(keyboard(set(), KINDS, "sub")) == rows(keyboard(set(), KINDS)) # and it cannot be left open +def strike(word): + return "".join(c + "\u0336" for c in word) -def test_unsubscribe_opens_the_types_that_are_on_and_subscribe_the_types_that_are_off(): - got = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "unsub")) - assert got[0] == [("🔔 ▸", "al:open:sub"), ("🔕 ▾", "al:close")] - assert got[1] == [("🌧️ rain", "al:off:rain:unsub"), ("💨 gusts", "al:off:gusts:unsub")] - assert [text for row in got[1:-1] for text, _ in row] == ["🌧️ rain", "💨 gusts", "🧴 UV", "🌡️ temperature crossing", "😷 particulates"] - assert got[-1] == [("🔕 Unsubscribe from all", "al:off:all:unsub")] - got = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "sub")) - assert got[0][0] == ("🔔 ▾", "al:close") - assert got[1:] == [[("🌼 pollen & asthma", "al:on:pollen_and_asthma:sub"), ("🔄 forecast changes", "al:on:forecast:sub")], [("🔔 Subscribe to all", "al:on:all:sub")]] - assert not any(data == "al:noop" for row in got for _, data in row) # no button that does nothing - assert options({"pollen_and_asthma"}, KINDS, "sub") == ["pollen_and_asthma"] and options({"pollen_and_asthma"}, ["rain", "pollen_and_asthma"], "unsub") == ["rain"] - assert len(rows(keyboard(set(), KINDS, "sub"))) == 1 # nothing off: nothing listed +def test_alerts_has_two_buttons_alerts_and_sound_and_a_group_has_only_the_first(): + assert rows(keyboard({"rain"}, KINDS, loud=set())) == [[("🔔 Alerts ▸", "al:open:alerts"), ("🔊 Sound ▸", "al:open:sound")]] + assert rows(keyboard(set(), KINDS)) == [[("🔔 Alerts ▸", "al:open:alerts")]] + assert rows(keyboard(set(), KINDS, "sound")) == rows(keyboard(set(), KINDS)) # a group cannot open Sound + + +def test_each_menu_lists_every_type_struck_through_when_off_with_back_and_the_other_menu_is_hidden(): + got = rows(keyboard({"gusts"}, KINDS, "alerts", loud={"rain"})) + assert [t for row in got[:-1] for t, _ in row] == ["rain", strike("gusts"), "UV", "temperature crossing", "particulates", + "pollen & asthma", "forecast changes"] # no emojis + assert got[0] == [("rain", "al:off:rain:alerts"), (strike("gusts"), "al:on:gusts:alerts")] and got[-1] == [("⬅️ Back", "al:close")] + assert "Sound" not in str(got) and "Alerts" not in str(got) # the other menu, and its button, are hidden + got = rows(keyboard({"gusts"}, KINDS, "sound", loud={"rain"})) + assert got[0] == [("rain", "al:snd:rain:off"), (strike("gusts"), "al:snd:gusts:on")] # (a type that is off can still have a sound set) + assert [t for row in got[1:-1] for t, _ in row] == [strike(w) for w in ("UV", "temperature crossing", "particulates", "pollen & asthma", "forecast changes")] + assert got[-1] == [("⬅️ Back", "al:close")] and "Alerts" not in str(got) + for old, new in (("sub", "alerts"), ("unsub", "alerts"), ("other", "alerts"), ("set", "sound"), ("snd_on", "sound"), ("snd_off_o", "sound")): + assert rows(keyboard({"gusts"}, KINDS, old, loud=set())) == rows(keyboard({"gusts"}, KINDS, new, loud=set())), old # an earlier version's sections def test_only_the_types_the_bot_has_are_listed_and_every_callback_is_short(): assert available_kinds({"Ecowitt"}) == ["rain", "gusts", "uv", "temps"] assert available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast"}) == KINDS and available_kinds({"AirGradient"}) == ["particulates"] - for section in (None, "sub", "unsub"): - for text, data in (b for row in rows(keyboard({"uv"}, KINDS, section)) for b in row): + for section in (None, "alerts", "sound"): + for text, data in (b for row in rows(keyboard({"uv"}, KINDS, section, loud=set())) for b in row): assert len(data.encode()) <= 64 @@ -129,39 +133,30 @@ async def press(bot, query, **kw): await bot.on_alert_button(NS(callback_query=query), kw.get("context") or ctx()) -async def test_pressing_unsubscribe_then_a_type_updates_the_state_and_redraws_the_open_section(tmp_path): +async def test_the_alerts_menu_toggles_a_type_in_place_and_back_returns_to_the_two_buttons(tmp_path): bot, state = make_bot(tmp_path) - q = Query("al:open:unsub") - await press(bot, q) - assert q.toast is None and rows(q.edits[0][1])[1][0] == ("🌧️ rain", "al:off:rain:unsub") and q.edits[0][0] == "markup" - q = Query("al:off:rain:unsub") - await press(bot, q) - assert state.muted(1) == {"rain"} and q.toast == "Rain alerts off in this chat" - drawn = rows(q.edits[0][1]) - assert drawn[0][1] == ("🔕 ▾", "al:close") and "🌧️ rain" not in [t for row in drawn[1:] for t, _ in row] - q = Query("al:on:rain:sub") - await press(bot, q) - assert state.muted(1) == set() and q.toast == "Rain alerts on in this chat" - q = Query("al:off:all:unsub") + q = Query("al:open:alerts", text=f"{TITLE}\n✅ On: ...") await press(bot, q) - assert q.toast == "All alerts off in this chat" and not state.alerts_on(1) and len(rows(q.edits[0][1])) == 1 # nothing left: closed - q = Query("al:open:unsub") + drawn = rows(q.edits[0][2]) + assert q.toast is None and drawn[0][0] == ("rain", "al:off:rain:alerts") and drawn[-1] == [("⬅️ Back", "al:close")] + q = Query("al:off:rain:alerts", text=f"{TITLE}\n✅ On: ...") await press(bot, q) - assert q.toast == "No alerts are on" and q.edits == [] # nothing to list: a toast, no dead button - q = Query("al:open:sub") + assert state.muted(1) == {"rain"} and q.toast == "Rain alerts off in this chat" and "🔕 Off: rain" in q.edits[0][1] + assert rows(q.edits[0][2])[0][0] == (strike("rain"), "al:on:rain:alerts") # stays open, now struck through + q = Query("al:on:rain:alerts", text=f"{TITLE}\n✅ On: ...") await press(bot, q) - assert rows(q.edits[0][1])[-1] == [("🔔 Subscribe to all", "al:on:all:sub")] - q = Query("al:close") + assert state.muted(1) == set() and q.toast == "Rain alerts on in this chat" and rows(q.edits[0][2])[0][0] == ("rain", "al:off:rain:alerts") + q = Query("al:close", text=f"{TITLE}\n✅ On: ...") await press(bot, q) - assert len(rows(q.edits[0][1])) == 1 + assert rows(q.edits[0][2]) == [[("🔔 Alerts ▸", "al:open:alerts"), ("🔊 Sound ▸", "al:open:sound")]] async def test_the_alerts_message_keeps_its_summary_current_but_an_alert_keeps_its_text(tmp_path): bot, state = make_bot(tmp_path) - q = Query("al:off:uv:unsub", text=f"{TITLE}\n✅ On: ...") + q = Query("al:off:uv:alerts", text=f"{TITLE}\n✅ On: ...") await press(bot, q) assert q.edits[0][0] == "text" and "🔕 Off: UV" in q.edits[0][1] - q = Query("al:off:gusts:unsub", text="💨 Strong gusts: 55 km/h") + q = Query("al:off:gusts:alerts", text="💨 Strong gusts: 55 km/h") await press(bot, q) assert q.edits[0][0] == "markup" @@ -171,12 +166,12 @@ async def test_noop_unknown_kinds_and_unchanged_markup_are_harmless(tmp_path): q = Query("al:noop") await press(bot, q) assert q.edits == [] and q.toast is None - for data in ("al:off:pollen:unsub", "al:off:bogus:unsub", "al:weird", "garbage"): # pollen is not a type here + for data in ("al:off:pollen:alerts", "al:off:bogus:alerts", "al:weird", "garbage"): # pollen is not a type here q = Query(data) await press(bot, q) assert q.edits == [] and state.muted(1) == set(), data state.set_kind(1, "rain", False) # something to list, so it edits - q = Query("al:open:sub") + q = Query("al:open:alerts") q.fail = BadRequest("Message is not modified: specified new message content and reply markup are exactly the same") await press(bot, q) # swallowed q.fail = BadRequest("Message to edit not found") @@ -186,12 +181,12 @@ async def test_noop_unknown_kinds_and_unchanged_markup_are_harmless(tmp_path): async def test_in_a_group_only_admins_may_change_alerts(tmp_path): bot, state = make_bot(tmp_path) - q = Query("al:off:rain:unsub", chat_type="supergroup", chat_id=-5) + q = Query("al:off:rain:alerts", chat_type="supergroup", chat_id=-5) await press(bot, q, context=ctx("member")) assert q.toast == "Only group admins can change alerts" and state.muted(-5) == set() and q.edits == [] await press(bot, q, context=ctx("administrator")) assert state.muted(-5) == {"rain"} and q.edits - q = Query("al:off:uv:unsub", chat_type="group", chat_id=-5) + q = Query("al:off:uv:alerts", chat_type="group", chat_id=-5) await press(bot, q, context=ctx("creator")) assert state.muted(-5) == {"rain", "uv"} @@ -206,7 +201,7 @@ async def reply_text(body, **kw): update = NS(effective_message=NS(reply_text=reply_text, chat_id=1), effective_chat=chat, effective_user=NS(full_name="Rob", username="rob")) await bot.on_alerts(update, NS(args=[])) assert replies[0][0].startswith(TITLE) and "✅ On: rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes" in replies[0][0] - assert rows(replies[0][1]["reply_markup"])[0] == [("🔕 ▸", "al:open:unsub"), ("⚙️ ▸", "al:open:set")] + assert rows(replies[0][1]["reply_markup"])[0] == [("🔔 Alerts ▸", "al:open:alerts"), ("🔊 Sound ▸", "al:open:sound")] await bot.on_alerts(update, NS(args=["off"])) assert not state.alerts_on(1) and "✅ On: nothing" in replies[1][0] await bot.on_alerts(update, NS(args=["on"])) @@ -258,47 +253,34 @@ async def spy(chat_id, text, link=None, kind=None): def test_under_an_alert_one_gear_opens_the_alert_toggle_and_then_the_sound_toggle_struck_through_when_off(): - strike = lambda word: "".join(c + "\u0336" for c in word) shut = rows(keyboard(set(), KINDS, None, parent="uv", loud=set())) assert shut == [[("⚙️ ▸", "al:open:gear:uv")]] on = rows(keyboard(set(), KINDS, "gear", parent="uv", loud=set())) # both on... the sound is off - assert on == [[("⚙️ ▾", "al:close:uv")], [("🔔 Alert", "al:off:uv:gear:uv")], [("🔊 " + strike("Sound"), "al:snd:uv:on:uv")]] + assert on == [[("⚙️ ▾", "al:close:uv")], [("Alert", "al:off:uv:gear:uv")], [(strike("Sound"), "al:snd:uv:on:uv")]] both = rows(keyboard(set(), KINDS, "gear", parent="uv", loud={"uv"})) - assert both[2] == [("🔊 Sound", "al:snd:uv:off:uv")] + assert both[2] == [("Sound", "al:snd:uv:off:uv")] off = rows(keyboard({"uv"}, KINDS, "gear", parent="uv", loud={"uv"})) # alert off: struck through, and no sound toggle - assert off == [[("⚙️ ▾", "al:close:uv")], [("🔔 " + strike("Alert"), "al:on:uv:gear:uv")]] + assert off == [[("⚙️ ▾", "al:close:uv")], [(strike("Alert"), "al:on:uv:gear:uv")]] group = rows(keyboard(set(), KINDS, "gear", parent="uv", loud=None)) # a group has no sound - assert group == [[("⚙️ ▾", "al:close:uv")], [("🔔 Alert", "al:off:uv:gear:uv")]] + assert group == [[("⚙️ ▾", "al:close:uv")], [("Alert", "al:off:uv:gear:uv")]] assert rows(keyboard(set(), KINDS, "unsub", parent="uv", loud=set())) == shut # (an earlier version's list: just the gear) - sub = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "sub")) # Subscribe (the /alerts message) is the types that are off - assert [t for row in sub[1:-1] for t, _ in row] == ["🌼 pollen & asthma", "🔄 forecast changes"] and sub[-1][0][0] == "🔔 Subscribe to all" - assert rows(keyboard(set(), KINDS, "unsub"))[1][0][0] == "🌧️ rain" # the /alerts message has no parent: every type that is on - - -def test_the_all_button_is_only_there_when_the_list_has_more_than_one_item(): - assert not any("all" in data for row in rows(keyboard({"uv", "gusts", "rain", "temps", "particulates", "pollen_and_asthma"}, KINDS, "unsub")) - for _, data in row) # one type on (forecast changes): no "Unsubscribe from all" - assert not any("all" in data for row in rows(keyboard({"uv"}, KINDS, "sub")) for _, data in row) # one type off: no "Subscribe to all" - assert rows(keyboard({"uv", "gusts"}, KINDS, "sub"))[-1][0][0] == "🔔 Subscribe to all" - assert rows(keyboard(set(), KINDS, "unsub"))[-1][0][0] == "🔕 Unsubscribe from all" async def test_the_gear_under_an_alert_toggles_the_alert_and_its_sound_and_stays_open(tmp_path): bot, state = make_bot(tmp_path) - strike = lambda word: "".join(c + "\u0336" for c in word) q = Query("al:open:gear:uv", text="☀️ UV 10") await press(bot, q) - assert rows(q.edits[0][1]) == [[("⚙️ ▾", "al:close:uv")], [("🔔 Alert", "al:off:uv:gear:uv")], [("🔊 " + strike("Sound"), "al:snd:uv:on:uv")]] + assert rows(q.edits[0][1]) == [[("⚙️ ▾", "al:close:uv")], [("Alert", "al:off:uv:gear:uv")], [(strike("Sound"), "al:snd:uv:on:uv")]] q = Query("al:snd:uv:on:uv", text="☀️ UV 10") await press(bot, q) - assert state.loud(1) == {"uv"} and q.toast == "UV alerts now make a sound" and rows(q.edits[0][1])[2] == [("🔊 Sound", "al:snd:uv:off:uv")] + assert state.loud(1) == {"uv"} and q.toast == "UV alerts now make a sound" and rows(q.edits[0][1])[2] == [("Sound", "al:snd:uv:off:uv")] q = Query("al:off:uv:gear:uv", text="☀️ UV 10") # alert off: the sound toggle goes, the struck alert stays to turn it back on await press(bot, q) assert "uv" in state.muted(1) and q.toast == "UV alerts off in this chat" - assert rows(q.edits[0][1]) == [[("⚙️ ▾", "al:close:uv")], [("🔔 " + strike("Alert"), "al:on:uv:gear:uv")]] + assert rows(q.edits[0][1]) == [[("⚙️ ▾", "al:close:uv")], [(strike("Alert"), "al:on:uv:gear:uv")]] q = Query("al:on:uv:gear:uv", text="☀️ UV 10") await press(bot, q) - assert "uv" not in state.muted(1) and rows(q.edits[0][1])[2] == [("🔊 Sound", "al:snd:uv:off:uv")] # the sound setting was kept + assert "uv" not in state.muted(1) and rows(q.edits[0][1])[2] == [("Sound", "al:snd:uv:off:uv")] # the sound setting was kept q = Query("al:close:uv", text="☀️ UV 10") await press(bot, q) assert rows(q.edits[0][1]) == [[("⚙️ ▸", "al:open:gear:uv")]] @@ -306,53 +288,45 @@ async def test_the_gear_under_an_alert_toggles_the_alert_and_its_sound_and_stays async def test_an_unknown_parent_in_a_button_is_ignored(tmp_path): bot, state = make_bot(tmp_path) - q = Query("al:open:unsub:bogus", text="☀️ UV 10") + q = Query("al:open:alerts:bogus", text="☀️ UV 10") await press(bot, q) drawn = rows(q.edits[0][1]) - assert [t for row in drawn[1:] for t, _ in row][0] == "🌧️ rain" and not any(t.startswith("●") for row in drawn for t, _ in row) - assert drawn[0][0][1] == "al:close" # and it is not carried on + assert drawn[0][0] == ("rain", "al:off:rain:alerts") and drawn[-1] == [("⬅️ Back", "al:close")] # the /alerts menu, not carried on -def test_every_alert_type_has_its_emoji_and_every_button_in_the_lists_starts_with_it(): - from lib.alerts.menu import EMOJI, label - assert set(EMOJI) == set(LABELS) # a new type cannot be added without one - assert len(set(EMOJI.values())) == len(EMOJI) and all(not e.isascii() for e in EMOJI.values()) # all different, all emoji - assert label("rain") == "🌧️ rain" and label("irrigation_battery") == "🪫 irrigation battery" and label("irrigation_pause") == "☔ irrigation pause" - everything = list(LABELS) - for section in ("sub", "unsub"): - drawn = rows(keyboard(set(LABELS) if section == "sub" else set(), everything, section)) - texts = [t for row in drawn[1:] for t, _ in row] - assert texts and all(not t[0].isascii() for t in texts), texts # every button under it starts with an emoji - assert title(set(), ["rain"]) == f"{TITLE}\n✅ On: rain\n🔕 Off: nothing" # the on/off summary and the toasts stay plain words +def test_the_title_stays_plain_words(): + assert title(set(), ["rain"]) == f"{TITLE}\n✅ On: rain\n🔕 Off: nothing" -async def test_settings_hold_enable_and_disable_sub_menus_of_notification_sounds_in_private_chats_only(tmp_path): +async def test_the_sound_menu_toggles_every_type_in_private_chats_only_and_back_returns(tmp_path): bot, state = make_bot(tmp_path) - q = Query("al:open:set") # /alerts: Settings opens the two sub-menus (▸ marks a menu) - await press(bot, q) - drawn = rows(q.edits[0][1]) - assert drawn[0][1] == ("⚙️ ▾", "al:close") and drawn[1:] == [[("🔔 Enable notification sounds ▸", "al:open:snd_on")]] - q = Query("al:open:snd_on") # only the types with the sound off, as buttons + state.set_kind(1, "gusts", False) + q = Query("al:open:sound") await press(bot, q) drawn = rows(q.edits[0][1]) - assert drawn[1] == [("🔔 Enable notification sounds ▾", "al:open:set")] and ("🌧️ rain", "al:snd:rain:on") in [b for r in drawn[2:] for b in r] + assert drawn[0] == [(strike("rain"), "al:snd:rain:on"), (strike("gusts"), "al:snd:gusts:on")] and drawn[-1] == [("⬅️ Back", "al:close")] + assert "Alerts" not in str(drawn) # the Alerts menu is hidden while Sound is open q = Query("al:snd:rain:on") await press(bot, q) - assert state.loud(1) == {"rain"} and "make a sound" in q.toast - drawn = rows(q.edits[0][1]) # stays in the list, rain gone from it; Disable appears - assert "al:snd:rain:on" not in [d for r in drawn for _, d in r] and ("🔕 Disable notification sounds ▸", "al:open:snd_off") in [b for r in drawn for b in r] - q = Query("al:open:snd_off") + assert state.loud(1) == {"rain"} and q.toast == "Rain alerts now make a sound" and rows(q.edits[0][1])[0][0] == ("rain", "al:snd:rain:off") + await press(bot, Query("al:snd:gusts:on")) # a type that is off can still have its sound set + assert state.loud(1) == {"rain", "gusts"} + q = Query("al:snd:rain:off") await press(bot, q) - assert [b for r in rows(q.edits[0][1]) for b in r if b[1].startswith("al:snd")] == [("🌧️ rain", "al:snd:rain:off")] - await press(bot, Query("al:snd:rain:off")) - assert state.loud(1) == set() - for data in ("al:snd:bogus:on", "al:snd:rain:maybe", "al:snd:rain"): # nothing from the data reaches the state + assert state.loud(1) == {"gusts"} and q.toast == "Rain alerts no longer make a sound" + for data in ("al:snd:bogus:on", "al:snd:rain:maybe", "al:snd:rain", "al:snd:all:on"): # nothing from the data reaches the state await press(bot, Query(data)) - assert state.loud(1) == set() + assert state.loud(1) == {"gusts"} + q = Query("al:close") + await press(bot, q) + assert rows(q.edits[0][1]) == [[("🔔 Alerts ▸", "al:open:alerts"), ("🔊 Sound ▸", "al:open:sound")]] q = Query("al:snd:rain:on", chat_type="supergroup", chat_id=-5) # groups have no sound settings state.add_chat(-5, "G") await press(bot, q) assert state.loud(-5) == set() and q.edits == [] + q = Query("al:open:sound", chat_type="supergroup", chat_id=-5) + await press(bot, q) + assert rows(q.edits[0][1]) == [[("🔔 Alerts ▸", "al:open:alerts")]] async def test_an_alert_has_a_sound_only_in_a_chat_that_turned_it_on_and_groups_get_no_settings_button(tmp_path): @@ -386,7 +360,7 @@ async def send_message(self, chat_id, text, **kw): state.add_chat(-7, "H") # a press under an old alert of a moved group bot2, _ = make_bot(tmp_path) bot2.state = state - q = Query("al:open:unsub", chat_type="supergroup", chat_id=-7) + q = Query("al:open:alerts", chat_type="supergroup", chat_id=-7) q.message.reply_markup = keyboard(set(), KINDS) q.message.chat.title = "H" @@ -397,38 +371,6 @@ async def moved(*a, **k): assert -1007 in state.chats and -7 not in state.chats and "upgraded" in q.toast -async def test_the_settings_list_only_the_subscribed_types(tmp_path): - bot, state = make_bot(tmp_path) - state.set_kind(1, "rain", False) - q = Query("al:open:set") - await press(bot, q) - q = Query("al:open:snd_on") - await press(bot, q) - listed = [d for r in rows(q.edits[0][1])[1:] for _, d in r] - assert "al:snd:rain:on" not in listed and "al:snd:uv:on" in listed - state.set_kind(1, ALL, False) - q = Query("al:open:set") - await press(bot, q) - assert q.toast == "No alerts are on" and q.edits == [] - - -async def test_the_sound_lists_have_an_all_button(tmp_path): - bot, state = make_bot(tmp_path) - state.set_kind(1, "gusts", False) - q = Query("al:open:snd_on") # /alerts: a flat list with "Enable all" last - await press(bot, q) - assert rows(q.edits[0][1])[-1] == [("🔔 Enable all", "al:snd:all:on")] - q = Query("al:snd:all:on") - await press(bot, q) - assert state.loud(1) == {k for k in KINDS if k != "gusts"} and q.toast == "All alerts now make a sound" # only the types it gets - assert ("🔕 Disable notification sounds ▸", "al:open:snd_off") in [b for r in rows(q.edits[0][1]) for b in r] - q = Query("al:open:snd_off") - await press(bot, q) - assert rows(q.edits[0][1])[-1] == [("🔕 Disable all", "al:snd:all:off")] - await press(bot, Query("al:snd:all:off")) - assert state.loud(1) == set() - - def test_every_alert_type_sent_is_registered_and_each_monitor_declares_what_it_sends(): import re from pathlib import Path diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index ebdc3eb..59b2bef 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -204,8 +204,8 @@ def test_the_alert_list_gets_irrigation_battery_only_when_there_is_a_controller( assert "irrigation_battery" not in available_kinds({"Ecowitt"}) and "irrigation_pause" not in available_kinds({"Ecowitt"}) from lib.alerts.menu import LABELS, keyboard assert LABELS["irrigation_battery"] == "irrigation battery" and LABELS["irrigation_pause"] == "irrigation pause" - unsub = rows(keyboard(set(), ["rain", "irrigation_battery"], "unsub")) - assert ("🪫 irrigation battery", "al:off:irrigation_battery:unsub") in [b for row in unsub for b in row] + listed = rows(keyboard(set(), ["rain", "irrigation_battery"], "alerts")) + assert ("irrigation battery", "al:off:irrigation_battery:alerts") in [b for row in listed for b in row] # ---------- the daily check ---------- @@ -711,9 +711,9 @@ async def send_message(self, chat_id, text, **kw): assert rows(markup) == [[("⚙️ ▸", "al:open:gear:irrigation_pause")]] from lib.alerts.menu import keyboard kinds = available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast", "Irrigation"}) - for section in (None, "sub", "unsub"): # every button's data fits Telegram's 64 bytes, for the new type too + for section in (None, "alerts", "sound", "gear"): # every button's data fits Telegram's 64 bytes, for the new type too for parent in (None, "irrigation_pause", "irrigation_battery"): - assert all(len(data.encode()) <= 64 for row in rows(keyboard(set(), kinds, section, parent)) for _, data in row) + assert all(len(data.encode()) <= 64 for row in rows(keyboard(set(), kinds, section, parent, set())) for _, data in row) async def test_refresh_reads_the_controller_again_and_redraws_without_sending_anything_or_waiting(tmp_path, monkeypatch): From 7598ed8f462ea163fc2ef1a915bcc58c2c8a7f9a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 11:44:54 +0000 Subject: [PATCH 10/19] /alerts Sound menu lists only the alerts that are on With every alert off there is no Sound button. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- lib/alerts/menu.py | 9 +++++---- tests/test_alert_menu.py | 16 +++++++++------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 37728d2..da7bafa 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A Telegram bot for a personal Ecowitt weather station and AirGradient air-quality sensor. Ask it about the weather or air quality (text or charts); it also sends silent alerts to every chat it is in: rain starting/stopping, gusts over 40 km/h (with the last day's wind chart), UV index of 10 or more in two readings in a row (with the last day's UV chart), indoor/outdoor temperatures crossing by more than 1°C (held back for the cooldown, then announced if it still stands, with the last day's temperature chart), -and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one ⚙️ button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). A toggle is struck through while it is off. `/alerts` has two buttons, 🔔 Alerts and (in a private chat) 🔊 Sound; each opens the full list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes) as toggles, struck through while off, with a ⬅️ Back button, and while one is open the other is hidden until you go back; `/alerts on|off` still subscribes or unsubscribes every type. `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). +and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one ⚙️ button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). A toggle is struck through while it is off. `/alerts` has two buttons, 🔔 Alerts and (in a private chat) 🔊 Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, struck through while off, with a ⬅️ Back button, and while one is open the other is hidden until you go back; `/alerts on|off` still subscribes or unsubscribes every type. `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). ## Run diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index 466d9b7..1128be2 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -78,20 +78,21 @@ def _list(kinds: list[str], is_on, data) -> list[list]: def keyboard(muted: set[str], available: list[str], open: str | None = None, parent: str | None = None, loud: set[str] | None = None) -> InlineKeyboardMarkup: """The /alerts menu: two buttons, 🔔 Alerts and (private chats) 🔊 Sound. Each opens a menu with the full list of alert types, each a - toggle struck through while it is off (alerts on or off in this chat; notification sound on or off), and ⬅️ Back. While one is open + toggle struck through while it is off (alerts on or off in this chat; notification sound on or off, for the types that are on), and ⬅️ Back. While one is open the other is hidden, its button included, until Back. `open`: alerts, sound (or one of an earlier version's sections). `loud`: the types whose alerts make a notification sound; None (a group) has no Sound. `parent`: the type of the alert these buttons sit under, which has a menu of its own (alert_keyboard).""" if parent: return alert_keyboard(parent, muted, loud, open) open = {"sub": "alerts", "unsub": "alerts", "other": "alerts", **dict.fromkeys(SETTINGS, "sound")}.get(open, open) + sound_types = [k for k in available if k not in muted] if loud is not None else [] # (a type that is off has no sound to set) if open == "alerts": rows = _list(available, lambda k: k not in muted, lambda k: f"al:{'off' if k not in muted else 'on'}:{k}:alerts") - elif open == "sound" and loud is not None: - rows = _list(available, lambda k: k in loud, lambda k: f"al:snd:{k}:{'off' if k in loud else 'on'}") + elif open == "sound" and sound_types: + rows = _list(sound_types, lambda k: k in loud, lambda k: f"al:snd:{k}:{'off' if k in loud else 'on'}") else: return InlineKeyboardMarkup([[_button(menu_text("🔔 Alerts", False), "al:open:alerts"), - *([_button(menu_text("🔊 Sound", False), "al:open:sound")] if loud is not None else [])]]) + *([_button(menu_text("🔊 Sound", False), "al:open:sound")] if sound_types else [])]]) return InlineKeyboardMarkup([*rows, [_button("⬅️ Back", "al:close")]]) diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 9432028..ba1aac0 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -32,8 +32,9 @@ def test_each_menu_lists_every_type_struck_through_when_off_with_back_and_the_ot assert got[0] == [("rain", "al:off:rain:alerts"), (strike("gusts"), "al:on:gusts:alerts")] and got[-1] == [("⬅️ Back", "al:close")] assert "Sound" not in str(got) and "Alerts" not in str(got) # the other menu, and its button, are hidden got = rows(keyboard({"gusts"}, KINDS, "sound", loud={"rain"})) - assert got[0] == [("rain", "al:snd:rain:off"), (strike("gusts"), "al:snd:gusts:on")] # (a type that is off can still have a sound set) - assert [t for row in got[1:-1] for t, _ in row] == [strike(w) for w in ("UV", "temperature crossing", "particulates", "pollen & asthma", "forecast changes")] + assert got[0] == [("rain", "al:snd:rain:off"), (strike("UV"), "al:snd:uv:on")] # gusts is off, so it is not listed + assert [t for row in got[1:-1] for t, _ in row] == [strike(w) for w in ("temperature crossing", "particulates", "pollen & asthma", "forecast changes")] + assert rows(keyboard(set(KINDS), KINDS, "sound", loud=set())) == [[("🔔 Alerts ▸", "al:open:alerts")]] # nothing is on: no Sound button either assert got[-1] == [("⬅️ Back", "al:close")] and "Alerts" not in str(got) for old, new in (("sub", "alerts"), ("unsub", "alerts"), ("other", "alerts"), ("set", "sound"), ("snd_on", "sound"), ("snd_off_o", "sound")): assert rows(keyboard({"gusts"}, KINDS, old, loud=set())) == rows(keyboard({"gusts"}, KINDS, new, loud=set())), old # an earlier version's sections @@ -304,19 +305,20 @@ async def test_the_sound_menu_toggles_every_type_in_private_chats_only_and_back_ q = Query("al:open:sound") await press(bot, q) drawn = rows(q.edits[0][1]) - assert drawn[0] == [(strike("rain"), "al:snd:rain:on"), (strike("gusts"), "al:snd:gusts:on")] and drawn[-1] == [("⬅️ Back", "al:close")] + assert drawn[0] == [(strike("rain"), "al:snd:rain:on"), (strike("UV"), "al:snd:uv:on")] and drawn[-1] == [("⬅️ Back", "al:close")] + assert "gusts" not in str(drawn) # an alert that is off has no sound to set assert "Alerts" not in str(drawn) # the Alerts menu is hidden while Sound is open q = Query("al:snd:rain:on") await press(bot, q) assert state.loud(1) == {"rain"} and q.toast == "Rain alerts now make a sound" and rows(q.edits[0][1])[0][0] == ("rain", "al:snd:rain:off") - await press(bot, Query("al:snd:gusts:on")) # a type that is off can still have its sound set - assert state.loud(1) == {"rain", "gusts"} + await press(bot, Query("al:snd:uv:on")) + assert state.loud(1) == {"rain", "uv"} q = Query("al:snd:rain:off") await press(bot, q) - assert state.loud(1) == {"gusts"} and q.toast == "Rain alerts no longer make a sound" + assert state.loud(1) == {"uv"} and q.toast == "Rain alerts no longer make a sound" for data in ("al:snd:bogus:on", "al:snd:rain:maybe", "al:snd:rain", "al:snd:all:on"): # nothing from the data reaches the state await press(bot, Query(data)) - assert state.loud(1) == {"gusts"} + assert state.loud(1) == {"uv"} q = Query("al:close") await press(bot, q) assert rows(q.edits[0][1]) == [[("🔔 Alerts ▸", "al:open:alerts"), ("🔊 Sound ▸", "al:open:sound")]] From faada1c55bf97127f46b538c35a769bc77582cbd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 11:51:05 +0000 Subject: [PATCH 11/19] /alerts menus: a tick or a cross before each type, and the opening button stays Each list shows its own button (open) above it, with the other hidden; a green tick marks what is on, a red cross what is off. An alert that is off is not in the Sound list at all. The individual alert's gear menu keeps its strikethrough toggles. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- lib/alerts/menu.py | 21 ++++++++++++++------- tests/test_alert_menu.py | 29 ++++++++++++++++------------- tests/test_irrigation.py | 2 +- 4 files changed, 32 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index da7bafa..f69d486 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A Telegram bot for a personal Ecowitt weather station and AirGradient air-quality sensor. Ask it about the weather or air quality (text or charts); it also sends silent alerts to every chat it is in: rain starting/stopping, gusts over 40 km/h (with the last day's wind chart), UV index of 10 or more in two readings in a row (with the last day's UV chart), indoor/outdoor temperatures crossing by more than 1°C (held back for the cooldown, then announced if it still stands, with the last day's temperature chart), -and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one ⚙️ button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). A toggle is struck through while it is off. `/alerts` has two buttons, 🔔 Alerts and (in a private chat) 🔊 Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, struck through while off, with a ⬅️ Back button, and while one is open the other is hidden until you go back; `/alerts on|off` still subscribes or unsubscribes every type. `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). +and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one ⚙️ button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). A toggle is struck through while it is off. `/alerts` has two buttons, 🔔 Alerts and (in a private chat) 🔊 Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, a ✅ before those that are on and a ❌ before those that are off, with a ⬅️ Back button; while one is open its own button stays and the other is hidden until you go back; `/alerts on|off` still subscribes or unsubscribes every type. `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). ## Run diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index 1128be2..46b2393 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -53,6 +53,11 @@ def _toggle(word: str, on: bool, data: str) -> InlineKeyboardButton: return _button(word if on else struck(word), data) +def _mark(word: str, on: bool, data: str) -> InlineKeyboardButton: + """A button that flips a setting in a list: its word after a green tick while the setting is on, a red cross while it is off.""" + return _button(f"{'✅' if on else '❌'} {word}", data) + + def alert_keyboard(kind: str, muted: set[str], loud: set[str] | None, open: str | None = None) -> InlineKeyboardMarkup: """The buttons under an alert of type `kind`: one gear (⚙️ ▸, ▾ once open) that opens up to two toggles, Alert (this type's alerts in this chat) and, only while that is on and in a private chat (`loud` is not None), Sound (whether they make a notification sound). @@ -70,16 +75,16 @@ def alert_keyboard(kind: str, muted: set[str], loud: set[str] | None, open: str def _list(kinds: list[str], is_on, data) -> list[list]: - """A toggle for each of the alert types, PER_ROW to a row, a type struck through while it is off. is_on(kind): its state; + """A toggle for each of the alert types, PER_ROW to a row, a green tick before a type that is on, a red cross before one that is off. is_on(kind): its state; data(kind): the callback data that flips it.""" - return list(map(list, batched((_toggle(LABELS[k], is_on(k), data(k)) for k in kinds), PER_ROW))) + return list(map(list, batched((_mark(LABELS[k], is_on(k), data(k)) for k in kinds), PER_ROW))) def keyboard(muted: set[str], available: list[str], open: str | None = None, parent: str | None = None, loud: set[str] | None = None) -> InlineKeyboardMarkup: - """The /alerts menu: two buttons, 🔔 Alerts and (private chats) 🔊 Sound. Each opens a menu with the full list of alert types, each a - toggle struck through while it is off (alerts on or off in this chat; notification sound on or off, for the types that are on), and ⬅️ Back. While one is open - the other is hidden, its button included, until Back. `open`: alerts, sound (or one of an earlier version's sections). `loud`: the + """The /alerts menu: two buttons, 🔔 Alerts and (private chats) 🔊 Sound. Each opens a menu with a list of alert types, each a toggle + with a ✅ before it while on and a ❌ while off (alerts on or off in this chat; notification sound on or off, for the types that are + on), and ⬅️ Back. While one is open its own button stays (▾) above the list and the other is hidden until Back. `open`: alerts, sound (or one of an earlier version's sections). `loud`: the types whose alerts make a notification sound; None (a group) has no Sound. `parent`: the type of the alert these buttons sit under, which has a menu of its own (alert_keyboard).""" if parent: @@ -87,9 +92,11 @@ def keyboard(muted: set[str], available: list[str], open: str | None = None, par open = {"sub": "alerts", "unsub": "alerts", "other": "alerts", **dict.fromkeys(SETTINGS, "sound")}.get(open, open) sound_types = [k for k in available if k not in muted] if loud is not None else [] # (a type that is off has no sound to set) if open == "alerts": - rows = _list(available, lambda k: k not in muted, lambda k: f"al:{'off' if k not in muted else 'on'}:{k}:alerts") + rows = [[_button(menu_text("🔔 Alerts", True), "al:close")], + *_list(available, lambda k: k not in muted, lambda k: f"al:{'off' if k not in muted else 'on'}:{k}:alerts")] elif open == "sound" and sound_types: - rows = _list(sound_types, lambda k: k in loud, lambda k: f"al:snd:{k}:{'off' if k in loud else 'on'}") + rows = [[_button(menu_text("🔊 Sound", True), "al:close")], + *_list(sound_types, lambda k: k in loud, lambda k: f"al:snd:{k}:{'off' if k in loud else 'on'}")] else: return InlineKeyboardMarkup([[_button(menu_text("🔔 Alerts", False), "al:open:alerts"), *([_button(menu_text("🔊 Sound", False), "al:open:sound")] if sound_types else [])]]) diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index ba1aac0..0de8900 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -25,15 +25,18 @@ def test_alerts_has_two_buttons_alerts_and_sound_and_a_group_has_only_the_first( assert rows(keyboard(set(), KINDS, "sound")) == rows(keyboard(set(), KINDS)) # a group cannot open Sound -def test_each_menu_lists_every_type_struck_through_when_off_with_back_and_the_other_menu_is_hidden(): +def test_each_menu_lists_its_types_with_a_tick_or_a_cross_and_keeps_only_its_own_button(): got = rows(keyboard({"gusts"}, KINDS, "alerts", loud={"rain"})) - assert [t for row in got[:-1] for t, _ in row] == ["rain", strike("gusts"), "UV", "temperature crossing", "particulates", - "pollen & asthma", "forecast changes"] # no emojis - assert got[0] == [("rain", "al:off:rain:alerts"), (strike("gusts"), "al:on:gusts:alerts")] and got[-1] == [("⬅️ Back", "al:close")] - assert "Sound" not in str(got) and "Alerts" not in str(got) # the other menu, and its button, are hidden + assert got[0] == [("🔔 Alerts ▾", "al:close")] # the button that opened it stays, open + assert [t for row in got[1:-1] for t, _ in row] == ["✅ rain", "❌ gusts", "✅ UV", "✅ temperature crossing", "✅ particulates", + "✅ pollen & asthma", "✅ forecast changes"] + assert got[1] == [("✅ rain", "al:off:rain:alerts"), ("❌ gusts", "al:on:gusts:alerts")] and got[-1] == [("⬅️ Back", "al:close")] + assert "Sound" not in str(got) # the other menu's button is hidden got = rows(keyboard({"gusts"}, KINDS, "sound", loud={"rain"})) - assert got[0] == [("rain", "al:snd:rain:off"), (strike("UV"), "al:snd:uv:on")] # gusts is off, so it is not listed - assert [t for row in got[1:-1] for t, _ in row] == [strike(w) for w in ("temperature crossing", "particulates", "pollen & asthma", "forecast changes")] + assert got[0] == [("🔊 Sound ▾", "al:close")] and "Alerts" not in str(got) + assert got[1] == [("✅ rain", "al:snd:rain:off"), ("❌ UV", "al:snd:uv:on")] # gusts is off, so it is not listed at all + assert [t for row in got[2:-1] for t, _ in row] == ["❌ " + w for w in ("temperature crossing", "particulates", "pollen & asthma", "forecast changes")] + assert got[-1] == [("⬅️ Back", "al:close")] and "gusts" not in str(got) assert rows(keyboard(set(KINDS), KINDS, "sound", loud=set())) == [[("🔔 Alerts ▸", "al:open:alerts")]] # nothing is on: no Sound button either assert got[-1] == [("⬅️ Back", "al:close")] and "Alerts" not in str(got) for old, new in (("sub", "alerts"), ("unsub", "alerts"), ("other", "alerts"), ("set", "sound"), ("snd_on", "sound"), ("snd_off_o", "sound")): @@ -139,14 +142,14 @@ async def test_the_alerts_menu_toggles_a_type_in_place_and_back_returns_to_the_t q = Query("al:open:alerts", text=f"{TITLE}\n✅ On: ...") await press(bot, q) drawn = rows(q.edits[0][2]) - assert q.toast is None and drawn[0][0] == ("rain", "al:off:rain:alerts") and drawn[-1] == [("⬅️ Back", "al:close")] + assert q.toast is None and drawn[0] == [("🔔 Alerts ▾", "al:close")] and drawn[1][0] == ("✅ rain", "al:off:rain:alerts") and drawn[-1] == [("⬅️ Back", "al:close")] q = Query("al:off:rain:alerts", text=f"{TITLE}\n✅ On: ...") await press(bot, q) assert state.muted(1) == {"rain"} and q.toast == "Rain alerts off in this chat" and "🔕 Off: rain" in q.edits[0][1] - assert rows(q.edits[0][2])[0][0] == (strike("rain"), "al:on:rain:alerts") # stays open, now struck through + assert rows(q.edits[0][2])[1][0] == ("❌ rain", "al:on:rain:alerts") # stays open, now with a cross q = Query("al:on:rain:alerts", text=f"{TITLE}\n✅ On: ...") await press(bot, q) - assert state.muted(1) == set() and q.toast == "Rain alerts on in this chat" and rows(q.edits[0][2])[0][0] == ("rain", "al:off:rain:alerts") + assert state.muted(1) == set() and q.toast == "Rain alerts on in this chat" and rows(q.edits[0][2])[1][0] == ("✅ rain", "al:off:rain:alerts") q = Query("al:close", text=f"{TITLE}\n✅ On: ...") await press(bot, q) assert rows(q.edits[0][2]) == [[("🔔 Alerts ▸", "al:open:alerts"), ("🔊 Sound ▸", "al:open:sound")]] @@ -292,7 +295,7 @@ async def test_an_unknown_parent_in_a_button_is_ignored(tmp_path): q = Query("al:open:alerts:bogus", text="☀️ UV 10") await press(bot, q) drawn = rows(q.edits[0][1]) - assert drawn[0][0] == ("rain", "al:off:rain:alerts") and drawn[-1] == [("⬅️ Back", "al:close")] # the /alerts menu, not carried on + assert drawn[1][0] == ("✅ rain", "al:off:rain:alerts") and drawn[-1] == [("⬅️ Back", "al:close")] # the /alerts menu, not carried on def test_the_title_stays_plain_words(): @@ -305,12 +308,12 @@ async def test_the_sound_menu_toggles_every_type_in_private_chats_only_and_back_ q = Query("al:open:sound") await press(bot, q) drawn = rows(q.edits[0][1]) - assert drawn[0] == [(strike("rain"), "al:snd:rain:on"), (strike("UV"), "al:snd:uv:on")] and drawn[-1] == [("⬅️ Back", "al:close")] + assert drawn[0] == [("🔊 Sound ▾", "al:close")] and drawn[1] == [("❌ rain", "al:snd:rain:on"), ("❌ UV", "al:snd:uv:on")] and drawn[-1] == [("⬅️ Back", "al:close")] assert "gusts" not in str(drawn) # an alert that is off has no sound to set assert "Alerts" not in str(drawn) # the Alerts menu is hidden while Sound is open q = Query("al:snd:rain:on") await press(bot, q) - assert state.loud(1) == {"rain"} and q.toast == "Rain alerts now make a sound" and rows(q.edits[0][1])[0][0] == ("rain", "al:snd:rain:off") + assert state.loud(1) == {"rain"} and q.toast == "Rain alerts now make a sound" and rows(q.edits[0][1])[1][0] == ("✅ rain", "al:snd:rain:off") await press(bot, Query("al:snd:uv:on")) assert state.loud(1) == {"rain", "uv"} q = Query("al:snd:rain:off") diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index 59b2bef..a662def 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -205,7 +205,7 @@ def test_the_alert_list_gets_irrigation_battery_only_when_there_is_a_controller( from lib.alerts.menu import LABELS, keyboard assert LABELS["irrigation_battery"] == "irrigation battery" and LABELS["irrigation_pause"] == "irrigation pause" listed = rows(keyboard(set(), ["rain", "irrigation_battery"], "alerts")) - assert ("irrigation battery", "al:off:irrigation_battery:alerts") in [b for row in listed for b in row] + assert ("✅ irrigation battery", "al:off:irrigation_battery:alerts") in [b for row in listed for b in row] # ---------- the daily check ---------- From a895f43ecad145fc1e67f196c1f1fb2cde72cd3c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 11:53:49 +0000 Subject: [PATCH 12/19] /alerts lists: a type that is on is a green button, one that is off a red one The tick and the cross stay for a client that doesn't draw button colours. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- lib/alerts/menu.py | 10 ++++++---- tests/test_alert_menu.py | 7 +++++++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f69d486..5aae77b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A Telegram bot for a personal Ecowitt weather station and AirGradient air-quality sensor. Ask it about the weather or air quality (text or charts); it also sends silent alerts to every chat it is in: rain starting/stopping, gusts over 40 km/h (with the last day's wind chart), UV index of 10 or more in two readings in a row (with the last day's UV chart), indoor/outdoor temperatures crossing by more than 1°C (held back for the cooldown, then announced if it still stands, with the last day's temperature chart), -and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one ⚙️ button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). A toggle is struck through while it is off. `/alerts` has two buttons, 🔔 Alerts and (in a private chat) 🔊 Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, a ✅ before those that are on and a ❌ before those that are off, with a ⬅️ Back button; while one is open its own button stays and the other is hidden until you go back; `/alerts on|off` still subscribes or unsubscribes every type. `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). +and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one ⚙️ button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). A toggle is struck through while it is off. `/alerts` has two buttons, 🔔 Alerts and (in a private chat) 🔊 Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, a ✅ before those that are on and a ❌ before those that are off (on a green and a red button where the Telegram app draws button colours), with a ⬅️ Back button; while one is open its own button stays and the other is hidden until you go back; `/alerts on|off` still subscribes or unsubscribes every type. `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). ## Run diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index 46b2393..05cd7f1 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -13,6 +13,7 @@ from itertools import batched from telegram import InlineKeyboardButton, InlineKeyboardMarkup +from telegram.constants import KeyboardButtonStyle from ..buttons import menu_text @@ -39,8 +40,8 @@ def kinds_for(source_names, irrigation: bool) -> list[str]: return available_kinds(set(source_names) | ({"Irrigation"} if irrigation else set())) -def _button(text: str, data: str) -> InlineKeyboardButton: - return InlineKeyboardButton(text, callback_data=data) +def _button(text: str, data: str, style: str | None = None) -> InlineKeyboardButton: + return InlineKeyboardButton(text, callback_data=data, style=style) def struck(text: str) -> str: @@ -54,8 +55,9 @@ def _toggle(word: str, on: bool, data: str) -> InlineKeyboardButton: def _mark(word: str, on: bool, data: str) -> InlineKeyboardButton: - """A button that flips a setting in a list: its word after a green tick while the setting is on, a red cross while it is off.""" - return _button(f"{'✅' if on else '❌'} {word}", data) + """A button that flips a setting in a list: its word after a green tick on a green button while the setting is on, a red cross on a + red one while it is off (the tick and cross stay for a client that does not draw button colours).""" + return _button(f"{'✅' if on else '❌'} {word}", data, KeyboardButtonStyle.SUCCESS if on else KeyboardButtonStyle.DANGER) def alert_keyboard(kind: str, muted: set[str], loud: set[str] | None, open: str | None = None) -> InlineKeyboardMarkup: diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 0de8900..d01e36f 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -25,6 +25,13 @@ def test_alerts_has_two_buttons_alerts_and_sound_and_a_group_has_only_the_first( assert rows(keyboard(set(), KINDS, "sound")) == rows(keyboard(set(), KINDS)) # a group cannot open Sound +def test_a_type_that_is_on_is_a_green_button_and_one_that_is_off_a_red_one(): + markup = keyboard({"gusts"}, KINDS, "alerts", loud=set()) + styles = {b.text: b.api_kwargs.get("style") or b.style for row in markup.inline_keyboard for b in row} + assert styles["✅ rain"] == "success" and styles["❌ gusts"] == "danger" and not styles["🔔 Alerts ▾"] and not styles["⬅️ Back"] + assert markup.inline_keyboard[1][0].to_dict()["style"] == "success" # and it is sent to Telegram + + def test_each_menu_lists_its_types_with_a_tick_or_a_cross_and_keeps_only_its_own_button(): got = rows(keyboard({"gusts"}, KINDS, "alerts", loud={"rain"})) assert got[0] == [("🔔 Alerts ▾", "al:close")] # the button that opened it stays, open From 66f1ea1e397fa1017a7b4dce413c0f5491c8803b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 12:02:07 +0000 Subject: [PATCH 13/19] Every toggle button is a green tick or a red cross MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The individual alert's Alert and Sound toggles and the irrigation Alerts menu's battery and pause toggles now show their state as /alerts does (a shared toggle_button): ✅ on a green button while on, ❌ on a red one while off. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- lib/alerts/menu.py | 33 ++++++++------------------------- lib/buttons.py | 10 ++++++++++ lib/irrigation/menu.py | 8 ++++---- tests/test_alert_menu.py | 28 +++++++++++++++------------- tests/test_irrigation.py | 18 +++++++++--------- 6 files changed, 47 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 5aae77b..cd17857 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A Telegram bot for a personal Ecowitt weather station and AirGradient air-quality sensor. Ask it about the weather or air quality (text or charts); it also sends silent alerts to every chat it is in: rain starting/stopping, gusts over 40 km/h (with the last day's wind chart), UV index of 10 or more in two readings in a row (with the last day's UV chart), indoor/outdoor temperatures crossing by more than 1°C (held back for the cooldown, then announced if it still stands, with the last day's temperature chart), -and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one ⚙️ button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). A toggle is struck through while it is off. `/alerts` has two buttons, 🔔 Alerts and (in a private chat) 🔊 Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, a ✅ before those that are on and a ❌ before those that are off (on a green and a red button where the Telegram app draws button colours), with a ⬅️ Back button; while one is open its own button stays and the other is hidden until you go back; `/alerts on|off` still subscribes or unsubscribes every type. `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). +and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one ⚙️ button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). Every toggle in the bot is a ✅ green button while on and a ❌ red one while off. `/alerts` has two buttons, 🔔 Alerts and (in a private chat) 🔊 Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, a ✅ before those that are on and a ❌ before those that are off (on a green and a red button where the Telegram app draws button colours), with a ⬅️ Back button; while one is open its own button stays and the other is hidden until you go back; `/alerts on|off` still subscribes or unsubscribes every type. `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). ## Run diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index 05cd7f1..9c4013f 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -1,5 +1,5 @@ """The alert settings as buttons. /alerts has two: 🔔 Alerts and (private chats) 🔊 Sound, each opening the full list of alert types as -toggles (struck through while off) with a Back button, the other hidden meanwhile. Under an alert there is one gear that opens that type's +toggles (a green ✅ while on, a red ❌ while off) with a Back button, the other hidden meanwhile. Under an alert there is one gear that opens that type's two toggles, Alert and Sound. Pure: it only builds the keyboard and the title from what a chat has muted. Callback data (self-contained and short, so an old message's buttons still work after a restart): @@ -13,9 +13,8 @@ from itertools import batched from telegram import InlineKeyboardButton, InlineKeyboardMarkup -from telegram.constants import KeyboardButtonStyle -from ..buttons import menu_text +from ..buttons import menu_text, toggle_button # kind -> the name on the button, in the order listed LABELS = {"rain": "rain", "gusts": "gusts", "uv": "UV", "temps": "temperature crossing", @@ -40,46 +39,30 @@ def kinds_for(source_names, irrigation: bool) -> list[str]: return available_kinds(set(source_names) | ({"Irrigation"} if irrigation else set())) -def _button(text: str, data: str, style: str | None = None) -> InlineKeyboardButton: - return InlineKeyboardButton(text, callback_data=data, style=style) - - -def struck(text: str) -> str: - """The text with a line through it (a button cannot be styled, so each letter gets the combining long stroke).""" - return "".join(f"{c}\u0336" for c in text) - - -def _toggle(word: str, on: bool, data: str) -> InlineKeyboardButton: - """A button that flips a setting: just its word, struck through while the setting is off.""" - return _button(word if on else struck(word), data) - - -def _mark(word: str, on: bool, data: str) -> InlineKeyboardButton: - """A button that flips a setting in a list: its word after a green tick on a green button while the setting is on, a red cross on a - red one while it is off (the tick and cross stay for a client that does not draw button colours).""" - return _button(f"{'✅' if on else '❌'} {word}", data, KeyboardButtonStyle.SUCCESS if on else KeyboardButtonStyle.DANGER) +def _button(text: str, data: str) -> InlineKeyboardButton: + return InlineKeyboardButton(text, callback_data=data) def alert_keyboard(kind: str, muted: set[str], loud: set[str] | None, open: str | None = None) -> InlineKeyboardMarkup: """The buttons under an alert of type `kind`: one gear (⚙️ ▸, ▾ once open) that opens up to two toggles, Alert (this type's alerts in this chat) and, only while that is on and in a private chat (`loud` is not None), Sound (whether they make a notification sound). - A toggle is struck through while it is off. Every button carries the type: al:open:gear:, al:close:, + A toggle is a ✅ green button while on, a ❌ red one while off. Every button carries the type: al:open:gear:, al:close:, al:on|off::gear:, al:snd::on|off:.""" is_open = open == "gear" rows = [[_button(menu_text("⚙️", is_open), f"al:close:{kind}" if is_open else f"al:open:gear:{kind}")]] if is_open: on = kind not in muted - rows.append([_toggle("Alert", on, f"al:{'off' if on else 'on'}:{kind}:gear:{kind}")]) + rows.append([toggle_button("Alert", on, f"al:{'off' if on else 'on'}:{kind}:gear:{kind}")]) if on and loud is not None: sound = kind in loud - rows.append([_toggle("Sound", sound, f"al:snd:{kind}:{'off' if sound else 'on'}:{kind}")]) + rows.append([toggle_button("Sound", sound, f"al:snd:{kind}:{'off' if sound else 'on'}:{kind}")]) return InlineKeyboardMarkup(rows) def _list(kinds: list[str], is_on, data) -> list[list]: """A toggle for each of the alert types, PER_ROW to a row, a green tick before a type that is on, a red cross before one that is off. is_on(kind): its state; data(kind): the callback data that flips it.""" - return list(map(list, batched((_mark(LABELS[k], is_on(k), data(k)) for k in kinds), PER_ROW))) + return list(map(list, batched((toggle_button(LABELS[k], is_on(k), data(k)) for k in kinds), PER_ROW))) def keyboard(muted: set[str], available: list[str], open: str | None = None, parent: str | None = None, diff --git a/lib/buttons.py b/lib/buttons.py index af7135e..ee21331 100644 --- a/lib/buttons.py +++ b/lib/buttons.py @@ -1,7 +1,17 @@ """Shared look of the bot's inline buttons.""" +from telegram import InlineKeyboardButton +from telegram.constants import KeyboardButtonStyle + def menu_text(text: str, is_open: bool) -> str: """The label of a button that opens a menu: ▸ after the text while it is closed, ▾ in the same place once it is open (so pressing it looks like the arrow turning). Buttons that do something have neither.""" return f"{text} {'▾' if is_open else '▸'}" + + +def toggle_button(word: str, on: bool, data: str) -> InlineKeyboardButton: + """A button that flips a setting: its word after a ✅ on a green button while the setting is on, after a ❌ on a red one while it is + off (the tick and cross stay for a client that does not draw button colours).""" + return InlineKeyboardButton(f"{'✅' if on else '❌'} {word}", callback_data=data, + style=KeyboardButtonStyle.SUCCESS if on else KeyboardButtonStyle.DANGER) diff --git a/lib/irrigation/menu.py b/lib/irrigation/menu.py index 9cdb17d..06a6654 100644 --- a/lib/irrigation/menu.py +++ b/lib/irrigation/menu.py @@ -3,7 +3,7 @@ Collapsed: [💧 Water] [⏸ Pause] / [🔔 Alerts] [🔄 Refresh]. While the valve is on, a [Water off ❌] row follows. While the timer is paused, Pause is [▶️ Unpause] (one press ends the pause) and Water is hidden. A press on Water, Pause or Alerts opens its options under the rows (▸ after the text marks a menu, ▾ the open one; pressing it again closes them). Water lists 5, 10, 20 and 30 minutes (a run is always timed). Alerts has -one button per alert type, each naming what pressing it does for this chat (enable or disable). +one toggle per alert type, ✅ green while this chat gets it, ❌ red while it does not; pressing it flips it. Callback data (short, self-contained, so an old message's buttons still work after a restart): ir:open: | ir:close | ir:refresh (read the state again, change nothing) @@ -16,7 +16,7 @@ from telegram import InlineKeyboardButton, InlineKeyboardMarkup, MessageEntity -from ..buttons import menu_text +from ..buttons import menu_text, toggle_button TITLE = "🌱 Irrigation" WATER_MINUTES = (5, 10, 20, 30) @@ -89,7 +89,7 @@ def status_text(status: dict, alerts: bool | None = None, pause_alerts: bool | N def keyboard(battery: bool, pause: bool, open: str | None = None, paused: bool = False, running: bool = False) -> InlineKeyboardMarkup: """The buttons under the status. `battery` and `pause`: this chat gets the irrigation battery / pause alerts; each Alerts button - names what pressing it does (disable when it is on, enable when it is off). `paused`: the timer is paused, so its + is a toggle (✅ when on, ❌ when off). `paused`: the timer is paused, so its button unpauses and Water is hidden. `running`: the valve is on, so Water off is a top-level row.""" def head(name: str, text: str) -> InlineKeyboardButton: return _button(menu_text(text, open == name), "ir:close" if open == name else f"ir:open:{name}") @@ -104,5 +104,5 @@ def head(name: str, text: str) -> InlineKeyboardButton: rows.append([_button(d, f"ir:delay:{d}") for d in DELAYS if d != "cancel"]) elif open == "alerts": for on, kind, name in ((battery, "batt", "battery"), (pause, "pause", "pause")): - rows.append([_button(f"🔕 Disable {name} alerts" if on else f"🔔 Enable {name} alerts", f"ir:{kind}:{'off' if on else 'on'}")]) + rows.append([toggle_button(f"{name} alerts", on, f"ir:{kind}:{'off' if on else 'on'}")]) return InlineKeyboardMarkup(rows) diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index d01e36f..9da299e 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -15,16 +15,18 @@ def rows(markup): return [[(b.text, b.callback_data) for b in row] for row in markup.inline_keyboard] -def strike(word): - return "".join(c + "\u0336" for c in word) - - def test_alerts_has_two_buttons_alerts_and_sound_and_a_group_has_only_the_first(): assert rows(keyboard({"rain"}, KINDS, loud=set())) == [[("🔔 Alerts ▸", "al:open:alerts"), ("🔊 Sound ▸", "al:open:sound")]] assert rows(keyboard(set(), KINDS)) == [[("🔔 Alerts ▸", "al:open:alerts")]] assert rows(keyboard(set(), KINDS, "sound")) == rows(keyboard(set(), KINDS)) # a group cannot open Sound +def test_the_gear_toggles_are_green_when_on_and_red_when_off(): + styles = lambda m: {b.text: b.style for row in m.inline_keyboard for b in row} + assert styles(keyboard(set(), KINDS, "gear", parent="uv", loud=set())) == {"⚙️ ▾": None, "✅ Alert": "success", "❌ Sound": "danger"} + assert styles(keyboard({"uv"}, KINDS, "gear", parent="uv", loud={"uv"})) == {"⚙️ ▾": None, "❌ Alert": "danger"} + + def test_a_type_that_is_on_is_a_green_button_and_one_that_is_off_a_red_one(): markup = keyboard({"gusts"}, KINDS, "alerts", loud=set()) styles = {b.text: b.api_kwargs.get("style") or b.style for row in markup.inline_keyboard for b in row} @@ -263,17 +265,17 @@ async def spy(chat_id, text, link=None, kind=None): assert seen == ["forecast"] -def test_under_an_alert_one_gear_opens_the_alert_toggle_and_then_the_sound_toggle_struck_through_when_off(): +def test_under_an_alert_one_gear_opens_the_alert_toggle_and_then_the_sound_toggle_each_a_tick_or_a_cross(): shut = rows(keyboard(set(), KINDS, None, parent="uv", loud=set())) assert shut == [[("⚙️ ▸", "al:open:gear:uv")]] on = rows(keyboard(set(), KINDS, "gear", parent="uv", loud=set())) # both on... the sound is off - assert on == [[("⚙️ ▾", "al:close:uv")], [("Alert", "al:off:uv:gear:uv")], [(strike("Sound"), "al:snd:uv:on:uv")]] + assert on == [[("⚙️ ▾", "al:close:uv")], [("✅ Alert", "al:off:uv:gear:uv")], [("❌ Sound", "al:snd:uv:on:uv")]] both = rows(keyboard(set(), KINDS, "gear", parent="uv", loud={"uv"})) - assert both[2] == [("Sound", "al:snd:uv:off:uv")] + assert both[2] == [("✅ Sound", "al:snd:uv:off:uv")] off = rows(keyboard({"uv"}, KINDS, "gear", parent="uv", loud={"uv"})) # alert off: struck through, and no sound toggle - assert off == [[("⚙️ ▾", "al:close:uv")], [(strike("Alert"), "al:on:uv:gear:uv")]] + assert off == [[("⚙️ ▾", "al:close:uv")], [("❌ Alert", "al:on:uv:gear:uv")]] group = rows(keyboard(set(), KINDS, "gear", parent="uv", loud=None)) # a group has no sound - assert group == [[("⚙️ ▾", "al:close:uv")], [("Alert", "al:off:uv:gear:uv")]] + assert group == [[("⚙️ ▾", "al:close:uv")], [("✅ Alert", "al:off:uv:gear:uv")]] assert rows(keyboard(set(), KINDS, "unsub", parent="uv", loud=set())) == shut # (an earlier version's list: just the gear) @@ -281,17 +283,17 @@ async def test_the_gear_under_an_alert_toggles_the_alert_and_its_sound_and_stays bot, state = make_bot(tmp_path) q = Query("al:open:gear:uv", text="☀️ UV 10") await press(bot, q) - assert rows(q.edits[0][1]) == [[("⚙️ ▾", "al:close:uv")], [("Alert", "al:off:uv:gear:uv")], [(strike("Sound"), "al:snd:uv:on:uv")]] + assert rows(q.edits[0][1]) == [[("⚙️ ▾", "al:close:uv")], [("✅ Alert", "al:off:uv:gear:uv")], [("❌ Sound", "al:snd:uv:on:uv")]] q = Query("al:snd:uv:on:uv", text="☀️ UV 10") await press(bot, q) - assert state.loud(1) == {"uv"} and q.toast == "UV alerts now make a sound" and rows(q.edits[0][1])[2] == [("Sound", "al:snd:uv:off:uv")] + assert state.loud(1) == {"uv"} and q.toast == "UV alerts now make a sound" and rows(q.edits[0][1])[2] == [("✅ Sound", "al:snd:uv:off:uv")] q = Query("al:off:uv:gear:uv", text="☀️ UV 10") # alert off: the sound toggle goes, the struck alert stays to turn it back on await press(bot, q) assert "uv" in state.muted(1) and q.toast == "UV alerts off in this chat" - assert rows(q.edits[0][1]) == [[("⚙️ ▾", "al:close:uv")], [(strike("Alert"), "al:on:uv:gear:uv")]] + assert rows(q.edits[0][1]) == [[("⚙️ ▾", "al:close:uv")], [("❌ Alert", "al:on:uv:gear:uv")]] q = Query("al:on:uv:gear:uv", text="☀️ UV 10") await press(bot, q) - assert "uv" not in state.muted(1) and rows(q.edits[0][1])[2] == [("Sound", "al:snd:uv:off:uv")] # the sound setting was kept + assert "uv" not in state.muted(1) and rows(q.edits[0][1])[2] == [("✅ Sound", "al:snd:uv:off:uv")] # the sound setting was kept q = Query("al:close:uv", text="☀️ UV 10") await press(bot, q) assert rows(q.edits[0][1]) == [[("⚙️ ▸", "al:open:gear:uv")]] diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index a662def..a4f4ad6 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -177,10 +177,10 @@ def test_the_buttons_open_one_section_at_a_time_and_every_callback_is_short(): assert rows(menu.keyboard(True, True, "water", paused=True)) == paused # and no minutes or Off either assert menu.SECTIONS == ("water", "delay", "alerts") and len(rows(menu.keyboard(True, True, "switch"))) == 2 # no Switch menu any more for battery, pause, battery_button, pause_button in ( - (False, False, ("🔔 Enable battery alerts", "ir:batt:on"), ("🔔 Enable pause alerts", "ir:pause:on")), - (True, False, ("🔕 Disable battery alerts", "ir:batt:off"), ("🔔 Enable pause alerts", "ir:pause:on")), - (False, True, ("🔔 Enable battery alerts", "ir:batt:on"), ("🔕 Disable pause alerts", "ir:pause:off")), - (True, True, ("🔕 Disable battery alerts", "ir:batt:off"), ("🔕 Disable pause alerts", "ir:pause:off"))): + (False, False, ("❌ battery alerts", "ir:batt:on"), ("❌ pause alerts", "ir:pause:on")), + (True, False, ("✅ battery alerts", "ir:batt:off"), ("❌ pause alerts", "ir:pause:on")), + (False, True, ("❌ battery alerts", "ir:batt:on"), ("✅ pause alerts", "ir:pause:off")), + (True, True, ("✅ battery alerts", "ir:batt:off"), ("✅ pause alerts", "ir:pause:off"))): alerts = rows(menu.keyboard(battery, pause, "alerts")) assert alerts[:2] == collapsed[:1] + [[("🔔 Alerts ▾", "ir:close"), ("🔄 Refresh", "ir:refresh")]] assert alerts[2:] == [[battery_button], [pause_button]] # each names the action it takes @@ -490,24 +490,24 @@ async def test_watering_a_delay_and_the_switch_act_on_the_controller_and_refresh async def test_the_alerts_menu_toggles_each_alert_type_for_this_chat_alone_and_stays_open(tmp_path): bot, state, _ = irrigation_bot(tmp_path) q = await press(bot, "ir:open:alerts") - assert q.edits[0][0] == "markup" and [r[0][0] for r in rows(q.edits[0][1])[2:]] == ["🔕 Disable battery alerts", "🔕 Disable pause alerts"] + assert q.edits[0][0] == "markup" and [r[0][0] for r in rows(q.edits[0][1])[2:]] == ["✅ battery alerts", "✅ pause alerts"] q = await press(bot, "ir:batt:off") assert state.muted(1) == {"irrigation_battery"} and q.toast == "Irrigation battery alerts off in this chat" - assert rows(q.edits[0][2])[2:] == [[("🔔 Enable battery alerts", "ir:batt:on")], [("🔕 Disable pause alerts", "ir:pause:off")]] # still open + assert rows(q.edits[0][2])[2:] == [[("❌ battery alerts", "ir:batt:on")], [("✅ pause alerts", "ir:pause:off")]] # still open assert q.edits[0][1].endswith("pause: not paused (alerts: on)\nbattery: 100% 🔋 (alerts: off)") # the status text follows q = await press(bot, "ir:pause:off") assert state.muted(1) == {"irrigation_battery", "irrigation_pause"} and q.toast == "Irrigation pause alerts off in this chat" - assert q.edits[0][1].endswith("pause: not paused (alerts: off)\nbattery: 100% 🔋 (alerts: off)") and rows(q.edits[0][2])[3] == [("🔔 Enable pause alerts", "ir:pause:on")] + assert q.edits[0][1].endswith("pause: not paused (alerts: off)\nbattery: 100% 🔋 (alerts: off)") and rows(q.edits[0][2])[3] == [("❌ pause alerts", "ir:pause:on")] q = await press(bot, "ir:batt:on") assert state.muted(1) == {"irrigation_pause"} and q.toast == "Irrigation battery alerts on in this chat" # the other type is untouched q = await press(bot, "ir:pause:on") assert state.muted(1) == set() and state.alert_chats("irrigation_battery") == [1] == state.alert_chats("irrigation_pause") - for kind, prefix in (("irrigation_battery", "batt"), ("irrigation_pause", "pause")): # whatever a button says, pressing it does that + for kind, prefix in (("irrigation_battery", "batt"), ("irrigation_pause", "pause")): # a button shows the state, pressing it flips it for _ in range(2): alerts = rows(menu.keyboard(state.subscribed(1, "irrigation_battery"), state.subscribed(1, "irrigation_pause"), "alerts")) text, data = next(b for row in alerts[2:] for b in row if b[1].startswith(f"ir:{prefix}:")) await press(bot, data) - assert state.subscribed(1, kind) is text.startswith("🔔 Enable"), (text, data) + assert state.subscribed(1, kind) is text.startswith("❌"), (text, data) other = await press(bot, "ir:water:5") assert len(rows(other.edits[0][2])) == 3 # any other action redraws collapsed (3 rows: the valve is on) other = await press(bot, "ir:refresh") From ca6980248b2fc1af52aad46201b301a4367c24a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 12:11:17 +0000 Subject: [PATCH 14/19] Toggle buttons: no tick or cross; green when on, red and struck through when off Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- lib/alerts/menu.py | 8 +++---- lib/buttons.py | 11 ++++++--- lib/irrigation/menu.py | 4 ++-- tests/test_alert_menu.py | 51 ++++++++++++++++++++-------------------- tests/test_irrigation.py | 19 ++++++++------- 6 files changed, 51 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index cd17857..57310d5 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A Telegram bot for a personal Ecowitt weather station and AirGradient air-quality sensor. Ask it about the weather or air quality (text or charts); it also sends silent alerts to every chat it is in: rain starting/stopping, gusts over 40 km/h (with the last day's wind chart), UV index of 10 or more in two readings in a row (with the last day's UV chart), indoor/outdoor temperatures crossing by more than 1°C (held back for the cooldown, then announced if it still stands, with the last day's temperature chart), -and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one ⚙️ button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). Every toggle in the bot is a ✅ green button while on and a ❌ red one while off. `/alerts` has two buttons, 🔔 Alerts and (in a private chat) 🔊 Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, a ✅ before those that are on and a ❌ before those that are off (on a green and a red button where the Telegram app draws button colours), with a ⬅️ Back button; while one is open its own button stays and the other is hidden until you go back; `/alerts on|off` still subscribes or unsubscribes every type. `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). +and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one ⚙️ button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). Every toggle in the bot is a green button while on and a red one with its name struck through while off. `/alerts` has two buttons, 🔔 Alerts and (in a private chat) 🔊 Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, green while on and red with the name struck through while off, with a ⬅️ Back button; while one is open its own button stays and the other is hidden until you go back; `/alerts on|off` still subscribes or unsubscribes every type. `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). ## Run diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index 9c4013f..34a4862 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -1,5 +1,5 @@ """The alert settings as buttons. /alerts has two: 🔔 Alerts and (private chats) 🔊 Sound, each opening the full list of alert types as -toggles (a green ✅ while on, a red ❌ while off) with a Back button, the other hidden meanwhile. Under an alert there is one gear that opens that type's +toggles (green while on, red and struck through while off) with a Back button, the other hidden meanwhile. Under an alert there is one gear that opens that type's two toggles, Alert and Sound. Pure: it only builds the keyboard and the title from what a chat has muted. Callback data (self-contained and short, so an old message's buttons still work after a restart): @@ -46,7 +46,7 @@ def _button(text: str, data: str) -> InlineKeyboardButton: def alert_keyboard(kind: str, muted: set[str], loud: set[str] | None, open: str | None = None) -> InlineKeyboardMarkup: """The buttons under an alert of type `kind`: one gear (⚙️ ▸, ▾ once open) that opens up to two toggles, Alert (this type's alerts in this chat) and, only while that is on and in a private chat (`loud` is not None), Sound (whether they make a notification sound). - A toggle is a ✅ green button while on, a ❌ red one while off. Every button carries the type: al:open:gear:, al:close:, + A toggle is a green button while on, a red struck-through one while off. Every button carries the type: al:open:gear:, al:close:, al:on|off::gear:, al:snd::on|off:.""" is_open = open == "gear" rows = [[_button(menu_text("⚙️", is_open), f"al:close:{kind}" if is_open else f"al:open:gear:{kind}")]] @@ -60,7 +60,7 @@ def alert_keyboard(kind: str, muted: set[str], loud: set[str] | None, open: str def _list(kinds: list[str], is_on, data) -> list[list]: - """A toggle for each of the alert types, PER_ROW to a row, a green tick before a type that is on, a red cross before one that is off. is_on(kind): its state; + """A toggle for each of the alert types, PER_ROW to a row, green for a type that is on, red and struck through for one that is off. is_on(kind): its state; data(kind): the callback data that flips it.""" return list(map(list, batched((toggle_button(LABELS[k], is_on(k), data(k)) for k in kinds), PER_ROW))) @@ -68,7 +68,7 @@ def _list(kinds: list[str], is_on, data) -> list[list]: def keyboard(muted: set[str], available: list[str], open: str | None = None, parent: str | None = None, loud: set[str] | None = None) -> InlineKeyboardMarkup: """The /alerts menu: two buttons, 🔔 Alerts and (private chats) 🔊 Sound. Each opens a menu with a list of alert types, each a toggle - with a ✅ before it while on and a ❌ while off (alerts on or off in this chat; notification sound on or off, for the types that are + green while on, red and struck through while off (alerts on or off in this chat; notification sound on or off, for the types that are on), and ⬅️ Back. While one is open its own button stays (▾) above the list and the other is hidden until Back. `open`: alerts, sound (or one of an earlier version's sections). `loud`: the types whose alerts make a notification sound; None (a group) has no Sound. `parent`: the type of the alert these buttons sit under, which has a menu of its own (alert_keyboard).""" diff --git a/lib/buttons.py b/lib/buttons.py index ee21331..46542cf 100644 --- a/lib/buttons.py +++ b/lib/buttons.py @@ -10,8 +10,13 @@ def menu_text(text: str, is_open: bool) -> str: return f"{text} {'▾' if is_open else '▸'}" +def struck(text: str) -> str: + """The text with a line through it (a button cannot be styled, so each letter gets the combining long stroke).""" + return "".join(f"{c}\u0336" for c in text) + + def toggle_button(word: str, on: bool, data: str) -> InlineKeyboardButton: - """A button that flips a setting: its word after a ✅ on a green button while the setting is on, after a ❌ on a red one while it is - off (the tick and cross stay for a client that does not draw button colours).""" - return InlineKeyboardButton(f"{'✅' if on else '❌'} {word}", callback_data=data, + """A button that flips a setting: its word on a green button while the setting is on, the word struck through on a red one while it + is off.""" + return InlineKeyboardButton(word if on else struck(word), callback_data=data, style=KeyboardButtonStyle.SUCCESS if on else KeyboardButtonStyle.DANGER) diff --git a/lib/irrigation/menu.py b/lib/irrigation/menu.py index 06a6654..c899e21 100644 --- a/lib/irrigation/menu.py +++ b/lib/irrigation/menu.py @@ -3,7 +3,7 @@ Collapsed: [💧 Water] [⏸ Pause] / [🔔 Alerts] [🔄 Refresh]. While the valve is on, a [Water off ❌] row follows. While the timer is paused, Pause is [▶️ Unpause] (one press ends the pause) and Water is hidden. A press on Water, Pause or Alerts opens its options under the rows (▸ after the text marks a menu, ▾ the open one; pressing it again closes them). Water lists 5, 10, 20 and 30 minutes (a run is always timed). Alerts has -one toggle per alert type, ✅ green while this chat gets it, ❌ red while it does not; pressing it flips it. +one toggle per alert type, green while this chat gets it, red and struck through while it does not; pressing it flips it. Callback data (short, self-contained, so an old message's buttons still work after a restart): ir:open: | ir:close | ir:refresh (read the state again, change nothing) @@ -89,7 +89,7 @@ def status_text(status: dict, alerts: bool | None = None, pause_alerts: bool | N def keyboard(battery: bool, pause: bool, open: str | None = None, paused: bool = False, running: bool = False) -> InlineKeyboardMarkup: """The buttons under the status. `battery` and `pause`: this chat gets the irrigation battery / pause alerts; each Alerts button - is a toggle (✅ when on, ❌ when off). `paused`: the timer is paused, so its + is a toggle (green when on, red and struck through when off). `paused`: the timer is paused, so its button unpauses and Water is hidden. `running`: the valve is on, so Water off is a top-level row.""" def head(name: str, text: str) -> InlineKeyboardButton: return _button(menu_text(text, open == name), "ir:close" if open == name else f"ir:open:{name}") diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 9da299e..050d83a 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -6,6 +6,7 @@ from lib.alerts import AlertState, Notifier from lib.alerts.menu import ALL, LABELS, TITLE, available_kinds, keyboard, title from lib.bot import Bot +from lib.buttons import struck as strike from tests.fakes import TZ KINDS = ["rain", "gusts", "uv", "temps", "particulates", "pollen_and_asthma", "forecast"] @@ -23,28 +24,28 @@ def test_alerts_has_two_buttons_alerts_and_sound_and_a_group_has_only_the_first( def test_the_gear_toggles_are_green_when_on_and_red_when_off(): styles = lambda m: {b.text: b.style for row in m.inline_keyboard for b in row} - assert styles(keyboard(set(), KINDS, "gear", parent="uv", loud=set())) == {"⚙️ ▾": None, "✅ Alert": "success", "❌ Sound": "danger"} - assert styles(keyboard({"uv"}, KINDS, "gear", parent="uv", loud={"uv"})) == {"⚙️ ▾": None, "❌ Alert": "danger"} + assert styles(keyboard(set(), KINDS, "gear", parent="uv", loud=set())) == {"⚙️ ▾": None, "Alert": "success", strike("Sound"): "danger"} + assert styles(keyboard({"uv"}, KINDS, "gear", parent="uv", loud={"uv"})) == {"⚙️ ▾": None, strike("Alert"): "danger"} def test_a_type_that_is_on_is_a_green_button_and_one_that_is_off_a_red_one(): markup = keyboard({"gusts"}, KINDS, "alerts", loud=set()) styles = {b.text: b.api_kwargs.get("style") or b.style for row in markup.inline_keyboard for b in row} - assert styles["✅ rain"] == "success" and styles["❌ gusts"] == "danger" and not styles["🔔 Alerts ▾"] and not styles["⬅️ Back"] + assert styles["rain"] == "success" and styles[strike("gusts")] == "danger" and not styles["🔔 Alerts ▾"] and not styles["⬅️ Back"] assert markup.inline_keyboard[1][0].to_dict()["style"] == "success" # and it is sent to Telegram def test_each_menu_lists_its_types_with_a_tick_or_a_cross_and_keeps_only_its_own_button(): got = rows(keyboard({"gusts"}, KINDS, "alerts", loud={"rain"})) assert got[0] == [("🔔 Alerts ▾", "al:close")] # the button that opened it stays, open - assert [t for row in got[1:-1] for t, _ in row] == ["✅ rain", "❌ gusts", "✅ UV", "✅ temperature crossing", "✅ particulates", - "✅ pollen & asthma", "✅ forecast changes"] - assert got[1] == [("✅ rain", "al:off:rain:alerts"), ("❌ gusts", "al:on:gusts:alerts")] and got[-1] == [("⬅️ Back", "al:close")] + assert [t for row in got[1:-1] for t, _ in row] == ["rain", strike("gusts"), "UV", "temperature crossing", "particulates", + "pollen & asthma", "forecast changes"] + assert got[1] == [("rain", "al:off:rain:alerts"), (strike("gusts"), "al:on:gusts:alerts")] and got[-1] == [("⬅️ Back", "al:close")] assert "Sound" not in str(got) # the other menu's button is hidden got = rows(keyboard({"gusts"}, KINDS, "sound", loud={"rain"})) assert got[0] == [("🔊 Sound ▾", "al:close")] and "Alerts" not in str(got) - assert got[1] == [("✅ rain", "al:snd:rain:off"), ("❌ UV", "al:snd:uv:on")] # gusts is off, so it is not listed at all - assert [t for row in got[2:-1] for t, _ in row] == ["❌ " + w for w in ("temperature crossing", "particulates", "pollen & asthma", "forecast changes")] + assert got[1] == [("rain", "al:snd:rain:off"), (strike("UV"), "al:snd:uv:on")] # gusts is off, so it is not listed at all + assert [t for row in got[2:-1] for t, _ in row] == [strike(w) for w in ("temperature crossing", "particulates", "pollen & asthma", "forecast changes")] assert got[-1] == [("⬅️ Back", "al:close")] and "gusts" not in str(got) assert rows(keyboard(set(KINDS), KINDS, "sound", loud=set())) == [[("🔔 Alerts ▸", "al:open:alerts")]] # nothing is on: no Sound button either assert got[-1] == [("⬅️ Back", "al:close")] and "Alerts" not in str(got) @@ -62,7 +63,7 @@ def test_only_the_types_the_bot_has_are_listed_and_every_callback_is_short(): def test_the_title_lists_what_is_on_and_off(): assert title({"gusts", "uv"}, KINDS[:4]) == f"{TITLE}\n✅ On: rain, temperature crossing\n🔕 Off: gusts, UV" - assert title(set(), ["particulates"]).endswith("🔕 Off: nothing") and title({"particulates"}, ["particulates"]).endswith("✅ On: nothing\n🔕 Off: particulates") + assert title(set(), ["particulates"]).endswith("🔕 Off: nothing") and title({"particulates"}, ["particulates"]).endswith("On: nothing\n🔕 Off: particulates") def test_state_mutes_one_type_or_all_and_old_files_load(tmp_path): @@ -151,14 +152,14 @@ async def test_the_alerts_menu_toggles_a_type_in_place_and_back_returns_to_the_t q = Query("al:open:alerts", text=f"{TITLE}\n✅ On: ...") await press(bot, q) drawn = rows(q.edits[0][2]) - assert q.toast is None and drawn[0] == [("🔔 Alerts ▾", "al:close")] and drawn[1][0] == ("✅ rain", "al:off:rain:alerts") and drawn[-1] == [("⬅️ Back", "al:close")] + assert q.toast is None and drawn[0] == [("🔔 Alerts ▾", "al:close")] and drawn[1][0] == ("rain", "al:off:rain:alerts") and drawn[-1] == [("⬅️ Back", "al:close")] q = Query("al:off:rain:alerts", text=f"{TITLE}\n✅ On: ...") await press(bot, q) assert state.muted(1) == {"rain"} and q.toast == "Rain alerts off in this chat" and "🔕 Off: rain" in q.edits[0][1] - assert rows(q.edits[0][2])[1][0] == ("❌ rain", "al:on:rain:alerts") # stays open, now with a cross + assert rows(q.edits[0][2])[1][0] == (strike("rain"), "al:on:rain:alerts") # stays open, now with a cross q = Query("al:on:rain:alerts", text=f"{TITLE}\n✅ On: ...") await press(bot, q) - assert state.muted(1) == set() and q.toast == "Rain alerts on in this chat" and rows(q.edits[0][2])[1][0] == ("✅ rain", "al:off:rain:alerts") + assert state.muted(1) == set() and q.toast == "Rain alerts on in this chat" and rows(q.edits[0][2])[1][0] == ("rain", "al:off:rain:alerts") q = Query("al:close", text=f"{TITLE}\n✅ On: ...") await press(bot, q) assert rows(q.edits[0][2]) == [[("🔔 Alerts ▸", "al:open:alerts"), ("🔊 Sound ▸", "al:open:sound")]] @@ -213,10 +214,10 @@ async def reply_text(body, **kw): chat = NS(type="private", id=1, title=None) update = NS(effective_message=NS(reply_text=reply_text, chat_id=1), effective_chat=chat, effective_user=NS(full_name="Rob", username="rob")) await bot.on_alerts(update, NS(args=[])) - assert replies[0][0].startswith(TITLE) and "✅ On: rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes" in replies[0][0] + assert replies[0][0].startswith(TITLE) and "On: rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes" in replies[0][0] assert rows(replies[0][1]["reply_markup"])[0] == [("🔔 Alerts ▸", "al:open:alerts"), ("🔊 Sound ▸", "al:open:sound")] await bot.on_alerts(update, NS(args=["off"])) - assert not state.alerts_on(1) and "✅ On: nothing" in replies[1][0] + assert not state.alerts_on(1) and "On: nothing" in replies[1][0] await bot.on_alerts(update, NS(args=["on"])) assert state.alerts_on(1) and "🔕 Off: nothing" in replies[2][0] @@ -269,13 +270,13 @@ def test_under_an_alert_one_gear_opens_the_alert_toggle_and_then_the_sound_toggl shut = rows(keyboard(set(), KINDS, None, parent="uv", loud=set())) assert shut == [[("⚙️ ▸", "al:open:gear:uv")]] on = rows(keyboard(set(), KINDS, "gear", parent="uv", loud=set())) # both on... the sound is off - assert on == [[("⚙️ ▾", "al:close:uv")], [("✅ Alert", "al:off:uv:gear:uv")], [("❌ Sound", "al:snd:uv:on:uv")]] + assert on == [[("⚙️ ▾", "al:close:uv")], [("Alert", "al:off:uv:gear:uv")], [(strike("Sound"), "al:snd:uv:on:uv")]] both = rows(keyboard(set(), KINDS, "gear", parent="uv", loud={"uv"})) - assert both[2] == [("✅ Sound", "al:snd:uv:off:uv")] + assert both[2] == [("Sound", "al:snd:uv:off:uv")] off = rows(keyboard({"uv"}, KINDS, "gear", parent="uv", loud={"uv"})) # alert off: struck through, and no sound toggle - assert off == [[("⚙️ ▾", "al:close:uv")], [("❌ Alert", "al:on:uv:gear:uv")]] + assert off == [[("⚙️ ▾", "al:close:uv")], [(strike("Alert"), "al:on:uv:gear:uv")]] group = rows(keyboard(set(), KINDS, "gear", parent="uv", loud=None)) # a group has no sound - assert group == [[("⚙️ ▾", "al:close:uv")], [("✅ Alert", "al:off:uv:gear:uv")]] + assert group == [[("⚙️ ▾", "al:close:uv")], [("Alert", "al:off:uv:gear:uv")]] assert rows(keyboard(set(), KINDS, "unsub", parent="uv", loud=set())) == shut # (an earlier version's list: just the gear) @@ -283,17 +284,17 @@ async def test_the_gear_under_an_alert_toggles_the_alert_and_its_sound_and_stays bot, state = make_bot(tmp_path) q = Query("al:open:gear:uv", text="☀️ UV 10") await press(bot, q) - assert rows(q.edits[0][1]) == [[("⚙️ ▾", "al:close:uv")], [("✅ Alert", "al:off:uv:gear:uv")], [("❌ Sound", "al:snd:uv:on:uv")]] + assert rows(q.edits[0][1]) == [[("⚙️ ▾", "al:close:uv")], [("Alert", "al:off:uv:gear:uv")], [(strike("Sound"), "al:snd:uv:on:uv")]] q = Query("al:snd:uv:on:uv", text="☀️ UV 10") await press(bot, q) - assert state.loud(1) == {"uv"} and q.toast == "UV alerts now make a sound" and rows(q.edits[0][1])[2] == [("✅ Sound", "al:snd:uv:off:uv")] + assert state.loud(1) == {"uv"} and q.toast == "UV alerts now make a sound" and rows(q.edits[0][1])[2] == [("Sound", "al:snd:uv:off:uv")] q = Query("al:off:uv:gear:uv", text="☀️ UV 10") # alert off: the sound toggle goes, the struck alert stays to turn it back on await press(bot, q) assert "uv" in state.muted(1) and q.toast == "UV alerts off in this chat" - assert rows(q.edits[0][1]) == [[("⚙️ ▾", "al:close:uv")], [("❌ Alert", "al:on:uv:gear:uv")]] + assert rows(q.edits[0][1]) == [[("⚙️ ▾", "al:close:uv")], [(strike("Alert"), "al:on:uv:gear:uv")]] q = Query("al:on:uv:gear:uv", text="☀️ UV 10") await press(bot, q) - assert "uv" not in state.muted(1) and rows(q.edits[0][1])[2] == [("✅ Sound", "al:snd:uv:off:uv")] # the sound setting was kept + assert "uv" not in state.muted(1) and rows(q.edits[0][1])[2] == [("Sound", "al:snd:uv:off:uv")] # the sound setting was kept q = Query("al:close:uv", text="☀️ UV 10") await press(bot, q) assert rows(q.edits[0][1]) == [[("⚙️ ▸", "al:open:gear:uv")]] @@ -304,7 +305,7 @@ async def test_an_unknown_parent_in_a_button_is_ignored(tmp_path): q = Query("al:open:alerts:bogus", text="☀️ UV 10") await press(bot, q) drawn = rows(q.edits[0][1]) - assert drawn[1][0] == ("✅ rain", "al:off:rain:alerts") and drawn[-1] == [("⬅️ Back", "al:close")] # the /alerts menu, not carried on + assert drawn[1][0] == ("rain", "al:off:rain:alerts") and drawn[-1] == [("⬅️ Back", "al:close")] # the /alerts menu, not carried on def test_the_title_stays_plain_words(): @@ -317,12 +318,12 @@ async def test_the_sound_menu_toggles_every_type_in_private_chats_only_and_back_ q = Query("al:open:sound") await press(bot, q) drawn = rows(q.edits[0][1]) - assert drawn[0] == [("🔊 Sound ▾", "al:close")] and drawn[1] == [("❌ rain", "al:snd:rain:on"), ("❌ UV", "al:snd:uv:on")] and drawn[-1] == [("⬅️ Back", "al:close")] + assert drawn[0] == [("🔊 Sound ▾", "al:close")] and drawn[1] == [(strike("rain"), "al:snd:rain:on"), (strike("UV"), "al:snd:uv:on")] and drawn[-1] == [("⬅️ Back", "al:close")] assert "gusts" not in str(drawn) # an alert that is off has no sound to set assert "Alerts" not in str(drawn) # the Alerts menu is hidden while Sound is open q = Query("al:snd:rain:on") await press(bot, q) - assert state.loud(1) == {"rain"} and q.toast == "Rain alerts now make a sound" and rows(q.edits[0][1])[1][0] == ("✅ rain", "al:snd:rain:off") + assert state.loud(1) == {"rain"} and q.toast == "Rain alerts now make a sound" and rows(q.edits[0][1])[1][0] == ("rain", "al:snd:rain:off") await press(bot, Query("al:snd:uv:on")) assert state.loud(1) == {"rain", "uv"} q = Query("al:snd:rain:off") diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index a4f4ad6..1ab83be 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -17,6 +17,7 @@ from lib.alerts import irrigation as monitor_module from lib.alerts.menu import available_kinds from lib.bot import Bot +from lib.buttons import struck as strike from lib.config import Config from lib.irrigation import IrrigationError, Tuya, Watchdog, menu from lib.irrigation.tuya import sign @@ -177,10 +178,10 @@ def test_the_buttons_open_one_section_at_a_time_and_every_callback_is_short(): assert rows(menu.keyboard(True, True, "water", paused=True)) == paused # and no minutes or Off either assert menu.SECTIONS == ("water", "delay", "alerts") and len(rows(menu.keyboard(True, True, "switch"))) == 2 # no Switch menu any more for battery, pause, battery_button, pause_button in ( - (False, False, ("❌ battery alerts", "ir:batt:on"), ("❌ pause alerts", "ir:pause:on")), - (True, False, ("✅ battery alerts", "ir:batt:off"), ("❌ pause alerts", "ir:pause:on")), - (False, True, ("❌ battery alerts", "ir:batt:on"), ("✅ pause alerts", "ir:pause:off")), - (True, True, ("✅ battery alerts", "ir:batt:off"), ("✅ pause alerts", "ir:pause:off"))): + (False, False, (strike("battery alerts"), "ir:batt:on"), (strike("pause alerts"), "ir:pause:on")), + (True, False, ("battery alerts", "ir:batt:off"), (strike("pause alerts"), "ir:pause:on")), + (False, True, (strike("battery alerts"), "ir:batt:on"), ("pause alerts", "ir:pause:off")), + (True, True, ("battery alerts", "ir:batt:off"), ("pause alerts", "ir:pause:off"))): alerts = rows(menu.keyboard(battery, pause, "alerts")) assert alerts[:2] == collapsed[:1] + [[("🔔 Alerts ▾", "ir:close"), ("🔄 Refresh", "ir:refresh")]] assert alerts[2:] == [[battery_button], [pause_button]] # each names the action it takes @@ -205,7 +206,7 @@ def test_the_alert_list_gets_irrigation_battery_only_when_there_is_a_controller( from lib.alerts.menu import LABELS, keyboard assert LABELS["irrigation_battery"] == "irrigation battery" and LABELS["irrigation_pause"] == "irrigation pause" listed = rows(keyboard(set(), ["rain", "irrigation_battery"], "alerts")) - assert ("✅ irrigation battery", "al:off:irrigation_battery:alerts") in [b for row in listed for b in row] + assert ("irrigation battery", "al:off:irrigation_battery:alerts") in [b for row in listed for b in row] # ---------- the daily check ---------- @@ -490,14 +491,14 @@ async def test_watering_a_delay_and_the_switch_act_on_the_controller_and_refresh async def test_the_alerts_menu_toggles_each_alert_type_for_this_chat_alone_and_stays_open(tmp_path): bot, state, _ = irrigation_bot(tmp_path) q = await press(bot, "ir:open:alerts") - assert q.edits[0][0] == "markup" and [r[0][0] for r in rows(q.edits[0][1])[2:]] == ["✅ battery alerts", "✅ pause alerts"] + assert q.edits[0][0] == "markup" and [r[0][0] for r in rows(q.edits[0][1])[2:]] == ["battery alerts", "pause alerts"] q = await press(bot, "ir:batt:off") assert state.muted(1) == {"irrigation_battery"} and q.toast == "Irrigation battery alerts off in this chat" - assert rows(q.edits[0][2])[2:] == [[("❌ battery alerts", "ir:batt:on")], [("✅ pause alerts", "ir:pause:off")]] # still open + assert rows(q.edits[0][2])[2:] == [[(strike("battery alerts"), "ir:batt:on")], [("pause alerts", "ir:pause:off")]] # still open assert q.edits[0][1].endswith("pause: not paused (alerts: on)\nbattery: 100% 🔋 (alerts: off)") # the status text follows q = await press(bot, "ir:pause:off") assert state.muted(1) == {"irrigation_battery", "irrigation_pause"} and q.toast == "Irrigation pause alerts off in this chat" - assert q.edits[0][1].endswith("pause: not paused (alerts: off)\nbattery: 100% 🔋 (alerts: off)") and rows(q.edits[0][2])[3] == [("❌ pause alerts", "ir:pause:on")] + assert q.edits[0][1].endswith("pause: not paused (alerts: off)\nbattery: 100% 🔋 (alerts: off)") and rows(q.edits[0][2])[3] == [(strike("pause alerts"), "ir:pause:on")] q = await press(bot, "ir:batt:on") assert state.muted(1) == {"irrigation_pause"} and q.toast == "Irrigation battery alerts on in this chat" # the other type is untouched q = await press(bot, "ir:pause:on") @@ -507,7 +508,7 @@ async def test_the_alerts_menu_toggles_each_alert_type_for_this_chat_alone_and_s alerts = rows(menu.keyboard(state.subscribed(1, "irrigation_battery"), state.subscribed(1, "irrigation_pause"), "alerts")) text, data = next(b for row in alerts[2:] for b in row if b[1].startswith(f"ir:{prefix}:")) await press(bot, data) - assert state.subscribed(1, kind) is text.startswith("❌"), (text, data) + assert state.subscribed(1, kind) is (text != text.replace("\u0336", "")), (text, data) other = await press(bot, "ir:water:5") assert len(rows(other.edits[0][2])) == 3 # any other action redraws collapsed (3 rows: the valve is on) other = await press(bot, "ir:refresh") From 3c75c90df73509da0cfbf7124984a11c0e7869a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 12:18:37 +0000 Subject: [PATCH 15/19] /alerts menus: no Back button; the open Alerts or Sound button closes the list Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- lib/alerts/menu.py | 8 ++++---- tests/test_alert_menu.py | 19 +++++++++---------- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 57310d5..1bfcae1 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A Telegram bot for a personal Ecowitt weather station and AirGradient air-quality sensor. Ask it about the weather or air quality (text or charts); it also sends silent alerts to every chat it is in: rain starting/stopping, gusts over 40 km/h (with the last day's wind chart), UV index of 10 or more in two readings in a row (with the last day's UV chart), indoor/outdoor temperatures crossing by more than 1°C (held back for the cooldown, then announced if it still stands, with the last day's temperature chart), -and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one ⚙️ button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). Every toggle in the bot is a green button while on and a red one with its name struck through while off. `/alerts` has two buttons, 🔔 Alerts and (in a private chat) 🔊 Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, green while on and red with the name struck through while off, with a ⬅️ Back button; while one is open its own button stays and the other is hidden until you go back; `/alerts on|off` still subscribes or unsubscribes every type. `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). +and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one ⚙️ button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). Every toggle in the bot is a green button while on and a red one with its name struck through while off. `/alerts` has two buttons, 🔔 Alerts and (in a private chat) 🔊 Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, green while on and red with the name struck through while off, while one is open its own button stays (press it to close the list) and the other is hidden until you do; `/alerts on|off` still subscribes or unsubscribes every type. `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). ## Run diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index 34a4862..3052827 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -1,9 +1,9 @@ """The alert settings as buttons. /alerts has two: 🔔 Alerts and (private chats) 🔊 Sound, each opening the full list of alert types as -toggles (green while on, red and struck through while off) with a Back button, the other hidden meanwhile. Under an alert there is one gear that opens that type's +toggles (green while on, red and struck through while off) with the button that opened it kept (▾, pressing it closes the list), the other hidden meanwhile. Under an alert there is one gear that opens that type's two toggles, Alert and Sound. Pure: it only builds the keyboard and the title from what a chat has muted. Callback data (self-contained and short, so an old message's buttons still work after a restart): - al:open:alerts | al:open:sound | al:close (Back) | al:noop (the heading rows of messages sent by an earlier version) + al:open:alerts | al:open:sound | al:close (the open button) | al:noop (the heading rows of messages sent by an earlier version) al:on::alerts | al:off::alerts (alerts of one type on or off in the chat) al:snd:: (notification sound for one type; private chats only) An alert's buttons end with the alert's own type: al:open:gear:rain, al:close:rain, al:off:rain:gear:rain, al:snd:rain:on:rain. A chat's @@ -69,7 +69,7 @@ def keyboard(muted: set[str], available: list[str], open: str | None = None, par loud: set[str] | None = None) -> InlineKeyboardMarkup: """The /alerts menu: two buttons, 🔔 Alerts and (private chats) 🔊 Sound. Each opens a menu with a list of alert types, each a toggle green while on, red and struck through while off (alerts on or off in this chat; notification sound on or off, for the types that are - on), and ⬅️ Back. While one is open its own button stays (▾) above the list and the other is hidden until Back. `open`: alerts, sound (or one of an earlier version's sections). `loud`: the + on). While one is open its own button stays (▾) above the list, pressing it closes the list, and the other is hidden until then. `open`: alerts, sound (or one of an earlier version's sections). `loud`: the types whose alerts make a notification sound; None (a group) has no Sound. `parent`: the type of the alert these buttons sit under, which has a menu of its own (alert_keyboard).""" if parent: @@ -85,7 +85,7 @@ def keyboard(muted: set[str], available: list[str], open: str | None = None, par else: return InlineKeyboardMarkup([[_button(menu_text("🔔 Alerts", False), "al:open:alerts"), *([_button(menu_text("🔊 Sound", False), "al:open:sound")] if sound_types else [])]]) - return InlineKeyboardMarkup([*rows, [_button("⬅️ Back", "al:close")]]) + return InlineKeyboardMarkup(rows) def title(muted: set[str], available: list[str]) -> str: diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 050d83a..d53db89 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -31,24 +31,23 @@ def test_the_gear_toggles_are_green_when_on_and_red_when_off(): def test_a_type_that_is_on_is_a_green_button_and_one_that_is_off_a_red_one(): markup = keyboard({"gusts"}, KINDS, "alerts", loud=set()) styles = {b.text: b.api_kwargs.get("style") or b.style for row in markup.inline_keyboard for b in row} - assert styles["rain"] == "success" and styles[strike("gusts")] == "danger" and not styles["🔔 Alerts ▾"] and not styles["⬅️ Back"] + assert styles["rain"] == "success" and styles[strike("gusts")] == "danger" and not styles["🔔 Alerts ▾"] assert markup.inline_keyboard[1][0].to_dict()["style"] == "success" # and it is sent to Telegram -def test_each_menu_lists_its_types_with_a_tick_or_a_cross_and_keeps_only_its_own_button(): +def test_each_menu_lists_its_types_struck_through_when_off_and_keeps_only_its_own_button(): got = rows(keyboard({"gusts"}, KINDS, "alerts", loud={"rain"})) assert got[0] == [("🔔 Alerts ▾", "al:close")] # the button that opened it stays, open - assert [t for row in got[1:-1] for t, _ in row] == ["rain", strike("gusts"), "UV", "temperature crossing", "particulates", + assert [t for row in got[1:] for t, _ in row] == ["rain", strike("gusts"), "UV", "temperature crossing", "particulates", "pollen & asthma", "forecast changes"] - assert got[1] == [("rain", "al:off:rain:alerts"), (strike("gusts"), "al:on:gusts:alerts")] and got[-1] == [("⬅️ Back", "al:close")] + assert got[1] == [("rain", "al:off:rain:alerts"), (strike("gusts"), "al:on:gusts:alerts")] assert "Sound" not in str(got) # the other menu's button is hidden got = rows(keyboard({"gusts"}, KINDS, "sound", loud={"rain"})) assert got[0] == [("🔊 Sound ▾", "al:close")] and "Alerts" not in str(got) assert got[1] == [("rain", "al:snd:rain:off"), (strike("UV"), "al:snd:uv:on")] # gusts is off, so it is not listed at all - assert [t for row in got[2:-1] for t, _ in row] == [strike(w) for w in ("temperature crossing", "particulates", "pollen & asthma", "forecast changes")] - assert got[-1] == [("⬅️ Back", "al:close")] and "gusts" not in str(got) + assert [t for row in got[2:] for t, _ in row] == [strike(w) for w in ("temperature crossing", "particulates", "pollen & asthma", "forecast changes")] + assert "gusts" not in str(got) assert rows(keyboard(set(KINDS), KINDS, "sound", loud=set())) == [[("🔔 Alerts ▸", "al:open:alerts")]] # nothing is on: no Sound button either - assert got[-1] == [("⬅️ Back", "al:close")] and "Alerts" not in str(got) for old, new in (("sub", "alerts"), ("unsub", "alerts"), ("other", "alerts"), ("set", "sound"), ("snd_on", "sound"), ("snd_off_o", "sound")): assert rows(keyboard({"gusts"}, KINDS, old, loud=set())) == rows(keyboard({"gusts"}, KINDS, new, loud=set())), old # an earlier version's sections @@ -152,7 +151,7 @@ async def test_the_alerts_menu_toggles_a_type_in_place_and_back_returns_to_the_t q = Query("al:open:alerts", text=f"{TITLE}\n✅ On: ...") await press(bot, q) drawn = rows(q.edits[0][2]) - assert q.toast is None and drawn[0] == [("🔔 Alerts ▾", "al:close")] and drawn[1][0] == ("rain", "al:off:rain:alerts") and drawn[-1] == [("⬅️ Back", "al:close")] + assert q.toast is None and drawn[0] == [("🔔 Alerts ▾", "al:close")] and drawn[1][0] == ("rain", "al:off:rain:alerts") q = Query("al:off:rain:alerts", text=f"{TITLE}\n✅ On: ...") await press(bot, q) assert state.muted(1) == {"rain"} and q.toast == "Rain alerts off in this chat" and "🔕 Off: rain" in q.edits[0][1] @@ -305,7 +304,7 @@ async def test_an_unknown_parent_in_a_button_is_ignored(tmp_path): q = Query("al:open:alerts:bogus", text="☀️ UV 10") await press(bot, q) drawn = rows(q.edits[0][1]) - assert drawn[1][0] == ("rain", "al:off:rain:alerts") and drawn[-1] == [("⬅️ Back", "al:close")] # the /alerts menu, not carried on + assert drawn[1][0] == ("rain", "al:off:rain:alerts") # the /alerts menu, not carried on def test_the_title_stays_plain_words(): @@ -318,7 +317,7 @@ async def test_the_sound_menu_toggles_every_type_in_private_chats_only_and_back_ q = Query("al:open:sound") await press(bot, q) drawn = rows(q.edits[0][1]) - assert drawn[0] == [("🔊 Sound ▾", "al:close")] and drawn[1] == [(strike("rain"), "al:snd:rain:on"), (strike("UV"), "al:snd:uv:on")] and drawn[-1] == [("⬅️ Back", "al:close")] + assert drawn[0] == [("🔊 Sound ▾", "al:close")] and drawn[1] == [(strike("rain"), "al:snd:rain:on"), (strike("UV"), "al:snd:uv:on")] assert "gusts" not in str(drawn) # an alert that is off has no sound to set assert "Alerts" not in str(drawn) # the Alerts menu is hidden while Sound is open q = Query("al:snd:rain:on") From 644bc206871bbe12a1f2b673c60f2d3ed229f9c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 12:23:19 +0000 Subject: [PATCH 16/19] Individual alert menu opens with a bell; toggles are plain when on and red when off No green anywhere: a toggle that is on is an ordinary button, one that is off is red with its name struck through. This covers /alerts, the individual alert's menu and the irrigation Alerts options. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- lib/alerts/menu.py | 12 ++++++------ lib/bot.py | 2 +- lib/buttons.py | 6 ++---- lib/irrigation/menu.py | 4 ++-- tests/test_alert_menu.py | 38 +++++++++++++++++++------------------- tests/test_irrigation.py | 2 +- 7 files changed, 32 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 1bfcae1..9b0a8ec 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A Telegram bot for a personal Ecowitt weather station and AirGradient air-quality sensor. Ask it about the weather or air quality (text or charts); it also sends silent alerts to every chat it is in: rain starting/stopping, gusts over 40 km/h (with the last day's wind chart), UV index of 10 or more in two readings in a row (with the last day's UV chart), indoor/outdoor temperatures crossing by more than 1°C (held back for the cooldown, then announced if it still stands, with the last day's temperature chart), -and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one ⚙️ button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). Every toggle in the bot is a green button while on and a red one with its name struck through while off. `/alerts` has two buttons, 🔔 Alerts and (in a private chat) 🔊 Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, green while on and red with the name struck through while off, while one is open its own button stays (press it to close the list) and the other is hidden until you do; `/alerts on|off` still subscribes or unsubscribes every type. `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). +and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one 🔔 button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). Every toggle in the bot is a plain button while on and a red one with its name struck through while off. `/alerts` has two buttons, 🔔 Alerts and (in a private chat) 🔊 Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, plain while on and red with the name struck through while off, while one is open its own button stays (press it to close the list) and the other is hidden until you do; `/alerts on|off` still subscribes or unsubscribes every type. `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). ## Run diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index 3052827..de88b8a 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -1,5 +1,5 @@ """The alert settings as buttons. /alerts has two: 🔔 Alerts and (private chats) 🔊 Sound, each opening the full list of alert types as -toggles (green while on, red and struck through while off) with the button that opened it kept (▾, pressing it closes the list), the other hidden meanwhile. Under an alert there is one gear that opens that type's +toggles (plain while on, red and struck through while off) with the button that opened it kept (▾, pressing it closes the list), the other hidden meanwhile. Under an alert there is one bell that opens that type's two toggles, Alert and Sound. Pure: it only builds the keyboard and the title from what a chat has muted. Callback data (self-contained and short, so an old message's buttons still work after a restart): @@ -44,12 +44,12 @@ def _button(text: str, data: str) -> InlineKeyboardButton: def alert_keyboard(kind: str, muted: set[str], loud: set[str] | None, open: str | None = None) -> InlineKeyboardMarkup: - """The buttons under an alert of type `kind`: one gear (⚙️ ▸, ▾ once open) that opens up to two toggles, Alert (this type's alerts + """The buttons under an alert of type `kind`: one bell (🔔 ▸, ▾ once open) that opens up to two toggles, Alert (this type's alerts in this chat) and, only while that is on and in a private chat (`loud` is not None), Sound (whether they make a notification sound). - A toggle is a green button while on, a red struck-through one while off. Every button carries the type: al:open:gear:, al:close:, + A toggle is plain while on, red and struck through while off. Every button carries the type: al:open:gear:, al:close:, al:on|off::gear:, al:snd::on|off:.""" is_open = open == "gear" - rows = [[_button(menu_text("⚙️", is_open), f"al:close:{kind}" if is_open else f"al:open:gear:{kind}")]] + rows = [[_button(menu_text("🔔", is_open), f"al:close:{kind}" if is_open else f"al:open:gear:{kind}")]] if is_open: on = kind not in muted rows.append([toggle_button("Alert", on, f"al:{'off' if on else 'on'}:{kind}:gear:{kind}")]) @@ -60,7 +60,7 @@ def alert_keyboard(kind: str, muted: set[str], loud: set[str] | None, open: str def _list(kinds: list[str], is_on, data) -> list[list]: - """A toggle for each of the alert types, PER_ROW to a row, green for a type that is on, red and struck through for one that is off. is_on(kind): its state; + """A toggle for each of the alert types, PER_ROW to a row, plain for a type that is on, red and struck through for one that is off. is_on(kind): its state; data(kind): the callback data that flips it.""" return list(map(list, batched((toggle_button(LABELS[k], is_on(k), data(k)) for k in kinds), PER_ROW))) @@ -68,7 +68,7 @@ def _list(kinds: list[str], is_on, data) -> list[list]: def keyboard(muted: set[str], available: list[str], open: str | None = None, parent: str | None = None, loud: set[str] | None = None) -> InlineKeyboardMarkup: """The /alerts menu: two buttons, 🔔 Alerts and (private chats) 🔊 Sound. Each opens a menu with a list of alert types, each a toggle - green while on, red and struck through while off (alerts on or off in this chat; notification sound on or off, for the types that are + plain while on, red and struck through while off (alerts on or off in this chat; notification sound on or off, for the types that are on). While one is open its own button stays (▾) above the list, pressing it closes the list, and the other is hidden until then. `open`: alerts, sound (or one of an earlier version's sections). `loud`: the types whose alerts make a notification sound; None (a group) has no Sound. `parent`: the type of the alert these buttons sit under, which has a menu of its own (alert_keyboard).""" diff --git a/lib/bot.py b/lib/bot.py index 0473b05..8d60b29 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -434,7 +434,7 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY elif action == "snd" and chat.type == ChatType.PRIVATE and arg in kinds and section in ("on", "off") and chat.id in self.state.chats: self.state.set_sound(chat.id, arg, section == "on") parent = tail if tail in kinds else None - opened = "gear" if parent else "sound" # stay in the list (under an alert: its gear stays open) + opened = "gear" if parent else "sound" # stay in the list (under an alert: its bell stays open) toast = f"{self._alerts_named(arg)} {'now make' if section == 'on' else 'no longer make'} a sound" elif action in ("on", "off") and (arg == menu.ALL or arg in kinds) and chat.id in self.state.chats: self.state.set_kind(chat.id, arg, action == "on") diff --git a/lib/buttons.py b/lib/buttons.py index 46542cf..68bd333 100644 --- a/lib/buttons.py +++ b/lib/buttons.py @@ -16,7 +16,5 @@ def struck(text: str) -> str: def toggle_button(word: str, on: bool, data: str) -> InlineKeyboardButton: - """A button that flips a setting: its word on a green button while the setting is on, the word struck through on a red one while it - is off.""" - return InlineKeyboardButton(word if on else struck(word), callback_data=data, - style=KeyboardButtonStyle.SUCCESS if on else KeyboardButtonStyle.DANGER) + """A button that flips a setting: its plain word while the setting is on, the word struck through on a red button while it is off.""" + return InlineKeyboardButton(word if on else struck(word), callback_data=data, style=None if on else KeyboardButtonStyle.DANGER) diff --git a/lib/irrigation/menu.py b/lib/irrigation/menu.py index c899e21..1360bfa 100644 --- a/lib/irrigation/menu.py +++ b/lib/irrigation/menu.py @@ -3,7 +3,7 @@ Collapsed: [💧 Water] [⏸ Pause] / [🔔 Alerts] [🔄 Refresh]. While the valve is on, a [Water off ❌] row follows. While the timer is paused, Pause is [▶️ Unpause] (one press ends the pause) and Water is hidden. A press on Water, Pause or Alerts opens its options under the rows (▸ after the text marks a menu, ▾ the open one; pressing it again closes them). Water lists 5, 10, 20 and 30 minutes (a run is always timed). Alerts has -one toggle per alert type, green while this chat gets it, red and struck through while it does not; pressing it flips it. +one toggle per alert type, plain while this chat gets it, red and struck through while it does not; pressing it flips it. Callback data (short, self-contained, so an old message's buttons still work after a restart): ir:open: | ir:close | ir:refresh (read the state again, change nothing) @@ -89,7 +89,7 @@ def status_text(status: dict, alerts: bool | None = None, pause_alerts: bool | N def keyboard(battery: bool, pause: bool, open: str | None = None, paused: bool = False, running: bool = False) -> InlineKeyboardMarkup: """The buttons under the status. `battery` and `pause`: this chat gets the irrigation battery / pause alerts; each Alerts button - is a toggle (green when on, red and struck through when off). `paused`: the timer is paused, so its + is a toggle (plain when on, red and struck through when off). `paused`: the timer is paused, so its button unpauses and Water is hidden. `running`: the valve is on, so Water off is a top-level row.""" def head(name: str, text: str) -> InlineKeyboardButton: return _button(menu_text(text, open == name), "ir:close" if open == name else f"ir:open:{name}") diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index d53db89..0f67848 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -22,17 +22,17 @@ def test_alerts_has_two_buttons_alerts_and_sound_and_a_group_has_only_the_first( assert rows(keyboard(set(), KINDS, "sound")) == rows(keyboard(set(), KINDS)) # a group cannot open Sound -def test_the_gear_toggles_are_green_when_on_and_red_when_off(): +def test_the_bell_toggles_are_plain_when_on_and_red_when_off(): styles = lambda m: {b.text: b.style for row in m.inline_keyboard for b in row} - assert styles(keyboard(set(), KINDS, "gear", parent="uv", loud=set())) == {"⚙️ ▾": None, "Alert": "success", strike("Sound"): "danger"} - assert styles(keyboard({"uv"}, KINDS, "gear", parent="uv", loud={"uv"})) == {"⚙️ ▾": None, strike("Alert"): "danger"} + assert styles(keyboard(set(), KINDS, "gear", parent="uv", loud=set())) == {"🔔 ▾": None, "Alert": None, strike("Sound"): "danger"} + assert styles(keyboard({"uv"}, KINDS, "gear", parent="uv", loud={"uv"})) == {"🔔 ▾": None, strike("Alert"): "danger"} -def test_a_type_that_is_on_is_a_green_button_and_one_that_is_off_a_red_one(): +def test_a_type_that_is_on_is_a_plain_button_and_one_that_is_off_a_red_one(): markup = keyboard({"gusts"}, KINDS, "alerts", loud=set()) styles = {b.text: b.api_kwargs.get("style") or b.style for row in markup.inline_keyboard for b in row} - assert styles["rain"] == "success" and styles[strike("gusts")] == "danger" and not styles["🔔 Alerts ▾"] - assert markup.inline_keyboard[1][0].to_dict()["style"] == "success" # and it is sent to Telegram + assert styles["rain"] is None and styles[strike("gusts")] == "danger" and not styles["🔔 Alerts ▾"] + assert markup.inline_keyboard[1][1].to_dict()["style"] == "danger" and "style" not in markup.inline_keyboard[1][0].to_dict() # and it is sent to Telegram def test_each_menu_lists_its_types_struck_through_when_off_and_keeps_only_its_own_button(): @@ -104,7 +104,7 @@ async def test_an_alert_goes_to_the_chats_subscribed_to_its_type_and_carries_the await notifier("Gusts", kind="gusts") assert [(c, t.split("\n")[0]) for c, t, _ in bot.sent] == [(1, "It's raining"), (1, "Gusts"), (2, "Gusts")] markup = bot.sent[0][2]["reply_markup"] - assert rows(markup) == [[("⚙️ ▸", "al:open:gear:rain")]] # the alert's own type rides along + assert rows(markup) == [[("🔔 ▸", "al:open:gear:rain")]] # the alert's own type rides along assert bot.sent[0][1] == "It's raining" # no footer: the buttons say how to change it assert await notifier.to_chat(2, "x", kind="rain") is False and await notifier.to_chat(2, "x", kind="uv") is True @@ -265,38 +265,38 @@ async def spy(chat_id, text, link=None, kind=None): assert seen == ["forecast"] -def test_under_an_alert_one_gear_opens_the_alert_toggle_and_then_the_sound_toggle_each_a_tick_or_a_cross(): +def test_under_an_alert_one_bell_opens_the_alert_toggle_and_then_the_sound_toggle(): shut = rows(keyboard(set(), KINDS, None, parent="uv", loud=set())) - assert shut == [[("⚙️ ▸", "al:open:gear:uv")]] + assert shut == [[("🔔 ▸", "al:open:gear:uv")]] on = rows(keyboard(set(), KINDS, "gear", parent="uv", loud=set())) # both on... the sound is off - assert on == [[("⚙️ ▾", "al:close:uv")], [("Alert", "al:off:uv:gear:uv")], [(strike("Sound"), "al:snd:uv:on:uv")]] + assert on == [[("🔔 ▾", "al:close:uv")], [("Alert", "al:off:uv:gear:uv")], [(strike("Sound"), "al:snd:uv:on:uv")]] both = rows(keyboard(set(), KINDS, "gear", parent="uv", loud={"uv"})) assert both[2] == [("Sound", "al:snd:uv:off:uv")] off = rows(keyboard({"uv"}, KINDS, "gear", parent="uv", loud={"uv"})) # alert off: struck through, and no sound toggle - assert off == [[("⚙️ ▾", "al:close:uv")], [(strike("Alert"), "al:on:uv:gear:uv")]] + assert off == [[("🔔 ▾", "al:close:uv")], [(strike("Alert"), "al:on:uv:gear:uv")]] group = rows(keyboard(set(), KINDS, "gear", parent="uv", loud=None)) # a group has no sound - assert group == [[("⚙️ ▾", "al:close:uv")], [("Alert", "al:off:uv:gear:uv")]] - assert rows(keyboard(set(), KINDS, "unsub", parent="uv", loud=set())) == shut # (an earlier version's list: just the gear) + assert group == [[("🔔 ▾", "al:close:uv")], [("Alert", "al:off:uv:gear:uv")]] + assert rows(keyboard(set(), KINDS, "unsub", parent="uv", loud=set())) == shut # (an earlier version's list: just the bell) -async def test_the_gear_under_an_alert_toggles_the_alert_and_its_sound_and_stays_open(tmp_path): +async def test_the_bell_under_an_alert_toggles_the_alert_and_its_sound_and_stays_open(tmp_path): bot, state = make_bot(tmp_path) q = Query("al:open:gear:uv", text="☀️ UV 10") await press(bot, q) - assert rows(q.edits[0][1]) == [[("⚙️ ▾", "al:close:uv")], [("Alert", "al:off:uv:gear:uv")], [(strike("Sound"), "al:snd:uv:on:uv")]] + assert rows(q.edits[0][1]) == [[("🔔 ▾", "al:close:uv")], [("Alert", "al:off:uv:gear:uv")], [(strike("Sound"), "al:snd:uv:on:uv")]] q = Query("al:snd:uv:on:uv", text="☀️ UV 10") await press(bot, q) assert state.loud(1) == {"uv"} and q.toast == "UV alerts now make a sound" and rows(q.edits[0][1])[2] == [("Sound", "al:snd:uv:off:uv")] q = Query("al:off:uv:gear:uv", text="☀️ UV 10") # alert off: the sound toggle goes, the struck alert stays to turn it back on await press(bot, q) assert "uv" in state.muted(1) and q.toast == "UV alerts off in this chat" - assert rows(q.edits[0][1]) == [[("⚙️ ▾", "al:close:uv")], [(strike("Alert"), "al:on:uv:gear:uv")]] + assert rows(q.edits[0][1]) == [[("🔔 ▾", "al:close:uv")], [(strike("Alert"), "al:on:uv:gear:uv")]] q = Query("al:on:uv:gear:uv", text="☀️ UV 10") await press(bot, q) assert "uv" not in state.muted(1) and rows(q.edits[0][1])[2] == [("Sound", "al:snd:uv:off:uv")] # the sound setting was kept q = Query("al:close:uv", text="☀️ UV 10") await press(bot, q) - assert rows(q.edits[0][1]) == [[("⚙️ ▸", "al:open:gear:uv")]] + assert rows(q.edits[0][1]) == [[("🔔 ▸", "al:open:gear:uv")]] async def test_an_unknown_parent_in_a_button_is_ignored(tmp_path): @@ -354,7 +354,7 @@ async def test_an_alert_has_a_sound_only_in_a_chat_that_turned_it_on_and_groups_ flags = {(c, t.split("\n")[0]): kw["disable_notification"] for c, t, kw in bot.sent} assert flags[(1, "It's raining")] is False and flags[(1, "UV")] is True and flags[(-5, "It's raining")] is True group = next(kw for c, _, kw in bot.sent if c == -5) - assert [b[0] for b in rows(group["reply_markup"])[0]] == ["⚙️ ▸"] + assert [b[0] for b in rows(group["reply_markup"])[0]] == ["🔔 ▸"] async def test_a_chat_that_became_a_supergroup_is_followed_not_a_crash(tmp_path): @@ -414,7 +414,7 @@ async def test_an_alert_with_a_picture_is_the_picture_with_the_text_as_its_capti assert bot.sent == [] and len(bot.photos) == 1 chat, photo, kw = bot.photos[0] assert (chat, photo, kw["caption"]) == (1, b"png", "It's warmer outside") and kw["disable_notification"] is True - assert rows(kw["reply_markup"])[0][0] == ("⚙️ ▸", "al:open:gear:temps") # the alert's own buttons ride along + assert rows(kw["reply_markup"])[0][0] == ("🔔 ▸", "al:open:gear:temps") # the alert's own buttons ride along async def test_an_alerts_picture_is_uploaded_once_and_the_other_chats_are_sent_telegrams_copy(tmp_path): diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index 1ab83be..1ea96fa 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -709,7 +709,7 @@ async def send_message(self, chat_id, text, **kw): await m.check(NOW) assert sorted(c for c, _, _ in delivered) == [-7, 1, 3] and all(text.startswith("☔ Irrigation paused") for _, text, _ in delivered) markup = delivered[0][2]["reply_markup"] - assert rows(markup) == [[("⚙️ ▸", "al:open:gear:irrigation_pause")]] + assert rows(markup) == [[("🔔 ▸", "al:open:gear:irrigation_pause")]] from lib.alerts.menu import keyboard kinds = available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast", "Irrigation"}) for section in (None, "alerts", "sound", "gear"): # every button's data fits Telegram's 64 bytes, for the new type too From 6550a86421d5441666530834bdaafe10443bef71 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 12:31:26 +0000 Subject: [PATCH 17/19] Toggles: green when on, struck through and uncoloured when off Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- lib/alerts/menu.py | 8 ++++---- lib/buttons.py | 5 +++-- lib/irrigation/menu.py | 4 ++-- tests/test_alert_menu.py | 12 ++++++------ 5 files changed, 16 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 9b0a8ec..38bfe81 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A Telegram bot for a personal Ecowitt weather station and AirGradient air-quality sensor. Ask it about the weather or air quality (text or charts); it also sends silent alerts to every chat it is in: rain starting/stopping, gusts over 40 km/h (with the last day's wind chart), UV index of 10 or more in two readings in a row (with the last day's UV chart), indoor/outdoor temperatures crossing by more than 1°C (held back for the cooldown, then announced if it still stands, with the last day's temperature chart), -and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one 🔔 button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). Every toggle in the bot is a plain button while on and a red one with its name struck through while off. `/alerts` has two buttons, 🔔 Alerts and (in a private chat) 🔊 Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, plain while on and red with the name struck through while off, while one is open its own button stays (press it to close the list) and the other is hidden until you do; `/alerts on|off` still subscribes or unsubscribes every type. `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). +and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one 🔔 button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). Every toggle in the bot is a green button while on and a plain one with its name struck through while off. `/alerts` has two buttons, 🔔 Alerts and (in a private chat) 🔊 Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, green while on and with the name struck through while off, while one is open its own button stays (press it to close the list) and the other is hidden until you do; `/alerts on|off` still subscribes or unsubscribes every type. `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). ## Run diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index de88b8a..a537c59 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -1,5 +1,5 @@ """The alert settings as buttons. /alerts has two: 🔔 Alerts and (private chats) 🔊 Sound, each opening the full list of alert types as -toggles (plain while on, red and struck through while off) with the button that opened it kept (▾, pressing it closes the list), the other hidden meanwhile. Under an alert there is one bell that opens that type's +toggles (green while on, struck through (not coloured) while off) with the button that opened it kept (▾, pressing it closes the list), the other hidden meanwhile. Under an alert there is one bell that opens that type's two toggles, Alert and Sound. Pure: it only builds the keyboard and the title from what a chat has muted. Callback data (self-contained and short, so an old message's buttons still work after a restart): @@ -46,7 +46,7 @@ def _button(text: str, data: str) -> InlineKeyboardButton: def alert_keyboard(kind: str, muted: set[str], loud: set[str] | None, open: str | None = None) -> InlineKeyboardMarkup: """The buttons under an alert of type `kind`: one bell (🔔 ▸, ▾ once open) that opens up to two toggles, Alert (this type's alerts in this chat) and, only while that is on and in a private chat (`loud` is not None), Sound (whether they make a notification sound). - A toggle is plain while on, red and struck through while off. Every button carries the type: al:open:gear:, al:close:, + A toggle is green while on, struck through (not coloured) while off. Every button carries the type: al:open:gear:, al:close:, al:on|off::gear:, al:snd::on|off:.""" is_open = open == "gear" rows = [[_button(menu_text("🔔", is_open), f"al:close:{kind}" if is_open else f"al:open:gear:{kind}")]] @@ -60,7 +60,7 @@ def alert_keyboard(kind: str, muted: set[str], loud: set[str] | None, open: str def _list(kinds: list[str], is_on, data) -> list[list]: - """A toggle for each of the alert types, PER_ROW to a row, plain for a type that is on, red and struck through for one that is off. is_on(kind): its state; + """A toggle for each of the alert types, PER_ROW to a row, green for a type that is on, struck through and uncoloured for one that is off. is_on(kind): its state; data(kind): the callback data that flips it.""" return list(map(list, batched((toggle_button(LABELS[k], is_on(k), data(k)) for k in kinds), PER_ROW))) @@ -68,7 +68,7 @@ def _list(kinds: list[str], is_on, data) -> list[list]: def keyboard(muted: set[str], available: list[str], open: str | None = None, parent: str | None = None, loud: set[str] | None = None) -> InlineKeyboardMarkup: """The /alerts menu: two buttons, 🔔 Alerts and (private chats) 🔊 Sound. Each opens a menu with a list of alert types, each a toggle - plain while on, red and struck through while off (alerts on or off in this chat; notification sound on or off, for the types that are + green while on, struck through (not coloured) while off (alerts on or off in this chat; notification sound on or off, for the types that are on). While one is open its own button stays (▾) above the list, pressing it closes the list, and the other is hidden until then. `open`: alerts, sound (or one of an earlier version's sections). `loud`: the types whose alerts make a notification sound; None (a group) has no Sound. `parent`: the type of the alert these buttons sit under, which has a menu of its own (alert_keyboard).""" diff --git a/lib/buttons.py b/lib/buttons.py index 68bd333..1a5e8a3 100644 --- a/lib/buttons.py +++ b/lib/buttons.py @@ -16,5 +16,6 @@ def struck(text: str) -> str: def toggle_button(word: str, on: bool, data: str) -> InlineKeyboardButton: - """A button that flips a setting: its plain word while the setting is on, the word struck through on a red button while it is off.""" - return InlineKeyboardButton(word if on else struck(word), callback_data=data, style=None if on else KeyboardButtonStyle.DANGER) + """A button that flips a setting: its word on a green button while the setting is on, the word struck through on a plain button while it + is off.""" + return InlineKeyboardButton(word if on else struck(word), callback_data=data, style=KeyboardButtonStyle.SUCCESS if on else None) diff --git a/lib/irrigation/menu.py b/lib/irrigation/menu.py index 1360bfa..203d7de 100644 --- a/lib/irrigation/menu.py +++ b/lib/irrigation/menu.py @@ -3,7 +3,7 @@ Collapsed: [💧 Water] [⏸ Pause] / [🔔 Alerts] [🔄 Refresh]. While the valve is on, a [Water off ❌] row follows. While the timer is paused, Pause is [▶️ Unpause] (one press ends the pause) and Water is hidden. A press on Water, Pause or Alerts opens its options under the rows (▸ after the text marks a menu, ▾ the open one; pressing it again closes them). Water lists 5, 10, 20 and 30 minutes (a run is always timed). Alerts has -one toggle per alert type, plain while this chat gets it, red and struck through while it does not; pressing it flips it. +one toggle per alert type, green while this chat gets it, struck through and uncoloured while it does not; pressing it flips it. Callback data (short, self-contained, so an old message's buttons still work after a restart): ir:open: | ir:close | ir:refresh (read the state again, change nothing) @@ -89,7 +89,7 @@ def status_text(status: dict, alerts: bool | None = None, pause_alerts: bool | N def keyboard(battery: bool, pause: bool, open: str | None = None, paused: bool = False, running: bool = False) -> InlineKeyboardMarkup: """The buttons under the status. `battery` and `pause`: this chat gets the irrigation battery / pause alerts; each Alerts button - is a toggle (plain when on, red and struck through when off). `paused`: the timer is paused, so its + is a toggle (green when on, struck through and uncoloured when off). `paused`: the timer is paused, so its button unpauses and Water is hidden. `running`: the valve is on, so Water off is a top-level row.""" def head(name: str, text: str) -> InlineKeyboardButton: return _button(menu_text(text, open == name), "ir:close" if open == name else f"ir:open:{name}") diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 0f67848..89595b6 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -22,17 +22,17 @@ def test_alerts_has_two_buttons_alerts_and_sound_and_a_group_has_only_the_first( assert rows(keyboard(set(), KINDS, "sound")) == rows(keyboard(set(), KINDS)) # a group cannot open Sound -def test_the_bell_toggles_are_plain_when_on_and_red_when_off(): +def test_the_bell_toggles_are_green_when_on_and_struck_through_when_off(): styles = lambda m: {b.text: b.style for row in m.inline_keyboard for b in row} - assert styles(keyboard(set(), KINDS, "gear", parent="uv", loud=set())) == {"🔔 ▾": None, "Alert": None, strike("Sound"): "danger"} - assert styles(keyboard({"uv"}, KINDS, "gear", parent="uv", loud={"uv"})) == {"🔔 ▾": None, strike("Alert"): "danger"} + assert styles(keyboard(set(), KINDS, "gear", parent="uv", loud=set())) == {"🔔 ▾": None, "Alert": "success", strike("Sound"): None} + assert styles(keyboard({"uv"}, KINDS, "gear", parent="uv", loud={"uv"})) == {"🔔 ▾": None, strike("Alert"): None} -def test_a_type_that_is_on_is_a_plain_button_and_one_that_is_off_a_red_one(): +def test_a_type_that_is_on_is_a_green_button_and_one_that_is_off_is_struck_through_and_plain(): markup = keyboard({"gusts"}, KINDS, "alerts", loud=set()) styles = {b.text: b.api_kwargs.get("style") or b.style for row in markup.inline_keyboard for b in row} - assert styles["rain"] is None and styles[strike("gusts")] == "danger" and not styles["🔔 Alerts ▾"] - assert markup.inline_keyboard[1][1].to_dict()["style"] == "danger" and "style" not in markup.inline_keyboard[1][0].to_dict() # and it is sent to Telegram + assert styles["rain"] == "success" and styles[strike("gusts")] is None and not styles["🔔 Alerts ▾"] + assert markup.inline_keyboard[1][0].to_dict()["style"] == "success" and "style" not in markup.inline_keyboard[1][1].to_dict() # and it is sent to Telegram def test_each_menu_lists_its_types_struck_through_when_off_and_keeps_only_its_own_button(): From 233135de512d3af56cd10a517f384f52047d674e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:01:22 +0000 Subject: [PATCH 18/19] Consolidate the button code: shared helpers, one callback-data scheme, no legacy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/buttons.py is the one home for button(), opener() (the ▸/▾ menu button) and toggle_button() (its data is a function of what a press sets), used by the alert, irrigation, period and report menus. In lib/alerts/menu.py the callback data is built and read in one place (Press, parse), keyboard() is only /alerts and alert_keyboard() only the bell under an alert (internal name "bell", was "gear"), and Bot.on_alert_button reads a Press instead of splitting the data. Dropped: the legacy sections of earlier menus, al:noop, the al:on|off:all branch and the Notifier's unused kinds. Every keyboard in a 407-state matrix is identical to before (apart from gear -> bell in the data). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- ecobot.py | 2 +- lib/alerts/menu.py | 123 ++++++++++++++++++++++++--------------- lib/alerts/notify.py | 12 ++-- lib/bot.py | 42 ++++++------- lib/buttons.py | 25 +++++--- lib/irrigation/menu.py | 23 +++----- lib/periods.py | 7 ++- lib/report_menu.py | 6 +- tests/test_admin_only.py | 14 +++-- tests/test_alert_menu.py | 76 ++++++++++++++---------- tests/test_irrigation.py | 13 +++-- 11 files changed, 193 insertions(+), 150 deletions(-) diff --git a/ecobot.py b/ecobot.py index f246e16..d4344d5 100644 --- a/ecobot.py +++ b/ecobot.py @@ -144,7 +144,7 @@ async def main(): log.info("@%s + %s", app.bot.username, cfg.xai_model) log.info("Sources: %s", ", ".join(s.name for s in sources)) kinds = kinds_for((s.name for s in sources), irrigation is not None) - notify = Notifier(app.bot, state, kinds) + notify = Notifier(app.bot, state) await bot.refresh_keyboards(app.bot) # chats whose buttons are out of date are told, with the new ones # Alerts diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index a537c59..5614a1c 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -1,20 +1,22 @@ -"""The alert settings as buttons. /alerts has two: 🔔 Alerts and (private chats) 🔊 Sound, each opening the full list of alert types as -toggles (green while on, struck through (not coloured) while off) with the button that opened it kept (▾, pressing it closes the list), the other hidden meanwhile. Under an alert there is one bell that opens that type's -two toggles, Alert and Sound. Pure: it only builds the keyboard and the title from what a chat has muted. - -Callback data (self-contained and short, so an old message's buttons still work after a restart): - al:open:alerts | al:open:sound | al:close (the open button) | al:noop (the heading rows of messages sent by an earlier version) - al:on::alerts | al:off::alerts (alerts of one type on or off in the chat) - al:snd:: (notification sound for one type; private chats only) -An alert's buttons end with the alert's own type: al:open:gear:rain, al:close:rain, al:off:rain:gear:rain, al:snd:rain:on:rain. A chat's -/alerts message has no such field. +"""The alert settings as buttons. Pure: it only builds the keyboards and the title from what a chat has muted. + +/alerts has two buttons, 🔔 Alerts and (private chats) 🔊 Sound. Each opens a list of alert types as toggles (Sound lists only the types +that are on), the button that opened it kept above the list (▾; pressing it closes the list) and the other hidden meanwhile. +Under an alert there is one 🔔 that opens that type's two toggles, Alert and (while it is on, private chats) Sound. + +Callback data is self-contained and short, so an old message's buttons still work after a restart. It is built and read here only (Press): + al:open: al:close the /alerts message + al:on|off::alerts al:snd::on|off a type's alerts, or its sound, on or off + al:open:bell: al:close: the bell under an alert of that type + al:on|off::bell: al:snd::on|off: its two toggles """ +from dataclasses import dataclass from itertools import batched -from telegram import InlineKeyboardButton, InlineKeyboardMarkup +from telegram import InlineKeyboardMarkup -from ..buttons import menu_text, toggle_button +from ..buttons import opener, toggle_button # kind -> the name on the button, in the order listed LABELS = {"rain": "rain", "gusts": "gusts", "uv": "UV", "temps": "temperature crossing", @@ -23,7 +25,8 @@ ALL = "all" TITLE = "🔔 Alerts in this chat" PER_ROW = 2 -SETTINGS = ("set", "snd_on", "snd_off", "snd_on_o", "snd_off_o") # (_o: the lists of an earlier version) +ALERTS, SOUND, BELL = "alerts", "sound", "bell" # the menus that can be open +MENUS = {ALERTS: "🔔 Alerts", SOUND: "🔊 Sound"} # the /alerts buttons def available_kinds(sources: set[str]) -> list[str]: @@ -39,53 +42,77 @@ def kinds_for(source_names, irrigation: bool) -> list[str]: return available_kinds(set(source_names) | ({"Irrigation"} if irrigation else set())) -def _button(text: str, data: str) -> InlineKeyboardButton: - return InlineKeyboardButton(text, callback_data=data) +@dataclass(frozen=True) +class Press: + """What a button of these menus does, and its callback data both ways (data() builds it, parse() reads it back).""" + action: str # open or close a menu, flip a type's alert (alert) or its sound (sound) + kind: str | None = None # the alert type a toggle flips + section: str | None = None # the menu to show: ALERTS, SOUND or BELL + parent: str | None = None # the alert type the buttons sit under (their menu is the bell), if any + on: bool = False # what a toggle sets + + def data(self) -> str: + tail = f":{self.parent}" if self.parent else "" + verb = "on" if self.on else "off" + match self.action: + case "open": + return f"al:open:{self.section}{tail}" + case "close": + return f"al:close{tail}" + case "alert": + return f"al:{verb}:{self.kind}:{self.section}{tail}" + case _: + return f"al:snd:{self.kind}:{verb}{tail}" + + +def parse(data: str | None) -> Press | None: + """The press that callback `data` is, or None when it is not the data of any button these menus draw.""" + parts = (data or "").split(":") + if parts[0] != "al" or len(parts) < 2: + return None + action, rest = parts[1], parts[2:] + if action == "close" and len(rest) <= 1: + return Press("close", parent=rest[0] if rest else None) + if action == "open" and len(rest) in (1, 2): + return Press("open", section=rest[0], parent=rest[1] if len(rest) == 2 else None) + if action in ("on", "off") and len(rest) in (2, 3): + return Press("alert", rest[0], rest[1], rest[2] if len(rest) == 3 else None, action == "on") + if action == "snd" and len(rest) in (2, 3) and rest[1] in ("on", "off"): + parent = rest[2] if len(rest) == 3 else None + return Press("sound", rest[0], BELL if parent else SOUND, parent, rest[1] == "on") + return None def alert_keyboard(kind: str, muted: set[str], loud: set[str] | None, open: str | None = None) -> InlineKeyboardMarkup: - """The buttons under an alert of type `kind`: one bell (🔔 ▸, ▾ once open) that opens up to two toggles, Alert (this type's alerts - in this chat) and, only while that is on and in a private chat (`loud` is not None), Sound (whether they make a notification sound). - A toggle is green while on, struck through (not coloured) while off. Every button carries the type: al:open:gear:, al:close:, - al:on|off::gear:, al:snd::on|off:.""" - is_open = open == "gear" - rows = [[_button(menu_text("🔔", is_open), f"al:close:{kind}" if is_open else f"al:open:gear:{kind}")]] + """The buttons under an alert of type `kind`: one bell (🔔 ▸, ▾ once `open` is BELL) that opens up to two toggles, Alert (this type's + alerts in this chat) and, only while that is on and in a private chat (`loud` is not None), Sound (whether they make a notification + sound).""" + is_open = open == BELL + rows = [[opener("🔔", is_open, Press("open", section=BELL, parent=kind).data(), Press("close", parent=kind).data())]] if is_open: on = kind not in muted - rows.append([toggle_button("Alert", on, f"al:{'off' if on else 'on'}:{kind}:gear:{kind}")]) + rows.append([toggle_button("Alert", on, lambda turn_on: Press("alert", kind, BELL, kind, turn_on).data())]) if on and loud is not None: - sound = kind in loud - rows.append([toggle_button("Sound", sound, f"al:snd:{kind}:{'off' if sound else 'on'}:{kind}")]) + rows.append([toggle_button("Sound", kind in loud, lambda turn_on: Press("sound", kind, BELL, kind, turn_on).data())]) return InlineKeyboardMarkup(rows) -def _list(kinds: list[str], is_on, data) -> list[list]: - """A toggle for each of the alert types, PER_ROW to a row, green for a type that is on, struck through and uncoloured for one that is off. is_on(kind): its state; - data(kind): the callback data that flips it.""" - return list(map(list, batched((toggle_button(LABELS[k], is_on(k), data(k)) for k in kinds), PER_ROW))) +def _toggles(kinds: list[str], is_on, section: str, action: str) -> list[list]: + """A toggle for each of these alert types, PER_ROW to a row. is_on(kind): its state.""" + return list(map(list, batched((toggle_button(LABELS[k], is_on(k), lambda turn_on, k=k: Press(action, k, section, on=turn_on).data()) + for k in kinds), PER_ROW))) -def keyboard(muted: set[str], available: list[str], open: str | None = None, parent: str | None = None, - loud: set[str] | None = None) -> InlineKeyboardMarkup: - """The /alerts menu: two buttons, 🔔 Alerts and (private chats) 🔊 Sound. Each opens a menu with a list of alert types, each a toggle - green while on, struck through (not coloured) while off (alerts on or off in this chat; notification sound on or off, for the types that are - on). While one is open its own button stays (▾) above the list, pressing it closes the list, and the other is hidden until then. `open`: alerts, sound (or one of an earlier version's sections). `loud`: the - types whose alerts make a notification sound; None (a group) has no Sound. `parent`: the type of the alert these buttons sit under, - which has a menu of its own (alert_keyboard).""" - if parent: - return alert_keyboard(parent, muted, loud, open) - open = {"sub": "alerts", "unsub": "alerts", "other": "alerts", **dict.fromkeys(SETTINGS, "sound")}.get(open, open) +def keyboard(muted: set[str], available: list[str], open: str | None = None, loud: set[str] | None = None) -> InlineKeyboardMarkup: + """The /alerts menu: 🔔 Alerts and, when some type is on in a private chat, 🔊 Sound. `open` (ALERTS or SOUND): that menu's list, with + its own button above it and the other one hidden. `loud`: the types whose alerts make a notification sound; None (a group) has no Sound.""" sound_types = [k for k in available if k not in muted] if loud is not None else [] # (a type that is off has no sound to set) - if open == "alerts": - rows = [[_button(menu_text("🔔 Alerts", True), "al:close")], - *_list(available, lambda k: k not in muted, lambda k: f"al:{'off' if k not in muted else 'on'}:{k}:alerts")] - elif open == "sound" and sound_types: - rows = [[_button(menu_text("🔊 Sound", True), "al:close")], - *_list(sound_types, lambda k: k in loud, lambda k: f"al:snd:{k}:{'off' if k in loud else 'on'}")] - else: - return InlineKeyboardMarkup([[_button(menu_text("🔔 Alerts", False), "al:open:alerts"), - *([_button(menu_text("🔊 Sound", False), "al:open:sound")] if sound_types else [])]]) - return InlineKeyboardMarkup(rows) + head = lambda section: opener(MENUS[section], open == section, Press("open", section=section).data(), Press("close").data()) + if open == ALERTS: + return InlineKeyboardMarkup([[head(ALERTS)], *_toggles(available, lambda k: k not in muted, ALERTS, "alert")]) + if open == SOUND and sound_types: + return InlineKeyboardMarkup([[head(SOUND)], *_toggles(sound_types, lambda k: k in loud, SOUND, "sound")]) + return InlineKeyboardMarkup([[head(ALERTS), *([head(SOUND)] if sound_types else [])]]) def title(muted: set[str], available: list[str]) -> str: diff --git a/lib/alerts/notify.py b/lib/alerts/notify.py index 437e32b..712339b 100644 --- a/lib/alerts/notify.py +++ b/lib/alerts/notify.py @@ -16,7 +16,7 @@ from telegram.error import BadRequest, ChatMigrated, Forbidden, TelegramError from ..logs import QUOTES -from .menu import ALL, LABELS, keyboard +from .menu import ALL, LABELS, alert_keyboard log = logging.getLogger(__name__) @@ -192,9 +192,8 @@ def with_footer(text: str, link: tuple[str, str] | None = None, extra: str | Non class Notifier: - def __init__(self, bot, state: AlertState, kinds: list[str] | None = None): - """kinds: the alert types this bot sends (the settings buttons under each alert list them).""" - self.bot, self.state, self.kinds = bot, state, kinds or list(LABELS) + def __init__(self, bot, state: AlertState): + self.bot, self.state = bot, state async def __call__(self, text: str, link: tuple[str, str] | None = None, kind: str | None = None, photo: bytes | str | None = None): """Send an alert (silently) to every chat subscribed to its type; forget chats the bot can no @@ -221,10 +220,9 @@ async def to_chat(self, chat_id: int, text: str, link: tuple[str, str] | None = return sent async def _post(self, chat_id: int, text: str, entities, kind: str | None, photo: bytes | str | None): - """The message itself (returned): silent unless the chat turned sound on for this type, with the settings buttons (which highlight this - alert's own type). With a photo it is the picture with the text as its caption; a picture Telegram refuses falls back to the text alone.""" + """The message itself (returned): silent unless the chat turned sound on for this type, with the alert type's settings button (the bell). With a photo it is the picture with the text as its caption; a picture Telegram refuses falls back to the text alone.""" common = dict(disable_notification=kind not in self.state.loud(chat_id), - reply_markup=keyboard(self.state.muted(chat_id), self.kinds, parent=kind, loud=self.state.loud(chat_id) if chat_id > 0 else None)) + reply_markup=alert_keyboard(kind, self.state.muted(chat_id), self.state.loud(chat_id) if chat_id > 0 else None) if kind else None) if photo: try: return await self.bot.send_photo(chat_id, photo, caption=text, caption_entities=entities, **common) diff --git a/lib/bot.py b/lib/bot.py index 8d60b29..583c27f 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -408,17 +408,15 @@ async def on_usage(self, update: Update, context: ContextTypes.DEFAULT_TYPE): @staticmethod def _alerts_named(kind: str) -> str: - """"All alerts" or "UV alerts" (not "Uv"), for a toast.""" - return "All alerts" if kind == menu.ALL else f"{menu.LABELS[kind][0].upper()}{menu.LABELS[kind][1:]} alerts" + """"UV alerts" (not "Uv"), for a toast.""" + return f"{menu.LABELS[kind][0].upper()}{menu.LABELS[kind][1:]} alerts" async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TYPE): - """A press on the alert settings buttons (under an alert or the /alerts message): open or close a section, or subscribe - or unsubscribe a type or all. In a group only admins may change them.""" + """A press on the alert settings buttons (under an alert or the /alerts message): open or close a menu, or turn a type's alerts or + its sound on or off. In a group only admins may change them.""" query = update.callback_query - parts = (query.data or "").split(":") - action, arg, section, tail = (parts + ["", "", "", ""])[1:5] - parent = section if action == "open" else arg if action == "close" else tail # the alert's own type, when under an alert - if action == "noop" or not self.state: + press = menu.parse(query.data) + if press is None or not self.state: await query.answer() return message = query.message @@ -427,25 +425,21 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY await query.answer("Only group admins can change alerts") return kinds = self._alert_kinds() - parent = parent if parent in kinds else None - toast, opened = None, section or None - if action in ("open", "close"): - opened = arg if action == "open" else None - elif action == "snd" and chat.type == ChatType.PRIVATE and arg in kinds and section in ("on", "off") and chat.id in self.state.chats: - self.state.set_sound(chat.id, arg, section == "on") - parent = tail if tail in kinds else None - opened = "gear" if parent else "sound" # stay in the list (under an alert: its bell stays open) - toast = f"{self._alerts_named(arg)} {'now make' if section == 'on' else 'no longer make'} a sound" - elif action in ("on", "off") and (arg == menu.ALL or arg in kinds) and chat.id in self.state.chats: - self.state.set_kind(chat.id, arg, action == "on") - toast = f"{self._alerts_named(arg)} {action} in this chat" - else: + parent = press.parent if press.parent in kinds else None + private = chat.type == ChatType.PRIVATE + toast, opened = None, press.section if press.action != "close" else None + if press.action == "sound" and private and press.kind in kinds and chat.id in self.state.chats: + self.state.set_sound(chat.id, press.kind, press.on) + toast = f"{self._alerts_named(press.kind)} {'now make' if press.on else 'no longer make'} a sound" + elif press.action == "alert" and press.kind in kinds and chat.id in self.state.chats: + self.state.set_kind(chat.id, press.kind, press.on) + toast = f"{self._alerts_named(press.kind)} {'on' if press.on else 'off'} in this chat" + elif press.action not in ("open", "close"): await query.answer() return muted = self.state.muted(chat.id) - private = chat.type == ChatType.PRIVATE - markup = menu.keyboard(muted, kinds, opened if opened in ("alerts", "sound", "gear", "sub", "unsub", "other", *menu.SETTINGS) else None, parent, - self.state.loud(chat.id) if private else None) + loud = self.state.loud(chat.id) if private else None + markup = menu.alert_keyboard(parent, muted, loud, opened) if parent else menu.keyboard(muted, kinds, opened, loud) await query.answer(toast) try: if (message.text or "").startswith(menu.TITLE): # the /alerts message: keep its on/off summary current diff --git a/lib/buttons.py b/lib/buttons.py index 1a5e8a3..5a23e5d 100644 --- a/lib/buttons.py +++ b/lib/buttons.py @@ -1,21 +1,28 @@ -"""Shared look of the bot's inline buttons.""" +"""Shared look of the bot's inline buttons: a plain button, a menu opener (▸ closed, ▾ open) and a toggle (green while on, struck +through while off). Every menu builds its buttons with these.""" + +from collections.abc import Callable from telegram import InlineKeyboardButton from telegram.constants import KeyboardButtonStyle -def menu_text(text: str, is_open: bool) -> str: - """The label of a button that opens a menu: ▸ after the text while it is closed, ▾ in the same place once it is open (so pressing it - looks like the arrow turning). Buttons that do something have neither.""" - return f"{text} {'▾' if is_open else '▸'}" +def button(text: str, data: str, style: str | None = None) -> InlineKeyboardButton: + return InlineKeyboardButton(text, callback_data=data, style=style) + + +def opener(text: str, is_open: bool, open_data: str, close_data: str) -> InlineKeyboardButton: + """A button that opens a menu: ▸ after the text while it is closed, ▾ in the same place once it is open (so pressing it looks like the + arrow turning), and pressing it then closes the menu. Buttons that do something have neither.""" + return button(f"{text} {'▾' if is_open else '▸'}", close_data if is_open else open_data) def struck(text: str) -> str: """The text with a line through it (a button cannot be styled, so each letter gets the combining long stroke).""" - return "".join(f"{c}\u0336" for c in text) + return "".join(f"{c}̶" for c in text) -def toggle_button(word: str, on: bool, data: str) -> InlineKeyboardButton: +def toggle_button(word: str, on: bool, data: Callable[[bool], str]) -> InlineKeyboardButton: """A button that flips a setting: its word on a green button while the setting is on, the word struck through on a plain button while it - is off.""" - return InlineKeyboardButton(word if on else struck(word), callback_data=data, style=KeyboardButtonStyle.SUCCESS if on else None) + is off. `data(turn_on)`: the callback data of pressing it, which sets the setting to `turn_on` (the opposite of how it is now).""" + return button(word if on else struck(word), data(not on), KeyboardButtonStyle.SUCCESS if on else None) diff --git a/lib/irrigation/menu.py b/lib/irrigation/menu.py index 203d7de..ede55c4 100644 --- a/lib/irrigation/menu.py +++ b/lib/irrigation/menu.py @@ -14,9 +14,9 @@ import math import re -from telegram import InlineKeyboardButton, InlineKeyboardMarkup, MessageEntity +from telegram import InlineKeyboardMarkup, MessageEntity -from ..buttons import menu_text, toggle_button +from ..buttons import button, opener, toggle_button TITLE = "🌱 Irrigation" WATER_MINUTES = (5, 10, 20, 30) @@ -59,10 +59,6 @@ def with_error(text: str, error: str = PAUSED_ERROR) -> tuple[str, list[MessageE return head + error, [MessageEntity(MessageEntity.BOLD, units(head), units(error))] -def _button(text: str, data: str) -> InlineKeyboardButton: - return InlineKeyboardButton(text, callback_data=data) - - def status_text(status: dict, alerts: bool | None = None, pause_alerts: bool | None = None) -> str: """The controller's state, every line always there, in this order: mode: auto @@ -91,18 +87,17 @@ def keyboard(battery: bool, pause: bool, open: str | None = None, paused: bool = """The buttons under the status. `battery` and `pause`: this chat gets the irrigation battery / pause alerts; each Alerts button is a toggle (green when on, struck through and uncoloured when off). `paused`: the timer is paused, so its button unpauses and Water is hidden. `running`: the valve is on, so Water off is a top-level row.""" - def head(name: str, text: str) -> InlineKeyboardButton: - return _button(menu_text(text, open == name), "ir:close" if open == name else f"ir:open:{name}") - delay = _button("▶️ Unpause", "ir:delay:cancel") if paused else head("delay", "⏸ Pause") + head = lambda name, text: opener(text, open == name, f"ir:open:{name}", "ir:close") + delay = button("▶️ Unpause", "ir:delay:cancel") if paused else head("delay", "⏸ Pause") rows = [[delay] if paused else [head("water", "💧 Water"), delay], - [head("alerts", "🔔 Alerts"), _button("🔄 Refresh", "ir:refresh")]] + [head("alerts", "🔔 Alerts"), button("🔄 Refresh", "ir:refresh")]] if running: # the valve is open: off is one press away, whatever else is open - rows.append([_button("Water off ❌", "ir:sw:off")]) + rows.append([button("Water off ❌", "ir:sw:off")]) if open == "water" and not paused: - rows.append([_button(f"{m} min", f"ir:water:{m}") for m in WATER_MINUTES]) + rows.append([button(f"{m} min", f"ir:water:{m}") for m in WATER_MINUTES]) elif open == "delay" and not paused: - rows.append([_button(d, f"ir:delay:{d}") for d in DELAYS if d != "cancel"]) + rows.append([button(d, f"ir:delay:{d}") for d in DELAYS if d != "cancel"]) elif open == "alerts": for on, kind, name in ((battery, "batt", "battery"), (pause, "pause", "pause")): - rows.append([toggle_button(f"{name} alerts", on, f"ir:{kind}:{'off' if on else 'on'}")]) + rows.append([toggle_button(f"{name} alerts", on, lambda turn_on, kind=kind: f"ir:{kind}:{'on' if turn_on else 'off'}")]) return InlineKeyboardMarkup(rows) diff --git a/lib/periods.py b/lib/periods.py index ed34a03..1677aac 100644 --- a/lib/periods.py +++ b/lib/periods.py @@ -4,7 +4,9 @@ from collections import OrderedDict from dataclasses import dataclass -from telegram import InlineKeyboardButton, InlineKeyboardMarkup +from telegram import InlineKeyboardMarkup + +from .buttons import button PERIODS = (1, 7, 30, 90, 365) NAMES = {1: "1D", 7: "7D", 30: "1M", 90: "3M", 365: "1Y"} # what the buttons say @@ -14,8 +16,7 @@ def keyboard(current: int | None = None) -> InlineKeyboardMarkup: """The one row of period buttons; the one for the chart's own period (`current`, in days) is marked with ●.""" - return InlineKeyboardMarkup([[InlineKeyboardButton(f"\u25cf {NAMES[days]}" if days == current else NAMES[days], - callback_data=f"{PREFIX}{days}") for days in PERIODS]]) + return InlineKeyboardMarkup([[button(f"\u25cf {NAMES[days]}" if days == current else NAMES[days], f"{PREFIX}{days}") for days in PERIODS]]) def days_in(data: str | None) -> int | None: diff --git a/lib/report_menu.py b/lib/report_menu.py index 48da949..77b0712 100644 --- a/lib/report_menu.py +++ b/lib/report_menu.py @@ -1,11 +1,13 @@ """The button under the full report ("status"): Refresh, which reads everything again and redraws the report in place (Bot.on_report_button).""" -from telegram import InlineKeyboardButton, InlineKeyboardMarkup +from telegram import InlineKeyboardMarkup + +from .buttons import button PREFIX = "rp:" REFRESH = f"{PREFIX}refresh" def keyboard() -> InlineKeyboardMarkup: - return InlineKeyboardMarkup([[InlineKeyboardButton("🔄 Refresh", callback_data=REFRESH)]]) + return InlineKeyboardMarkup([[button("🔄 Refresh", REFRESH)]]) diff --git a/tests/test_admin_only.py b/tests/test_admin_only.py index 41cea63..fa87839 100644 --- a/tests/test_admin_only.py +++ b/tests/test_admin_only.py @@ -282,8 +282,8 @@ async def test_usage_is_the_help_and_alerts_button(tmp_path, kind): # ---------- every button comes in through one place ---------- -def alert_keyboard(opened="alerts", parent=None): - return menu.keyboard(set(), menu.available_kinds({"Ecowitt"}), opened, parent, set()) +def alert_keyboard(opened="alerts"): + return menu.keyboard(set(), menu.available_kinds({"Ecowitt"}), opened, set()) async def test_a_stranger_is_refused_on_every_family_of_buttons_and_an_admin_is_let_through(tmp_path): @@ -321,10 +321,12 @@ def every_keyboard(bot): kinds = menu.available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast", "Irrigation"}) muted_sets = [set(), {"rain"}, {"rain", "uv", "particulates"}, set(menu.LABELS)] for muted in muted_sets: - for opened in (None, "alerts", "sound", "gear", "sub", "unsub", "set", "snd_on", "snd_off"): - for parent in (None, *kinds): - for loud in (None, set(), set(kinds)): - yield menu.keyboard(muted, kinds, opened, parent, loud) + for loud in (None, set(), set(kinds)): + for opened in (None, menu.ALERTS, menu.SOUND): + yield menu.keyboard(muted, kinds, opened, loud) + for kind in kinds: + for opened in (None, menu.BELL): + yield menu.alert_keyboard(kind, muted, loud, opened) for battery in (True, False): for pause in (True, False): for opened in (None, *irrigation_menu.SECTIONS, "bogus"): diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 89595b6..90ffe6e 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -4,7 +4,7 @@ from telegram.error import BadRequest from lib.alerts import AlertState, Notifier -from lib.alerts.menu import ALL, LABELS, TITLE, available_kinds, keyboard, title +from lib.alerts.menu import ALL, LABELS, TITLE, Press, alert_keyboard, available_kinds, keyboard, parse, title from lib.bot import Bot from lib.buttons import struck as strike from tests.fakes import TZ @@ -24,8 +24,27 @@ def test_alerts_has_two_buttons_alerts_and_sound_and_a_group_has_only_the_first( def test_the_bell_toggles_are_green_when_on_and_struck_through_when_off(): styles = lambda m: {b.text: b.style for row in m.inline_keyboard for b in row} - assert styles(keyboard(set(), KINDS, "gear", parent="uv", loud=set())) == {"🔔 ▾": None, "Alert": "success", strike("Sound"): None} - assert styles(keyboard({"uv"}, KINDS, "gear", parent="uv", loud={"uv"})) == {"🔔 ▾": None, strike("Alert"): None} + assert styles(alert_keyboard("uv", set(), set(), "bell")) == {"🔔 ▾": None, "Alert": "success", strike("Sound"): None} + assert styles(alert_keyboard("uv", {"uv"}, {"uv"}, "bell")) == {"🔔 ▾": None, strike("Alert"): None} + + +def test_every_button_of_every_keyboard_parses_and_builds_back_to_the_same_data(): + muted_sets = [set(), {"rain"}, {"rain", "uv", "particulates"}, set(LABELS)] + seen = set() + for muted in muted_sets: + for loud in (None, set(), set(LABELS), {"rain", "uv"}): + boards = [keyboard(muted, list(LABELS), open, loud) for open in (None, "alerts", "sound")] + boards += [alert_keyboard(kind, muted, loud, open) for kind in LABELS for open in (None, "bell")] + for board in boards: + for row in board.inline_keyboard: + for b in row: + press = parse(b.callback_data) + assert press is not None and press.data() == b.callback_data, b.callback_data + seen.add(press.action) + assert seen == {"open", "close", "alert", "sound"} + assert parse("al:snd:uv:on:uv") == Press("sound", "uv", "bell", "uv", True) and parse("al:snd:uv:off") == Press("sound", "uv", "sound", None, False) + for junk in (None, "", "al", "al:", "al:noop", "al:open", "al:on:rain", "al:snd:rain:maybe", "al:off:a:b:c:d", "ir:water:5", "garbage"): + assert parse(junk) is None, junk def test_a_type_that_is_on_is_a_green_button_and_one_that_is_off_is_struck_through_and_plain(): @@ -48,8 +67,6 @@ def test_each_menu_lists_its_types_struck_through_when_off_and_keeps_only_its_ow assert [t for row in got[2:] for t, _ in row] == [strike(w) for w in ("temperature crossing", "particulates", "pollen & asthma", "forecast changes")] assert "gusts" not in str(got) assert rows(keyboard(set(KINDS), KINDS, "sound", loud=set())) == [[("🔔 Alerts ▸", "al:open:alerts")]] # nothing is on: no Sound button either - for old, new in (("sub", "alerts"), ("unsub", "alerts"), ("other", "alerts"), ("set", "sound"), ("snd_on", "sound"), ("snd_off_o", "sound")): - assert rows(keyboard({"gusts"}, KINDS, old, loud=set())) == rows(keyboard({"gusts"}, KINDS, new, loud=set())), old # an earlier version's sections def test_only_the_types_the_bot_has_are_listed_and_every_callback_is_short(): @@ -99,12 +116,12 @@ async def test_an_alert_goes_to_the_chats_subscribed_to_its_type_and_carries_the state.add_chat(chat, str(chat)) state.set_kind(2, "rain", False) state.set_kind(3, ALL, False) - notifier = Notifier(bot, state, KINDS) + notifier = Notifier(bot, state) await notifier("It's raining", kind="rain") await notifier("Gusts", kind="gusts") assert [(c, t.split("\n")[0]) for c, t, _ in bot.sent] == [(1, "It's raining"), (1, "Gusts"), (2, "Gusts")] markup = bot.sent[0][2]["reply_markup"] - assert rows(markup) == [[("🔔 ▸", "al:open:gear:rain")]] # the alert's own type rides along + assert rows(markup) == [[("🔔 ▸", "al:open:bell:rain")]] # the alert's own type rides along assert bot.sent[0][1] == "It's raining" # no footer: the buttons say how to change it assert await notifier.to_chat(2, "x", kind="rain") is False and await notifier.to_chat(2, "x", kind="uv") is True @@ -266,37 +283,36 @@ async def spy(chat_id, text, link=None, kind=None): def test_under_an_alert_one_bell_opens_the_alert_toggle_and_then_the_sound_toggle(): - shut = rows(keyboard(set(), KINDS, None, parent="uv", loud=set())) - assert shut == [[("🔔 ▸", "al:open:gear:uv")]] - on = rows(keyboard(set(), KINDS, "gear", parent="uv", loud=set())) # both on... the sound is off - assert on == [[("🔔 ▾", "al:close:uv")], [("Alert", "al:off:uv:gear:uv")], [(strike("Sound"), "al:snd:uv:on:uv")]] - both = rows(keyboard(set(), KINDS, "gear", parent="uv", loud={"uv"})) + shut = rows(alert_keyboard("uv", set(), set())) + assert shut == [[("🔔 ▸", "al:open:bell:uv")]] + on = rows(alert_keyboard("uv", set(), set(), "bell")) # both on... the sound is off + assert on == [[("🔔 ▾", "al:close:uv")], [("Alert", "al:off:uv:bell:uv")], [(strike("Sound"), "al:snd:uv:on:uv")]] + both = rows(alert_keyboard("uv", set(), {"uv"}, "bell")) assert both[2] == [("Sound", "al:snd:uv:off:uv")] - off = rows(keyboard({"uv"}, KINDS, "gear", parent="uv", loud={"uv"})) # alert off: struck through, and no sound toggle - assert off == [[("🔔 ▾", "al:close:uv")], [(strike("Alert"), "al:on:uv:gear:uv")]] - group = rows(keyboard(set(), KINDS, "gear", parent="uv", loud=None)) # a group has no sound - assert group == [[("🔔 ▾", "al:close:uv")], [("Alert", "al:off:uv:gear:uv")]] - assert rows(keyboard(set(), KINDS, "unsub", parent="uv", loud=set())) == shut # (an earlier version's list: just the bell) + off = rows(alert_keyboard("uv", {"uv"}, {"uv"}, "bell")) # alert off: struck through, and no sound toggle + assert off == [[("🔔 ▾", "al:close:uv")], [(strike("Alert"), "al:on:uv:bell:uv")]] + group = rows(alert_keyboard("uv", set(), None, "bell")) # a group has no sound + assert group == [[("🔔 ▾", "al:close:uv")], [("Alert", "al:off:uv:bell:uv")]] async def test_the_bell_under_an_alert_toggles_the_alert_and_its_sound_and_stays_open(tmp_path): bot, state = make_bot(tmp_path) - q = Query("al:open:gear:uv", text="☀️ UV 10") + q = Query("al:open:bell:uv", text="☀️ UV 10") await press(bot, q) - assert rows(q.edits[0][1]) == [[("🔔 ▾", "al:close:uv")], [("Alert", "al:off:uv:gear:uv")], [(strike("Sound"), "al:snd:uv:on:uv")]] + assert rows(q.edits[0][1]) == [[("🔔 ▾", "al:close:uv")], [("Alert", "al:off:uv:bell:uv")], [(strike("Sound"), "al:snd:uv:on:uv")]] q = Query("al:snd:uv:on:uv", text="☀️ UV 10") await press(bot, q) assert state.loud(1) == {"uv"} and q.toast == "UV alerts now make a sound" and rows(q.edits[0][1])[2] == [("Sound", "al:snd:uv:off:uv")] - q = Query("al:off:uv:gear:uv", text="☀️ UV 10") # alert off: the sound toggle goes, the struck alert stays to turn it back on + q = Query("al:off:uv:bell:uv", text="☀️ UV 10") # alert off: the sound toggle goes, the struck alert stays to turn it back on await press(bot, q) assert "uv" in state.muted(1) and q.toast == "UV alerts off in this chat" - assert rows(q.edits[0][1]) == [[("🔔 ▾", "al:close:uv")], [(strike("Alert"), "al:on:uv:gear:uv")]] - q = Query("al:on:uv:gear:uv", text="☀️ UV 10") + assert rows(q.edits[0][1]) == [[("🔔 ▾", "al:close:uv")], [(strike("Alert"), "al:on:uv:bell:uv")]] + q = Query("al:on:uv:bell:uv", text="☀️ UV 10") await press(bot, q) assert "uv" not in state.muted(1) and rows(q.edits[0][1])[2] == [("Sound", "al:snd:uv:off:uv")] # the sound setting was kept q = Query("al:close:uv", text="☀️ UV 10") await press(bot, q) - assert rows(q.edits[0][1]) == [[("🔔 ▸", "al:open:gear:uv")]] + assert rows(q.edits[0][1]) == [[("🔔 ▸", "al:open:bell:uv")]] async def test_an_unknown_parent_in_a_button_is_ignored(tmp_path): @@ -348,7 +364,7 @@ async def test_an_alert_has_a_sound_only_in_a_chat_that_turned_it_on_and_groups_ state.add_chat(1, "1") state.add_chat(-5, "G") state.set_sound(1, "rain", True) - notifier = Notifier(bot, state, KINDS) + notifier = Notifier(bot, state) await notifier("It's raining", kind="rain") await notifier("UV", kind="uv") flags = {(c, t.split("\n")[0]): kw["disable_notification"] for c, t, kw in bot.sent} @@ -369,7 +385,7 @@ async def send_message(self, chat_id, text, **kw): state, bot = AlertState(tmp_path / "s3.json"), Moved() state.add_chat(-5, "G") state.set_kind(-5, "uv", False) - await Notifier(bot, state, KINDS)("It's raining", kind="rain") + await Notifier(bot, state)("It's raining", kind="rain") assert list(state.chats) == [-1005] and state.muted(-1005) == {"uv"} and [c for c, *_ in bot.sent] == [-1005] state.add_chat(-7, "H") # a press under an old alert of a moved group bot2, _ = make_bot(tmp_path) @@ -410,11 +426,11 @@ async def send_photo(self, chat_id, photo, **kw): async def test_an_alert_with_a_picture_is_the_picture_with_the_text_as_its_caption_and_the_buttons(tmp_path): state, bot = AlertState(tmp_path / "s.json"), SentWithPhotos() state.add_chat(1, "one") - await Notifier(bot, state, KINDS)("It's warmer outside", kind="temps", photo=b"png") + await Notifier(bot, state)("It's warmer outside", kind="temps", photo=b"png") assert bot.sent == [] and len(bot.photos) == 1 chat, photo, kw = bot.photos[0] assert (chat, photo, kw["caption"]) == (1, b"png", "It's warmer outside") and kw["disable_notification"] is True - assert rows(kw["reply_markup"])[0][0] == ("🔔 ▸", "al:open:gear:temps") # the alert's own buttons ride along + assert rows(kw["reply_markup"])[0][0] == ("🔔 ▸", "al:open:bell:temps") # the alert's own buttons ride along async def test_an_alerts_picture_is_uploaded_once_and_the_other_chats_are_sent_telegrams_copy(tmp_path): @@ -425,7 +441,7 @@ async def send_photo(self, chat_id, photo, **kw): state, bot = AlertState(tmp_path / "s.json"), Keeps() for chat in (1, 2, 3): state.add_chat(chat, str(chat)) - await Notifier(bot, state, KINDS)("It's warmer outside", kind="temps", photo=b"png") + await Notifier(bot, state)("It's warmer outside", kind="temps", photo=b"png") assert [(chat, photo) for chat, photo, _ in bot.photos] == [(1, b"png"), (2, "large"), (3, "large")] @@ -433,8 +449,8 @@ async def test_a_picture_telegram_refuses_falls_back_to_the_text_but_a_missing_c from telegram.error import BadRequest state, bot = AlertState(tmp_path / "s.json"), SentWithPhotos(refuse=BadRequest("Wrong file identifier")) state.add_chat(1, "one") - await Notifier(bot, state, KINDS)("It's warmer outside", kind="temps", photo=b"png") + await Notifier(bot, state)("It's warmer outside", kind="temps", photo=b"png") assert bot.photos == [] and [(c, t) for c, t, _ in bot.sent] == [(1, "It's warmer outside")] gone = SentWithPhotos(refuse=BadRequest("Chat not found")) - await Notifier(gone, state, KINDS)("again", kind="temps", photo=b"png") + await Notifier(gone, state)("again", kind="temps", photo=b"png") assert gone.sent == [] and 1 not in state.chats diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index 1ea96fa..fed4ba2 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -704,17 +704,18 @@ async def test_the_pause_alert_reaches_every_subscribed_chat_with_the_usual_menu class Telegram: async def send_message(self, chat_id, text, **kw): delivered.append((chat_id, text, kw)) - notifier = Notifier(Telegram(), state, available_kinds({"Ecowitt", "Irrigation"})) + notifier = Notifier(Telegram(), state) m = IrrigationMonitor(Device(), state, notifier, TZ, 18, None, Forecast(90, 90)) await m.check(NOW) assert sorted(c for c, _, _ in delivered) == [-7, 1, 3] and all(text.startswith("☔ Irrigation paused") for _, text, _ in delivered) markup = delivered[0][2]["reply_markup"] - assert rows(markup) == [[("🔔 ▸", "al:open:gear:irrigation_pause")]] - from lib.alerts.menu import keyboard + assert rows(markup) == [[("🔔 ▸", "al:open:bell:irrigation_pause")]] + from lib.alerts.menu import alert_keyboard, keyboard kinds = available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast", "Irrigation"}) - for section in (None, "alerts", "sound", "gear"): # every button's data fits Telegram's 64 bytes, for the new type too - for parent in (None, "irrigation_pause", "irrigation_battery"): - assert all(len(data.encode()) <= 64 for row in rows(keyboard(set(), kinds, section, parent, set())) for _, data in row) + boards = [keyboard(set(), kinds, section, set()) for section in (None, "alerts", "sound")] + boards += [alert_keyboard(kind, set(), set(), section) for kind in ("irrigation_pause", "irrigation_battery") for section in (None, "bell")] + for board in boards: # every button's data fits Telegram's 64 bytes, for the new type too + assert all(len(data.encode()) <= 64 for row in rows(board) for _, data in row) async def test_refresh_reads_the_controller_again_and_redraws_without_sending_anything_or_waiting(tmp_path, monkeypatch): From c5e4a6aa0340f70d2e82e9db1fd820bd4c009ddd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:26:49 +0000 Subject: [PATCH 19/19] Two Ecowitt tests no longer fail in the first hours of the local day Yesterday's last readings are settled only some hours after local midnight (store.horizon), so the wind rose reads two ranges, not one, between 00:00 and then; and a window of the last 3 hours reaches back into yesterday, which memory does not hold. The tests now allow two ranges and ask for the readings since local midnight when it is earlier than 3 hours ago. The bot's code was right. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- tests/test_ecowitt.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index 767a91d..2ce42a0 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -1,6 +1,6 @@ import asyncio import json -from datetime import datetime, timedelta +from datetime import datetime, time, timedelta import pytest @@ -167,8 +167,9 @@ async def handler(request): refresh = asyncio.create_task(eco.warm(True)) await asyncio.sleep(0.05) # the refresh's requests are out, holding the 5-minute and 30-minute fetches now = eco.now() + since = max(now - timedelta(hours=3), datetime.combine(now.date(), time())) # (memory holds today: not the evening before, in the first hours of the day) question = eco.fetcher(["outdoor"], urgent=True) - data = await asyncio.wait_for(question.get("5min", now - timedelta(hours=3), now), 2) + data = await asyncio.wait_for(question.get("5min", since, now), 2) assert data["outdoor"]["temperature"]["list"] and question.calls == 0 and question.from_memory == 1 assert not refresh.done() answering.set() @@ -512,7 +513,7 @@ def __init__(self, *a, **k): out = json.loads(await eco.tools[1].handler({"groups": "wind", "chart": True, "chart_field": "wind", "start_date": f"{today - timedelta(days=30)} 00:00:00"}, turn)) assert out["series"]["wind.wind_direction"] and turn.charts[0].compass - assert len(made) == 1 and made[0].ranges == 1 # the archived days and today in one read, not one for each of 31 days + assert len(made) == 1 and made[0].ranges <= 2 # the archived days and today in one read (two in the first hours of the local day, while yesterday's last readings are not yet settled), not one for each of 31 days await eco.close() day = lambda n: today + timedelta(days=n) assert consecutive([day(1), day(2), day(3), day(5), day(6), day(9)]) == [[day(1), day(2), day(3)], [day(5), day(6)], [day(9)]]