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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
180 changes: 162 additions & 18 deletions stacklets/core/bot-runner/microbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
import json
import time
from pathlib import Path
from urllib.parse import quote

import aiohttp
import markdown
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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 <option> <value>`` set an option (from CONFIG_OPTIONS)

Any room member may set it; families are high-trust. Dispatched
ahead of the room-mode gate so a room can always be switched back
out of react mode.
"""
del ctx
body = (getattr(event, "body", "") or "").strip()
if not body.startswith("!config"):
return False
reply_to = getattr(event, "event_id", None)
parts = body.split()

# `!config <option> <value>` β€” set, if the option/value are known.
if len(parts) >= 3:
key, value = parts[1], parts[2]
opt = self.CONFIG_OPTIONS.get(key)
if opt and value in opt["describe"]:
if await self.set_room_config(room.room_id, **{key: value}):
ack = f"βœ… **{key}** is now **{value}**: {opt['describe'][value]}."
else:
ack = ("⚠️ Couldn't save that setting (homeserver error). "
"The room config is unchanged.")
await self._send(room.room_id, ack, reply_to)
return True

# Bare `!config`, or an unrecognized option/value: show the status
# view, which doubles as the help (it lists every option + values).
await self._send(
room.room_id, await self._render_config(room.room_id), reply_to,
)
return True

async def _render_config(self, room_id: str) -> str:
"""The `!config` status view: current value of each option and the
values it can take, built from CONFIG_OPTIONS so it never drifts.
Greeting-flavored but compact."""
cfg = await self.get_room_config(room_id)
lines = ["βš™οΈ **Room config**", ""]
for key, opt in self.CONFIG_OPTIONS.items():
current = cfg.get(key, opt["default"])
lines.append(f"**{key}**: {current}")
for value, desc in opt["describe"].items():
mark = "β–Έ" if value == current else "Β·"
lines.append(f" {mark} {value}: {desc}")
lines.append("")
lines.append(
"Set one with `!config <option> <value>`, "
"for example `!config process react`."
)
return "\n".join(lines)

@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,
Expand Down
105 changes: 102 additions & 3 deletions stacklets/docs/bot/archivist.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from PIL import Image
from nio import (
AsyncClient,
ReactionEvent,
RoomMessageMedia,
RoomMessageImage,
RoomMessageFile,
Expand All @@ -49,7 +50,7 @@
from capture_tags import CaptureTagCache
from extractors import TextExtractor, UrlExtractor
from git_mirror import GitMirror
from microbot import EYES, MicroBot
from microbot import CHECK, CROSS, EYES, MicroBot
from pdf_analysis import (
DEFAULT_REFORMAT_MAX_PDF_PAGES,
DEFAULT_VISION_MAX_PDF_PAGES,
Expand Down Expand Up @@ -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={}",
Expand Down Expand Up @@ -1271,6 +1275,14 @@ async def _reply_for_outcome(
That chain depends on chat-only inputs (openai_url, the
translator) so it lives here, not in the pipeline.
"""
# Terminal glyph alongside the πŸ‘€: ❌ when nothing was filed
# (upload/OCR failed), βœ… otherwise (filed, even if classification
# was partial or the document was already on file).
if o.status in ("upload_failed", "ocr_failed"):
await self._react(room_id, reply_to, CROSS)
else:
await self._react(room_id, reply_to, CHECK)

if o.status == "upload_failed":
await self._answer(room_id, self.t("upload_failed", name=o.display_name), reply_to)
return
Expand Down Expand Up @@ -1363,7 +1375,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,
Expand Down Expand Up @@ -1482,6 +1494,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

Expand All @@ -1498,7 +1515,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,
Expand Down Expand Up @@ -1668,6 +1685,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:
Expand Down Expand Up @@ -2055,6 +2147,13 @@ async def _reply_for_capture(
`capture.*` envelope as metadata so the user can reply-to-
correct the same way they do with document filings.
"""
# Terminal glyph alongside the πŸ‘€: ❌ on a genuine failure, βœ…
# otherwise. `empty` did nothing, so it gets no glyph.
if o.status in ("extract_failed", "no_mirror"):
await self._react(room_id, reply_to, CROSS)
elif o.status != "empty":
await self._react(room_id, reply_to, CHECK)

if o.status == "empty":
return
if o.status == "extract_failed":
Expand Down
Loading
Loading