diff --git a/README.md b/README.md index 6decc26..8d4dca6 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, rain likely soon, gusts over 40 km/h, UV index of 10 or more, indoor/outdoor temperatures crossing (held back for the cooldown, then announced if it still stands), -and unhealthy air (with when it is safe again). Every alert carries Subscribe / Unsubscribe buttons that expand into the alert types (rain, rain predicted, 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 turns the notification sound on or off per alert type; 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 (with when it is safe again). Every alert carries Subscribe / Unsubscribe buttons that expand into the alert types (rain, rain predicted, 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`). ## Run diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index 0494c28..b5b7605 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -3,17 +3,19 @@ the title from what a chat has muted (the same menu goes under every alert and under /alerts). 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: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: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: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 the Unsubscribe list is just that type (marked) and "all": al:open:unsub:rain, al:close:rain, -al:off:uv:unsub:rain. A chat's /alerts message has no parent and no such field. +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. """ from itertools import batched from telegram import InlineKeyboardButton, InlineKeyboardMarkup +from ..buttons import menu_text + # kind -> the name on the button, in the order listed LABELS = {"rain": "rain", "rain_likely": "rain predicted", "gusts": "gusts", "uv": "UV", "temps": "temperature crossing", "air": "particulates", "pollen": "pollen & asthma", "forecast": "forecast changes", @@ -24,6 +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) def available_kinds(sources: set[str]) -> list[str]: @@ -43,35 +46,58 @@ 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): + rows.append([every]) + return rows + + def keyboard(muted: set[str], available: list[str], open: str | None = None, parent: str | None = None, loud: set[str] | None = None) -> InlineKeyboardMarkup: - """The collapsed menu, or with one section open (open = "sub" or "unsub") and its options under it. `parent` is the type of the - alert the buttons sit under and is carried in every button. Under an alert the Unsubscribe list is that type (marked) and - "Unsubscribe from all"; on the /alerts message it lists every type that is on. "... all" is there only when it does something - different from the one type listed. `loud`: the types whose alerts make a notification sound; None (a group) leaves out the Settings button, - which opens one button per type (just the alert's own under an alert) that turns that sound on or off.""" + """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.""" tail = f":{parent}" if parent else "" - rows = [[_button(("▾ " if open == "sub" else "") + "➕ Subscribe", f"al:close{tail}" if open == "sub" else f"al:open:sub{tail}"), - _button(("▾ " if open == "unsub" else "") + "➖ Unsubscribe", f"al:close{tail}" if open == "unsub" else f"al:open:unsub{tail}")]] + subscribed = options(muted, available, "unsub") + if open == "other" and not (parent in subscribed and len(subscribed) > 1): + 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}") + rows = [[head(open == "sub", "➕ Sub", "sub"), head(unsub_open, "➖ Unsub", "unsub")]] if loud is not None: - rows[0].append(_button(("▾ " if open == "set" else "") + "⚙️ Settings", f"al:close{tail}" if open == "set" else f"al:open:set{tail}")) - if open == "set" and loud is not None: - for k in ([parent] if parent else available): - on = k in loud - rows.append([_button(f"🔕 Disable {LABELS[k]} sound" if on else f"🔔 Enable {LABELS[k]} sound", f"al:snd:{k}:{'off' if on else 'on'}{tail}")]) - if open in ("sub", "unsub"): + rows[0].append(head(open in SETTINGS, "⚙️ 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]), + ("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}")]) + 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 in ("sub", "unsub", "other"): verb = "on" if open == "sub" else "off" - listed = options(muted, available, open) - if open == "unsub" and parent: - others = [k for k in listed if k != parent] - listed, show_all = [k for k in listed if k == parent], bool(others) - else: - show_all = len(listed) > 1 - mark = lambda k: f"● {label(k)}" if open == "unsub" and k == parent else label(k) # the alert these buttons are under - buttons = [_button(mark(k), f"al:{verb}:{k}:{open}{tail}") for k in listed] - rows += map(list, batched(buttons, PER_ROW)) - if show_all: - rows.append([_button("🔔 Subscribe to all" if open == "sub" else "🔕 Unsubscribe from all", f"al:{verb}:{ALL}:{open}{tail}")]) + 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}")) return InlineKeyboardMarkup(rows) diff --git a/lib/bot.py b/lib/bot.py index e080cb5..f3ad98d 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -381,6 +381,11 @@ async def on_usage(self, update: Update, context: ContextTypes.DEFAULT_TYPE): self.remember_chat(update) await self._help_and_alerts(update, context) + @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" + 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.""" @@ -401,26 +406,31 @@ 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 in ("sub", "unsub") and not menu.has_options(self.state.muted(chat.id), kinds, opened, parent): + 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): 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 and section in ("on", "off") and chat.id in self.state.chats: - self.state.set_sound(chat.id, arg, section == "on") + 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 = "set" - toast = f"{menu.LABELS[arg][0].upper()}{menu.LABELS[arg][1:]} alerts {'now make' if section == 'on' else 'no longer make'} a sound" + 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" + 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") - what = "All alerts" if arg == menu.ALL else f"{menu.LABELS[arg][0].upper()}{menu.LABELS[arg][1:]} alerts" # "UV alerts", not "Uv" - toast = f"{what} {action} in this chat" + toast = f"{self._alerts_named(arg)} {action} in this chat" else: await query.answer() return muted = self.state.muted(chat.id) - if opened in ("sub", "unsub") and not menu.has_options(muted, kinds, opened, parent): + if opened in ("sub", "unsub", "other") and not menu.has_options(muted, kinds, opened, parent): 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", "set") else None, parent, + markup = menu.keyboard(muted, kinds, opened if opened in ("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/lib/buttons.py b/lib/buttons.py new file mode 100644 index 0000000..af7135e --- /dev/null +++ b/lib/buttons.py @@ -0,0 +1,7 @@ +"""Shared look of the bot's inline buttons.""" + + +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 '▸'}" diff --git a/lib/irrigation/menu.py b/lib/irrigation/menu.py index 078a3cf..9cdb17d 100644 --- a/lib/irrigation/menu.py +++ b/lib/irrigation/menu.py @@ -1,8 +1,10 @@ """The irrigation status message and its buttons. Pure: it only builds the text and the keyboard. -Collapsed: [💧 Water] [⏸ Pause] / [🔔 Alerts] [🔄 Refresh]. While the timer is paused the Pause button is [▶️ Unpause] (one press ends the pause) and Water is -hidden. A press on Water, Pause or Alerts opens its options under the rows (▾ marks the open one; pressing -it again closes them). Water lists 5, 10, 20 and 30 minutes (a run is always timed). While the valve is on, a Water off ❌ button is the third row. Alerts has one button per alert type, each naming what pressing it does for this chat (enable or disable). Callback data +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). +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) ir:water: | ir:delay:<24h|48h|72h|cancel> (cancel = unpause, the Unpause button) | ir:sw:off @@ -14,6 +16,8 @@ from telegram import InlineKeyboardButton, InlineKeyboardMarkup, MessageEntity +from ..buttons import menu_text + TITLE = "🌱 Irrigation" WATER_MINUTES = (5, 10, 20, 30) DELAYS = ("24h", "48h", "72h", "cancel") @@ -85,13 +89,14 @@ 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 button unpauses and Water is hidden. `running`: the valve is on, so Water off is a top-level row.""" + names what pressing it does (disable when it is on, enable when it is 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(("▾ " if open == name else "") + text, "ir:close" if open == name else f"ir:open:{name}") + 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") rows = [[delay] if paused else [head("water", "💧 Water"), delay], [head("alerts", "🔔 Alerts"), _button("🔄 Refresh", "ir:refresh")]] - if running: # the valve is open: Off is one press away, whatever else is open + if running: # the valve is open: off is one press away, whatever else is open 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]) diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index d1ff551..3c8872a 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -16,17 +16,17 @@ def rows(markup): def test_the_collapsed_menu_is_subscribe_and_unsubscribe(): - assert rows(keyboard(set(), KINDS)) == [[("➕ Subscribe", "al:open:sub"), ("➖ Unsubscribe", "al:open:unsub")]] + assert rows(keyboard(set(), KINDS)) == [[("➕ Sub ▸", "al:open:sub"), ("➖ Unsub ▸", "al:open:unsub")]] def test_unsubscribe_opens_the_types_that_are_on_and_subscribe_the_types_that_are_off(): got = rows(keyboard({"pollen", "forecast"}, KINDS, "unsub")) - assert got[0] == [("➕ Subscribe", "al:open:sub"), ("▾ ➖ Unsubscribe", "al:close")] + assert got[0] == [("➕ Sub ▸", "al:open:sub"), ("➖ Unsub ▾", "al:close")] assert got[1] == [("🌧️ rain", "al:off:rain:unsub"), ("🌦️ rain predicted", "al:off:rain_likely:unsub")] assert [text for row in got[1:-1] for text, _ in row] == ["🌧️ rain", "🌦️ rain predicted", "💨 gusts", "🧴 UV", "🌡️ temperature crossing", "😷 particulates"] assert got[-1] == [("🔕 Unsubscribe from all", "al:off:all:unsub")] got = rows(keyboard({"pollen", "forecast"}, KINDS, "sub")) - assert got[0][0] == ("▾ ➕ Subscribe", "al:close") + assert got[0][0] == ("➕ Sub ▾", "al:close") assert got[1:] == [[("🌼 pollen & asthma", "al:on:pollen: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"}, KINDS, "sub") == ["pollen"] and options({"pollen"}, ["rain", "pollen"], "unsub") == ["rain"] @@ -85,8 +85,8 @@ 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) == [[("➕ Subscribe", "al:open:sub:rain"), ("➖ Unsubscribe", "al:open:unsub:rain"), - ("⚙️ Settings", "al:open:set:rain")]] # the alert's own type rides along + 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 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 +137,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] == ("▾ ➖ Unsubscribe", "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" @@ -205,7 +205,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, rain predicted, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes" in replies[0][0] - assert rows(replies[0][1]["reply_markup"])[0] == [("➕ Subscribe", "al:open:sub"), ("➖ Unsubscribe", "al:open:unsub"), ("⚙️ Settings", "al:open:set")] + assert rows(replies[0][1]["reply_markup"])[0] == [("➕ Sub ▸", "al:open:sub"), ("➖ Unsub ▸", "al:open:unsub"), ("⚙️ Settings ▸", "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"])) @@ -256,14 +256,20 @@ 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_every_button_carries_the_type(): +def test_under_an_alert_the_unsubscribe_list_is_its_type_then_other_which_holds_the_rest_and_all(): got = rows(keyboard({"pollen", "forecast"}, KINDS, "unsub", parent="uv")) - assert got == [[("➕ Subscribe", "al:open:sub:uv"), ("▾ ➖ Unsubscribe", "al:close:uv")], - [("● 🧴 UV", "al:off:uv:unsub:uv")], [("🔕 Unsubscribe from all", "al:off:all:unsub:uv")]] - only = rows(keyboard(set(KINDS) - {"uv"}, KINDS, "unsub", parent="uv")) # the alert's type is the only one on: no "all" + 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", "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 only[1:] == [[("● 🧴 UV", "al:off:uv:unsub:uv")]] - gone = rows(keyboard({"uv", "gusts"}, KINDS, "unsub", parent="uv")) # its type is already off, others on: just "all" - assert gone[1:] == [[("🔕 Unsubscribe from all", "al:off:all: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", "forecast"}, KINDS, "sub", parent="uv")) # Subscribe is the types that are off, as ever assert [t for row in sub[1:-1] for t, _ in row] == ["🌼 pollen & asthma", "🔄 forecast changes"] and sub[-1][0][0] == "🔔 Subscribe to all" @@ -283,16 +289,19 @@ 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] == ("▾ ➖ Unsubscribe", "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 "all" for the rest + assert drawn[0][1] == ("➖ 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) + assert rows(q.edits[0][1])[-1] == [("🔕 Unsubscribe from all", "al:off:all:other: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 rows(q.edits[0][1])[1:] == [[("🔕 Unsubscribe from all", "al:off:all:unsub:uv")]] + 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]) == [[("➕ Subscribe", "al:open:sub:uv"), ("➖ Unsubscribe", "al:open:unsub:uv"), - ("⚙️ Settings", "al:open:set:uv")]] + assert rows(q.edits[0][1]) == [[("➕ Sub ▸", "al:open:sub:uv"), ("➖ Unsub ▸", "al:open:unsub:uv"), + ("⚙️ Settings ▸", "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 @@ -317,31 +326,52 @@ def test_every_alert_type_has_its_emoji_and_every_button_in_the_lists_starts_wit for parent in (None, "uv"): 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 assert title(set(), ["rain"]) == f"{TITLE}\n✅ On: rain\n🔕 Off: nothing" # the on/off summary and the toasts stay plain words -async def test_settings_turn_the_notification_sound_on_and_off_per_type_in_private_chats_only(tmp_path): +async def test_settings_hold_enable_and_disable_sub_menus_of_notification_sounds_in_private_chats_only(tmp_path): bot, state = make_bot(tmp_path) - q = Query("al:open:set") # /alerts: one button per type + 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")]] + 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]) - assert drawn[0][2] == ("▾ ⚙️ Settings", "al:close") and ("🔔 Enable rain sound", "al:snd:rain:on") in [b for r in drawn[1:] for b in r] + 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] q = Query("al:snd:rain:on") await press(bot, q) assert state.loud(1) == {"rain"} and "make a sound" in q.toast - assert ("🔕 Disable rain sound", "al:snd:rain:off") in [b for r in rows(q.edits[0][1]) for b in r] # stays open, now offering to turn it off - q = Query("al:open:set:uv", text="☀️ UV 10") # under an alert: just that type - await press(bot, q) - assert rows(q.edits[0][1])[1:] == [[("🔔 Enable UV sound", "al:snd:uv:on:uv")]] - q = Query("al:snd:uv:on:uv", text="☀️ UV 10") + 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") await press(bot, q) - assert state.loud(1) == {"rain", "uv"} and rows(q.edits[0][1])[1:] == [[("🔕 Disable UV sound", "al:snd:uv:off:uv")]] + 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) == {"uv"} + assert state.loud(1) == set() + q = Query("al:open:snd_on:uv", text="☀️ UV 10") # under an alert: its own type, then Other + 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 + 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 + 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() 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) == {"uv"} + assert state.loud(1) == set() 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) @@ -359,7 +389,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]] == ["➕ Subscribe", "➖ Unsubscribe"] + assert [b[0] for b in rows(group["reply_markup"])[0]] == ["➕ Sub ▸", "➖ Unsub ▸"] async def test_a_chat_that_became_a_supergroup_is_followed_not_a_crash(tmp_path): @@ -388,3 +418,40 @@ async def moved(*a, **k): bot2._is_group_admin = moved await bot2.on_button(NS(callback_query=q, effective_user=NS(id=7), effective_message=q.message, effective_chat=q.message.chat), ctx()) 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() + 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) diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index c4d981b..c4b94a8 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -162,10 +162,10 @@ def test_the_status_always_shows_mode_state_pause_time_and_battery_in_that_order def test_the_buttons_open_one_section_at_a_time_and_every_callback_is_short(): - collapsed = [[("💧 Water", "ir:open:water"), ("⏸ Pause", "ir:open:delay")], [("🔔 Alerts", "ir:open:alerts"), ("🔄 Refresh", "ir:refresh")]] + collapsed = [[("💧 Water ▸", "ir:open:water"), ("⏸ Pause ▸", "ir:open:delay")], [("🔔 Alerts ▸", "ir:open:alerts"), ("🔄 Refresh", "ir:refresh")]] assert rows(menu.keyboard(False, False)) == collapsed == rows(menu.keyboard(True, True)) # the alert states show only once Alerts is open water = rows(menu.keyboard(True, True, "water")) - assert water[0][0] == ("▾ 💧 Water", "ir:close") and water[-1] == [(f"{m} min", f"ir:water:{m}") for m in (5, 10, 20, 30)] + assert water[0][0] == ("💧 Water ▾", "ir:close") and water[-1] == [(f"{m} min", f"ir:water:{m}") for m in (5, 10, 20, 30)] assert "ir:sw:off" not in [data for row in water for _, data in row] # Off is not in the Water list: it is a top-level row while on assert rows(menu.keyboard(True, True, running=True)) == collapsed + [[("Water off ❌", "ir:sw:off")]] # only while the valve is on assert rows(menu.keyboard(True, True, paused=True, running=True))[-1] == [("Water off ❌", "ir:sw:off")] @@ -182,7 +182,7 @@ def test_the_buttons_open_one_section_at_a_time_and_every_callback_is_short(): (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"))): alerts = rows(menu.keyboard(battery, pause, "alerts")) - assert alerts[:2] == collapsed[:1] + [[("▾ 🔔 Alerts", "ir:close"), ("🔄 Refresh", "ir:refresh")]] + assert alerts[:2] == collapsed[:1] + [[("🔔 Alerts ▾", "ir:close"), ("🔄 Refresh", "ir:refresh")]] assert alerts[2:] == [[battery_button], [pause_button]] # each names the action it takes for section in (None, *menu.SECTIONS): for battery in (True, False): @@ -451,7 +451,7 @@ async def reply_text(body, **kw): update = NS(effective_message=msg, effective_chat=msg.chat, effective_user=NS(id=7, full_name="Rob", username="rob")) await bot.on_message(update, NS(bot=NS(username="b", id=99))) # no model is involved (the agent is None) assert sent[0][0].startswith("🌱 Irrigation\nmode: idle\nstate: off ❌\npause: not paused (alerts: on)\nbattery: 100% 🔋 (alerts: on)") - assert rows(sent[0][1]["reply_markup"])[0][0] == ("💧 Water", "ir:open:water") and rows(sent[0][1]["reply_markup"])[1][0] == ("🔔 Alerts", "ir:open:alerts") + assert rows(sent[0][1]["reply_markup"])[0][0] == ("💧 Water ▸", "ir:open:water") and rows(sent[0][1]["reply_markup"])[1][0] == ("🔔 Alerts ▸", "ir:open:alerts") device.down = True await bot.on_message(update, NS(bot=NS(username="b", id=99))) assert sent[1][0] == "Sorry, I couldn't reach the irrigation controller." and len(sent) == 2 @@ -591,7 +591,7 @@ async def test_typing_irrigation_does_what_the_button_does_in_private_chats_and_ assert sent[0][0].startswith("🌱 Irrigation\nmode: idle\nstate: off ❌") and sent[0][1]["reply_markup"], text update, sent = group_message("@testbot irrigation") await bot.on_message(update, ctx) - assert sent[0][0].startswith("🌱 Irrigation") and rows(sent[0][1]["reply_markup"])[0][0] == ("💧 Water", "ir:open:water") + assert sent[0][0].startswith("🌱 Irrigation") and rows(sent[0][1]["reply_markup"])[0][0] == ("💧 Water ▸", "ir:open:water") update, sent = group_message("water", replied=True) # a reply to the bot counts as addressing it await bot.on_message(update, NS(bot=NS(username="testbot", id=99))) assert sent == [] or sent[0][0].startswith("🌱 Irrigation") @@ -708,8 +708,8 @@ 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) == [[("➕ Subscribe", "al:open:sub:irrigation_pause"), ("➖ Unsubscribe", "al:open:unsub:irrigation_pause"), - ("⚙️ Settings", "al:open:set:irrigation_pause")]] + assert rows(markup) == [[("➕ Sub ▸", "al:open:sub:irrigation_pause"), ("➖ Unsub ▸", "al:open:unsub:irrigation_pause"), + ("⚙️ Settings ▸", "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 @@ -816,7 +816,7 @@ async def test_while_paused_the_button_is_unpause_and_one_press_ends_the_pause(t bot, state, device = irrigation_bot(tmp_path) update, sent = private_message("irrigation") await bot.on_message(update, NS(bot=NS(username="testbot", id=99))) - assert rows(sent[0][1]["reply_markup"])[0][1] == ("⏸ Pause", "ir:open:delay") # not paused + assert rows(sent[0][1]["reply_markup"])[0][1] == ("⏸ Pause ▸", "ir:open:delay") # not paused q = await press(bot, "ir:open:delay") assert [b[0] for b in rows(q.edits[0][1])[-1]] == ["24h", "48h", "72h"] q = await press(bot, "ir:delay:48h") # pause it @@ -831,7 +831,7 @@ async def test_while_paused_the_button_is_unpause_and_one_press_ends_the_pause(t assert rows(q.edits[0][2])[0][0] == ("▶️ Unpause", "ir:delay:cancel") q = await press(bot, "ir:delay:cancel") # the Unpause button assert q.toast == "Unpaused" and device.commands[-1] == ("delay", "cancel") and "pause: not paused" in q.edits[0][1] - assert rows(q.edits[0][2])[0][1] == ("⏸ Pause", "ir:open:delay") # and it is Pause again + assert rows(q.edits[0][2])[0][1] == ("⏸ Pause ▸", "ir:open:delay") # and it is Pause again device.status_now["weather_delay"] = "24h" # paused from the app q = await press(bot, "ir:refresh") assert rows(q.edits[0][2])[0][0] == ("▶️ Unpause", "ir:delay:cancel")