diff --git a/README.md b/README.md index 4f4ef1c..38bfe81 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A Telegram bot for a personal Ecowitt weather station and AirGradient air-quality sensor. Ask it about the weather or air quality (text or charts); it also sends silent alerts to every chat it is in: rain starting/stopping, gusts over 40 km/h (with the last day's wind chart), UV index of 10 or more in two readings in a row (with the last day's UV chart), indoor/outdoor temperatures crossing by more than 1Β°C (held back for the cooldown, then announced if it still stands, with the last day's temperature chart), -and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries Subscribe / Unsubscribe buttons that expand into the alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes): Subscribe lists the types that are off, Unsubscribe the ones that are on; `/alerts` opens the same settings (in a private chat, a βš™οΈ Settings button there and under each alert has Enable and Disable notification sounds sub-menus that list the alert types; alerts are silent unless you do; and `/alerts on|off` still subscribes or unsubscribes every type); `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). +and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries one πŸ”” button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). Every toggle in the bot is a green button while on and a plain one with its name struck through while off. `/alerts` has two buttons, πŸ”” Alerts and (in a private chat) πŸ”Š Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, green while on and with the name struck through while off, while one is open its own button stays (press it to close the list) and the other is hidden until you do; `/alerts on|off` still subscribes or unsubscribes every type. `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). ## Run @@ -46,7 +46,7 @@ The state and caches go in `/data` (its `DATA_DIR`), here a named volume. A host | `CHART_ALL_VPD=on` | optional: "weather all week" also draws the vapour pressure deficit panel; off by default, as it is temperature and humidity combined (the station reports it for the outdoor sensor only). Naming it ("plot vpd") always works | | `TELEGRAM_PRELOAD_CHAT` | optional: a chat id (negative for a group or channel) the bot silently uploads charts to ahead of need. With it set, each chart sent with period buttons has its other periods drawn and uploaded there in the background, each on its own the moment it is drawn, starting as soon as the chart is sent and whatever else is being answered, so pressing a period button edits the message with Telegram's copy and uploads nothing. A chart uploaded this way is used for 30 minutes, then drawn afresh. The bot must be able to post in that chat; the uploaded messages are left there. If Telegram refuses an upload (the bot can't post there) none is tried again until a restart; a flood limit or a timeout only skips that photo. Unset: off | | `ANTISPIKE_REQUIRE_CONSECUTIVE` | optional: how many readings in a row a rise of the particles (PM1, PM2.5, PM10) needs to count as the air rather than a glitch of the sensor. Default `2`: a single-reading spike (at least 2.5 times, and 5 Β΅g/mΒ³ above, the readings either side) is left out of PM charts and of every air figure (highs, lows, averages, days over a limit), and the unhealthy-air alert counts a check only when the sensor's last 2 readings are all over the mask level. `3` also drops two-reading spikes, and so on; `1` or `off` turns the rule off. The live "air now" reading and the "safe again" check are unchanged | -| `LOG_WIDTH` | optional: the most characters of a log line, cut with an ellipsis (…); `0` cuts none. Default 71. Every INFO line obeys it; a `WARN` or `ERROR` entry is never cut (a warning is printed as `WARN`); a request, chat message, reply or alert is also put on one line, and the startup "Alerts:" list wraps at it (at 90 when `0`) | +| `LOG_WIDTH` | optional: the most characters of a log line, cut with an ellipsis (…); `0` cuts none. Default 71. Every log line obeys it, whatever its level (a warning is printed as `WARN`); a request, chat message, reply or alert is also put on one line | | `LOG_LEVEL` | optional: `DEBUG`, `INFO` (default), `WARNING` or `ERROR`. `DEBUG` adds the bot's own debug lines (the chart timings, charts it couldn't draw ahead); the libraries stay at their usual level. | ## Layout diff --git a/ecobot.py b/ecobot.py index 90132ac..d4344d5 100644 --- a/ecobot.py +++ b/ecobot.py @@ -95,27 +95,10 @@ def commit_in(git_dir) -> str: return "unknown" -INDENT = 13 # the continuation lines of a wrapped log line - - -def wrapped(head: str, items: list[str], tail: str = "", width: int = 90) -> str: - """head + the items joined by ", " + tail, over several lines when it is longer than `width`: a line breaks only between items, - and the next one is indented.""" - lines, line = [], head - for i, item in enumerate(items): - piece = item + (", " if i < len(items) - 1 else tail) - if line.strip() != head.strip() and len(line + piece.rstrip()) > width: - lines.append(line.rstrip()) - line = " " * INDENT - line += piece - return "\n".join([*lines, line]) - - async def main(): log.info("Starting ecobot %s", version()) cfg = Config.from_env() _handler.formatter.width = cfg.log_width # every log line is cut to it from here on - WRAP = max(cfg.log_width - len("INFO "), 30) if cfg.log_width else 90 # where the lists in the log wrap air_metrics.REQUIRE_CONSECUTIVE = cfg.antispike_require_consecutive intent.FEELS_LIKE_IN_ALL = cfg.chart_all_feels_like intent.VPD_IN_ALL = cfg.chart_all_vpd @@ -161,7 +144,7 @@ async def main(): log.info("@%s + %s", app.bot.username, cfg.xai_model) log.info("Sources: %s", ", ".join(s.name for s in sources)) kinds = kinds_for((s.name for s in sources), irrigation is not None) - notify = Notifier(app.bot, state, kinds) + notify = Notifier(app.bot, state) await bot.refresh_keyboards(app.bot) # chats whose buttons are out of date are told, with the new ones # Alerts @@ -186,7 +169,7 @@ async def main(): # Keeping warm: everything questions need, refreshed before they arrive tasks.extend(asyncio.create_task(s.warmer.run()) for s in sources) - log.info("%s", wrapped("Keeping warm: ", [f"{s.name} {s.warmer.interval:.0f}s" for s in sources], "", WRAP)) + log.info("Keeping warm: %s", ", ".join(f"{s.name} {s.warmer.interval:.0f}s" for s in sources)) async def startup_warmup(): """Fetch everything once, together, and log one summary line.""" @@ -226,7 +209,7 @@ async def air_backfill(): time.monotonic() - started) tasks.append(asyncio.create_task(every(PREDRAW_SECONDS, bot.predraw))) # the button charts, ready before they are pressed on = [k for k in LABELS if k in running] # the line says what started, not what is configured - log.info("%s", wrapped("Alerts: ", [LABELS[k] for k in on] or ["none"], f": {len(state.alert_chats())} chats", WRAP)) + log.info("Alerts: %s: %d chats", ", ".join(LABELS[k] for k in on) or "none", len(state.alert_chats())) if missing := [LABELS[k] for k in kinds if k not in running]: log.warning("Alerts NOT running: %s (see the error above)", ", ".join(missing)) diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index 1825e14..5614a1c 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -1,32 +1,32 @@ -"""The alert settings as buttons: Subscribe | Unsubscribe, each opening its options below: Subscribe lists the types that are off, -Unsubscribe the types that are on, so each list says by itself whether its alerts are on. Pure: it only builds the keyboard and -the title from what a chat has muted (the same menu goes under every alert and under /alerts). - -Callback data (self-contained and short, so an old message's buttons still work after a restart): - al:open:sub | al:open:unsub | al:open:other | al:open:set | al:open:snd_on | al:open:snd_off | al:close | al:noop (the heading rows of messages sent by an earlier version) - al:snd::[:parent] (notification sound for one type; Settings is in private chats only) - al:on::
| al:off::
(section: sub or unsub, the part left open) -An alert's buttons end with the alert's own type (parent): under an alert each list is its own type (marked ●) and "Other", which holds the rest and "all": al:open:unsub:rain, al:close:rain, -al:off:uv:unsub:rain (the sound menus' Other is al:open:snd_on_o:rain, al:open:snd_off_o:rain). A chat's /alerts message has no parent and no such field. +"""The alert settings as buttons. Pure: it only builds the keyboards and the title from what a chat has muted. + +/alerts has two buttons, πŸ”” Alerts and (private chats) πŸ”Š Sound. Each opens a list of alert types as toggles (Sound lists only the types +that are on), the button that opened it kept above the list (β–Ύ; pressing it closes the list) and the other hidden meanwhile. +Under an alert there is one πŸ”” that opens that type's two toggles, Alert and (while it is on, private chats) Sound. + +Callback data is self-contained and short, so an old message's buttons still work after a restart. It is built and read here only (Press): + al:open: al:close the /alerts message + al:on|off::alerts al:snd::on|off a type's alerts, or its sound, on or off + al:open:bell: al:close: the bell under an alert of that type + al:on|off::bell: al:snd::on|off: its two toggles """ +from dataclasses import dataclass from itertools import batched -from telegram import InlineKeyboardButton, InlineKeyboardMarkup +from telegram import InlineKeyboardMarkup -from ..buttons import menu_text +from ..buttons import opener, toggle_button # kind -> the name on the button, in the order listed LABELS = {"rain": "rain", "gusts": "gusts", "uv": "UV", "temps": "temperature crossing", "particulates": "particulates", "pollen_and_asthma": "pollen & asthma", "forecast": "forecast changes", "irrigation_battery": "irrigation battery", "irrigation_pause": "irrigation pause"} -# the emoji on each type's button: the one its own alerts start with (every type has one: a test holds the two lists together) -EMOJI = {"rain": "\U0001f327️", "gusts": "\U0001f4a8", "uv": "\U0001f9f4", "temps": "\U0001f321️", - "particulates": "\U0001f637", "pollen_and_asthma": "\U0001f33c", "forecast": "\U0001f504", "irrigation_battery": "\U0001faab", "irrigation_pause": "β˜”"} ALL = "all" TITLE = "πŸ”” Alerts in this chat" PER_ROW = 2 -SETTINGS = ("set", "snd_on", "snd_off", "snd_on_o", "snd_off_o") # (_o: the "Other" list inside a sound menu) +ALERTS, SOUND, BELL = "alerts", "sound", "bell" # the menus that can be open +MENUS = {ALERTS: "πŸ”” Alerts", SOUND: "πŸ”Š Sound"} # the /alerts buttons def available_kinds(sources: set[str]) -> list[str]: @@ -42,78 +42,77 @@ def kinds_for(source_names, irrigation: bool) -> list[str]: return available_kinds(set(source_names) | ({"Irrigation"} if irrigation else set())) -def label(kind: str) -> str: - """The name of an alert type on its button: its emoji, then its name.""" - return f"{EMOJI[kind]} {LABELS[kind]}" - - -def _button(text: str, data: str) -> InlineKeyboardButton: - return InlineKeyboardButton(text, callback_data=data) - - -def _type_rows(items: list[str], 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 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 "" - 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(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" - 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}")) +@dataclass(frozen=True) +class Press: + """What a button of these menus does, and its callback data both ways (data() builds it, parse() reads it back).""" + action: str # open or close a menu, flip a type's alert (alert) or its sound (sound) + kind: str | None = None # the alert type a toggle flips + section: str | None = None # the menu to show: ALERTS, SOUND or BELL + parent: str | None = None # the alert type the buttons sit under (their menu is the bell), if any + on: bool = False # what a toggle sets + + def data(self) -> str: + tail = f":{self.parent}" if self.parent else "" + verb = "on" if self.on else "off" + match self.action: + case "open": + return f"al:open:{self.section}{tail}" + case "close": + return f"al:close{tail}" + case "alert": + return f"al:{verb}:{self.kind}:{self.section}{tail}" + case _: + return f"al:snd:{self.kind}:{verb}{tail}" + + +def parse(data: str | None) -> Press | None: + """The press that callback `data` is, or None when it is not the data of any button these menus draw.""" + parts = (data or "").split(":") + if parts[0] != "al" or len(parts) < 2: + return None + action, rest = parts[1], parts[2:] + if action == "close" and len(rest) <= 1: + return Press("close", parent=rest[0] if rest else None) + if action == "open" and len(rest) in (1, 2): + return Press("open", section=rest[0], parent=rest[1] if len(rest) == 2 else None) + if action in ("on", "off") and len(rest) in (2, 3): + return Press("alert", rest[0], rest[1], rest[2] if len(rest) == 3 else None, action == "on") + if action == "snd" and len(rest) in (2, 3) and rest[1] in ("on", "off"): + parent = rest[2] if len(rest) == 3 else None + return Press("sound", rest[0], BELL if parent else SOUND, parent, rest[1] == "on") + return None + + +def alert_keyboard(kind: str, muted: set[str], loud: set[str] | None, open: str | None = None) -> InlineKeyboardMarkup: + """The buttons under an alert of type `kind`: one bell (πŸ”” β–Έ, β–Ύ once `open` is BELL) that opens up to two toggles, Alert (this type's + alerts in this chat) and, only while that is on and in a private chat (`loud` is not None), Sound (whether they make a notification + sound).""" + is_open = open == BELL + rows = [[opener("πŸ””", is_open, Press("open", section=BELL, parent=kind).data(), Press("close", parent=kind).data())]] + if is_open: + on = kind not in muted + rows.append([toggle_button("Alert", on, lambda turn_on: Press("alert", kind, BELL, kind, turn_on).data())]) + if on and loud is not None: + rows.append([toggle_button("Sound", kind in loud, lambda turn_on: Press("sound", kind, BELL, kind, turn_on).data())]) return InlineKeyboardMarkup(rows) -def has_options(muted: set[str], available: list[str], section: str, parent: str | None = None) -> bool: - """Would this section show any button under its heading?""" - return len(keyboard(muted, available, section, parent).inline_keyboard) > 1 +def _toggles(kinds: list[str], is_on, section: str, action: str) -> list[list]: + """A toggle for each of these alert types, PER_ROW to a row. is_on(kind): its state.""" + return list(map(list, batched((toggle_button(LABELS[k], is_on(k), lambda turn_on, k=k: Press(action, k, section, on=turn_on).data()) + for k in kinds), PER_ROW))) -def options(muted: set[str], available: list[str], section: str) -> list[str]: - """What a section lists: Subscribe the types that are off, Unsubscribe the types that are on.""" - return [k for k in available if (k in muted) == (section == "sub")] +def keyboard(muted: set[str], available: list[str], open: str | None = None, loud: set[str] | None = None) -> InlineKeyboardMarkup: + """The /alerts menu: πŸ”” Alerts and, when some type is on in a private chat, πŸ”Š Sound. `open` (ALERTS or SOUND): that menu's list, with + its own button above it and the other one hidden. `loud`: the types whose alerts make a notification sound; None (a group) has no Sound.""" + sound_types = [k for k in available if k not in muted] if loud is not None else [] # (a type that is off has no sound to set) + head = lambda section: opener(MENUS[section], open == section, Press("open", section=section).data(), Press("close").data()) + if open == ALERTS: + return InlineKeyboardMarkup([[head(ALERTS)], *_toggles(available, lambda k: k not in muted, ALERTS, "alert")]) + if open == SOUND and sound_types: + return InlineKeyboardMarkup([[head(SOUND)], *_toggles(sound_types, lambda k: k in loud, SOUND, "sound")]) + return InlineKeyboardMarkup([[head(ALERTS), *([head(SOUND)] if sound_types else [])]]) def title(muted: set[str], available: list[str]) -> str: diff --git a/lib/alerts/notify.py b/lib/alerts/notify.py index 437e32b..712339b 100644 --- a/lib/alerts/notify.py +++ b/lib/alerts/notify.py @@ -16,7 +16,7 @@ from telegram.error import BadRequest, ChatMigrated, Forbidden, TelegramError from ..logs import QUOTES -from .menu import ALL, LABELS, keyboard +from .menu import ALL, LABELS, alert_keyboard log = logging.getLogger(__name__) @@ -192,9 +192,8 @@ def with_footer(text: str, link: tuple[str, str] | None = None, extra: str | Non class Notifier: - def __init__(self, bot, state: AlertState, kinds: list[str] | None = None): - """kinds: the alert types this bot sends (the settings buttons under each alert list them).""" - self.bot, self.state, self.kinds = bot, state, kinds or list(LABELS) + def __init__(self, bot, state: AlertState): + self.bot, self.state = bot, state async def __call__(self, text: str, link: tuple[str, str] | None = None, kind: str | None = None, photo: bytes | str | None = None): """Send an alert (silently) to every chat subscribed to its type; forget chats the bot can no @@ -221,10 +220,9 @@ async def to_chat(self, chat_id: int, text: str, link: tuple[str, str] | None = return sent async def _post(self, chat_id: int, text: str, entities, kind: str | None, photo: bytes | str | None): - """The message itself (returned): silent unless the chat turned sound on for this type, with the settings buttons (which highlight this - alert's own type). With a photo it is the picture with the text as its caption; a picture Telegram refuses falls back to the text alone.""" + """The message itself (returned): silent unless the chat turned sound on for this type, with the alert type's settings button (the bell). With a photo it is the picture with the text as its caption; a picture Telegram refuses falls back to the text alone.""" common = dict(disable_notification=kind not in self.state.loud(chat_id), - reply_markup=keyboard(self.state.muted(chat_id), self.kinds, parent=kind, loud=self.state.loud(chat_id) if chat_id > 0 else None)) + reply_markup=alert_keyboard(kind, self.state.muted(chat_id), self.state.loud(chat_id) if chat_id > 0 else None) if kind else None) if photo: try: return await self.bot.send_photo(chat_id, photo, caption=text, caption_entities=entities, **common) diff --git a/lib/bot.py b/lib/bot.py index d1e16cc..583c27f 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -408,17 +408,15 @@ async def on_usage(self, update: Update, context: ContextTypes.DEFAULT_TYPE): @staticmethod def _alerts_named(kind: str) -> str: - """"All alerts" or "UV alerts" (not "Uv"), for a toast.""" - return "All alerts" if kind == menu.ALL else f"{menu.LABELS[kind][0].upper()}{menu.LABELS[kind][1:]} alerts" + """"UV alerts" (not "Uv"), for a toast.""" + return f"{menu.LABELS[kind][0].upper()}{menu.LABELS[kind][1:]} alerts" async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TYPE): - """A press on the alert settings buttons (under an alert or the /alerts message): open or close a section, or subscribe - or unsubscribe a type or all. In a group only admins may change them.""" + """A press on the alert settings buttons (under an alert or the /alerts message): open or close a menu, or turn a type's alerts or + its sound on or off. In a group only admins may change them.""" query = update.callback_query - parts = (query.data or "").split(":") - action, arg, section, tail = (parts + ["", "", "", ""])[1:5] - parent = section if action == "open" else arg if action == "close" else tail # the alert's own type, when under an alert - if action == "noop" or not self.state: + press = menu.parse(query.data) + if press is None or not self.state: await query.answer() return message = query.message @@ -427,36 +425,21 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY await query.answer("Only group admins can change alerts") return kinds = self._alert_kinds() - parent = parent if parent in kinds else None - toast, opened = None, section or None - if action in ("open", "close"): - opened = arg if action == "open" else None - 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 or arg == menu.ALL) and section in ("on", "off") and chat.id in self.state.chats: - for k in ([k for k in kinds if k not in self.state.muted(chat.id)] if arg == menu.ALL else [arg]): # "all": the types offered here - self.state.set_sound(chat.id, k, section == "on") - parent = tail if tail in kinds else None - opened = "snd_on" if section == "on" else "snd_off" # stay in the list (under an alert: in "Other" for another type) - if parent and arg != parent: - opened += "_o" - toast = f"{self._alerts_named(arg)} {'now make' if section == 'on' else 'no longer make'} a sound" - elif action in ("on", "off") and (arg == menu.ALL or arg in kinds) and chat.id in self.state.chats: - self.state.set_kind(chat.id, arg, action == "on") - toast = f"{self._alerts_named(arg)} {action} in this chat" - else: + parent = press.parent if press.parent in kinds else None + private = chat.type == ChatType.PRIVATE + toast, opened = None, press.section if press.action != "close" else None + if press.action == "sound" and private and press.kind in kinds and chat.id in self.state.chats: + self.state.set_sound(chat.id, press.kind, press.on) + toast = f"{self._alerts_named(press.kind)} {'now make' if press.on else 'no longer make'} a sound" + elif press.action == "alert" and press.kind in kinds and chat.id in self.state.chats: + self.state.set_kind(chat.id, press.kind, press.on) + toast = f"{self._alerts_named(press.kind)} {'on' if press.on else 'off'} in this chat" + elif press.action not in ("open", "close"): await query.answer() return muted = self.state.muted(chat.id) - 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", "other", *menu.SETTINGS) else None, parent, - self.state.loud(chat.id) if private else None) + loud = self.state.loud(chat.id) if private else None + markup = menu.alert_keyboard(parent, muted, loud, opened) if parent else menu.keyboard(muted, kinds, opened, loud) await query.answer(toast) try: if (message.text or "").startswith(menu.TITLE): # the /alerts message: keep its on/off summary current diff --git a/lib/buttons.py b/lib/buttons.py index af7135e..5a23e5d 100644 --- a/lib/buttons.py +++ b/lib/buttons.py @@ -1,7 +1,28 @@ -"""Shared look of the bot's inline buttons.""" +"""Shared look of the bot's inline buttons: a plain button, a menu opener (β–Έ closed, β–Ύ open) and a toggle (green while on, struck +through while off). Every menu builds its buttons with these.""" +from collections.abc import Callable -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 'β–Έ'}" +from telegram import InlineKeyboardButton +from telegram.constants import KeyboardButtonStyle + + +def button(text: str, data: str, style: str | None = None) -> InlineKeyboardButton: + return InlineKeyboardButton(text, callback_data=data, style=style) + + +def opener(text: str, is_open: bool, open_data: str, close_data: str) -> InlineKeyboardButton: + """A button that opens a menu: β–Έ after the text while it is closed, β–Ύ in the same place once it is open (so pressing it looks like the + arrow turning), and pressing it then closes the menu. Buttons that do something have neither.""" + return button(f"{text} {'β–Ύ' if is_open else 'β–Έ'}", close_data if is_open else open_data) + + +def struck(text: str) -> str: + """The text with a line through it (a button cannot be styled, so each letter gets the combining long stroke).""" + return "".join(f"{c}ΜΆ" for c in text) + + +def toggle_button(word: str, on: bool, data: Callable[[bool], str]) -> InlineKeyboardButton: + """A button that flips a setting: its word on a green button while the setting is on, the word struck through on a plain button while it + is off. `data(turn_on)`: the callback data of pressing it, which sets the setting to `turn_on` (the opposite of how it is now).""" + return button(word if on else struck(word), data(not on), KeyboardButtonStyle.SUCCESS if on else None) diff --git a/lib/irrigation/menu.py b/lib/irrigation/menu.py index 9cdb17d..ede55c4 100644 --- a/lib/irrigation/menu.py +++ b/lib/irrigation/menu.py @@ -3,7 +3,7 @@ Collapsed: [πŸ’§ Water] [⏸ Pause] / [πŸ”” Alerts] [πŸ”„ Refresh]. While the valve is on, a [Water off ❌] row follows. While the timer is paused, Pause is [▢️ Unpause] (one press ends the pause) and Water is hidden. A press on Water, Pause or Alerts opens its options under the rows (β–Έ after the text marks a menu, β–Ύ the open one; pressing it again closes them). Water lists 5, 10, 20 and 30 minutes (a run is always timed). Alerts has -one button per alert type, each naming what pressing it does for this chat (enable or disable). +one toggle per alert type, green while this chat gets it, struck through and uncoloured while it does not; pressing it flips it. Callback data (short, self-contained, so an old message's buttons still work after a restart): ir:open: | ir:close | ir:refresh (read the state again, change nothing) @@ -14,9 +14,9 @@ import math import re -from telegram import InlineKeyboardButton, InlineKeyboardMarkup, MessageEntity +from telegram import InlineKeyboardMarkup, MessageEntity -from ..buttons import menu_text +from ..buttons import button, opener, toggle_button TITLE = "🌱 Irrigation" WATER_MINUTES = (5, 10, 20, 30) @@ -59,10 +59,6 @@ def with_error(text: str, error: str = PAUSED_ERROR) -> tuple[str, list[MessageE return head + error, [MessageEntity(MessageEntity.BOLD, units(head), units(error))] -def _button(text: str, data: str) -> InlineKeyboardButton: - return InlineKeyboardButton(text, callback_data=data) - - def status_text(status: dict, alerts: bool | None = None, pause_alerts: bool | None = None) -> str: """The controller's state, every line always there, in this order: mode: auto @@ -89,20 +85,19 @@ def status_text(status: dict, alerts: bool | None = None, pause_alerts: bool | N def keyboard(battery: bool, pause: bool, open: str | None = None, paused: bool = False, running: bool = False) -> InlineKeyboardMarkup: """The buttons under the status. `battery` and `pause`: this chat gets the irrigation battery / pause alerts; each Alerts button - names what pressing it does (disable when it is on, enable when it is off). `paused`: the timer is paused, so its + is a toggle (green when on, struck through and uncoloured when off). `paused`: the timer is paused, so its button unpauses and Water is hidden. `running`: the valve is on, so Water off is a top-level row.""" - def head(name: str, text: str) -> InlineKeyboardButton: - return _button(menu_text(text, open == name), "ir:close" if open == name else f"ir:open:{name}") - delay = _button("▢️ Unpause", "ir:delay:cancel") if paused else head("delay", "⏸ Pause") + head = lambda name, text: opener(text, open == name, f"ir:open:{name}", "ir:close") + delay = button("▢️ Unpause", "ir:delay:cancel") if paused else head("delay", "⏸ Pause") rows = [[delay] if paused else [head("water", "πŸ’§ Water"), delay], - [head("alerts", "πŸ”” Alerts"), _button("πŸ”„ Refresh", "ir:refresh")]] + [head("alerts", "πŸ”” Alerts"), button("πŸ”„ Refresh", "ir:refresh")]] if running: # the valve is open: off is one press away, whatever else is open - rows.append([_button("Water off ❌", "ir:sw:off")]) + rows.append([button("Water off ❌", "ir:sw:off")]) if open == "water" and not paused: - rows.append([_button(f"{m} min", f"ir:water:{m}") for m in WATER_MINUTES]) + rows.append([button(f"{m} min", f"ir:water:{m}") for m in WATER_MINUTES]) elif open == "delay" and not paused: - rows.append([_button(d, f"ir:delay:{d}") for d in DELAYS if d != "cancel"]) + rows.append([button(d, f"ir:delay:{d}") for d in DELAYS if d != "cancel"]) elif open == "alerts": for on, kind, name in ((battery, "batt", "battery"), (pause, "pause", "pause")): - rows.append([_button(f"πŸ”• Disable {name} alerts" if on else f"πŸ”” Enable {name} alerts", f"ir:{kind}:{'off' if on else 'on'}")]) + rows.append([toggle_button(f"{name} alerts", on, lambda turn_on, kind=kind: f"ir:{kind}:{'on' if turn_on else 'off'}")]) return InlineKeyboardMarkup(rows) diff --git a/lib/logs.py b/lib/logs.py index 4b09aee..3144521 100644 --- a/lib/logs.py +++ b/lib/logs.py @@ -1,5 +1,5 @@ """How the bot's log lines look. Every line of an INFO or DEBUG entry is cut to the log width (LOG_WIDTH), an ellipsis (…) marking the -cut; a WARN or an ERROR is never cut. A line that quotes a request or a chat message (the quote last on the line) also has the quote +cut, whatever its level. A line that quotes a request or a chat message (the quote last on the line) also has the quote put on one line first.""" import logging @@ -13,8 +13,7 @@ def fit(text: str, room: int) -> str: class LogFormat(logging.Formatter): - """"INFO message": the level (WARNING is shown as WARN) and the message, with no module name. Cuts each line to the width, - except for a WARN or an ERROR.""" + """"INFO message": the level (WARNING is shown as WARN) and the message, with no module name. Cuts each line to the width.""" def __init__(self, fmt: str, width: int = 0): """width: the most characters of a line of the log; 0 cuts none.""" @@ -33,6 +32,6 @@ def format(self, record): quote = str(record.args[-1]) if getattr(record, "quotes", False) and isinstance(record.args, tuple) and record.args else None if quote and line.endswith(quote): # (not when a traceback follows it) line = line[:len(line) - len(quote)] + " ".join(quote.split()) # the quote on one line - if not self.width or record.levelno >= logging.WARNING: + if not self.width: return line return "\n".join(fit(part, self.width) for part in line.split("\n")) diff --git a/lib/periods.py b/lib/periods.py index ed34a03..1677aac 100644 --- a/lib/periods.py +++ b/lib/periods.py @@ -4,7 +4,9 @@ from collections import OrderedDict from dataclasses import dataclass -from telegram import InlineKeyboardButton, InlineKeyboardMarkup +from telegram import InlineKeyboardMarkup + +from .buttons import button PERIODS = (1, 7, 30, 90, 365) NAMES = {1: "1D", 7: "7D", 30: "1M", 90: "3M", 365: "1Y"} # what the buttons say @@ -14,8 +16,7 @@ def keyboard(current: int | None = None) -> InlineKeyboardMarkup: """The one row of period buttons; the one for the chart's own period (`current`, in days) is marked with ●.""" - return InlineKeyboardMarkup([[InlineKeyboardButton(f"\u25cf {NAMES[days]}" if days == current else NAMES[days], - callback_data=f"{PREFIX}{days}") for days in PERIODS]]) + return InlineKeyboardMarkup([[button(f"\u25cf {NAMES[days]}" if days == current else NAMES[days], f"{PREFIX}{days}") for days in PERIODS]]) def days_in(data: str | None) -> int | None: diff --git a/lib/report_menu.py b/lib/report_menu.py index 48da949..77b0712 100644 --- a/lib/report_menu.py +++ b/lib/report_menu.py @@ -1,11 +1,13 @@ """The button under the full report ("status"): Refresh, which reads everything again and redraws the report in place (Bot.on_report_button).""" -from telegram import InlineKeyboardButton, InlineKeyboardMarkup +from telegram import InlineKeyboardMarkup + +from .buttons import button PREFIX = "rp:" REFRESH = f"{PREFIX}refresh" def keyboard() -> InlineKeyboardMarkup: - return InlineKeyboardMarkup([[InlineKeyboardButton("πŸ”„ Refresh", callback_data=REFRESH)]]) + return InlineKeyboardMarkup([[button("πŸ”„ Refresh", REFRESH)]]) diff --git a/tests/test_admin_only.py b/tests/test_admin_only.py index 7806e5c..fa87839 100644 --- a/tests/test_admin_only.py +++ b/tests/test_admin_only.py @@ -282,14 +282,14 @@ async def test_usage_is_the_help_and_alerts_button(tmp_path, kind): # ---------- every button comes in through one place ---------- -def alert_keyboard(opened="unsub", parent=None): - return menu.keyboard(set(), menu.available_kinds({"Ecowitt"}), opened, parent) +def alert_keyboard(opened="alerts"): + return menu.keyboard(set(), menu.available_kinds({"Ecowitt"}), opened, set()) async def test_a_stranger_is_refused_on_every_family_of_buttons_and_an_admin_is_let_through(tmp_path): bot, state, tg = make(tmp_path, irrigation=Remote()) state.add_chat(ADMIN, "Rob") - cases = [("al:off:rain:unsub", alert_keyboard()), (f"{periods.PREFIX}7", periods.keyboard(1)), + cases = [("al:off:rain:alerts", alert_keyboard()), (f"{periods.PREFIX}7", periods.keyboard(1)), ("ir:open:water", irrigation_menu.keyboard(True, True))] for data, markup in cases: press = Press(data, markup, user=STRANGER) @@ -307,10 +307,10 @@ async def test_a_stranger_is_refused_on_every_family_of_buttons_and_an_admin_is_ async def test_alert_buttons_in_a_group_stay_admin_only_even_when_admin_only_is_off(tmp_path): bot, state, tg = make(tmp_path, admin_only=False) - press = Press("al:off:rain:unsub", alert_keyboard(), user=STRANGER, kind="supergroup") + press = Press("al:off:rain:alerts", alert_keyboard(), user=STRANGER, kind="supergroup") await bot.on_button(press.update(), ctx(tg)) assert press.toast == "Only group admins can change alerts" and state.muted(GROUP) == set() - press = Press("al:off:rain:unsub", alert_keyboard(), user=ADMIN, kind="supergroup") + press = Press("al:off:rain:alerts", alert_keyboard(), user=ADMIN, kind="supergroup") await bot.on_button(press.update(), ctx(tg)) assert state.muted(GROUP) == {"rain"} @@ -321,9 +321,12 @@ def every_keyboard(bot): kinds = menu.available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast", "Irrigation"}) muted_sets = [set(), {"rain"}, {"rain", "uv", "particulates"}, set(menu.LABELS)] for muted in muted_sets: - for opened in (None, "sub", "unsub"): - for parent in (None, *kinds): - yield menu.keyboard(muted, kinds, opened, parent) + for loud in (None, set(), set(kinds)): + for opened in (None, menu.ALERTS, menu.SOUND): + yield menu.keyboard(muted, kinds, opened, loud) + for kind in kinds: + for opened in (None, menu.BELL): + yield menu.alert_keyboard(kind, muted, loud, opened) for battery in (True, False): for pause in (True, False): for opened in (None, *irrigation_menu.SECTIONS, "bogus"): @@ -420,7 +423,7 @@ async def test_a_prefix_nobody_handles_is_refused_even_when_the_message_really_h async def test_a_real_press_on_a_button_the_message_carries_still_works(tmp_path): bot, state, tg = make(tmp_path, irrigation=Remote()) state.add_chat(ADMIN, "Rob") - for data, markup in (("ir:refresh", irrigation_menu.keyboard(True, True)), ("al:off:rain:unsub", alert_keyboard()), + for data, markup in (("ir:refresh", irrigation_menu.keyboard(True, True)), ("al:off:rain:alerts", alert_keyboard()), ("ir:water:10", irrigation_menu.keyboard(True, True, "water"))): press = await press_through(bot, tg, data, markup) assert press.toast not in (STALE_BUTTON, NOT_ADMIN_TOAST) and press.edits, data diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 582e483..90ffe6e 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -4,8 +4,9 @@ from telegram.error import BadRequest from lib.alerts import AlertState, Notifier -from lib.alerts.menu import ALL, LABELS, TITLE, available_kinds, keyboard, options, title +from lib.alerts.menu import ALL, LABELS, TITLE, Press, alert_keyboard, available_kinds, keyboard, parse, title from lib.bot import Bot +from lib.buttons import struck as strike from tests.fakes import TZ KINDS = ["rain", "gusts", "uv", "temps", "particulates", "pollen_and_asthma", "forecast"] @@ -15,35 +16,70 @@ def rows(markup): return [[(b.text, b.callback_data) for b in row] for row in markup.inline_keyboard] -def test_the_collapsed_menu_is_subscribe_and_unsubscribe(): - assert rows(keyboard(set(), KINDS)) == [[("βž• Sub β–Έ", "al:open:sub"), ("βž– Unsub β–Έ", "al:open:unsub")]] - - -def test_unsubscribe_opens_the_types_that_are_on_and_subscribe_the_types_that_are_off(): - got = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "unsub")) - assert got[0] == [("βž• Sub β–Έ", "al:open:sub"), ("βž– Unsub β–Ύ", "al:close")] - assert got[1] == [("🌧️ rain", "al:off:rain:unsub"), ("πŸ’¨ gusts", "al:off:gusts:unsub")] - assert [text for row in got[1:-1] for text, _ in row] == ["🌧️ rain", "πŸ’¨ gusts", "🧴 UV", "🌑️ temperature crossing", "😷 particulates"] - assert got[-1] == [("πŸ”• Unsubscribe from all", "al:off:all:unsub")] - got = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "sub")) - assert got[0][0] == ("βž• Sub β–Ύ", "al:close") - assert got[1:] == [[("🌼 pollen & asthma", "al:on:pollen_and_asthma:sub"), ("πŸ”„ forecast changes", "al:on:forecast:sub")], [("πŸ”” Subscribe to all", "al:on:all:sub")]] - assert not any(data == "al:noop" for row in got for _, data in row) # no button that does nothing - assert options({"pollen_and_asthma"}, KINDS, "sub") == ["pollen_and_asthma"] and options({"pollen_and_asthma"}, ["rain", "pollen_and_asthma"], "unsub") == ["rain"] - assert len(rows(keyboard(set(), KINDS, "sub"))) == 1 # nothing off: nothing listed +def test_alerts_has_two_buttons_alerts_and_sound_and_a_group_has_only_the_first(): + assert rows(keyboard({"rain"}, KINDS, loud=set())) == [[("πŸ”” Alerts β–Έ", "al:open:alerts"), ("πŸ”Š Sound β–Έ", "al:open:sound")]] + assert rows(keyboard(set(), KINDS)) == [[("πŸ”” Alerts β–Έ", "al:open:alerts")]] + assert rows(keyboard(set(), KINDS, "sound")) == rows(keyboard(set(), KINDS)) # a group cannot open Sound + + +def test_the_bell_toggles_are_green_when_on_and_struck_through_when_off(): + styles = lambda m: {b.text: b.style for row in m.inline_keyboard for b in row} + assert styles(alert_keyboard("uv", set(), set(), "bell")) == {"πŸ”” β–Ύ": None, "Alert": "success", strike("Sound"): None} + assert styles(alert_keyboard("uv", {"uv"}, {"uv"}, "bell")) == {"πŸ”” β–Ύ": None, strike("Alert"): None} + + +def test_every_button_of_every_keyboard_parses_and_builds_back_to_the_same_data(): + muted_sets = [set(), {"rain"}, {"rain", "uv", "particulates"}, set(LABELS)] + seen = set() + for muted in muted_sets: + for loud in (None, set(), set(LABELS), {"rain", "uv"}): + boards = [keyboard(muted, list(LABELS), open, loud) for open in (None, "alerts", "sound")] + boards += [alert_keyboard(kind, muted, loud, open) for kind in LABELS for open in (None, "bell")] + for board in boards: + for row in board.inline_keyboard: + for b in row: + press = parse(b.callback_data) + assert press is not None and press.data() == b.callback_data, b.callback_data + seen.add(press.action) + assert seen == {"open", "close", "alert", "sound"} + assert parse("al:snd:uv:on:uv") == Press("sound", "uv", "bell", "uv", True) and parse("al:snd:uv:off") == Press("sound", "uv", "sound", None, False) + for junk in (None, "", "al", "al:", "al:noop", "al:open", "al:on:rain", "al:snd:rain:maybe", "al:off:a:b:c:d", "ir:water:5", "garbage"): + assert parse(junk) is None, junk + + +def test_a_type_that_is_on_is_a_green_button_and_one_that_is_off_is_struck_through_and_plain(): + markup = keyboard({"gusts"}, KINDS, "alerts", loud=set()) + styles = {b.text: b.api_kwargs.get("style") or b.style for row in markup.inline_keyboard for b in row} + assert styles["rain"] == "success" and styles[strike("gusts")] is None and not styles["πŸ”” Alerts β–Ύ"] + assert markup.inline_keyboard[1][0].to_dict()["style"] == "success" and "style" not in markup.inline_keyboard[1][1].to_dict() # and it is sent to Telegram + + +def test_each_menu_lists_its_types_struck_through_when_off_and_keeps_only_its_own_button(): + got = rows(keyboard({"gusts"}, KINDS, "alerts", loud={"rain"})) + assert got[0] == [("πŸ”” Alerts β–Ύ", "al:close")] # the button that opened it stays, open + assert [t for row in got[1:] for t, _ in row] == ["rain", strike("gusts"), "UV", "temperature crossing", "particulates", + "pollen & asthma", "forecast changes"] + assert got[1] == [("rain", "al:off:rain:alerts"), (strike("gusts"), "al:on:gusts:alerts")] + assert "Sound" not in str(got) # the other menu's button is hidden + got = rows(keyboard({"gusts"}, KINDS, "sound", loud={"rain"})) + assert got[0] == [("πŸ”Š Sound β–Ύ", "al:close")] and "Alerts" not in str(got) + assert got[1] == [("rain", "al:snd:rain:off"), (strike("UV"), "al:snd:uv:on")] # gusts is off, so it is not listed at all + assert [t for row in got[2:] for t, _ in row] == [strike(w) for w in ("temperature crossing", "particulates", "pollen & asthma", "forecast changes")] + assert "gusts" not in str(got) + assert rows(keyboard(set(KINDS), KINDS, "sound", loud=set())) == [[("πŸ”” Alerts β–Έ", "al:open:alerts")]] # nothing is on: no Sound button either def test_only_the_types_the_bot_has_are_listed_and_every_callback_is_short(): assert available_kinds({"Ecowitt"}) == ["rain", "gusts", "uv", "temps"] assert available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast"}) == KINDS and available_kinds({"AirGradient"}) == ["particulates"] - for section in (None, "sub", "unsub"): - for text, data in (b for row in rows(keyboard({"uv"}, KINDS, section)) for b in row): + for section in (None, "alerts", "sound"): + for text, data in (b for row in rows(keyboard({"uv"}, KINDS, section, loud=set())) for b in row): assert len(data.encode()) <= 64 def test_the_title_lists_what_is_on_and_off(): assert title({"gusts", "uv"}, KINDS[:4]) == f"{TITLE}\nβœ… On: rain, temperature crossing\nπŸ”• Off: gusts, UV" - assert title(set(), ["particulates"]).endswith("πŸ”• Off: nothing") and title({"particulates"}, ["particulates"]).endswith("βœ… On: nothing\nπŸ”• Off: particulates") + assert title(set(), ["particulates"]).endswith("πŸ”• Off: nothing") and title({"particulates"}, ["particulates"]).endswith("On: nothing\nπŸ”• Off: particulates") def test_state_mutes_one_type_or_all_and_old_files_load(tmp_path): @@ -80,13 +116,12 @@ async def test_an_alert_goes_to_the_chats_subscribed_to_its_type_and_carries_the state.add_chat(chat, str(chat)) state.set_kind(2, "rain", False) state.set_kind(3, ALL, False) - notifier = Notifier(bot, state, KINDS) + notifier = Notifier(bot, state) await notifier("It's raining", kind="rain") await notifier("Gusts", kind="gusts") assert [(c, t.split("\n")[0]) for c, t, _ in bot.sent] == [(1, "It's raining"), (1, "Gusts"), (2, "Gusts")] markup = bot.sent[0][2]["reply_markup"] - assert rows(markup) == [[("βž• Sub β–Έ", "al:open:sub:rain"), ("βž– Unsub β–Έ", "al:open:unsub:rain"), - ("βš™οΈ Settings β–Έ", "al:open:set:rain")]] # the alert's own type rides along + assert rows(markup) == [[("πŸ”” β–Έ", "al:open:bell:rain")]] # the alert's own type rides along assert bot.sent[0][1] == "It's raining" # no footer: the buttons say how to change it assert await notifier.to_chat(2, "x", kind="rain") is False and await notifier.to_chat(2, "x", kind="uv") is True @@ -128,39 +163,30 @@ async def press(bot, query, **kw): await bot.on_alert_button(NS(callback_query=query), kw.get("context") or ctx()) -async def test_pressing_unsubscribe_then_a_type_updates_the_state_and_redraws_the_open_section(tmp_path): +async def test_the_alerts_menu_toggles_a_type_in_place_and_back_returns_to_the_two_buttons(tmp_path): bot, state = make_bot(tmp_path) - q = Query("al:open:unsub") - await press(bot, q) - assert q.toast is None and rows(q.edits[0][1])[1][0] == ("🌧️ rain", "al:off:rain:unsub") and q.edits[0][0] == "markup" - q = Query("al:off:rain:unsub") + q = Query("al:open:alerts", text=f"{TITLE}\nβœ… On: ...") await press(bot, q) - assert state.muted(1) == {"rain"} and q.toast == "Rain alerts off in this chat" - drawn = rows(q.edits[0][1]) - assert drawn[0][1] == ("βž– Unsub β–Ύ", "al:close") and "🌧️ rain" not in [t for row in drawn[1:] for t, _ in row] - q = Query("al:on:rain:sub") - await press(bot, q) - assert state.muted(1) == set() and q.toast == "Rain alerts on in this chat" - q = Query("al:off:all:unsub") + drawn = rows(q.edits[0][2]) + assert q.toast is None and drawn[0] == [("πŸ”” Alerts β–Ύ", "al:close")] and drawn[1][0] == ("rain", "al:off:rain:alerts") + q = Query("al:off:rain:alerts", text=f"{TITLE}\nβœ… On: ...") await press(bot, q) - assert q.toast == "All alerts off in this chat" and not state.alerts_on(1) and len(rows(q.edits[0][1])) == 1 # nothing left: closed - q = Query("al:open:unsub") + assert state.muted(1) == {"rain"} and q.toast == "Rain alerts off in this chat" and "πŸ”• Off: rain" in q.edits[0][1] + assert rows(q.edits[0][2])[1][0] == (strike("rain"), "al:on:rain:alerts") # stays open, now with a cross + q = Query("al:on:rain:alerts", text=f"{TITLE}\nβœ… On: ...") await press(bot, q) - assert q.toast == "No alerts are on" and q.edits == [] # nothing to list: a toast, no dead button - q = Query("al:open:sub") + assert state.muted(1) == set() and q.toast == "Rain alerts on in this chat" and rows(q.edits[0][2])[1][0] == ("rain", "al:off:rain:alerts") + q = Query("al:close", text=f"{TITLE}\nβœ… On: ...") await press(bot, q) - assert rows(q.edits[0][1])[-1] == [("πŸ”” Subscribe to all", "al:on:all:sub")] - q = Query("al:close") - await press(bot, q) - assert len(rows(q.edits[0][1])) == 1 + assert rows(q.edits[0][2]) == [[("πŸ”” Alerts β–Έ", "al:open:alerts"), ("πŸ”Š Sound β–Έ", "al:open:sound")]] async def test_the_alerts_message_keeps_its_summary_current_but_an_alert_keeps_its_text(tmp_path): bot, state = make_bot(tmp_path) - q = Query("al:off:uv:unsub", text=f"{TITLE}\nβœ… On: ...") + q = Query("al:off:uv:alerts", text=f"{TITLE}\nβœ… On: ...") await press(bot, q) assert q.edits[0][0] == "text" and "πŸ”• Off: UV" in q.edits[0][1] - q = Query("al:off:gusts:unsub", text="πŸ’¨ Strong gusts: 55 km/h") + q = Query("al:off:gusts:alerts", text="πŸ’¨ Strong gusts: 55 km/h") await press(bot, q) assert q.edits[0][0] == "markup" @@ -170,12 +196,12 @@ async def test_noop_unknown_kinds_and_unchanged_markup_are_harmless(tmp_path): q = Query("al:noop") await press(bot, q) assert q.edits == [] and q.toast is None - for data in ("al:off:pollen:unsub", "al:off:bogus:unsub", "al:weird", "garbage"): # pollen is not a type here + for data in ("al:off:pollen:alerts", "al:off:bogus:alerts", "al:weird", "garbage"): # pollen is not a type here q = Query(data) await press(bot, q) assert q.edits == [] and state.muted(1) == set(), data state.set_kind(1, "rain", False) # something to list, so it edits - q = Query("al:open:sub") + q = Query("al:open:alerts") q.fail = BadRequest("Message is not modified: specified new message content and reply markup are exactly the same") await press(bot, q) # swallowed q.fail = BadRequest("Message to edit not found") @@ -185,12 +211,12 @@ async def test_noop_unknown_kinds_and_unchanged_markup_are_harmless(tmp_path): async def test_in_a_group_only_admins_may_change_alerts(tmp_path): bot, state = make_bot(tmp_path) - q = Query("al:off:rain:unsub", chat_type="supergroup", chat_id=-5) + q = Query("al:off:rain:alerts", chat_type="supergroup", chat_id=-5) await press(bot, q, context=ctx("member")) assert q.toast == "Only group admins can change alerts" and state.muted(-5) == set() and q.edits == [] await press(bot, q, context=ctx("administrator")) assert state.muted(-5) == {"rain"} and q.edits - q = Query("al:off:uv:unsub", chat_type="group", chat_id=-5) + q = Query("al:off:uv:alerts", chat_type="group", chat_id=-5) await press(bot, q, context=ctx("creator")) assert state.muted(-5) == {"rain", "uv"} @@ -204,10 +230,10 @@ async def reply_text(body, **kw): chat = NS(type="private", id=1, title=None) update = NS(effective_message=NS(reply_text=reply_text, chat_id=1), effective_chat=chat, effective_user=NS(full_name="Rob", username="rob")) await bot.on_alerts(update, NS(args=[])) - assert replies[0][0].startswith(TITLE) and "βœ… On: rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes" in replies[0][0] - assert rows(replies[0][1]["reply_markup"])[0] == [("βž• Sub β–Έ", "al:open:sub"), ("βž– Unsub β–Έ", "al:open:unsub"), ("βš™οΈ Settings β–Έ", "al:open:set")] + assert replies[0][0].startswith(TITLE) and "On: rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes" in replies[0][0] + assert rows(replies[0][1]["reply_markup"])[0] == [("πŸ”” Alerts β–Έ", "al:open:alerts"), ("πŸ”Š Sound β–Έ", "al:open:sound")] await bot.on_alerts(update, NS(args=["off"])) - assert not state.alerts_on(1) and "βœ… On: nothing" in replies[1][0] + assert not state.alerts_on(1) and "On: nothing" in replies[1][0] await bot.on_alerts(update, NS(args=["on"])) assert state.alerts_on(1) and "πŸ”• Off: nothing" in replies[2][0] @@ -256,126 +282,81 @@ async def spy(chat_id, text, link=None, kind=None): assert seen == ["forecast"] -def test_under_an_alert_the_unsubscribe_list_is_its_type_then_other_which_holds_the_rest_and_all(): - got = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "unsub", parent="uv")) - assert got == [[("βž• Sub β–Έ", "al:open:sub:uv"), ("βž– Unsub β–Ύ", "al:close:uv")], - [("● 🧴 UV", "al:off:uv:unsub:uv")], [("Other β–Έ", "al:open:other:uv")]] - other = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "other", parent="uv")) - assert other[1:3] == [[("● 🧴 UV", "al:off:uv:other:uv")], [("Other β–Ύ", "al:open:unsub:uv")]] - 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")]] - assert rows(keyboard(set(KINDS) - {"uv"}, KINDS, "other", parent="uv")) == only # and an open Other degrades to the plain list - gone = rows(keyboard({"uv", "gusts"}, KINDS, "unsub", parent="uv")) # its type is already off: a plain list of the rest, with "all" - assert "Other" not in str(gone) and gone[-1] == [("πŸ”• Unsubscribe from all", "al:off:all:unsub:uv")] - assert len(rows(keyboard(set(KINDS), KINDS, "unsub", parent="uv"))) == 1 # nothing is on at all: nothing listed - sub = rows(keyboard({"pollen_and_asthma", "forecast"}, KINDS, "sub", parent="uv")) # Subscribe is the types that are off, as ever - assert [t for row in sub[1:-1] for t, _ in row] == ["🌼 pollen & asthma", "πŸ”„ forecast changes"] and sub[-1][0][0] == "πŸ”” Subscribe to all" - assert rows(keyboard(set(), KINDS, "unsub"))[1][0][0] == "🌧️ rain" # the /alerts message has no parent: every type that is on - - -def test_the_all_button_is_only_there_when_the_list_has_more_than_one_item(): - assert not any("all" in data for row in rows(keyboard({"uv", "gusts", "rain", "temps", "particulates", "pollen_and_asthma"}, KINDS, "unsub")) - for _, data in row) # one type on (forecast changes): no "Unsubscribe from all" - assert not any("all" in data for row in rows(keyboard({"uv"}, KINDS, "sub")) for _, data in row) # one type off: no "Subscribe to all" - assert rows(keyboard({"uv", "gusts"}, KINDS, "sub"))[-1][0][0] == "πŸ”” Subscribe to all" - assert rows(keyboard(set(), KINDS, "unsub"))[-1][0][0] == "πŸ”• Unsubscribe from all" - - -async def test_the_alerts_own_type_survives_opening_unsubscribing_and_closing_the_menu_under_it(tmp_path): +def test_under_an_alert_one_bell_opens_the_alert_toggle_and_then_the_sound_toggle(): + shut = rows(alert_keyboard("uv", set(), set())) + assert shut == [[("πŸ”” β–Έ", "al:open:bell:uv")]] + on = rows(alert_keyboard("uv", set(), set(), "bell")) # both on... the sound is off + assert on == [[("πŸ”” β–Ύ", "al:close:uv")], [("Alert", "al:off:uv:bell:uv")], [(strike("Sound"), "al:snd:uv:on:uv")]] + both = rows(alert_keyboard("uv", set(), {"uv"}, "bell")) + assert both[2] == [("Sound", "al:snd:uv:off:uv")] + off = rows(alert_keyboard("uv", {"uv"}, {"uv"}, "bell")) # alert off: struck through, and no sound toggle + assert off == [[("πŸ”” β–Ύ", "al:close:uv")], [(strike("Alert"), "al:on:uv:bell:uv")]] + group = rows(alert_keyboard("uv", set(), None, "bell")) # a group has no sound + assert group == [[("πŸ”” β–Ύ", "al:close:uv")], [("Alert", "al:off:uv:bell:uv")]] + + +async def test_the_bell_under_an_alert_toggles_the_alert_and_its_sound_and_stays_open(tmp_path): bot, state = make_bot(tmp_path) - q = Query("al:open:unsub:uv", text="β˜€οΈ UV 10") + q = Query("al:open:bell:uv", text="β˜€οΈ UV 10") await press(bot, q) - drawn = rows(q.edits[0][1]) - assert drawn[0][1] == ("βž– Unsub β–Ύ", "al:close:uv") and drawn[1:] == [ - [("● 🧴 UV", "al:off:uv:unsub:uv")], [("Other β–Έ", "al:open:other:uv")]] - q = Query("al:open:other:uv", text="β˜€οΈ UV 10") + assert rows(q.edits[0][1]) == [[("πŸ”” β–Ύ", "al:close:uv")], [("Alert", "al:off:uv:bell:uv")], [(strike("Sound"), "al:snd:uv:on:uv")]] + q = Query("al:snd:uv:on:uv", text="β˜€οΈ UV 10") await press(bot, q) - assert 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 + assert state.loud(1) == {"uv"} and q.toast == "UV alerts now make a sound" and rows(q.edits[0][1])[2] == [("Sound", "al:snd:uv:off:uv")] + q = Query("al:off:uv:bell:uv", text="β˜€οΈ UV 10") # alert off: the sound toggle goes, the struck alert stays to turn it back on await press(bot, q) assert "uv" in state.muted(1) and q.toast == "UV alerts off in this chat" - assert "Other" not in str(rows(q.edits[0][1])) and rows(q.edits[0][1])[-1] == [("πŸ”• Unsubscribe from all", "al:off:all:unsub:uv")] - q = Query("al:off:all:unsub:uv", text="β˜€οΈ UV 10") # and "all" turns the rest off, closing the empty section + assert rows(q.edits[0][1]) == [[("πŸ”” β–Ύ", "al:close:uv")], [(strike("Alert"), "al:on:uv:bell:uv")]] + q = Query("al:on:uv:bell:uv", text="β˜€οΈ UV 10") await press(bot, q) - assert rows(q.edits[0][1]) == [[("βž• Sub β–Έ", "al:open:sub:uv"), ("βž– Unsub β–Έ", "al:open:unsub:uv"), - ("βš™οΈ Settings β–Έ", "al:open:set:uv")]] - q = Query("al:open:unsub:uv", text="β˜€οΈ UV 10") # nothing is on now: a toast, no menu + assert "uv" not in state.muted(1) and rows(q.edits[0][1])[2] == [("Sound", "al:snd:uv:off:uv")] # the sound setting was kept + q = Query("al:close:uv", text="β˜€οΈ UV 10") await press(bot, q) - assert q.toast == "No alerts are on" and not q.edits + assert rows(q.edits[0][1]) == [[("πŸ”” β–Έ", "al:open:bell:uv")]] async def test_an_unknown_parent_in_a_button_is_ignored(tmp_path): bot, state = make_bot(tmp_path) - q = Query("al:open:unsub:bogus", text="β˜€οΈ UV 10") + q = Query("al:open:alerts:bogus", text="β˜€οΈ UV 10") await press(bot, q) drawn = rows(q.edits[0][1]) - assert [t for row in drawn[1:] for t, _ in row][0] == "🌧️ rain" and not any(t.startswith("●") for row in drawn for t, _ in row) - assert drawn[0][0][1] == "al:open:sub" # and it is not carried on - - -def test_every_alert_type_has_its_emoji_and_every_button_in_the_lists_starts_with_it(): - from lib.alerts.menu import EMOJI, label - assert set(EMOJI) == set(LABELS) # a new type cannot be added without one - assert len(set(EMOJI.values())) == len(EMOJI) and all(not e.isascii() for e in EMOJI.values()) # all different, all emoji - assert label("rain") == "🌧️ rain" and label("irrigation_battery") == "πŸͺ« irrigation battery" and label("irrigation_pause") == "β˜” irrigation pause" - everything = list(LABELS) - for section in ("sub", "unsub"): - 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_hold_enable_and_disable_sub_menus_of_notification_sounds_in_private_chats_only(tmp_path): + assert drawn[1][0] == ("rain", "al:off:rain:alerts") # the /alerts menu, not carried on + + +def test_the_title_stays_plain_words(): + assert title(set(), ["rain"]) == f"{TITLE}\nβœ… On: rain\nπŸ”• Off: nothing" + + +async def test_the_sound_menu_toggles_every_type_in_private_chats_only_and_back_returns(tmp_path): bot, state = make_bot(tmp_path) - q = Query("al:open:set") # /alerts: Settings opens the two sub-menus (β–Έ marks a menu) - await press(bot, q) - drawn = rows(q.edits[0][1]) - assert drawn[0][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 + state.set_kind(1, "gusts", False) + q = Query("al:open:sound") await press(bot, q) drawn = rows(q.edits[0][1]) - assert drawn[1] == [("πŸ”” Enable notification sounds β–Ύ", "al:open:set")] and ("🌧️ rain", "al:snd:rain:on") in [b for r in drawn[2:] for b in r] + assert drawn[0] == [("πŸ”Š Sound β–Ύ", "al:close")] and drawn[1] == [(strike("rain"), "al:snd:rain:on"), (strike("UV"), "al:snd:uv:on")] + assert "gusts" not in str(drawn) # an alert that is off has no sound to set + assert "Alerts" not in str(drawn) # the Alerts menu is hidden while Sound is open q = Query("al:snd:rain:on") await press(bot, q) - assert state.loud(1) == {"rain"} and "make a sound" in q.toast - drawn = rows(q.edits[0][1]) # stays in the list, rain gone from it; Disable appears - assert "al:snd:rain:on" not in [d for r in drawn for _, d in r] and ("πŸ”• Disable notification sounds β–Έ", "al:open:snd_off") in [b for r in drawn for b in r] - q = Query("al:open:snd_off") - await press(bot, q) - assert [b for r in rows(q.edits[0][1]) for b in r if b[1].startswith("al:snd")] == [("🌧️ rain", "al:snd:rain:off")] - await press(bot, Query("al:snd:rain:off")) - assert state.loud(1) == set() - 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 + assert state.loud(1) == {"rain"} and q.toast == "Rain alerts now make a sound" and rows(q.edits[0][1])[1][0] == ("rain", "al:snd:rain:off") + await press(bot, Query("al:snd:uv:on")) + assert state.loud(1) == {"rain", "uv"} + q = Query("al:snd:rain:off") 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 + assert state.loud(1) == {"uv"} and q.toast == "Rain alerts no longer make a sound" + for data in ("al:snd:bogus:on", "al:snd:rain:maybe", "al:snd:rain", "al:snd:all:on"): # nothing from the data reaches the state await press(bot, Query(data)) - assert state.loud(1) == set() + assert state.loud(1) == {"uv"} + q = Query("al:close") + await press(bot, q) + assert rows(q.edits[0][1]) == [[("πŸ”” Alerts β–Έ", "al:open:alerts"), ("πŸ”Š Sound β–Έ", "al:open:sound")]] q = Query("al:snd:rain:on", chat_type="supergroup", chat_id=-5) # groups have no sound settings state.add_chat(-5, "G") await press(bot, q) assert state.loud(-5) == set() and q.edits == [] + q = Query("al:open:sound", chat_type="supergroup", chat_id=-5) + await press(bot, q) + assert rows(q.edits[0][1]) == [[("πŸ”” Alerts β–Έ", "al:open:alerts")]] async def test_an_alert_has_a_sound_only_in_a_chat_that_turned_it_on_and_groups_get_no_settings_button(tmp_path): @@ -383,13 +364,13 @@ async def test_an_alert_has_a_sound_only_in_a_chat_that_turned_it_on_and_groups_ state.add_chat(1, "1") state.add_chat(-5, "G") state.set_sound(1, "rain", True) - notifier = Notifier(bot, state, KINDS) + notifier = Notifier(bot, state) await notifier("It's raining", kind="rain") await notifier("UV", kind="uv") flags = {(c, t.split("\n")[0]): kw["disable_notification"] for c, t, kw in bot.sent} assert flags[(1, "It's raining")] is False and flags[(1, "UV")] is True and flags[(-5, "It's raining")] is True group = next(kw for c, _, kw in bot.sent if c == -5) - assert [b[0] for b in rows(group["reply_markup"])[0]] == ["βž• Sub β–Έ", "βž– Unsub β–Έ"] + assert [b[0] for b in rows(group["reply_markup"])[0]] == ["πŸ”” β–Έ"] async def test_a_chat_that_became_a_supergroup_is_followed_not_a_crash(tmp_path): @@ -404,12 +385,12 @@ async def send_message(self, chat_id, text, **kw): state, bot = AlertState(tmp_path / "s3.json"), Moved() state.add_chat(-5, "G") state.set_kind(-5, "uv", False) - await Notifier(bot, state, KINDS)("It's raining", kind="rain") + await Notifier(bot, state)("It's raining", kind="rain") assert list(state.chats) == [-1005] and state.muted(-1005) == {"uv"} and [c for c, *_ in bot.sent] == [-1005] state.add_chat(-7, "H") # a press under an old alert of a moved group bot2, _ = make_bot(tmp_path) bot2.state = state - q = Query("al:open:unsub", chat_type="supergroup", chat_id=-7) + q = Query("al:open:alerts", chat_type="supergroup", chat_id=-7) q.message.reply_markup = keyboard(set(), KINDS) q.message.chat.title = "H" @@ -420,43 +401,6 @@ async def moved(*a, **k): assert -1007 in state.chats and -7 not in state.chats and "upgraded" in q.toast -async def test_the_settings_list_only_the_subscribed_types(tmp_path): - bot, state = make_bot(tmp_path) - state.set_kind(1, "rain", False) - q = Query("al:open:set") - await press(bot, q) - q = Query("al:open:snd_on") - await press(bot, q) - listed = [d for r in rows(q.edits[0][1])[1:] for _, d in r] - assert "al:snd:rain:on" not in listed and "al:snd:uv:on" in listed - state.set_kind(1, ALL, False) - q = Query("al:open:set") - await press(bot, q) - assert q.toast == "No alerts are on" and q.edits == [] - - -async def test_the_sound_lists_have_an_all_button(tmp_path): - bot, state = make_bot(tmp_path) - state.set_kind(1, "gusts", False) - q = Query("al:open:snd_on") # /alerts: a flat list with "Enable all" last - await press(bot, q) - assert rows(q.edits[0][1])[-1] == [("πŸ”” Enable all", "al:snd:all:on")] - q = Query("al:snd:all:on") - await press(bot, q) - assert state.loud(1) == {k for k in KINDS if k != "gusts"} and q.toast == "All alerts now make a sound" # only the types it gets - assert ("πŸ”• Disable notification sounds β–Έ", "al:open:snd_off") in [b for r in rows(q.edits[0][1]) for b in r] - q = Query("al:open:snd_off") - await press(bot, q) - assert rows(q.edits[0][1])[-1] == [("πŸ”• Disable all", "al:snd:all:off")] - await press(bot, Query("al:snd:all:off")) - assert state.loud(1) == set() - q = Query("al:open:snd_on_o:uv", text="β˜€οΈ UV 10") # under an alert, "all" sits in Other - await press(bot, q) - assert rows(q.edits[0][1])[-1] == [("πŸ”” Enable all", "al:snd:all:on:uv")] - await press(bot, Query("al:snd:all:on:uv", text="β˜€οΈ UV 10")) - assert "uv" in state.loud(1) and "rain" in state.loud(1) and "gusts" not in state.loud(1) - - def test_every_alert_type_sent_is_registered_and_each_monitor_declares_what_it_sends(): import re from pathlib import Path @@ -482,11 +426,11 @@ async def send_photo(self, chat_id, photo, **kw): async def test_an_alert_with_a_picture_is_the_picture_with_the_text_as_its_caption_and_the_buttons(tmp_path): state, bot = AlertState(tmp_path / "s.json"), SentWithPhotos() state.add_chat(1, "one") - await Notifier(bot, state, KINDS)("It's warmer outside", kind="temps", photo=b"png") + await Notifier(bot, state)("It's warmer outside", kind="temps", photo=b"png") assert bot.sent == [] and len(bot.photos) == 1 chat, photo, kw = bot.photos[0] assert (chat, photo, kw["caption"]) == (1, b"png", "It's warmer outside") and kw["disable_notification"] is True - assert rows(kw["reply_markup"])[0][0] == ("βž• Sub β–Έ", "al:open:sub:temps") # the alert's own buttons ride along + assert rows(kw["reply_markup"])[0][0] == ("πŸ”” β–Έ", "al:open:bell:temps") # the alert's own buttons ride along async def test_an_alerts_picture_is_uploaded_once_and_the_other_chats_are_sent_telegrams_copy(tmp_path): @@ -497,7 +441,7 @@ async def send_photo(self, chat_id, photo, **kw): state, bot = AlertState(tmp_path / "s.json"), Keeps() for chat in (1, 2, 3): state.add_chat(chat, str(chat)) - await Notifier(bot, state, KINDS)("It's warmer outside", kind="temps", photo=b"png") + await Notifier(bot, state)("It's warmer outside", kind="temps", photo=b"png") assert [(chat, photo) for chat, photo, _ in bot.photos] == [(1, b"png"), (2, "large"), (3, "large")] @@ -505,8 +449,8 @@ async def test_a_picture_telegram_refuses_falls_back_to_the_text_but_a_missing_c from telegram.error import BadRequest state, bot = AlertState(tmp_path / "s.json"), SentWithPhotos(refuse=BadRequest("Wrong file identifier")) state.add_chat(1, "one") - await Notifier(bot, state, KINDS)("It's warmer outside", kind="temps", photo=b"png") + await Notifier(bot, state)("It's warmer outside", kind="temps", photo=b"png") assert bot.photos == [] and [(c, t) for c, t, _ in bot.sent] == [(1, "It's warmer outside")] gone = SentWithPhotos(refuse=BadRequest("Chat not found")) - await Notifier(gone, state, KINDS)("again", kind="temps", photo=b"png") + await Notifier(gone, state)("again", kind="temps", photo=b"png") assert gone.sent == [] and 1 not in state.chats diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index 767a91d..2ce42a0 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -1,6 +1,6 @@ import asyncio import json -from datetime import datetime, timedelta +from datetime import datetime, time, timedelta import pytest @@ -167,8 +167,9 @@ async def handler(request): refresh = asyncio.create_task(eco.warm(True)) await asyncio.sleep(0.05) # the refresh's requests are out, holding the 5-minute and 30-minute fetches now = eco.now() + since = max(now - timedelta(hours=3), datetime.combine(now.date(), time())) # (memory holds today: not the evening before, in the first hours of the day) question = eco.fetcher(["outdoor"], urgent=True) - data = await asyncio.wait_for(question.get("5min", now - timedelta(hours=3), now), 2) + data = await asyncio.wait_for(question.get("5min", since, now), 2) assert data["outdoor"]["temperature"]["list"] and question.calls == 0 and question.from_memory == 1 assert not refresh.done() answering.set() @@ -512,7 +513,7 @@ def __init__(self, *a, **k): out = json.loads(await eco.tools[1].handler({"groups": "wind", "chart": True, "chart_field": "wind", "start_date": f"{today - timedelta(days=30)} 00:00:00"}, turn)) assert out["series"]["wind.wind_direction"] and turn.charts[0].compass - assert len(made) == 1 and made[0].ranges == 1 # the archived days and today in one read, not one for each of 31 days + assert len(made) == 1 and made[0].ranges <= 2 # the archived days and today in one read (two in the first hours of the local day, while yesterday's last readings are not yet settled), not one for each of 31 days await eco.close() day = lambda n: today + timedelta(days=n) assert consecutive([day(1), day(2), day(3), day(5), day(6), day(9)]) == [[day(1), day(2), day(3)], [day(5), day(6)], [day(9)]] diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index 0acd6c7..fed4ba2 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -17,6 +17,7 @@ from lib.alerts import irrigation as monitor_module from lib.alerts.menu import available_kinds from lib.bot import Bot +from lib.buttons import struck as strike from lib.config import Config from lib.irrigation import IrrigationError, Tuya, Watchdog, menu from lib.irrigation.tuya import sign @@ -177,10 +178,10 @@ def test_the_buttons_open_one_section_at_a_time_and_every_callback_is_short(): assert rows(menu.keyboard(True, True, "water", paused=True)) == paused # and no minutes or Off either assert menu.SECTIONS == ("water", "delay", "alerts") and len(rows(menu.keyboard(True, True, "switch"))) == 2 # no Switch menu any more for battery, pause, battery_button, pause_button in ( - (False, False, ("πŸ”” Enable battery alerts", "ir:batt:on"), ("πŸ”” Enable pause alerts", "ir:pause:on")), - (True, False, ("πŸ”• Disable battery alerts", "ir:batt:off"), ("πŸ”” Enable pause alerts", "ir:pause:on")), - (False, True, ("πŸ”” Enable battery alerts", "ir:batt:on"), ("πŸ”• Disable pause alerts", "ir:pause:off")), - (True, True, ("πŸ”• Disable battery alerts", "ir:batt:off"), ("πŸ”• Disable pause alerts", "ir:pause:off"))): + (False, False, (strike("battery alerts"), "ir:batt:on"), (strike("pause alerts"), "ir:pause:on")), + (True, False, ("battery alerts", "ir:batt:off"), (strike("pause alerts"), "ir:pause:on")), + (False, True, (strike("battery alerts"), "ir:batt:on"), ("pause alerts", "ir:pause:off")), + (True, True, ("battery alerts", "ir:batt:off"), ("pause alerts", "ir:pause:off"))): alerts = rows(menu.keyboard(battery, pause, "alerts")) assert alerts[:2] == collapsed[:1] + [[("πŸ”” Alerts β–Ύ", "ir:close"), ("πŸ”„ Refresh", "ir:refresh")]] assert alerts[2:] == [[battery_button], [pause_button]] # each names the action it takes @@ -204,8 +205,8 @@ def test_the_alert_list_gets_irrigation_battery_only_when_there_is_a_controller( assert "irrigation_battery" not in available_kinds({"Ecowitt"}) and "irrigation_pause" not in available_kinds({"Ecowitt"}) from lib.alerts.menu import LABELS, keyboard assert LABELS["irrigation_battery"] == "irrigation battery" and LABELS["irrigation_pause"] == "irrigation pause" - unsub = rows(keyboard(set(), ["rain", "irrigation_battery"], "unsub")) - assert ("πŸͺ« irrigation battery", "al:off:irrigation_battery:unsub") in [b for row in unsub for b in row] + listed = rows(keyboard(set(), ["rain", "irrigation_battery"], "alerts")) + assert ("irrigation battery", "al:off:irrigation_battery:alerts") in [b for row in listed for b in row] # ---------- the daily check ---------- @@ -490,24 +491,24 @@ async def test_watering_a_delay_and_the_switch_act_on_the_controller_and_refresh async def test_the_alerts_menu_toggles_each_alert_type_for_this_chat_alone_and_stays_open(tmp_path): bot, state, _ = irrigation_bot(tmp_path) q = await press(bot, "ir:open:alerts") - assert q.edits[0][0] == "markup" and [r[0][0] for r in rows(q.edits[0][1])[2:]] == ["πŸ”• Disable battery alerts", "πŸ”• Disable pause alerts"] + assert q.edits[0][0] == "markup" and [r[0][0] for r in rows(q.edits[0][1])[2:]] == ["battery alerts", "pause alerts"] q = await press(bot, "ir:batt:off") assert state.muted(1) == {"irrigation_battery"} and q.toast == "Irrigation battery alerts off in this chat" - assert rows(q.edits[0][2])[2:] == [[("πŸ”” Enable battery alerts", "ir:batt:on")], [("πŸ”• Disable pause alerts", "ir:pause:off")]] # still open + assert rows(q.edits[0][2])[2:] == [[(strike("battery alerts"), "ir:batt:on")], [("pause alerts", "ir:pause:off")]] # still open assert q.edits[0][1].endswith("pause: not paused (alerts: on)\nbattery: 100% πŸ”‹ (alerts: off)") # the status text follows q = await press(bot, "ir:pause:off") assert state.muted(1) == {"irrigation_battery", "irrigation_pause"} and q.toast == "Irrigation pause alerts off in this chat" - assert q.edits[0][1].endswith("pause: not paused (alerts: off)\nbattery: 100% πŸ”‹ (alerts: off)") and rows(q.edits[0][2])[3] == [("πŸ”” Enable pause alerts", "ir:pause:on")] + assert q.edits[0][1].endswith("pause: not paused (alerts: off)\nbattery: 100% πŸ”‹ (alerts: off)") and rows(q.edits[0][2])[3] == [(strike("pause alerts"), "ir:pause:on")] q = await press(bot, "ir:batt:on") assert state.muted(1) == {"irrigation_pause"} and q.toast == "Irrigation battery alerts on in this chat" # the other type is untouched q = await press(bot, "ir:pause:on") assert state.muted(1) == set() and state.alert_chats("irrigation_battery") == [1] == state.alert_chats("irrigation_pause") - for kind, prefix in (("irrigation_battery", "batt"), ("irrigation_pause", "pause")): # whatever a button says, pressing it does that + for kind, prefix in (("irrigation_battery", "batt"), ("irrigation_pause", "pause")): # a button shows the state, pressing it flips it for _ in range(2): alerts = rows(menu.keyboard(state.subscribed(1, "irrigation_battery"), state.subscribed(1, "irrigation_pause"), "alerts")) text, data = next(b for row in alerts[2:] for b in row if b[1].startswith(f"ir:{prefix}:")) await press(bot, data) - assert state.subscribed(1, kind) is text.startswith("πŸ”” Enable"), (text, data) + assert state.subscribed(1, kind) is (text != text.replace("\u0336", "")), (text, data) other = await press(bot, "ir:water:5") assert len(rows(other.edits[0][2])) == 3 # any other action redraws collapsed (3 rows: the valve is on) other = await press(bot, "ir:refresh") @@ -703,18 +704,18 @@ async def test_the_pause_alert_reaches_every_subscribed_chat_with_the_usual_menu class Telegram: async def send_message(self, chat_id, text, **kw): delivered.append((chat_id, text, kw)) - notifier = Notifier(Telegram(), state, available_kinds({"Ecowitt", "Irrigation"})) + notifier = Notifier(Telegram(), state) m = IrrigationMonitor(Device(), state, notifier, TZ, 18, None, Forecast(90, 90)) await m.check(NOW) assert sorted(c for c, _, _ in delivered) == [-7, 1, 3] and all(text.startswith("β˜” Irrigation paused") for _, text, _ in delivered) markup = delivered[0][2]["reply_markup"] - assert rows(markup) == [[("βž• Sub β–Έ", "al:open:sub:irrigation_pause"), ("βž– Unsub β–Έ", "al:open:unsub:irrigation_pause"), - ("βš™οΈ Settings β–Έ", "al:open:set:irrigation_pause")]] - from lib.alerts.menu import keyboard + assert rows(markup) == [[("πŸ”” β–Έ", "al:open:bell:irrigation_pause")]] + from lib.alerts.menu import alert_keyboard, keyboard kinds = available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast", "Irrigation"}) - for section in (None, "sub", "unsub"): # every button's data fits Telegram's 64 bytes, for the new type too - for parent in (None, "irrigation_pause", "irrigation_battery"): - assert all(len(data.encode()) <= 64 for row in rows(keyboard(set(), kinds, section, parent)) for _, data in row) + boards = [keyboard(set(), kinds, section, set()) for section in (None, "alerts", "sound")] + boards += [alert_keyboard(kind, set(), set(), section) for kind in ("irrigation_pause", "irrigation_battery") for section in (None, "bell")] + for board in boards: # every button's data fits Telegram's 64 bytes, for the new type too + assert all(len(data.encode()) <= 64 for row in rows(board) for _, data in row) async def test_refresh_reads_the_controller_again_and_redraws_without_sending_anything_or_waiting(tmp_path, monkeypatch): diff --git a/tests/test_startup.py b/tests/test_startup.py index c47ca0e..b66ad67 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -252,13 +252,6 @@ async def stop_when_archived(): assert len(app.handlers) == 10 and closed == [1] # the same handlers (buttons share one entry point), and the client closed at shutdown -def test_a_long_alerts_line_continues_indented_at_90_characters(): - from lib.alerts.menu import LABELS - lines = ecobot.wrapped("Alerts: ", list(LABELS.values()), ": 3 chats").splitlines() - assert len(lines) > 1 and all(len(line) <= 90 for line in lines) - assert all(line.startswith(" " * 13) and not line.startswith(" " * 14) for line in lines[1:]) - - def test_log_lines_do_not_name_their_module(): fmt = ecobot.LogFormat("%(levelname)s %(message)s") record = lambda name: logging.LogRecord(name, logging.INFO, __file__, 1, "hello", None, None) @@ -280,9 +273,9 @@ def line(msg, *args, quotes=True): timings = "Chart ms: data 12, calc 6, draw 112, PNG 41, send 9, more words" assert line(timings, quotes=False) == "INFO Chart ms: data 12, calc 6, draw 11…" # a line that quotes nothing is cut too assert line("a\n b " + "x" * 50, quotes=False).split("\n")[1] == " b " + "x" * 33 + "…" # each line of a long message - for level, name in ((logging.WARNING, "WARN"), (logging.ERROR, "ERROR")): # a warning or an error is shown whole + for level, name in ((logging.WARNING, "WARN"), (logging.ERROR, "ERROR")): # a warning or an error is cut too record = logging.LogRecord("lib.bot", level, __file__, 1, timings, None, None) - assert fmt.format(record) == f"{name} " + timings and record.levelname == logging.getLevelName(level) + assert fmt.format(record) == f"{name} " + timings[:40 - len(name) - 2] + "…" and record.levelname == logging.getLevelName(level) fmt.width = 0 assert line("[@rob] %s", asked) == "INFO [@rob] " + asked # 0 cuts none assert line(timings, quotes=False) == "INFO " + timings @@ -317,9 +310,3 @@ def test_the_http_clients_lines_do_not_start_with_http_request(): ("POST", "https://api.x.ai/v1/chat/completions", "HTTP/1.1", 200, "OK"), None) assert fmt.format(record) == 'INFO POST https://api.x.ai/v1/chat/completions "HTTP/1.1 200 OK"' assert record.msg.startswith("HTTP Request: ") # the record itself is left as it was - - -def test_the_alerts_list_wraps_at_the_log_width(): - from lib.alerts.menu import LABELS - lines = ecobot.wrapped("Alerts: ", list(LABELS.values()), ": 3 chats", 40).splitlines() - assert len(lines) > 1 and all(len(line) <= 40 for line in lines)