Skip to content
Merged
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
A Telegram bot for a personal Ecowitt weather station and AirGradient air-quality sensor.
Ask it about the weather or air quality (text or charts); it also sends silent alerts to every
chat it is in: rain starting/stopping, rain likely soon, gusts over 40 km/h, UV index of 10 or more, indoor/outdoor temperatures crossing (held back for the cooldown, then announced if it still stands),
and unhealthy air (with when it is safe again). Every alert carries Subscribe / Unsubscribe buttons that expand into the alert types (rain, rain predicted, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes): Subscribe lists the types that are off, Unsubscribe the ones that are on; `/alerts` opens the same settings (in a private chat, a ⚙️ Settings button there and under each alert turns the notification sound on or off per alert type; alerts are silent unless you do; and `/alerts on|off` still subscribes or unsubscribes every type); `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`).
and unhealthy air (with when it is safe again). Every alert carries Subscribe / Unsubscribe buttons that expand into the alert types (rain, rain predicted, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes): Subscribe lists the types that are off, Unsubscribe the ones that are on; `/alerts` opens the same settings (in a private chat, a ⚙️ Settings button there and under each alert has Enable and Disable notification sounds sub-menus that list the alert types; alerts are silent unless you do; and `/alerts on|off` still subscribes or unsubscribes every type); `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`).

## Run

Expand Down
82 changes: 54 additions & 28 deletions lib/alerts/menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@
the title from what a chat has muted (the same menu goes under every alert and under /alerts).

Callback data (self-contained and short, so an old message's buttons still work after a restart):
al:open:sub | al:open:unsub | al:open:set | al:close | al:noop (the heading rows of messages sent by an earlier version)
al:snd:<kind>:<on|off>[:parent] (notification sound for one type; Settings is in private chats only)
al:open:sub | al:open:unsub | al:open:other | al:open:set | al:open:snd_on | al:open:snd_off | al:close | al:noop (the heading rows of messages sent by an earlier version)
al:snd:<kind|all>:<on|off>[:parent] (notification sound for one type; Settings is in private chats only)
al:on:<kind|all>:<section> | al:off:<kind|all>:<section> (section: sub or unsub, the part left open)
An alert's buttons end with the alert's own type (parent): under an alert the Unsubscribe list is just that type (marked) and "all": al:open:unsub:rain, al:close:rain,
al:off:uv:unsub:rain. A chat's /alerts message has no parent and no such field.
An alert's buttons end with the alert's own type (parent): under an alert each list is its own type (marked ●) and "Other", which holds the rest and "all": al:open:unsub:rain, al:close:rain,
al:off:uv:unsub:rain (the sound menus' Other is al:open:snd_on_o:rain, al:open:snd_off_o:rain). A chat's /alerts message has no parent and no such field.
"""

from itertools import batched

from telegram import InlineKeyboardButton, InlineKeyboardMarkup

from ..buttons import menu_text

# kind -> the name on the button, in the order listed
LABELS = {"rain": "rain", "rain_likely": "rain predicted", "gusts": "gusts", "uv": "UV", "temps": "temperature crossing",
"air": "particulates", "pollen": "pollen & asthma", "forecast": "forecast changes",
Expand All @@ -24,6 +26,7 @@
ALL = "all"
TITLE = "🔔 Alerts in this chat"
PER_ROW = 2
SETTINGS = ("set", "snd_on", "snd_off", "snd_on_o", "snd_off_o") # (_o: the "Other" list inside a sound menu)


def available_kinds(sources: set[str]) -> list[str]:
Expand All @@ -43,35 +46,58 @@ def _button(text: str, data: str) -> InlineKeyboardButton:
return InlineKeyboardButton(text, callback_data=data)


def _type_rows(items: list[str], parent: str | None, more: bool, button, other_data: tuple[str, str], every: InlineKeyboardButton) -> list[list]:
"""A list of alert types as button rows, the same under Unsubscribe, Subscribe and both sound menus. Under an alert that is in the
list (with others), the alert's own type comes first, then an "Other" menu (`other_data` = the data that opens it, the data that
closes it; `more`: it is open) that holds the rest and `every` ("all"). Otherwise a flat list, with `every` when it has more than
one type. `button(kind)` draws one type."""
rows: list[list] = []
if parent in items and len(items) > 1:
rows += [[button(parent)], [_button(menu_text("Other", more), other_data[1] if more else other_data[0])]]
items = [k for k in items if k != parent] if more else []
elif more:
more = False # nothing to put under Other
rows += map(list, batched(map(button, items), PER_ROW))
if len(items) > 1 or (more and items):
rows.append([every])
return rows


def keyboard(muted: set[str], available: list[str], open: str | None = None, parent: str | None = None,
loud: set[str] | None = None) -> InlineKeyboardMarkup:
"""The collapsed menu, or with one section open (open = "sub" or "unsub") and its options under it. `parent` is the type of the
alert the buttons sit under and is carried in every button. Under an alert the Unsubscribe list is that type (marked) and
"Unsubscribe from all"; on the /alerts message it lists every type that is on. "... all" is there only when it does something
different from the one type listed. `loud`: the types whose alerts make a notification sound; None (a group) leaves out the Settings button,
which opens one button per type (just the alert's own under an alert) that turns that sound on or off."""
"""The menu: Subscribe, Unsubscribe and (private chats) Settings, one section open at a time (`open`: sub, unsub, other, set,
snd_on, snd_off, or snd_on_o / snd_off_o, the Other list of a sound menu) with its options under it. A ▸ after the text marks a button that opens
a menu, ▾ (in the same place) the open one; the rest do something. `parent` is the type of the alert the buttons sit under (carried in every button):
its lists put that type first (●) and the rest under "Other". `loud`: the types whose alerts make a notification sound; None (a
group) leaves out Settings, which holds two menus, Enable and Disable notification sounds, over the subscribed types."""
tail = f":{parent}" if parent else ""
rows = [[_button(("▾ " if open == "sub" else "") + "➕ Subscribe", f"al:close{tail}" if open == "sub" else f"al:open:sub{tail}"),
_button(("▾ " if open == "unsub" else "") + "➖ Unsubscribe", f"al:close{tail}" if open == "unsub" else f"al:open:unsub{tail}")]]
subscribed = options(muted, available, "unsub")
if open == "other" and not (parent in subscribed and len(subscribed) > 1):
open = "unsub"
unsub_open = open in ("unsub", "other")
def head(is_open: bool, text: str, section: str) -> InlineKeyboardButton:
return _button(menu_text(text, is_open), f"al:close{tail}" if is_open else f"al:open:{section}{tail}")
rows = [[head(open == "sub", "➕ Sub", "sub"), head(unsub_open, "➖ Unsub", "unsub")]]
if loud is not None:
rows[0].append(_button(("▾ " if open == "set" else "") + "⚙️ Settings", f"al:close{tail}" if open == "set" else f"al:open:set{tail}"))
if open == "set" and loud is not None:
for k in ([parent] if parent else available):
on = k in loud
rows.append([_button(f"🔕 Disable {LABELS[k]} sound" if on else f"🔔 Enable {LABELS[k]} sound", f"al:snd:{k}:{'off' if on else 'on'}{tail}")])
if open in ("sub", "unsub"):
rows[0].append(head(open in SETTINGS, "⚙️ Settings", "set"))
mark = lambda k: f"● {label(k)}" if k == parent else label(k) # the alert these buttons are under
if open in SETTINGS and loud is not None:
for section, heading, items in (("snd_on", "🔔 Enable notification sounds", [k for k in subscribed if k not in loud]),
("snd_off", "🔕 Disable notification sounds", [k for k in subscribed if k in loud])):
here = open in (section, f"{section}_o")
if not items:
continue
rows.append([_button(menu_text(heading, here), f"al:open:set{tail}" if here else f"al:open:{section}{tail}")])
if here:
verb = "on" if section == "snd_on" else "off"
rows += _type_rows(items, parent, open == f"{section}_o", lambda k: _button(mark(k), f"al:snd:{k}:{verb}{tail}"),
(f"al:open:{section}_o{tail}", f"al:open:{section}{tail}"),
_button("🔔 Enable all" if verb == "on" else "🔕 Disable all", f"al:snd:{ALL}:{verb}{tail}"))
if open in ("sub", "unsub", "other"):
verb = "on" if open == "sub" else "off"
listed = options(muted, available, open)
if open == "unsub" and parent:
others = [k for k in listed if k != parent]
listed, show_all = [k for k in listed if k == parent], bool(others)
else:
show_all = len(listed) > 1
mark = lambda k: f"● {label(k)}" if open == "unsub" and k == parent else label(k) # the alert these buttons are under
buttons = [_button(mark(k), f"al:{verb}:{k}:{open}{tail}") for k in listed]
rows += map(list, batched(buttons, PER_ROW))
if show_all:
rows.append([_button("🔔 Subscribe to all" if open == "sub" else "🔕 Unsubscribe from all", f"al:{verb}:{ALL}:{open}{tail}")])
rows += _type_rows(options(muted, available, "sub" if open == "sub" else "unsub"), parent if open != "sub" else None, open == "other",
lambda k: _button(mark(k), f"al:{verb}:{k}:{open}{tail}"), (f"al:open:other{tail}", f"al:open:unsub{tail}"),
_button("🔔 Subscribe to all" if verb == "on" else "🔕 Unsubscribe from all", f"al:{verb}:{ALL}:{open}{tail}"))
return InlineKeyboardMarkup(rows)


Expand Down
28 changes: 19 additions & 9 deletions lib/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,11 @@ async def on_usage(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
self.remember_chat(update)
await self._help_and_alerts(update, context)

@staticmethod
def _alerts_named(kind: str) -> str:
""""All alerts" or "UV alerts" (not "Uv"), for a toast."""
return "All alerts" if kind == menu.ALL else f"{menu.LABELS[kind][0].upper()}{menu.LABELS[kind][1:]} alerts"

async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""A press on the alert settings buttons (under an alert or the /alerts message): open or close a section, or subscribe
or unsubscribe a type or all. In a group only admins may change them."""
Expand All @@ -401,26 +406,31 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY
toast, opened = None, section or None
if action in ("open", "close"):
opened = arg if action == "open" else None
if opened in ("sub", "unsub") and not menu.has_options(self.state.muted(chat.id), kinds, opened, parent):
if opened == "set" and not parent and not menu.options(self.state.muted(chat.id), kinds, "unsub"):
await query.answer("No alerts are on") # no subscribed type to set a sound for
return
if opened in ("sub", "unsub", "other") and not menu.has_options(self.state.muted(chat.id), kinds, opened, parent):
await query.answer("You're subscribed to everything" if opened == "sub" else "No alerts are on") # nothing to list
return
elif action == "snd" and chat.type == ChatType.PRIVATE and arg in kinds and section in ("on", "off") and chat.id in self.state.chats:
self.state.set_sound(chat.id, arg, section == "on")
elif action == "snd" and chat.type == ChatType.PRIVATE and (arg in kinds or arg == menu.ALL) and section in ("on", "off") and chat.id in self.state.chats:
for k in ([k for k in kinds if k not in self.state.muted(chat.id)] if arg == menu.ALL else [arg]): # "all": the types offered here
self.state.set_sound(chat.id, k, section == "on")
parent = tail if tail in kinds else None
opened = "set"
toast = f"{menu.LABELS[arg][0].upper()}{menu.LABELS[arg][1:]} alerts {'now make' if section == 'on' else 'no longer make'} a sound"
opened = "snd_on" if section == "on" else "snd_off" # stay in the list (under an alert: in "Other" for another type)
if parent and arg != parent:
opened += "_o"
toast = f"{self._alerts_named(arg)} {'now make' if section == 'on' else 'no longer make'} a sound"
elif action in ("on", "off") and (arg == menu.ALL or arg in kinds) and chat.id in self.state.chats:
self.state.set_kind(chat.id, arg, action == "on")
what = "All alerts" if arg == menu.ALL else f"{menu.LABELS[arg][0].upper()}{menu.LABELS[arg][1:]} alerts" # "UV alerts", not "Uv"
toast = f"{what} {action} in this chat"
toast = f"{self._alerts_named(arg)} {action} in this chat"
else:
await query.answer()
return
muted = self.state.muted(chat.id)
if opened in ("sub", "unsub") and not menu.has_options(muted, kinds, opened, parent):
if opened in ("sub", "unsub", "other") and not menu.has_options(muted, kinds, opened, parent):
opened = None # the last one was just turned on or off: close the section
private = chat.type == ChatType.PRIVATE
markup = menu.keyboard(muted, kinds, opened if opened in ("sub", "unsub", "set") else None, parent,
markup = menu.keyboard(muted, kinds, opened if opened in ("sub", "unsub", "other", *menu.SETTINGS) else None, parent,
self.state.loud(chat.id) if private else None)
await query.answer(toast)
try:
Expand Down
7 changes: 7 additions & 0 deletions lib/buttons.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Shared look of the bot's inline buttons."""


def menu_text(text: str, is_open: bool) -> str:
"""The label of a button that opens a menu: ▸ after the text while it is closed, ▾ in the same place once it is open (so pressing it
looks like the arrow turning). Buttons that do something have neither."""
return f"{text} {'▾' if is_open else '▸'}"
17 changes: 11 additions & 6 deletions lib/irrigation/menu.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""The irrigation status message and its buttons. Pure: it only builds the text and the keyboard.

Collapsed: [💧 Water] [⏸ Pause] / [🔔 Alerts] [🔄 Refresh]. While the timer is paused the Pause button is [▶️ Unpause] (one press ends the pause) and Water is
hidden. A press on Water, Pause or Alerts opens its options under the rows (▾ marks the open one; pressing
it again closes them). Water lists 5, 10, 20 and 30 minutes (a run is always timed). While the valve is on, a Water off ❌ button is the third row. Alerts has one button per alert type, each naming what pressing it does for this chat (enable or disable). Callback data
Collapsed: [💧 Water] [⏸ Pause] / [🔔 Alerts] [🔄 Refresh]. While the valve is on, a [Water off ❌] row follows. While the timer is paused,
Pause is [▶️ Unpause] (one press ends the pause) and Water is hidden. A press on Water, Pause or Alerts opens its options under the
rows (▸ after the text marks a menu, ▾ the open one; pressing it again closes them). Water lists 5, 10, 20 and 30 minutes (a run is always timed). Alerts has
one button per alert type, each naming what pressing it does for this chat (enable or disable).
Callback data
(short, self-contained, so an old message's buttons still work after a restart):
ir:open:<water|delay|alerts> | ir:close | ir:refresh (read the state again, change nothing)
ir:water:<minutes> | ir:delay:<24h|48h|72h|cancel> (cancel = unpause, the Unpause button) | ir:sw:off
Expand All @@ -14,6 +16,8 @@

from telegram import InlineKeyboardButton, InlineKeyboardMarkup, MessageEntity

from ..buttons import menu_text

TITLE = "🌱 Irrigation"
WATER_MINUTES = (5, 10, 20, 30)
DELAYS = ("24h", "48h", "72h", "cancel")
Expand Down Expand Up @@ -85,13 +89,14 @@ def status_text(status: dict, alerts: bool | None = None, pause_alerts: bool | N

def keyboard(battery: bool, pause: bool, open: str | None = None, paused: bool = False, running: bool = False) -> InlineKeyboardMarkup:
"""The buttons under the status. `battery` and `pause`: this chat gets the irrigation battery / pause alerts; each Alerts button
names what pressing it does (disable when it is on, enable when it is off). `paused`: the timer is paused, so its button unpauses and Water is hidden. `running`: the valve is on, so Water off is a top-level row."""
names what pressing it does (disable when it is on, enable when it is off). `paused`: the timer is paused, so its
button unpauses and Water is hidden. `running`: the valve is on, so Water off is a top-level row."""
def head(name: str, text: str) -> InlineKeyboardButton:
return _button(("▾ " if open == name else "") + text, "ir:close" if open == name else f"ir:open:{name}")
return _button(menu_text(text, open == name), "ir:close" if open == name else f"ir:open:{name}")
delay = _button("▶️ Unpause", "ir:delay:cancel") if paused else head("delay", "⏸ Pause")
rows = [[delay] if paused else [head("water", "💧 Water"), delay],
[head("alerts", "🔔 Alerts"), _button("🔄 Refresh", "ir:refresh")]]
if running: # the valve is open: Off is one press away, whatever else is open
if running: # the valve is open: off is one press away, whatever else is open
rows.append([_button("Water off ❌", "ir:sw:off")])
if open == "water" and not paused:
rows.append([_button(f"{m} min", f"ir:water:{m}") for m in WATER_MINUTES])
Expand Down
Loading
Loading