diff --git a/pyproject.toml b/pyproject.toml index fda72b0..44ca719 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ asyncio_mode = "auto" norecursedirs = ["tests/integration/eval"] markers = [ "v2_only: tests for the lifecycle v2 refactor (will fail until migration is complete)", + "unverified: e2e intent specs not yet reconciled against the rig; non-blocking (xfail) until verified before the next beta tag", ] [tool.ruff] diff --git a/stacklets/core/bot-runner/microbot.py b/stacklets/core/bot-runner/microbot.py index 80076c0..c9647cc 100644 --- a/stacklets/core/bot-runner/microbot.py +++ b/stacklets/core/bot-runner/microbot.py @@ -54,6 +54,7 @@ import json import time from pathlib import Path +from urllib.parse import quote import aiohttp import markdown @@ -78,6 +79,12 @@ # attached to the specific message instead of a separate timeline event. EYES = "\U0001F440" +# Terminal outcome glyphs added alongside the πŸ‘€ when work finishes: βœ… +# on success, ❌ on failure. They give an at-a-glance result in the main +# timeline, since the detailed filing reply now lives in a thread. +CHECK = "\U00002705" +CROSS = "\U0000274C" + class MicroBot: """Base class for lightweight Matrix bots. @@ -1065,7 +1072,7 @@ def _is_bot_mentioned(self, event) -> bool: body = getattr(event, "body", "") or "" return self.user_id in body - def _should_react(self, ctx: RoomContext, *, mentioned: bool) -> bool: + async def _should_react(self, ctx: RoomContext, *, mentioned: bool) -> bool: """Decide whether the bot acts on the current event at all. Two cases are never gated β€” the bot always reacts: @@ -1078,36 +1085,173 @@ def _should_react(self, ctx: RoomContext, *, mentioned: bool) -> bool: message to be aimed at. Everything else (group rooms with 3+ members, no mention) is - subject to ``_room_mode_allows_react`` β€” the single seam a - subclass overrides when it wants per-room mode config. The - framework default lets every event through. + subject to ``_room_mode_allows_react``, which reads the room's + config. Async because that read hits the room state. """ if mentioned: return True if ctx.is_dm: return True - return self._room_mode_allows_react(ctx) + return await self._room_mode_allows_react(ctx) - def _room_mode_allows_react(self, ctx: RoomContext) -> bool: + async def _room_mode_allows_react(self, ctx: RoomContext) -> bool: """The configurable branch of ``_should_react``. - Group-room behavior is the only thing rooms might want to gate. - Anticipated shape β€” once a subclass / config lands: + Reads the room's ``process`` mode from its config state event: + + * ``react`` β€” the bot ignores plain messages; the only trigger + is an explicit user reaction (e.g. πŸ”– to save). Returns False. + * ``auto`` / unset β€” the bot processes messages as they arrive + (the default). Returns True. + + Set per room with ``!config process auto|react``. + """ + cfg = await self.get_room_config(ctx.room_id) + return cfg.get("process") != "react" + + # ── Per-room config (the bot's room account data) ──────────────────── + # + # Per-room bot settings live in the bot's *room account data*, not a + # room state event. Writing room state needs power level 50 (a room + # moderator), which a bot invited to a family room does not have, so a + # state-event write is silently rejected. Account data is the bot's + # own: writable regardless of power level, server-stored (survives + # restarts), and read fresh β€” no local cache. nio has no high-level + # account-data setter, so we hit the REST endpoint directly with the + # bot's token. The friendly `!config` command is the write interface. + + ROOM_CONFIG_TYPE = "dev.famstack.room" + + # Declarative registry of room-config options. One entry drives + # validation, the `!config` status view, and the set acknowledgement, + # so they never drift. `default` is the value assumed when the option + # is unset; `describe` maps each allowed value to a one-line meaning. + # Subclasses extend this dict to add their own options. + CONFIG_OPTIONS: dict[str, dict] = { + "process": { + "default": "auto", + "describe": { + "auto": "file everything as it arrives", + "react": "act only when you react πŸ”– to a message", + }, + }, + } + + def _room_config_url(self, room_id: str) -> str: + return ( + f"{self.homeserver}/_matrix/client/v3/user/" + f"{quote(self.user_id)}/rooms/{quote(room_id)}" + f"/account_data/{self.ROOM_CONFIG_TYPE}" + ) + + def _auth_headers(self) -> dict: + return {"Authorization": f"Bearer {self._client.access_token}"} + + async def get_room_config(self, room_id: str) -> dict: + """The bot's config for this room (its ``dev.famstack.room`` room + account data), or ``{}`` when unset. Read fresh; best-effort, so a + 404 or a transient error reads as "no config" rather than crashing + routing.""" + try: + async with self._ensure_http().get( + self._room_config_url(room_id), headers=self._auth_headers(), + ) as resp: + if resp.status == 200: + data = await resp.json() + return data if isinstance(data, dict) else {} + return {} + except Exception as e: + logger.debug("[{}] room config read failed in {}: {}", + self.name, room_id, e) + return {} + + async def set_room_config(self, room_id: str, **updates) -> bool: + """Merge ``updates`` into the room's config. Returns True on a + confirmed write β€” the caller reports honestly rather than acking a + write that never landed.""" + merged = {**await self.get_room_config(room_id), **updates} + try: + async with self._ensure_http().put( + self._room_config_url(room_id), + headers=self._auth_headers(), json=merged, + ) as resp: + if resp.status >= 400: + logger.warning("[{}] room config write failed in {}: {} {}", + self.name, room_id, resp.status, + await resp.text()) + return False + return True + except Exception as e: + logger.warning("[{}] room config write error in {}: {}", + self.name, room_id, e) + return False - [room_modes] - "#family:home.local" = "mention" # only when @-tagged - "#friends:home.local" = "off" # ignore entirely - "#open-chat:home" = "always" # current default + async def _maybe_handle_config_command(self, room, event) -> bool: + """Handle a ``!config`` room-config command. Returns True when the + message was a config command (and is now handled), so the caller + stops routing it as anything else. - With the two always-on cases handled upstream, "mention" mode - collapses to "ignore" here (mentions never reach this branch). - The framework default β€” react in every group room β€” is the - least-surprise baseline; bots that want to be quieter override - this method. + Grammar: + ``!config`` show the current config + options + ``!config