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..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", [])) @@ -81,6 +89,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,11 +192,15 @@ 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 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 a307cff..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 @@ -359,7 +365,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 +404,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 +419,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 +437,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 +451,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 +485,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 +498,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 +511,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 +531,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 90148bd..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 that ends the pause. 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 +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 (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.""" + 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 = [[head("water", "💧 Water"), delay], + rows = [[delay] if paused else [head("water", "💧 Water"), delay], [head("alerts", "🔔 Alerts"), _button("🔄 Refresh", "ir:refresh")]] - if open == "water": + 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..d1ff551 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,72 @@ 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"] + + +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 diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index 3f13c3c..c4d981b 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -165,15 +165,16 @@ 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 - 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")), @@ -459,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 @@ -508,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): @@ -707,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 @@ -743,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) @@ -819,22 +821,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):