From 2d4ffbd9d04432024e1e2b440a79e7e86dd465cf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 00:55:55 +0000 Subject: [PATCH 1/3] Hide the Water button (and its Off) while the irrigation timer is paused Unpause first; a forged water press is still refused with the error footer. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/irrigation/menu.py | 10 +++++----- tests/test_irrigation.py | 17 ++++++++--------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/lib/irrigation/menu.py b/lib/irrigation/menu.py index 90148bd..25b493b 100644 --- a/lib/irrigation/menu.py +++ b/lib/irrigation/menu.py @@ -1,7 +1,7 @@ """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 that ends the pause. A press on Water, Pause or Alerts opens its options under the rows (ā–¾ marks the open one; pressing +Collapsed: [šŸ’§ Water] [āø Pause] / [šŸ”” Alerts] [šŸ”„ Refresh]. While the timer is paused the Pause button is [ā–¶ļø Unpause] (one press ends the pause) and Water, with its Off, 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, then Off for the valve itself (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): @@ -81,13 +81,13 @@ 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) -> 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.""" + 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 (with its Off) is hidden.""" def head(name: str, text: str) -> InlineKeyboardButton: return _button(("ā–¾ " if open == name else "") + text, "ir:close" if open == name else f"ir:open:{name}") delay = _button("ā–¶ļø Unpause", "ir:delay:cancel") if paused else head("delay", "āø Pause") - rows = [[head("water", "šŸ’§ Water"), delay], + rows = [[delay] if paused else [head("water", "šŸ’§ Water"), delay], [head("alerts", "šŸ”” Alerts"), _button("šŸ”„ Refresh", "ir:refresh")]] - if open == "water": + if open == "water" and not paused: rows.append([_button(f"{m} min", f"ir:water:{m}") for m in WATER_MINUTES]) rows.append([_button("Off āŒ", "ir:sw:off")]) elif open == "delay" and not paused: diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index 3f13c3c..4d0c3bd 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -170,10 +170,9 @@ def test_the_buttons_open_one_section_at_a_time_and_every_callback_is_short(): assert "ir:sw:on" not in [data for row in water for _, data in row] assert rows(menu.keyboard(True, True, "delay"))[-1] == [("24h", "ir:delay:24h"), ("48h", "ir:delay:48h"), ("72h", "ir:delay:72h")] paused = rows(menu.keyboard(True, True, paused=True)) # paused: the button unpauses, in one press - assert paused[0] == [("šŸ’§ Water", "ir:open:water"), ("ā–¶ļø Unpause", "ir:delay:cancel")] and paused[1] == collapsed[1] + assert paused[0] == [("ā–¶ļø Unpause", "ir:delay:cancel")] and paused[1] == collapsed[1] # no Water button while paused assert rows(menu.keyboard(True, True, "delay", paused=True)) == paused # no list of periods while paused - open_water = rows(menu.keyboard(True, True, "water", paused=True)) - assert open_water[0] == [("ā–¾ šŸ’§ Water", "ir:close"), ("ā–¶ļø Unpause", "ir:delay:cancel")] + 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")), @@ -819,22 +818,22 @@ async def test_while_paused_the_button_is_unpause_and_one_press_ends_the_pause(t assert [b[0] for b in rows(q.edits[0][1])[-1]] == ["24h", "48h", "72h"] q = await press(bot, "ir:delay:48h") # pause it assert q.toast == "Paused 48h" and "pause: 48h" in q.edits[0][1] - assert rows(q.edits[0][2])[0][1] == ("ā–¶ļø Unpause", "ir:delay:cancel") # the redrawn button follows the controller + assert rows(q.edits[0][2])[0][0] == ("ā–¶ļø Unpause", "ir:delay:cancel") # the redrawn button follows the controller shown = q.edits[0][1] q = await press_on(bot, "ir:open:water", shown) # opening another list keeps the Unpause button - assert rows(q.edits[0][1])[0][1] == ("ā–¶ļø Unpause", "ir:delay:cancel") and rows(q.edits[0][1])[-2][0] == ("5 min", "ir:water:5") + assert rows(q.edits[0][1])[0] == [("ā–¶ļø Unpause", "ir:delay:cancel")] and len(rows(q.edits[0][1])) == 2 # no Water button, so no minutes q = await press_on(bot, "ir:close", shown) - assert rows(q.edits[0][1])[0][1] == ("ā–¶ļø Unpause", "ir:delay:cancel") + assert rows(q.edits[0][1])[0][0] == ("ā–¶ļø Unpause", "ir:delay:cancel") q = await press(bot, "ir:refresh") - assert rows(q.edits[0][2])[0][1] == ("ā–¶ļø Unpause", "ir:delay:cancel") + 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 device.status_now["weather_delay"] = "24h" # paused from the app q = await press(bot, "ir:refresh") - assert rows(q.edits[0][2])[0][1] == ("ā–¶ļø Unpause", "ir:delay:cancel") + assert rows(q.edits[0][2])[0][0] == ("ā–¶ļø Unpause", "ir:delay:cancel") q = await press(bot, "ir:water:5") # a refused water press keeps it too - assert rows(q.edits[0][2])[0][1] == ("ā–¶ļø Unpause", "ir:delay:cancel") and q.edits[0][1].endswith("paused!") + assert rows(q.edits[0][2])[0][0] == ("ā–¶ļø Unpause", "ir:delay:cancel") and q.edits[0][1].endswith("paused!") async def press_on(bot, data, text): From 9f8aaef50cc388402d4f432b8da2bed04f4f11ab Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:06:03 +0000 Subject: [PATCH 2/3] Water off button on the irrigation message while on; per-type notification sound under Settings in DMs Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- lib/alerts/menu.py | 15 +++++++++--- lib/alerts/notify.py | 23 ++++++++++++++++--- lib/bot.py | 29 ++++++++++++++++-------- lib/irrigation/menu.py | 17 +++++++++----- tests/test_alert_menu.py | 49 +++++++++++++++++++++++++++++++++++++--- tests/test_irrigation.py | 17 ++++++++------ 7 files changed, 119 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 3de9209..6decc26 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 (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 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`). ## Run diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index f7ea046..0494c28 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -3,7 +3,8 @@ 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:close | al:noop (the heading rows of messages sent by an earlier version) + 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: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. @@ -42,14 +43,22 @@ def _button(text: str, data: str) -> InlineKeyboardButton: return InlineKeyboardButton(text, callback_data=data) -def keyboard(muted: set[str], available: list[str], open: str | None = None, parent: str | None = None) -> InlineKeyboardMarkup: +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.""" + 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.""" 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}")]] + 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"): verb = "on" if open == "sub" else "off" listed = options(muted, available, open) diff --git a/lib/alerts/notify.py b/lib/alerts/notify.py index 1b4af8c..f539014 100644 --- a/lib/alerts/notify.py +++ b/lib/alerts/notify.py @@ -81,6 +81,22 @@ def set_kind(self, chat_id: int, kind: str, on: bool): self.chats[chat_id].pop("muted", None) self.save() + def loud(self, chat_id: int) -> set[str]: + """The alert types this chat gets with notification sound (alerts are silent unless a chat turned the sound on).""" + return set(self.chats.get(chat_id, {}).get("loud", [])) + + def set_sound(self, chat_id: int, kind: str, on: bool): + """Notification sound on (on) or off for one alert type in a known chat.""" + if chat_id not in self.chats or kind not in LABELS: + return + loud = self.loud(chat_id) + loud = loud | {kind} if on else loud - {kind} + if loud: + self.chats[chat_id]["loud"] = [k for k in LABELS if k in loud] + else: + self.chats[chat_id].pop("loud", None) + self.save() + def keyboard(self, chat_id: int) -> str | None: """The version of the button keyboard this chat was last sent (or "hidden" if it hid it).""" return self.chats.get(chat_id, {}).get("keyboard") @@ -168,10 +184,11 @@ async def to_chat(self, chat_id: int, text: str, link: tuple[str, str] | None = return sent async def _send(self, chat_id: int, text: str, entities, kind: str | None = None) -> bool: - """Send silently, with the settings buttons (which highlight this alert's own type); forget a chat the bot can no longer post to.""" + """Send silently, with the settings buttons (which highlight this alert's own type); with sound only if the chat turned it on for this type; forget a chat the bot can no longer post to.""" try: - await self.bot.send_message(chat_id, text, entities=entities, disable_notification=True, disable_web_page_preview=True, - reply_markup=keyboard(self.state.muted(chat_id), self.kinds, parent=kind)) + await self.bot.send_message(chat_id, text, entities=entities, disable_notification=kind not in self.state.loud(chat_id), + disable_web_page_preview=True, + reply_markup=keyboard(self.state.muted(chat_id), self.kinds, parent=kind, loud=self.state.loud(chat_id) if chat_id > 0 else None)) return True except Forbidden as e: # kicked from the group, or blocked in a private chat self.state.remove_chat(chat_id, f"can't post: {e}") diff --git a/lib/bot.py b/lib/bot.py index a307cff..2f17e87 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -359,7 +359,8 @@ async def on_alerts(self, update: Update, context: ContextTypes.DEFAULT_TYPE): self.state.set_alerts(chat.id, self._title(update), arg == "on") log.info("/alerts %s in %s", arg, describe_source(update)) muted, kinds = self.state.muted(chat.id), self._alert_kinds() - await msg.reply_text(menu.title(muted, kinds), reply_markup=menu.keyboard(muted, kinds)) + loud = self.state.loud(chat.id) if chat.type == ChatType.PRIVATE else None # sound settings: private chats only + await msg.reply_text(menu.title(muted, kinds), reply_markup=menu.keyboard(muted, kinds, loud=loud)) async def _help_and_alerts(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """What the bot can do, then the alert settings: the Help & Alerts button and /usage.""" @@ -397,6 +398,11 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY if opened in ("sub", "unsub") 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") + 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" 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" @@ -407,7 +413,9 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY muted = self.state.muted(chat.id) if opened in ("sub", "unsub") and not menu.has_options(muted, kinds, opened, parent): opened = None # the last one was just turned on or off: close the section - markup = menu.keyboard(muted, kinds, opened if opened in ("sub", "unsub") else None, parent) + private = chat.type == ChatType.PRIVATE + markup = menu.keyboard(muted, kinds, opened if opened in ("sub", "unsub", "set") else None, parent, + self.state.loud(chat.id) if private else None) await query.answer(toast) try: if (message.text or "").startswith(menu.TITLE): # the /alerts message: keep its on/off summary current @@ -423,8 +431,8 @@ def _irrigation_alerts(self, chat_id: int, kind: str = "irrigation") -> bool: """Does this chat get the irrigation battery alerts (kind "irrigation") or the pause alerts ("irrigation_pause")?""" return bool(self.state and self.state.subscribed(chat_id, kind)) - def _irrigation_keyboard(self, chat_id: int, opened: str | None = None, paused: bool = False): - return irrigation_menu.keyboard(self._irrigation_alerts(chat_id), self._irrigation_alerts(chat_id, "irrigation_pause"), opened, paused) + def _irrigation_keyboard(self, chat_id: int, opened: str | None = None, paused: bool = False, running: bool = False): + return irrigation_menu.keyboard(self._irrigation_alerts(chat_id), self._irrigation_alerts(chat_id, "irrigation_pause"), opened, paused, running) def _irrigation_text(self, status: dict, chat_id: int) -> str: return irrigation_menu.status_text(status, self._irrigation_alerts(chat_id), self._irrigation_alerts(chat_id, "irrigation_pause")) @@ -437,7 +445,7 @@ async def send_irrigation(self, msg: Message): log.warning("Irrigation status failed: %s", e) await msg.reply_text("Sorry, I couldn't reach the irrigation controller.") return - await msg.reply_text(self._irrigation_text(status, msg.chat_id), reply_markup=self._irrigation_keyboard(msg.chat_id, None, irrigation_menu.is_paused(status))) + await msg.reply_text(self._irrigation_text(status, msg.chat_id), reply_markup=self._irrigation_keyboard(msg.chat_id, None, irrigation_menu.is_paused(status), bool(status.get("switch")))) async def _irrigation_act(self, action: str, arg: str, chat_id: int) -> str | None: """Do what a button asks. Returns the toast to show, or None for a button that means nothing. Raises IrrigationError.""" @@ -471,8 +479,9 @@ async def on_irrigation_button(self, update: Update, context: ContextTypes.DEFAU if action in ("open", "close"): opened = arg if action == "open" and arg in irrigation_menu.SECTIONS else None await query.answer() - paused = irrigation_menu.paused_in(message.text) # the message says it: no need to ask the controller to open a list - await self._edit_irrigation(query, None, self._irrigation_keyboard(message.chat_id, opened, paused)) + # the message says it: no need to ask the controller to open a list + await self._edit_irrigation(query, None, self._irrigation_keyboard(message.chat_id, opened, irrigation_menu.paused_in(message.text), + irrigation_menu.running_in(message.text))) return if action == "refresh": # read the controller again and redraw: nothing is sent to it, so no wait try: @@ -483,7 +492,7 @@ async def on_irrigation_button(self, update: Update, context: ContextTypes.DEFAU return await query.answer("Refreshed") await self._edit_irrigation(query, self._irrigation_text(status, message.chat_id), - self._irrigation_keyboard(message.chat_id, None, irrigation_menu.is_paused(status))) + self._irrigation_keyboard(message.chat_id, None, irrigation_menu.is_paused(status), bool(status.get("switch")))) return if action == "water" and arg.isdigit() and int(arg) in irrigation_menu.WATER_MINUTES: # not while the timer is paused try: @@ -496,7 +505,7 @@ async def on_irrigation_button(self, update: Update, context: ContextTypes.DEFAU log.info("Irrigation: water %s refused, paused for %s (chat %s)", arg, status["weather_delay"], message.chat_id) await query.answer(irrigation_menu.PAUSED_ERROR) text, entities = irrigation_menu.with_error(self._irrigation_text(status, message.chat_id)) - await self._edit_irrigation(query, text, self._irrigation_keyboard(message.chat_id, None, True), entities) # nothing was sent: no wait + await self._edit_irrigation(query, text, self._irrigation_keyboard(message.chat_id, None, True, bool(status.get("switch"))), entities) # nothing was sent: no wait return try: toast = await self._irrigation_act(action, arg, message.chat_id) @@ -516,7 +525,7 @@ async def on_irrigation_button(self, update: Update, context: ContextTypes.DEFAU log.warning("Irrigation status after %s %s failed: %s", action, arg, e) # the toast said what was done; the message stays as it was return stay = "alerts" if action in ("batt", "pause") else None # the Alerts menu stays open after a toggle, to show the new label - await self._edit_irrigation(query, self._irrigation_text(status, message.chat_id), self._irrigation_keyboard(message.chat_id, stay, irrigation_menu.is_paused(status))) + await self._edit_irrigation(query, self._irrigation_text(status, message.chat_id), self._irrigation_keyboard(message.chat_id, stay, irrigation_menu.is_paused(status), bool(status.get("switch")))) @staticmethod async def _edit_irrigation(query, text: str | None, markup, entities=None): diff --git a/lib/irrigation/menu.py b/lib/irrigation/menu.py index 25b493b..078a3cf 100644 --- a/lib/irrigation/menu.py +++ b/lib/irrigation/menu.py @@ -1,9 +1,8 @@ """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, with its Off, is +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, then Off for the valve itself (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 +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 (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 @@ -43,6 +42,11 @@ def paused_in(text: str | None) -> bool: return bool(value) and not value.startswith(("not paused", "inactive")) +def running_in(text: str | None) -> bool: + """Does a status message say the valve is on? (For redrawing only the buttons, as paused_in.)""" + return any(line.startswith("state: on") for line in (text or "").splitlines()) + + def with_error(text: str, error: str = PAUSED_ERROR) -> tuple[str, list[MessageEntity]]: """The status with an error under it as a bold footer. Plain text plus entities (no markup to escape); Telegram counts positions in UTF-16 code units, where an emoji is two.""" @@ -79,17 +83,18 @@ def status_text(status: dict, alerts: bool | None = None, pause_alerts: bool | N f"battery: {level}{bracket(alerts)}"]) -def keyboard(battery: bool, pause: bool, open: str | None = None, paused: bool = False) -> InlineKeyboardMarkup: +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 (with its Off) is hidden.""" + 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}") 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 + 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("Off āŒ", "ir:sw:off")]) elif open == "delay" and not paused: rows.append([_button(d, f"ir:delay:{d}") for d in DELAYS if d != "cancel"]) elif open == "alerts": diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 5a08d31..fb354cb 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -85,7 +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")]] # the alert's own type rides along + 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 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 @@ -204,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")] + assert rows(replies[0][1]["reply_markup"])[0] == [("āž• Subscribe", "al:open:sub"), ("āž– Unsubscribe", "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"])) @@ -290,7 +291,8 @@ async def test_the_alerts_own_type_survives_opening_unsubscribing_and_closing_th assert 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")]] + assert rows(q.edits[0][1]) == [[("āž• Subscribe", "al:open:sub:uv"), ("āž– Unsubscribe", "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,3 +319,44 @@ def test_every_alert_type_has_its_emoji_and_every_button_in_the_lists_starts_wit texts = [t for row in drawn[1:] for t, _ in row] 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): + bot, state = make_bot(tmp_path) + q = Query("al:open:set") # /alerts: one button per type + 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] + 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") + 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")]] + await press(bot, Query("al:snd:rain:off")) + assert state.loud(1) == {"uv"} + 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"} + 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 == [] + + +async def test_an_alert_has_a_sound_only_in_a_chat_that_turned_it_on_and_groups_get_no_settings_button(tmp_path): + state, bot = AlertState(tmp_path / "s2.json"), Sent() + state.add_chat(1, "1") + state.add_chat(-5, "G") + state.set_sound(1, "rain", True) + notifier = Notifier(bot, state, KINDS) + 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} + 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"] diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index 4d0c3bd..c4d981b 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -165,8 +165,10 @@ 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")]] 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[-2] == [(f"{m} min", f"ir:water:{m}") for m in (5, 10, 20, 30)] - assert water[-1] == [("Off āŒ", "ir:sw:off")] # the valve itself: only off (a run is always timed) + 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")] assert "ir:sw:on" not in [data for row in water for _, data in row] assert rows(menu.keyboard(True, True, "delay"))[-1] == [("24h", "ir:delay:24h"), ("48h", "ir:delay:48h"), ("72h", "ir:delay:72h")] paused = rows(menu.keyboard(True, True, paused=True)) # paused: the button unpauses, in one press @@ -458,7 +460,7 @@ async def reply_text(body, **kw): async def test_opening_and_closing_a_section_only_changes_the_buttons(tmp_path): bot, _, device = irrigation_bot(tmp_path) q = await press(bot, "ir:open:water") - assert q.edits[0][0] == "markup" and rows(q.edits[0][1])[-2][0] == ("5 min", "ir:water:5") and rows(q.edits[0][1])[-1][0] == ("Off āŒ", "ir:sw:off") and device.commands == [] + assert q.edits[0][0] == "markup" and rows(q.edits[0][1])[-1][0] == ("5 min", "ir:water:5") and device.commands == [] q = await press(bot, "ir:close") assert len(rows(q.edits[0][1])) == 2 for gone in ("ir:open:bogus", "ir:open:switch"): # an unknown section, or an old message's Switch, just closes @@ -507,9 +509,9 @@ async def test_the_alerts_menu_toggles_each_alert_type_for_this_chat_alone_and_s await press(bot, data) assert state.subscribed(1, kind) is text.startswith("šŸ”” Enable"), (text, data) other = await press(bot, "ir:water:5") - assert len(rows(other.edits[0][2])) == 2 # any other action redraws collapsed + 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") - assert len(rows(other.edits[0][2])) == 2 + assert len(rows(other.edits[0][2])) == 3 async def test_a_controller_that_cannot_be_reached_gives_a_toast_not_a_traceback_and_an_unchanged_message_is_fine(tmp_path): @@ -706,7 +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")]] + assert rows(markup) == [[("āž• Subscribe", "al:open:sub:irrigation_pause"), ("āž– Unsubscribe", "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 @@ -742,7 +745,7 @@ async def test_every_member_of_a_group_may_use_the_irrigation_buttons(tmp_path): state.add_chat(-5, "Home") group = dict(chat_id=-5, chat_type="supergroup", user=42) # an ordinary member: no admin check is made q = await press(bot, "ir:open:water", **group) - assert q.edits[0][0] == "markup" and rows(q.edits[0][1])[-2][0] == ("5 min", "ir:water:5") + assert q.edits[0][0] == "markup" and rows(q.edits[0][1])[-1][0] == ("5 min", "ir:water:5") q = await press(bot, "ir:water:10", **group) assert device.commands == [("water", 10)] and q.toast == "Watering for 10 minutes" and "state: on āœ…" in q.edits[0][1] q = await press(bot, "ir:sw:off", **group) From c6b26302412b6d0d37284dc57c209ee10add5dd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:11:06 +0000 Subject: [PATCH 3/3] Follow a group that became a supergroup instead of crashing on its old alerts Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/notify.py | 13 ++++++++++++- lib/bot.py | 10 ++++++++-- tests/test_alert_menu.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/lib/alerts/notify.py b/lib/alerts/notify.py index f539014..5d73dae 100644 --- a/lib/alerts/notify.py +++ b/lib/alerts/notify.py @@ -12,7 +12,7 @@ from datetime import date from telegram import MessageEntity -from telegram.error import BadRequest, Forbidden, TelegramError +from telegram.error import BadRequest, ChatMigrated, Forbidden, TelegramError from .menu import ALL, LABELS, keyboard @@ -65,6 +65,14 @@ def set_alerts(self, chat_id: int, title: str, on: bool): self.chats.setdefault(chat_id, {"title": title}) self.set_kind(chat_id, ALL, on) + def migrate_chat(self, old: int, new: int): + """A group became a supergroup: its settings move to the new chat id.""" + entry = self.chats.pop(old, None) + if entry is not None: + self.chats.setdefault(new, entry) + log.info("Alerts: chat %s became %s", old, new) + self.save() + def muted(self, chat_id: int) -> set[str]: return set(self.chats.get(chat_id, {}).get("muted", [])) @@ -190,6 +198,9 @@ async def _send(self, chat_id: int, text: str, entities, kind: str | None = None disable_web_page_preview=True, reply_markup=keyboard(self.state.muted(chat_id), self.kinds, parent=kind, loud=self.state.loud(chat_id) if chat_id > 0 else None)) return True + except ChatMigrated as e: # the group became a supergroup: follow it, and send again there + self.state.migrate_chat(chat_id, e.new_chat_id) + return await self._send(e.new_chat_id, text, entities, kind) if e.new_chat_id != chat_id else False except Forbidden as e: # kicked from the group, or blocked in a private chat self.state.remove_chat(chat_id, f"can't post: {e}") except BadRequest as e: diff --git a/lib/bot.py b/lib/bot.py index 2f17e87..e080cb5 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -17,7 +17,7 @@ from telegram import InputMediaPhoto, Message, ReplyKeyboardRemove, Update from telegram.constants import ChatAction, ChatType -from telegram.error import BadRequest, Forbidden, NetworkError, TelegramError +from telegram.error import BadRequest, ChatMigrated, Forbidden, NetworkError, TelegramError from telegram.ext import CallbackQueryHandler, ChatMemberHandler, CommandHandler, ContextTypes, MessageHandler, filters from . import intent, periods, prompt, report, templates @@ -313,7 +313,13 @@ async def on_button(self, update: Update, context: ContextTypes.DEFAULT_TYPE): return if not await self._allowed(update, context): return - await handler(update, context) + try: + await handler(update, context) + except ChatMigrated as e: # a press on an alert from before its group became a supergroup (the chat has a new id) + if self.state: + self.state.migrate_chat(query.message.chat.id, e.new_chat_id) + with contextlib.suppress(TelegramError): + await query.answer("This group was upgraded to a supergroup: please use the buttons on new alerts") # ---------- alert chats ---------- @staticmethod diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index fb354cb..d1ff551 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -360,3 +360,31 @@ async def test_an_alert_has_a_sound_only_in_a_chat_that_turned_it_on_and_groups_ 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"] + + +async def test_a_chat_that_became_a_supergroup_is_followed_not_a_crash(tmp_path): + from telegram.error import ChatMigrated + + class Moved(Sent): + async def send_message(self, chat_id, text, **kw): + if chat_id == -5: + raise ChatMigrated(-1005) + await super().send_message(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") + 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) + bot2.state = state + q = Query("al:open:unsub", chat_type="supergroup", chat_id=-7) + q.message.reply_markup = keyboard(set(), KINDS) + q.message.chat.title = "H" + + async def moved(*a, **k): + raise ChatMigrated(-1007) + 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