Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 (and `/alerts on|off` still subscribes or unsubscribes every type); `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`).
and unhealthy air (with when it is safe again). Every alert carries Subscribe / Unsubscribe buttons that expand into the alert types (rain, rain predicted, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes): Subscribe lists the types that are off, Unsubscribe the ones that are on; `/alerts` opens the same settings (in a private chat, a ⚙️ Settings button there and under each alert turns the notification sound on or off per alert type; alerts are silent unless you do; and `/alerts on|off` still subscribes or unsubscribes every type); `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`).

## Run

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

Callback data (self-contained and short, so an old message's buttons still work after a restart):
al:open:sub | al:open:unsub | al:close | al:noop (the heading rows of messages sent by an earlier version)
al:open:sub | al:open:unsub | al:open:set | al:close | al:noop (the heading rows of messages sent by an earlier version)
al:snd:<kind>:<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.
Expand Down Expand Up @@ -42,14 +43,22 @@ def _button(text: str, data: str) -> InlineKeyboardButton:
return InlineKeyboardButton(text, callback_data=data)


def keyboard(muted: set[str], available: list[str], open: str | None = None, parent: str | None = None) -> InlineKeyboardMarkup:
def keyboard(muted: set[str], available: list[str], open: str | None = None, parent: str | None = None,
loud: set[str] | None = None) -> InlineKeyboardMarkup:
"""The collapsed menu, or with one section open (open = "sub" or "unsub") and its options under it. `parent` is the type of the
alert the buttons sit under and is carried in every button. Under an alert the Unsubscribe list is that type (marked) and
"Unsubscribe from all"; on the /alerts message it lists every type that is on. "... all" is there only when it does something
different from the one type listed."""
different from the one type listed. `loud`: the types whose alerts make a notification sound; None (a group) leaves out the Settings button,
which opens one button per type (just the alert's own under an alert) that turns that sound on or off."""
tail = f":{parent}" if parent else ""
rows = [[_button(("▾ " if open == "sub" else "") + "➕ Subscribe", f"al:close{tail}" if open == "sub" else f"al:open:sub{tail}"),
_button(("▾ " if open == "unsub" else "") + "➖ Unsubscribe", f"al:close{tail}" if open == "unsub" else f"al:open:unsub{tail}")]]
if loud is not None:
rows[0].append(_button(("▾ " if open == "set" else "") + "⚙️ Settings", f"al:close{tail}" if open == "set" else f"al:open:set{tail}"))
if open == "set" and loud is not None:
for k in ([parent] if parent else available):
on = k in loud
rows.append([_button(f"🔕 Disable {LABELS[k]} sound" if on else f"🔔 Enable {LABELS[k]} sound", f"al:snd:{k}:{'off' if on else 'on'}{tail}")])
if open in ("sub", "unsub"):
verb = "on" if open == "sub" else "off"
listed = options(muted, available, open)
Expand Down
36 changes: 32 additions & 4 deletions lib/alerts/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from datetime import date

from telegram import MessageEntity
from telegram.error import BadRequest, Forbidden, TelegramError
from telegram.error import BadRequest, ChatMigrated, Forbidden, TelegramError

from .menu import ALL, LABELS, keyboard

Expand Down Expand Up @@ -65,6 +65,14 @@ def set_alerts(self, chat_id: int, title: str, on: bool):
self.chats.setdefault(chat_id, {"title": title})
self.set_kind(chat_id, ALL, on)

def migrate_chat(self, old: int, new: int):
"""A group became a supergroup: its settings move to the new chat id."""
entry = self.chats.pop(old, None)
if entry is not None:
self.chats.setdefault(new, entry)
log.info("Alerts: chat %s became %s", old, new)
self.save()

def muted(self, chat_id: int) -> set[str]:
return set(self.chats.get(chat_id, {}).get("muted", []))

Expand All @@ -81,6 +89,22 @@ def set_kind(self, chat_id: int, kind: str, on: bool):
self.chats[chat_id].pop("muted", None)
self.save()

def loud(self, chat_id: int) -> set[str]:
"""The alert types this chat gets with notification sound (alerts are silent unless a chat turned the sound on)."""
return set(self.chats.get(chat_id, {}).get("loud", []))

def set_sound(self, chat_id: int, kind: str, on: bool):
"""Notification sound on (on) or off for one alert type in a known chat."""
if chat_id not in self.chats or kind not in LABELS:
return
loud = self.loud(chat_id)
loud = loud | {kind} if on else loud - {kind}
if loud:
self.chats[chat_id]["loud"] = [k for k in LABELS if k in loud]
else:
self.chats[chat_id].pop("loud", None)
self.save()

def keyboard(self, chat_id: int) -> str | None:
"""The version of the button keyboard this chat was last sent (or "hidden" if it hid it)."""
return self.chats.get(chat_id, {}).get("keyboard")
Expand Down Expand Up @@ -168,11 +192,15 @@ async def to_chat(self, chat_id: int, text: str, link: tuple[str, str] | None =
return sent

async def _send(self, chat_id: int, text: str, entities, kind: str | None = None) -> bool:
"""Send silently, with the settings buttons (which highlight this alert's own type); forget a chat the bot can no longer post to."""
"""Send silently, with the settings buttons (which highlight this alert's own type); with sound only if the chat turned it on for this type; forget a chat the bot can no longer post to."""
try:
await self.bot.send_message(chat_id, text, entities=entities, disable_notification=True, disable_web_page_preview=True,
reply_markup=keyboard(self.state.muted(chat_id), self.kinds, parent=kind))
await self.bot.send_message(chat_id, text, entities=entities, disable_notification=kind not in self.state.loud(chat_id),
disable_web_page_preview=True,
reply_markup=keyboard(self.state.muted(chat_id), self.kinds, parent=kind, loud=self.state.loud(chat_id) if chat_id > 0 else None))
return True
except ChatMigrated as e: # the group became a supergroup: follow it, and send again there
self.state.migrate_chat(chat_id, e.new_chat_id)
return await self._send(e.new_chat_id, text, entities, kind) if e.new_chat_id != chat_id else False
except Forbidden as e: # kicked from the group, or blocked in a private chat
self.state.remove_chat(chat_id, f"can't post: {e}")
except BadRequest as e:
Expand Down
39 changes: 27 additions & 12 deletions lib/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

from telegram import InputMediaPhoto, Message, ReplyKeyboardRemove, Update
from telegram.constants import ChatAction, ChatType
from telegram.error import BadRequest, Forbidden, NetworkError, TelegramError
from telegram.error import BadRequest, ChatMigrated, Forbidden, NetworkError, TelegramError
from telegram.ext import CallbackQueryHandler, ChatMemberHandler, CommandHandler, ContextTypes, MessageHandler, filters

from . import intent, periods, prompt, report, templates
Expand Down Expand Up @@ -313,7 +313,13 @@ async def on_button(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
return
if not await self._allowed(update, context):
return
await handler(update, context)
try:
await handler(update, context)
except ChatMigrated as e: # a press on an alert from before its group became a supergroup (the chat has a new id)
if self.state:
self.state.migrate_chat(query.message.chat.id, e.new_chat_id)
with contextlib.suppress(TelegramError):
await query.answer("This group was upgraded to a supergroup: please use the buttons on new alerts")

# ---------- alert chats ----------
@staticmethod
Expand Down Expand Up @@ -359,7 +365,8 @@ async def on_alerts(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
self.state.set_alerts(chat.id, self._title(update), arg == "on")
log.info("/alerts %s in %s", arg, describe_source(update))
muted, kinds = self.state.muted(chat.id), self._alert_kinds()
await msg.reply_text(menu.title(muted, kinds), reply_markup=menu.keyboard(muted, kinds))
loud = self.state.loud(chat.id) if chat.type == ChatType.PRIVATE else None # sound settings: private chats only
await msg.reply_text(menu.title(muted, kinds), reply_markup=menu.keyboard(muted, kinds, loud=loud))

async def _help_and_alerts(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""What the bot can do, then the alert settings: the Help & Alerts button and /usage."""
Expand Down Expand Up @@ -397,6 +404,11 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY
if opened in ("sub", "unsub") and not menu.has_options(self.state.muted(chat.id), kinds, opened, parent):
await query.answer("You're subscribed to everything" if opened == "sub" else "No alerts are on") # nothing to list
return
elif action == "snd" and chat.type == ChatType.PRIVATE and arg in kinds and section in ("on", "off") and chat.id in self.state.chats:
self.state.set_sound(chat.id, arg, section == "on")
parent = tail if tail in kinds else None
opened = "set"
toast = f"{menu.LABELS[arg][0].upper()}{menu.LABELS[arg][1:]} alerts {'now make' if section == 'on' else 'no longer make'} a sound"
elif action in ("on", "off") and (arg == menu.ALL or arg in kinds) and chat.id in self.state.chats:
self.state.set_kind(chat.id, arg, action == "on")
what = "All alerts" if arg == menu.ALL else f"{menu.LABELS[arg][0].upper()}{menu.LABELS[arg][1:]} alerts" # "UV alerts", not "Uv"
Expand All @@ -407,7 +419,9 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY
muted = self.state.muted(chat.id)
if opened in ("sub", "unsub") and not menu.has_options(muted, kinds, opened, parent):
opened = None # the last one was just turned on or off: close the section
markup = menu.keyboard(muted, kinds, opened if opened in ("sub", "unsub") else None, parent)
private = chat.type == ChatType.PRIVATE
markup = menu.keyboard(muted, kinds, opened if opened in ("sub", "unsub", "set") else None, parent,
self.state.loud(chat.id) if private else None)
await query.answer(toast)
try:
if (message.text or "").startswith(menu.TITLE): # the /alerts message: keep its on/off summary current
Expand All @@ -423,8 +437,8 @@ def _irrigation_alerts(self, chat_id: int, kind: str = "irrigation") -> bool:
"""Does this chat get the irrigation battery alerts (kind "irrigation") or the pause alerts ("irrigation_pause")?"""
return bool(self.state and self.state.subscribed(chat_id, kind))

def _irrigation_keyboard(self, chat_id: int, opened: str | None = None, paused: bool = False):
return irrigation_menu.keyboard(self._irrigation_alerts(chat_id), self._irrigation_alerts(chat_id, "irrigation_pause"), opened, paused)
def _irrigation_keyboard(self, chat_id: int, opened: str | None = None, paused: bool = False, running: bool = False):
return irrigation_menu.keyboard(self._irrigation_alerts(chat_id), self._irrigation_alerts(chat_id, "irrigation_pause"), opened, paused, running)

def _irrigation_text(self, status: dict, chat_id: int) -> str:
return irrigation_menu.status_text(status, self._irrigation_alerts(chat_id), self._irrigation_alerts(chat_id, "irrigation_pause"))
Expand All @@ -437,7 +451,7 @@ async def send_irrigation(self, msg: Message):
log.warning("Irrigation status failed: %s", e)
await msg.reply_text("Sorry, I couldn't reach the irrigation controller.")
return
await msg.reply_text(self._irrigation_text(status, msg.chat_id), reply_markup=self._irrigation_keyboard(msg.chat_id, None, irrigation_menu.is_paused(status)))
await msg.reply_text(self._irrigation_text(status, msg.chat_id), reply_markup=self._irrigation_keyboard(msg.chat_id, None, irrigation_menu.is_paused(status), bool(status.get("switch"))))

async def _irrigation_act(self, action: str, arg: str, chat_id: int) -> str | None:
"""Do what a button asks. Returns the toast to show, or None for a button that means nothing. Raises IrrigationError."""
Expand Down Expand Up @@ -471,8 +485,9 @@ async def on_irrigation_button(self, update: Update, context: ContextTypes.DEFAU
if action in ("open", "close"):
opened = arg if action == "open" and arg in irrigation_menu.SECTIONS else None
await query.answer()
paused = irrigation_menu.paused_in(message.text) # the message says it: no need to ask the controller to open a list
await self._edit_irrigation(query, None, self._irrigation_keyboard(message.chat_id, opened, paused))
# the message says it: no need to ask the controller to open a list
await self._edit_irrigation(query, None, self._irrigation_keyboard(message.chat_id, opened, irrigation_menu.paused_in(message.text),
irrigation_menu.running_in(message.text)))
return
if action == "refresh": # read the controller again and redraw: nothing is sent to it, so no wait
try:
Expand All @@ -483,7 +498,7 @@ async def on_irrigation_button(self, update: Update, context: ContextTypes.DEFAU
return
await query.answer("Refreshed")
await self._edit_irrigation(query, self._irrigation_text(status, message.chat_id),
self._irrigation_keyboard(message.chat_id, None, irrigation_menu.is_paused(status)))
self._irrigation_keyboard(message.chat_id, None, irrigation_menu.is_paused(status), bool(status.get("switch"))))
return
if action == "water" and arg.isdigit() and int(arg) in irrigation_menu.WATER_MINUTES: # not while the timer is paused
try:
Expand All @@ -496,7 +511,7 @@ async def on_irrigation_button(self, update: Update, context: ContextTypes.DEFAU
log.info("Irrigation: water %s refused, paused for %s (chat %s)", arg, status["weather_delay"], message.chat_id)
await query.answer(irrigation_menu.PAUSED_ERROR)
text, entities = irrigation_menu.with_error(self._irrigation_text(status, message.chat_id))
await self._edit_irrigation(query, text, self._irrigation_keyboard(message.chat_id, None, True), entities) # nothing was sent: no wait
await self._edit_irrigation(query, text, self._irrigation_keyboard(message.chat_id, None, True, bool(status.get("switch"))), entities) # nothing was sent: no wait
return
try:
toast = await self._irrigation_act(action, arg, message.chat_id)
Expand All @@ -516,7 +531,7 @@ async def on_irrigation_button(self, update: Update, context: ContextTypes.DEFAU
log.warning("Irrigation status after %s %s failed: %s", action, arg, e) # the toast said what was done; the message stays as it was
return
stay = "alerts" if action in ("batt", "pause") else None # the Alerts menu stays open after a toggle, to show the new label
await self._edit_irrigation(query, self._irrigation_text(status, message.chat_id), self._irrigation_keyboard(message.chat_id, stay, irrigation_menu.is_paused(status)))
await self._edit_irrigation(query, self._irrigation_text(status, message.chat_id), self._irrigation_keyboard(message.chat_id, stay, irrigation_menu.is_paused(status), bool(status.get("switch"))))

@staticmethod
async def _edit_irrigation(query, text: str | None, markup, entities=None):
Expand Down
Loading
Loading