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
7 changes: 5 additions & 2 deletions 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, air quality, 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). In a group only admins can change them (`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 (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 All @@ -28,6 +28,8 @@ Under systemd see `ecobot.service` (it loads the environment file; the clone's l
| `POLLEN=on` | optional: Melbourne grass pollen and thunderstorm asthma risk (scraped from melbournepollen.com.au) in the report, a `pollen_asthma` tool and an alert when High or Extreme. Off by default; `POLLEN_DISTRICT` picks the district (default `Central`, never printed) |
| `PLACE` | the name shown after the Pollen & asthma and Forecast headings in the report (default `Melbourne`) |
| `FORECAST=on` | optional: the Open-Meteo daily forecast in the report and a `weather_forecast` tool. Off by default; `FORECAST_LAT` / `FORECAST_LON` set the location, else the weather station's own is used |
| `ADMIN_ONLY` | on by default: the bot answers nobody (private chats and groups alike) except the admins and creators of the groups it is in; anyone else gets "Sorry, only group admins can use this bot." when they address it (ordinary group talk is ignored). The one exception is `/alerts` in a group, which anyone may open (its buttons and `/alerts on|off` stay admin-only). `ADMIN_ONLY=off` answers everyone, as before. The admins are asked of Telegram and remembered for 10 minutes; a group counts once the bot is added to it or someone speaks there |
| `ADMIN_CHAT_IDS` | optional, with `ADMIN_ONLY`: more ids allowed besides those admins (added to them, not instead of them), separated by commas or spaces. A user id (the same number as their private chat) lets someone in before the bot is in any group; a group id (negative) adds that group's admins and lets its anonymous admins (those who post "as the group") in |
| `ALERT_COOLDOWN_MINUTES` | optional: the rain and temperature-crossing alerts: how long it must stay dry before "the rain has stopped" is sent, and the least time between alerts of the same kind (default 30, from 5 to 150). A change that comes inside the cooldown is held and announced when it ends, if the state still differs from the last one alerted; if it is back where it was, nothing is sent |
| `RAIN_QUIET_HOURS` | optional: local hours with no rain alerts (started, stopped, predicted), as `0-6` (the default; `22-6` wraps midnight; `off` for none). Rain that fell in them is summed up in one message once they end |
| `CHART_ALL_FEELS_LIKE=on` | optional: "weather all week" (every reading, a panel each, sent with just its title; UV is left out as it has the same shape as solar) also draws the feels-like panel; off by default, as it nearly repeats temperature. Naming either ("plot feels like", "plot solar and uv") always works |
Expand All @@ -39,7 +41,8 @@ Under systemd see `ecobot.service` (it loads the environment file; the clone's l
```
ecobot.py wiring and lifecycle
lib/config.py settings
lib/bot.py Telegram handlers and replies
lib/bot.py Telegram handlers and replies; every button press comes in through one entry point that checks it first
lib/admins.py who may use the bot with ADMIN_ONLY: the admins of the groups it is in, plus ADMIN_CHAT_IDS
lib/llm.py, prompt.py tool-calling loop; system prompt
lib/intent.py intent.read(text) -> one Reading: effort, needs data, fast path, chart asks, period hints
lib/tools.py, warm.py tool registry and the per-question Turn; keep-warm helper shared by both sources
Expand Down
6 changes: 6 additions & 0 deletions ecobot.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ async def main():
tools = Tools([t for s in sources for t in s.tools] + (composer.tools if composer else []))
agent = Agent(AsyncOpenAI(api_key=cfg.xai_api_key, base_url=cfg.xai_base_url), cfg.xai_model, tools)
state = AlertState(cfg.state_path)
if cfg.admin_only:
log.info("Admin only: the bot answers the admins of the groups it is in%s",
f" and {len(cfg.admin_chat_ids)} listed id(s)" if cfg.admin_chat_ids else "")
if not cfg.admin_chat_ids and not any(chat < 0 for chat in state.chats):
log.warning("Admin only is on but no group is known yet and ADMIN_CHAT_IDS is empty, so nobody can use the bot: "
"add it to a group, list your user id in ADMIN_CHAT_IDS, or set ADMIN_ONLY=off")
irrigation = Tuya(cfg) if cfg.irrigation else None
bot = Bot(cfg, agent, sources, state, irrigation)

Expand Down
56 changes: 56 additions & 0 deletions lib/admins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Who may talk to the bot when ADMIN_ONLY is on: the admins of the groups it is in, and the ids listed in ADMIN_CHAT_IDS.

A "known admin" is anyone Telegram lists as an administrator or creator of a group the bot knows (a group becomes known when the bot is
added to it or someone speaks in it) or of a group named by a negative id in ADMIN_CHAT_IDS. The lists are asked for once and kept for
ten minutes. ADMIN_CHAT_IDS is added to that, never instead of it: a positive id is a user (it bootstraps a bot that is in no group yet),
a negative id is a group, whose own admins are then looked up and whose anonymous admins are let in: a message an admin sends "as the
group" carries the group as its `sender_chat` and no real user, so it is allowed only when that group's id is listed.
"""

import logging
import time

log = logging.getLogger(__name__)

TTL_SECONDS = 600
RETRY_SECONDS = 60 # after a failed lookup, ask again after this long (not on every message)


class Admins:
def __init__(self, state, extra_ids=(), ttl: float = TTL_SECONDS, clock=time.monotonic):
self.state, self.extra, self.ttl, self.clock = state, frozenset(extra_ids), ttl, clock
self._cache: dict[int, tuple[float, frozenset[int]]] = {} # group -> (when fetched, its admins)
self._failing: set[int] = set()

def groups(self) -> list[int]:
"""The groups whose admins count: the ones the bot knows, and the ones ADMIN_CHAT_IDS names."""
known = {c for c in (self.state.chats if self.state else {}) if c < 0}
return sorted(known | {i for i in self.extra if i < 0})

async def _admins_of(self, bot, chat_id: int) -> frozenset[int]:
cached = self._cache.get(chat_id)
if cached and self.clock() - cached[0] < self.ttl:
return cached[1]
try:
members = await bot.get_chat_administrators(chat_id)
ids = frozenset(m.user.id for m in members if not getattr(m.user, "is_bot", False))
except Exception as e: # the bot was removed, Telegram is down: keep what was known, try again soon, say it once
if chat_id not in self._failing:
self._failing.add(chat_id)
log.warning("Couldn't list the admins of %s (%s: %s)", chat_id, type(e).__name__, e)
ids = cached[1] if cached else frozenset()
self._cache[chat_id] = (self.clock() - self.ttl + RETRY_SECONDS, ids)
return ids
self._failing.discard(chat_id)
self._cache[chat_id] = (self.clock(), ids)
return ids

async def allowed(self, bot, sender_ids) -> bool:
"""Is one of these ids (the sender's user id and, for a message sent as a chat, that chat's id) allowed?"""
ids = {i for i in sender_ids if isinstance(i, int)}
if ids & self.extra:
return True
for chat_id in self.groups():
if ids & await self._admins_of(bot, chat_id):
return True
return False
14 changes: 11 additions & 3 deletions lib/alerts/menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@

# 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": "air quality", "pollen": "pollen & asthma", "forecast": "forecast changes",
"air": "particulates", "pollen": "pollen & asthma", "forecast": "forecast changes",
"irrigation": "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️", "rain_likely": "\U0001f326️", "gusts": "\U0001f4a8", "uv": "\U0001f9f4", "temps": "\U0001f321️",
"air": "\U0001f637", "pollen": "\U0001f33c", "forecast": "\U0001f504", "irrigation": "\U0001faab", "irrigation_pause": "☔"}
ALL = "all"
TITLE = "🔔 Alerts in this chat"
PER_ROW = 2
Expand All @@ -30,6 +33,11 @@ def available_kinds(sources: set[str]) -> list[str]:
return wanted


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)

Expand All @@ -50,11 +58,11 @@ def keyboard(muted: set[str], available: list[str], open: str | None = None, par
listed, show_all = [k for k in listed if k == parent], bool(others)
else:
show_all = len(listed) > 1
mark = lambda k: f"● {LABELS[k]}" if open == "unsub" and k == parent else LABELS[k] # the alert these buttons are under
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.append([_button("🔔 Subscribe to all" if open == "sub" else "🔕 Unsubscribe from all", f"al:{verb}:{ALL}:{open}{tail}")])
return InlineKeyboardMarkup(rows)


Expand Down
Loading
Loading