From f54f291d6d56f7ad0b74f0ea75e87095de28b617 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 26 Jun 2026 09:39:36 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(archivist):=20per-room=20process=20mod?= =?UTF-8?q?e=20+=20=F0=9F=94=96=20bookmark=20reactions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A room can be set to react-only with `!config process react`: the bot then ignores plain messages and acts only when a family member reacts πŸ”– / πŸ“Œ to a message to save it. `!config process auto` restores the default (process everything as it arrives). πŸ”– / πŸ“Œ on any message bookmarks it: the same capture the bot makes automatically, but on demand. A small emojiβ†’handler registry on the archivist makes adding bindings (πŸ—‘ redact, πŸ‘Ž reclassify) a one-line change; the dispatcher and reaction plumbing live in MicroBot. Room mode is stored in the bot's room account data, not a room state event: writing room state needs moderator power level, which an invited bot doesn't have, so a state-event write is silently rejected. Account data is the bot's own, survives restarts, and is read fresh. The `!config` command reports honestly when a write fails instead of acking. --- stacklets/core/bot-runner/microbot.py | 136 +++++++++++++--- stacklets/docs/bot/archivist.py | 88 ++++++++++- tests/stacklets/test_archivist_routing.py | 170 ++++++++++++++++---- tests/stacklets/test_archivist_source.py | 6 +- tests/stacklets/test_microbot.py | 179 ++++++++++++++++++++++ 5 files changed, 530 insertions(+), 49 deletions(-) diff --git a/stacklets/core/bot-runner/microbot.py b/stacklets/core/bot-runner/microbot.py index 80076c0..5463eb1 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 @@ -1065,7 +1066,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 +1079,135 @@ 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: - [room_modes] - "#family:home.local" = "mention" # only when @-tagged - "#friends:home.local" = "off" # ignore entirely - "#open-chat:home" = "always" # current default + * ``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. - 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. + Set per room with ``!config process auto|react``. """ - del ctx + 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" + + 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 + + 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. + + v1 grammar: ``!config process auto|react``. Any room member may + set it β€” families are high-trust; power-level gating is a later + refinement. Must be dispatched ahead of the room-mode gate so a + room can always be switched back out of react mode. + """ + body = (getattr(event, "body", "") or "").strip() + if not body.startswith("!config"): + return False + reply_to = getattr(event, "event_id", None) + parts = body.split() + if len(parts) >= 3 and parts[1] == "process" and parts[2] in ("auto", "react"): + mode = parts[2] + if not await self.set_room_config(room.room_id, process=mode): + msg = ("⚠️ Couldn't save that setting (homeserver error). " + "The room mode is unchanged.") + elif mode == "react": + msg = ("βœ… This room is now in react mode. I'll only act on " + "messages you react to (πŸ”– to save).") + else: + msg = ("βœ… This room is now in auto mode. I'll process " + "messages as they come in.") + await self._send(room.room_id, msg, reply_to) + else: + await self._send( + room.room_id, + "Usage: `!config process auto|react`", + reply_to, + ) return True + @staticmethod + def normalize_emoji(key: str) -> str: + """Strip the emoji variation selector so a reaction key like + ``'πŸ‘οΈ'`` (πŸ‘ + U+FE0F) compares equal to ``'πŸ‘'``. Reaction keys + frequently carry the selector; matching a binding without + normalizing silently misses those reactions.""" + return (key or "").replace("\uFE0F", "").strip() + @staticmethod def strip_mention( body: str, bot_user_id: str, *, formatted_body: str | None = None, diff --git a/stacklets/docs/bot/archivist.py b/stacklets/docs/bot/archivist.py index 5332e30..a0179bc 100644 --- a/stacklets/docs/bot/archivist.py +++ b/stacklets/docs/bot/archivist.py @@ -39,6 +39,7 @@ from PIL import Image from nio import ( AsyncClient, + ReactionEvent, RoomMessageMedia, RoomMessageImage, RoomMessageFile, @@ -350,6 +351,9 @@ def register_callbacks(self, client: AsyncClient) -> None: (RoomMessageMedia, RoomMessageImage, RoomMessageFile, RoomMessageAudio), ) self.add_event_callback(self._on_text, RoomMessageText) + # User β†’ bot reactions: πŸ”– to save the reacted message. The drain + # delivers reactions as typed ReactionEvents (verified on the rig). + self.add_event_callback(self._on_reaction, ReactionEvent) async def start(self) -> None: logger.info("[archivist] Config: paperless={} openai={} language={} classify={} reformat={}", @@ -1363,7 +1367,7 @@ async def _on_file(self, room, event) -> None: # always sees the intro before any other reply from the bot. await self._send_room_welcome_if_needed(room, ctx) mentioned = self._is_bot_mentioned(event) - if not self._should_react(ctx, mentioned=mentioned): + if not await self._should_react(ctx, mentioned=mentioned): logger.debug( "[archivist] skipping file from {} in {} per room mode", event.sender, ctx.room_id, @@ -1482,6 +1486,11 @@ async def _on_text(self, room, event: RoomMessageText) -> None: query = event.body.strip() if not query: return + # Room-config commands (`!config ...`) are handled before the + # room-mode gate, so a room can always be switched back out of + # react mode, and before routing so they never read as a capture. + if await self._maybe_handle_config_command(room, event): + return query_lower = query.lower() reply_to = event.event_id @@ -1498,7 +1507,7 @@ async def _on_text(self, room, event: RoomMessageText) -> None: event.sender, ctx.room_id, ctx.alias, ctx.is_dm, is_documents, len(ctx.members), mentioned, ) - if not self._should_react(ctx, mentioned=mentioned): + if not await self._should_react(ctx, mentioned=mentioned): logger.debug( "[archivist] skipping {} in {} per room mode", event.sender, ctx.room_id, @@ -1668,6 +1677,81 @@ async def _on_text(self, room, event: RoomMessageText) -> None: room.room_id, query[:60], ) + # ── Reactions: user β†’ bot per-message routing ──────────────────────── + # + # A small hard-coded registry maps a (normalized) emoji to the handler + # method that runs when a family member drops it on a message. To add a + # binding β€” πŸ—‘ redact, πŸ‘Ž reclassify β€” add one entry here and write the + # `_react_*` method; the dispatcher needs no changes. Handlers receive + # the already-fetched target event, so they decide for themselves + # whether a bot-authored target is valid (bookmark says no, redact yes). + + def _reaction_handlers(self) -> dict: + return { + "πŸ”–": self._react_bookmark, + "πŸ“Œ": self._react_bookmark, + } + + async def _on_reaction(self, room, event) -> None: + """Dispatch a user's reaction to its registered handler. + + Generic and binding-agnostic: ignore the bot's own (and other + bots') reactions, normalize the emoji, look up the handler, fetch + the reacted message once, and hand it off. Idempotency and any + bot-target policy live in the handlers, keyed on the target event + id so a drain replay dedups rather than acting twice. + """ + if event.sender == self.user_id or self.is_bot_user(event.sender): + return + emoji = self.normalize_emoji(getattr(event, "key", "")) + handler = self._reaction_handlers().get(emoji) + if handler is None: + return + target_id = getattr(event, "reacts_to", None) + if not target_id: + return + try: + resp = await self._client.room_get_event(room.room_id, target_id) + except Exception as e: + logger.debug("[archivist] reaction target fetch failed: {}", e) + return + target = getattr(resp, "event", None) + if target is None: + return + await handler(room, event, target, target_id) + + async def _react_bookmark(self, room, event, target, target_id) -> None: + """πŸ”– / πŸ“Œ β€” bookmark the reacted message into the room: the same + capture auto-mode would make, but on demand. The only capture path + in a `!config process react` room. + + Never bookmarks a bot message (a filing, a welcome). Attribution + is the message author, not the reactor β€” we're saving their + content β€” and the capture is keyed on the target event id so a + replay or a second reactor dedups downstream. + """ + if self.is_bot_user(getattr(target, "sender", "")): + return + body = (getattr(target, "body", "") or "").strip() + if not body: + # v1 bookmarks text/URL messages; file uploads are a follow-up. + return + author = target.sender + if _is_just_url(body): + await self._handle_capture( + room.room_id, body, author, target_id, capture_id=target_id, + ) + elif (embedded_url := _first_url(body)) is not None: + hint = body.replace(embedded_url, "", 1).strip(" \t\n\r:.,;!?β€”-") + await self._handle_capture( + room.room_id, embedded_url, author, target_id, + capture_id=target_id, user_hint=hint or None, + ) + else: + await self._handle_text_capture( + room.room_id, body, author, target_id, capture_id=target_id, + ) + # ── Inbound source events (mail bot, future ingest channels) ────────── async def _handle_source_message(self, room, event, source: dict) -> None: diff --git a/tests/stacklets/test_archivist_routing.py b/tests/stacklets/test_archivist_routing.py index b8973e1..788bb7a 100644 --- a/tests/stacklets/test_archivist_routing.py +++ b/tests/stacklets/test_archivist_routing.py @@ -205,10 +205,10 @@ class TestShouldReact: mention) goes through `_room_mode_allows_react` which is the placeholder for future config.""" - def test_mention_in_group_room_always_reacts(self, tmp_path): + async def test_mention_in_group_room_always_reacts(self, tmp_path): """An @-tag must never be ignored, regardless of room mode. - Even if a future config marks this room as off, the mention - bypasses the mode lookup entirely.""" + Even if the room is configured react-only, the mention bypasses + the mode lookup entirely.""" bot = _build_bot(tmp_path) room = _room( canonical_alias="#family-chat:server", @@ -216,22 +216,25 @@ def test_mention_in_group_room_always_reacts(self, tmp_path): ) ctx = bot._room_context(room) # Pin the contract by forcing the mode gate to deny β€” mention - # must still win. The day modes ship, this test catches any - # regression that routes mentions through the mode lookup. - bot._room_mode_allows_react = lambda _ctx: False - assert bot._should_react(ctx, mentioned=True) is True + # must still win, never routing through the mode lookup. + async def _deny(_ctx): + return False + bot._room_mode_allows_react = _deny + assert await bot._should_react(ctx, mentioned=True) is True - def test_dm_always_reacts(self, tmp_path): + async def test_dm_always_reacts(self, tmp_path): """A 2-member room with the bot is a private chat. There's nobody else for the message to be aimed at, so the mode gate doesn't apply.""" bot = _build_bot(tmp_path) room = _room(members=[BOT_ID, "@homer:server"]) ctx = bot._room_context(room) - bot._room_mode_allows_react = lambda _ctx: False - assert bot._should_react(ctx, mentioned=False) is True + async def _deny(_ctx): + return False + bot._room_mode_allows_react = _deny + assert await bot._should_react(ctx, mentioned=False) is True - def test_documents_room_with_mention_reacts(self, tmp_path): + async def test_documents_room_with_mention_reacts(self, tmp_path): # Mention beats every other consideration, including docs-room # routing β€” the upstream handlers still see ctx.is_documents_room # and dispatch accordingly. @@ -241,12 +244,11 @@ def test_documents_room_with_mention_reacts(self, tmp_path): members=[BOT_ID, "@homer:server", "@marge:server"], ) ctx = bot._room_context(room) - assert bot._should_react(ctx, mentioned=True) is True + assert await bot._should_react(ctx, mentioned=True) is True - def test_group_room_no_mention_consults_mode(self, tmp_path): - """The only branch that talks to the future mode lookup. Today - the lookup defaults to True; this test pins that wiring so an - accidental rewrite that hard-codes True in `_should_react` + async def test_group_room_no_mention_consults_mode(self, tmp_path): + """The only branch that talks to the mode lookup. Pin the wiring + so an accidental rewrite that hard-codes True in `_should_react` bypasses the seam.""" bot = _build_bot(tmp_path) room = _room( @@ -254,24 +256,136 @@ def test_group_room_no_mention_consults_mode(self, tmp_path): members=[BOT_ID, "@homer:server", "@marge:server", "@bart:server"], ) ctx = bot._room_context(room) - # Today: gate returns True, so should_react returns True. - assert bot._should_react(ctx, mentioned=False) is True - # Tomorrow: gate returns False β†’ should_react must respect it. - bot._room_mode_allows_react = lambda _ctx: False - assert bot._should_react(ctx, mentioned=False) is False - - def test_default_room_mode_is_react(self, tmp_path): - """`_room_mode_allows_react` returns True today (no modes - configured). This test pins that default so the day a mode - config lands, the default-on behavior is explicit not - accidental.""" + async def _allow(_ctx): + return True + bot._room_mode_allows_react = _allow + assert await bot._should_react(ctx, mentioned=False) is True + async def _deny(_ctx): + return False + bot._room_mode_allows_react = _deny + assert await bot._should_react(ctx, mentioned=False) is False + + async def test_room_mode_reads_process_config(self, tmp_path): + """`_room_mode_allows_react` reflects the room's `process` config: + unset/auto β†’ react to messages; `react` β†’ ignore plain messages + (reactions become the only trigger).""" bot = _build_bot(tmp_path) room = _room( canonical_alias="#whatever:server", members=[BOT_ID, "@a:server", "@b:server"], ) ctx = bot._room_context(room) - assert bot._room_mode_allows_react(ctx) is True + + async def _auto(_room_id): + return {} + bot.get_room_config = _auto + assert await bot._room_mode_allows_react(ctx) is True + + async def _react(_room_id): + return {"process": "react"} + bot.get_room_config = _react + assert await bot._room_mode_allows_react(ctx) is False + + +class TestReactionDispatch: + """`_on_reaction` routes a user's emoji to a registered handler. v1 + binding: πŸ”– / πŸ“Œ bookmark the reacted message (the same capture + auto-mode makes), attributed to the message author and keyed on the + target event id so a drain replay or a second reactor dedups.""" + + def _bot(self, tmp_path, *, target): + bot = _build_bot(tmp_path) + cap, txt = [], [] + + async def _cap(room_id, url, sender, reply_to, *, + capture_id=None, user_hint=None): + cap.append({"url": url, "sender": sender, "reply_to": reply_to, + "capture_id": capture_id, "hint": user_hint}) + + async def _txt(room_id, text, sender, reply_to, *, capture_id=None): + txt.append({"text": text, "sender": sender, "reply_to": reply_to, + "capture_id": capture_id}) + + bot._handle_capture = _cap + bot._handle_text_capture = _txt + + async def _get_event(room_id, event_id): + return SimpleNamespace(event=target) + + bot._client = SimpleNamespace(room_get_event=_get_event) + return bot, cap, txt + + @staticmethod + def _reaction(key="πŸ”–", reacts_to="$tgt", sender="@homer:server"): + return SimpleNamespace( + key=key, reacts_to=reacts_to, sender=sender, + source={"content": {}}, + ) + + @staticmethod + def _target(body, sender="@marge:server"): + return SimpleNamespace( + sender=sender, body=body, source={"content": {"body": body}}, + ) + + async def test_bookmark_url_message_captures(self, tmp_path): + bot, cap, txt = self._bot( + tmp_path, target=self._target("https://example.com/gear"), + ) + await bot._on_reaction(_room(room_id="!r:server"), self._reaction()) + assert len(cap) == 1 and not txt + assert cap[0]["url"] == "https://example.com/gear" + assert cap[0]["sender"] == "@marge:server" # message author, not reactor + assert cap[0]["capture_id"] == "$tgt" # idempotent on target id + + async def test_bookmark_text_message_captures_as_note(self, tmp_path): + bot, cap, txt = self._bot( + tmp_path, target=self._target("remember the boiler service in March"), + ) + await bot._on_reaction(_room(), self._reaction()) + assert len(txt) == 1 and not cap + assert txt[0]["text"].startswith("remember the boiler") + assert txt[0]["capture_id"] == "$tgt" + + async def test_bookmark_embedded_url_passes_hint(self, tmp_path): + bot, cap, txt = self._bot( + tmp_path, target=self._target("camping gear list https://example.com/x"), + ) + await bot._on_reaction(_room(), self._reaction()) + assert len(cap) == 1 + assert cap[0]["url"] == "https://example.com/x" + assert cap[0]["hint"] == "camping gear list" + + async def test_variation_selector_emoji_still_dispatches(self, tmp_path): + bot, cap, _ = self._bot(tmp_path, target=self._target("https://example.com")) + await bot._on_reaction(_room(), self._reaction(key="πŸ”–\uFE0F")) + assert len(cap) == 1 + + async def test_pushpin_emoji_dispatches(self, tmp_path): + bot, cap, _ = self._bot(tmp_path, target=self._target("https://example.com")) + await bot._on_reaction(_room(), self._reaction(key="πŸ“Œ")) + assert len(cap) == 1 + + async def test_unregistered_emoji_ignored(self, tmp_path): + bot, cap, txt = self._bot(tmp_path, target=self._target("https://example.com")) + await bot._on_reaction(_room(), self._reaction(key="πŸ‘")) + assert not cap and not txt + + async def test_bot_reactor_ignored(self, tmp_path): + bot, cap, txt = self._bot(tmp_path, target=self._target("https://example.com")) + await bot._on_reaction( + _room(), self._reaction(sender="@archivist-bot:server"), + ) + assert not cap and not txt + + async def test_bot_authored_target_not_bookmarked(self, tmp_path): + # πŸ”– on the bot's own filing must not re-capture the filing. + bot, cap, txt = self._bot( + tmp_path, + target=self._target("Filed: passport", sender="@archivist-bot:server"), + ) + await bot._on_reaction(_room(), self._reaction()) + assert not cap and not txt class TestPastePredicate: diff --git a/tests/stacklets/test_archivist_source.py b/tests/stacklets/test_archivist_source.py index 3ca4e25..01fedb1 100644 --- a/tests/stacklets/test_archivist_source.py +++ b/tests/stacklets/test_archivist_source.py @@ -85,6 +85,10 @@ async def _none(): return None +async def _true(): + return True + + @pytest.mark.asyncio async def test_email_source_folds_through_capture(tmp_path): bot = _bot(tmp_path) @@ -303,7 +307,7 @@ async def rec_binary(**kwargs): bot._room_context = lambda room: SimpleNamespace(room_id=room.room_id) bot._send_room_welcome_if_needed = lambda *_a, **_k: _none() bot._is_bot_mentioned = lambda _e: False - bot._should_react = lambda *_a, **_k: True + bot._should_react = lambda *_a, **_k: _true() bot._is_documents_room = lambda _ctx: False bot._download_media = lambda _url: _bytes() bot._send = lambda *_a, **_k: _none() diff --git a/tests/stacklets/test_microbot.py b/tests/stacklets/test_microbot.py index c307acd..c66c051 100644 --- a/tests/stacklets/test_microbot.py +++ b/tests/stacklets/test_microbot.py @@ -678,3 +678,182 @@ async def test_none_when_fetch_raises(self, tmp_path): bot, client = _bare_bot(tmp_path) client.get_event_raises = ConnectionError("synapse down") assert await bot._reply_parent_envelope("!r:server", self._reply_to("$x")) is None + + +# ── Per-room config + emoji + !config command ──────────────────────────── + + +class _FakeResp: + """Minimal aiohttp-response stand-in (async context manager).""" + + def __init__(self, status, data=None): + self.status = status + self._data = data + + async def json(self): + return self._data + + async def text(self): + return str(self._data) + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + +class _FakeHttp: + """Test double for the bot's aiohttp session backing room account + data: an in-memory keyβ†’json store. ``raise_on`` forces a transport + error to exercise the best-effort read path. We stub the network + boundary here, not nio β€” the real REST round-trip is covered e2e.""" + + def __init__(self): + self.store: dict[str, dict] = {} + self.raise_on: set[str] = set() + + def get(self, url, headers=None): + if "get" in self.raise_on: + raise RuntimeError("homeserver hiccup") + if url in self.store: + return _FakeResp(200, self.store[url]) + return _FakeResp(404) + + def put(self, url, headers=None, json=None): + if "put" in self.raise_on: + raise RuntimeError("homeserver hiccup") + self.store[url] = json + return _FakeResp(200, json) + + +class TestRoomConfig: + """`get_room_config` / `set_room_config` read+write the bot's room + account data over the Matrix REST API. No local cache β€” a read hits + the homeserver every time, and a 404 or transient error reads as "no + config" rather than crashing routing.""" + + @staticmethod + def _bot(tmp_path): + bot, _ = _bare_bot(tmp_path) + bot._client.access_token = "tok" + bot._http = _FakeHttp() + return bot, bot._http + + @pytest.mark.asyncio + async def test_get_returns_empty_when_unset(self, tmp_path): + bot, _ = self._bot(tmp_path) + assert await bot.get_room_config("!r:server") == {} + + @pytest.mark.asyncio + async def test_set_then_get_roundtrips(self, tmp_path): + bot, http = self._bot(tmp_path) + assert await bot.set_room_config("!r:server", process="react") is True + assert await bot.get_room_config("!r:server") == {"process": "react"} + # Stored under the room-scoped account-data URL for this bot. + assert any("/account_data/dev.famstack.room" in u for u in http.store) + + @pytest.mark.asyncio + async def test_set_merges_into_existing(self, tmp_path): + bot, _ = self._bot(tmp_path) + await bot.set_room_config("!r:server", process="react") + await bot.set_room_config("!r:server", other="x") + assert await bot.get_room_config("!r:server") == { + "process": "react", "other": "x", + } + + @pytest.mark.asyncio + async def test_get_swallows_read_error(self, tmp_path): + bot, http = self._bot(tmp_path) + http.raise_on.add("get") + assert await bot.get_room_config("!r:server") == {} + + @pytest.mark.asyncio + async def test_set_reports_failure(self, tmp_path): + bot, http = self._bot(tmp_path) + http.raise_on.add("put") + assert await bot.set_room_config("!r:server", process="react") is False + + +class TestNormalizeEmoji: + """Reaction keys often carry the U+FE0F variation selector; matching + a binding without normalizing silently misses those reactions.""" + + def test_strips_variation_selector(self): + assert MicroBot.normalize_emoji("\U0001F44D\uFE0F") == "\U0001F44D" + + def test_plain_emoji_unchanged(self): + assert MicroBot.normalize_emoji("\U0001F516") == "\U0001F516" + + def test_none_is_safe(self): + assert MicroBot.normalize_emoji(None) == "" + + +class TestConfigCommand: + """`!config process auto|react` writes the room mode. Handled (returns + True) so the caller stops routing it; non-config text passes through + (returns False).""" + + @staticmethod + def _bot(tmp_path): + bot, client = _bare_bot(tmp_path) + bot._client.access_token = "tok" + bot._http = _FakeHttp() + return bot, client + + @staticmethod + def _evt(body): + return SimpleNamespace(body=body, event_id="$e", source={"content": {}}) + + @pytest.mark.asyncio + async def test_sets_react_mode(self, tmp_path): + bot, _ = self._bot(tmp_path) + room = SimpleNamespace(room_id="!r:server") + handled = await bot._maybe_handle_config_command( + room, self._evt("!config process react"), + ) + assert handled is True + assert await bot.get_room_config("!r:server") == {"process": "react"} + + @pytest.mark.asyncio + async def test_sets_auto_mode(self, tmp_path): + bot, _ = self._bot(tmp_path) + room = SimpleNamespace(room_id="!r:server") + await bot._maybe_handle_config_command( + room, self._evt("!config process auto"), + ) + assert (await bot.get_room_config("!r:server"))["process"] == "auto" + + @pytest.mark.asyncio + async def test_non_config_message_passes_through(self, tmp_path): + bot, _ = self._bot(tmp_path) + room = SimpleNamespace(room_id="!r:server") + handled = await bot._maybe_handle_config_command( + room, self._evt("just chatting"), + ) + assert handled is False + + @pytest.mark.asyncio + async def test_unknown_subcommand_consumed_with_usage(self, tmp_path): + bot, client = self._bot(tmp_path) + room = SimpleNamespace(room_id="!r:server") + handled = await bot._maybe_handle_config_command( + room, self._evt("!config wat"), + ) + assert handled is True # consumed, not routed onward + assert await bot.get_room_config("!r:server") == {} # nothing written + assert any("Usage" in c[2].get("body", "") for c in client.sends) + + @pytest.mark.asyncio + async def test_write_failure_reported_not_acked(self, tmp_path): + # A failed write must not be acked as success β€” the regression the + # power-level eval exposed (state-event write silently rejected). + bot, client = self._bot(tmp_path) + bot._http.raise_on.add("put") + room = SimpleNamespace(room_id="!r:server") + handled = await bot._maybe_handle_config_command( + room, self._evt("!config process react"), + ) + assert handled is True + body = client.sends[-1][2].get("body", "") + assert "Couldn't save" in body and "react mode" not in body From 87831a31a79d6b7f0155e26000efb858a0f85681 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 26 Jun 2026 10:04:02 +0200 Subject: [PATCH 2/4] feat(archivist): !config prints current room config and its options A bare `!config` now shows the room's current settings and the values each option accepts, with the active value marked. Both the status view and the set acknowledgement are generated from a small config-options registry, so the help text can never drift from what the command actually accepts. Adding an option is one registry entry. --- stacklets/core/bot-runner/microbot.py | 82 ++++++++++++++++++++------- tests/stacklets/test_microbot.py | 45 ++++++++++++++- 2 files changed, 102 insertions(+), 25 deletions(-) diff --git a/stacklets/core/bot-runner/microbot.py b/stacklets/core/bot-runner/microbot.py index 5463eb1..a01d012 100644 --- a/stacklets/core/bot-runner/microbot.py +++ b/stacklets/core/bot-runner/microbot.py @@ -1116,6 +1116,21 @@ async def _room_mode_allows_react(self, ctx: RoomContext) -> bool: 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/" @@ -1170,36 +1185,59 @@ async def _maybe_handle_config_command(self, room, event) -> bool: message was a config command (and is now handled), so the caller stops routing it as anything else. - v1 grammar: ``!config process auto|react``. Any room member may - set it β€” families are high-trust; power-level gating is a later - refinement. Must be dispatched ahead of the room-mode gate so a - room can always be switched back out of react mode. + Grammar: + ``!config`` show the current config + options + ``!config