diff --git a/docs/design/brain/who-answers.md b/docs/design/brain/who-answers.md index 4d4c598b..189deb6f 100644 --- a/docs/design/brain/who-answers.md +++ b/docs/design/brain/who-answers.md @@ -50,7 +50,7 @@ make that true, and any new bot has to state which gate it uses. |---|---|---| | **Reaction** | This message, this action, chosen per message | archivist `_on_reaction` (πŸ”– πŸ“Œ bookmark, πŸ“Ž πŸ“„ archive, πŸ” πŸ”„ retry) | | **@-mention** | Deliberate address, overrides everything ambient | every bot, `MicroBot._is_bot_mentioned`; the agent adds nanobot's own pill check | -| **Name in the vocative** | "Merlin, what is missing?" - how people actually talk | agent only, `name_trigger.addressed_by_name` | +| **Name in the vocative** | "Merlin, what is missing?" - how people actually talk | every bot on the main timeline (`MicroBot._is_bot_mentioned`), and the agent, through the same `stack.name_trigger` | | **Thread ownership** | Inside a bounded conversation, the thread is the address | agent `AgentThreads`, archivist `MicroBot.thread_owner` | | **Room** | The room's default job, and its `!config process` mode | archivist (`documents` room means search; `react` mode means reactions only) | | **Message shape** | A URL, a long paste, a file | archivist only, and only on the main timeline | @@ -224,6 +224,99 @@ and the archivist when it posts a filing card). Their output carries `dev.famstack.source` / `dev.famstack.event`, and other bots read the envelope rather than the prose. +## The archivist, as a decision tree + +What the archivist does with one event, read from the code +(`stacklets/docs/bot/archivist.py`, gates in `MicroBot`). The first +matching line wins. "Addressed" means: picked from the `@` list +(`m.mentions`), or its Matrix ID in the text, or, on the main timeline +only, its name at the start or end of the message ("Archivist, ...", +"..., Archivist?", `stack.name_trigger`). + +### A text message (`_on_text`) + +``` +1. sent by the archivist itself ............................ ignore +2. in the Memories room .................................... diary_room.on_text: + in a thread under one of its diary cards, with words ... correct that card + anything else ......................................... silence +3. carries a dev.famstack.source envelope (mail bot) ....... file the source +4. sent by another bot (localpart ends in -bot) ............ ignore +5. empty ................................................... ignore +6. a `!config` command ..................................... change the room's settings + (first message in a room: post the welcome first) +7. not addressed, and the room's mode is `react` ........... ignore (reactions only) +8. not addressed, in a thread the archivist does not own ... ignore + (except a handoff: mail attachments posted under a mail card) +9. not addressed, and it lands on one of its filings + (a reply to it, or in its thread) ....................... correction: re-file +10. the command ladder: + help / hilfe / ? ....................................... the room's welcome text + `(` ... `)` ............................................ start / finish a scan session + `show 42` .............................................. post document 42 + only a URL ............................................. Documents room: archive in Paperless + elsewhere: bookmark + not addressed, text with a URL ......................... same, the words are the hint + addressed, or in the Documents room .................... search and answer + (topic room: that topic only) + a voice transcript, or a long paste with one + person in the room (bots and the tech admin not + counted) ............................................... note + anything else .......................................... ignore: people talking +``` + +### A voice message (`MicroBot._dispatch`) + +``` +Every m.audio with a plain mxc:// payload is transcribed before any +handler sees it (voice.is_voice, _decode_voice): + no speech, or no transcriber ................................ nothing at all + otherwise ................................................... the transcript goes through + "A text message" above, same + event id and thread, marked + as transcribed +In the Memories room only a voice reply in a thread is decoded +(a correction to a card); memos are left for the diary. +``` + +Speech and typing are routed identically, with one difference: a +transcript that reaches the end of the ladder is filed as a note, where +typed text would need to be a long paste in a one-person room. + +### A file: photo or PDF (`_on_file`) + +``` +1. sent by the archivist, unsupported type, or no mxc:// .... ignore +2. in the Memories room ..................................... ignore; the diary reads it + on its own schedule +3. not addressed, room mode `react` ......................... ignore +4. not addressed, in a thread it does not own ............... ignore (handoffs excepted) +5. the sender has an open scan session ...................... add as a page +6. in the Documents room .................................... file in Paperless +7. elsewhere ................................................ capture: text extracted, + summarised, filed +``` + +### A reaction (`_on_reaction`) + +``` +1. by a bot or the tech admin, or in the Memories room ...... ignore +2. πŸ”– πŸ“Œ ...................................................... keep the message: bookmark or note + πŸ“Ž πŸ“„ ...................................................... archive the source in Paperless + πŸ” πŸ”„ ...................................................... retry the filing + any other emoji ........................................... ignore +``` + +### What the tree says about priorities + +- An address beats every guess: steps 7 and 8 only apply when the + archivist was not addressed, and step 9 (correction) only then too. +- Inside a thread the name alone is not an address, so "Archivist, this + is Marge's car" in a filing's thread stays a correction (step 9). To + search from a thread, pick the archivist from the `@` list. +- The Memories room is decided first and keeps the archivist silent + except for corrections to its own cards. + ## The gaps 1. **The vocative is not shared.** "Merlin, save this" on the main diff --git a/docs/design/brain/write-layer.md b/docs/design/brain/write-layer.md index 2e5109a8..e0f939a5 100644 --- a/docs/design/brain/write-layer.md +++ b/docs/design/brain/write-layer.md @@ -77,9 +77,9 @@ that started the session. agent exists. 10. **The signal is free.** `AGENT_NAME=Stacky` and `AGENT_BOT_ID=stacky-bot` are already in the bot-runner's environment. -11. **But the matcher is not shared.** `name_trigger.py` lives in the agent - stacklet, and the agent container mounts no `lib/stack`. If the two ever - disagree about "was the agent addressed", either both act or neither does. +11. **The matcher is shared.** `lib/stack/name_trigger.py` is imported by the + bot-runner and mounted as one file into the agent container, so the bots + agree on "was I addressed by name". 12. **The two answerers are not redundant.** The archivist's search is dual (Paperless plus vault, with synthesis and deep-dive); the agent's `memory_search` is vault-only, and `stack docs search` does not exist. diff --git a/docs/user-guide.md b/docs/user-guide.md index 6623589a..45567e1c 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -111,7 +111,7 @@ Name a room `Topic: Powerplant Picnic` (German: `Thema: ...`), disable encryptio The room becomes the filing system. Plan the picnic in chat, drop the gear list, the location ideas, the photo of the flyer. When it's over you have a complete, searchable record of the project without ever having organized anything. -Questions asked inside a topic room automatically search just that topic. Ask "@archivist what did Lenny suggest?" in the picnic room and the answer comes from the picnic material, not from your tax documents. +Questions asked inside a topic room automatically search just that topic. Ask "Archivist, what did Lenny suggest?" in the picnic room and the answer comes from the picnic material, not from your tax documents. --- @@ -177,7 +177,7 @@ Memories work the same way, in the thread under each memory: see [Put a memory r Two ways to ask: - In **#Documents**, just type. Search is the default there. -- In **any other room**, @-mention the bot: `@archivist when does the car insurance renew?` +- In **any other room**, start with its name: `Archivist, when does the car insurance renew?` Picking it from the `@` list works too. Inside a thread the name alone is not enough, because a message there corrects the filing: pick the archivist from the `@` list to ask something instead. Plain words work as search ("Duff Insurance", "vaccination Bart"). Questions get real answers: the archivist searches the document archive and the family knowledge vault, reads what it finds, and answers with numbered sources you can check: diff --git a/stacklets/agent/runtime/name_trigger.py b/lib/stack/name_trigger.py similarity index 76% rename from stacklets/agent/runtime/name_trigger.py rename to lib/stack/name_trigger.py index 35a5e429..edcc892f 100644 --- a/stacklets/agent/runtime/name_trigger.py +++ b/lib/stack/name_trigger.py @@ -1,16 +1,18 @@ -"""Does this message address the agent by name? +"""Does this message address a bot by name? -nanobot's group policy answers "is the bot mentioned?" by reading the -`m.mentions` payload, which only exists when the sender picked the bot -out of an autocomplete list. That is a fine rule for Slack and a poor -one for a family room, where people type what they would say out loud: +Matrix answers "is the bot mentioned?" with the `m.mentions` payload, +which only exists when the sender picked the bot out of an autocomplete +list. That is a fine rule for Slack and a poor one for a family room, +where people type what they would say out loud: Stacky, what's on our list? No pill, no `m.mentions`, no reply. This module supplies the missing half of the question, and nothing else -- it is pure text in, bool out, so the rule can be argued with in tests rather than in a running -container. +container. It is stdlib-only and imports nothing from the framework: +the bot-runner uses it as `stack.name_trigger`, and the agent container +mounts this one file as `name_trigger`, so both agree on what counts. WHAT COUNTS AS BEING ADDRESSED @@ -111,3 +113,19 @@ def addressed_by_name(body: str, name: str) -> bool: if not text: return False return bool(_pattern(name).search(text)) + + +def strip_address(body: str, name: str) -> str: + """The message without the words that address `name`. + + "hey Archivist, when does the insurance renew?" becomes "when does + the insurance renew?", so the bot hears the question, not its own + name. Unchanged when the message does not address `name`. + """ + text = strip_reply_fallback(body).strip() + if not addressed_by_name(text, name): + return body or "" + n = re.escape(name.strip()) + text = re.sub(rf"^\W*(?:{_GREETINGS}\W+)?{n}(?!\w)[\s,:;.!-]*", "", text, count=1, flags=re.IGNORECASE) + text = re.sub(rf"[,;]\s*{n}(?!\w)(\W*)$", r"\1", text, count=1, flags=re.IGNORECASE) + return text.strip() diff --git a/stacklets/agent/Dockerfile b/stacklets/agent/Dockerfile index 89fa9c86..00d271ee 100644 --- a/stacklets/agent/Dockerfile +++ b/stacklets/agent/Dockerfile @@ -33,6 +33,9 @@ COPY workspace/ /app/workspace-seed/ # Runtime-context shim: per-turn family briefing injected into nanobot's prompt. # See runtime/README.md for what it patches and why. COPY runtime/ /app/runtime/ +# name_trigger.py is not baked: compose mounts it from lib/stack, where the +# bot-runner reads the same file. Without the mount the shim logs and the +# agent answers pill mentions only. # Container-side `stack` shim: forwards allowlisted commands to the host's # famstack API (host.docker.internal) and prints the CLI's plaintext output. # See client/stack and stacklets/core/famstack-api.py. @@ -44,7 +47,8 @@ USER nanobot ENV HOME=/home/nanobot # Put the runtime shim on the path so its sitecustomize.py auto-loads at # interpreter startup and patches nanobot's per-turn context assembly. -ENV PYTHONPATH=/app/runtime +# /app/shared holds framework files compose mounts one by one (name_trigger). +ENV PYTHONPATH=/app/runtime:/app/shared # Where the container-side `stack` shim reaches the host's famstack API (core). ENV STACK_API_ADDR=host.docker.internal:42001 diff --git a/stacklets/agent/docker-compose.yml b/stacklets/agent/docker-compose.yml index 2dd518c3..01d02750 100644 --- a/stacklets/agent/docker-compose.yml +++ b/stacklets/agent/docker-compose.yml @@ -48,6 +48,12 @@ services: # image runs standalone, but a bind mount means an edit + restart takes # effect without a rebuild. Keep in sync with the Dockerfile COPY. - ./runtime:/app/runtime:ro + # The name trigger is shared with the bot-runner, so every bot agrees + # on "was I addressed by name". It lives in lib/stack; only this one + # stdlib file is mounted, under the name the shim imports. Not into + # /app/runtime: that is a read-only mount itself, and Docker cannot + # create a mountpoint inside it. /app/shared is on PYTHONPATH. + - ../../lib/stack/name_trigger.py:/app/shared/name_trigger.py:ro # The `stack` shim, live for the same reason. It is the agent's only # route to the CLI, so a bug in it takes out every vault tool at once β€” # and it stayed baked-only long enough for one to go unnoticed. diff --git a/stacklets/core/bot-runner/microbot.py b/stacklets/core/bot-runner/microbot.py index 5d7bae64..b73642ba 100644 --- a/stacklets/core/bot-runner/microbot.py +++ b/stacklets/core/bot-runner/microbot.py @@ -77,6 +77,7 @@ from room_context import RoomContext, context_for from stack.ai.client import LLM, LLMError, LLMUnavailableError, Transcriber from stack.users import TECH_ADMIN_USERNAME +from stack.name_trigger import addressed_by_name, strip_address # The framework's "I picked this up and I'm working on it" signal. A # bot reacts with πŸ‘€ on the source message the moment it starts a @@ -1319,7 +1320,7 @@ def _room_context(self, room) -> RoomContext: def _is_bot_mentioned(self, event) -> bool: """True if the event explicitly addresses this bot. - Two paths, OR-ed: + Three paths, OR-ed: 1. ``m.mentions.user_ids`` (MSC3952) β€” modern Matrix clients (Element X, Element-web) populate this list when the user @@ -1330,6 +1331,12 @@ def _is_bot_mentioned(self, event) -> bool: ``@bot:server`` form. The check is on the full mxid, not the localpart, so a casual mention of the bot's name in chat doesn't trip the gate. + 3. Addressed by name, the way people talk: "Archivist, when does + the insurance renew?" (`stack.name_trigger`, the same rule the + agent uses, from the bot's `name` in bot.toml). Only on the + main timeline: in a thread or a reply the conversation already + has an address, and there a message without a pick from the + list keeps its meaning (for the archivist, a correction). """ content = ( event.source.get("content", {}) if hasattr(event, "source") else {} @@ -1338,7 +1345,12 @@ def _is_bot_mentioned(self, event) -> bool: if self.user_id in (mentions.get("user_ids") or []): return True body = getattr(event, "body", "") or "" - return self.user_id in body + if self.user_id in body: + return True + relates = content.get("m.relates_to") or {} + if relates.get("rel_type") == "m.thread" or relates.get("m.in_reply_to"): + return False + return addressed_by_name(body, self.display_name or "") async def _should_react(self, ctx: RoomContext, *, mentioned: bool) -> bool: """Decide whether the bot acts on the current event at all. @@ -1520,6 +1532,7 @@ def normalize_emoji(key: str) -> str: @staticmethod def strip_mention( body: str, bot_user_id: str, *, formatted_body: str | None = None, + display_name: str | None = None, ) -> str: """Remove the bot mention (and any clinging punctuation/whitespace) from a message body. @@ -1538,7 +1551,9 @@ def strip_mention( only inside the HTML ``formatted_body``. We pull the display name from the formatted_body's anchor element and strip it from the start of ``body``. - 3. Neither: nothing to strip. + 3. Addressed by name ("Archivist, find MLX", "..., Archivist?"): + the address is dropped, see `stack.name_trigger`. + 4. None of these: nothing to strip. Conservative on punctuation: trims a single trailing ``:`` or ``,`` after the mention (common in tab-complete output) and @@ -1576,6 +1591,9 @@ def strip_mention( rest = rest[1:].lstrip() return rest.strip() + # Path 3: addressed by name, no pill. + if display_name: + return strip_address(body, display_name) return body async def emit_event(self, room_id: str, event_type: str, body: dict) -> bool: diff --git a/stacklets/docs/bot/archivist.py b/stacklets/docs/bot/archivist.py index 4bad34c1..71c550b0 100644 --- a/stacklets/docs/bot/archivist.py +++ b/stacklets/docs/bot/archivist.py @@ -1729,6 +1729,7 @@ async def _on_text(self, room, event: RoomMessageText) -> None: formatted_body = event.source.get("content", {}).get("formatted_body") query = self.strip_mention( query, self.user_id, formatted_body=formatted_body, + display_name=self.display_name, ) if not query: query = "help" diff --git a/stacklets/docs/bot/messages/archivist.yml b/stacklets/docs/bot/messages/archivist.yml index 292fc976..b620da0b 100644 --- a/stacklets/docs/bot/messages/archivist.yml +++ b/stacklets/docs/bot/messages/archivist.yml @@ -183,7 +183,7 @@ en: - 🎀 **Voice memos:** record straight from the chat β†’ transcribed + filed - πŸ“· **Photos & PDFs:** drop any file β†’ text-extracted, summarised, and filed - 🎀 **Multi-memo notes:** type `(` then send multiple voice memos, then `)` β†’ combined into one note - - πŸ” **Search this topic:** start a message with `?` (e.g. `?how did we pack the trunk last trip`) β†’ I search only this topic's folder + - πŸ” **Ask about this topic:** start with my name (e.g. `Archivist, how did we pack the trunk last trip?`) β†’ I answer from this topic's folder only Every capture in this room is tagged `{slug}` automatically, so the wiki's **{display}** page accumulates everything in one place. @@ -196,7 +196,7 @@ en: - πŸ“ **Notes & links:** paste text or paste a URL β†’ filed under your personal bucket - 🎀 **Voice memos:** record straight from the chat β†’ transcribed + filed - πŸ“· **Photos & PDFs:** drop any file β†’ text-extracted, summarised, and filed - - πŸ” **Ask me anything:** start a message with `?` (e.g. `?when does our car insurance expire`) β†’ I search your captures and our household documents + - πŸ” **Ask me anything:** start with my name (e.g. `Archivist, when does our car insurance expire?`) β†’ I search your captures and our household documents Type **help** to see this message again. @@ -210,7 +210,7 @@ en: - πŸ“Œ **Notes:** react πŸ“Œ to any message to keep it (in a room with just you, a long paste is kept on its own) - 🎀 **Voice memos:** record straight from the chat β†’ transcribed + filed - πŸ“· **Photos & PDFs:** drop any file β†’ text-extracted, summarised, and filed - - πŸ” **Ask me anything:** start a message with `?` (e.g. `?did we get the dentist confirmation`) β†’ I search captures and documents + - πŸ” **Ask me anything:** start with my name (e.g. `Archivist, did we get the dentist confirmation?`) β†’ I search captures and documents Type **help** to see this message again. @@ -391,7 +391,7 @@ de: - 🎀 **Sprachnachrichten:** direkt im Chat aufnehmen β†’ wird transkribiert und abgelegt - πŸ“· **Fotos & PDFs:** beliebige Datei droppen β†’ Text wird extrahiert, zusammengefasst und abgelegt - 🎀 **Mehrere Sprachnachrichten:** `(` tippen, mehrere Memos senden, dann `)` β†’ werden zu einer Notiz kombiniert - - πŸ” **In diesem Thema suchen:** Nachricht mit `?` beginnen (z.B. `?wie haben wir letztes Mal die Kisten gepackt`) β†’ ich suche nur in diesem Topic-Ordner + - πŸ” **Zu diesem Thema fragen:** mit meinem Namen beginnen (z.B. `Archivist, wie haben wir letztes Mal die Kisten gepackt?`) β†’ ich antworte nur aus diesem Topic-Ordner Jeder Eintrag in diesem Raum wird automatisch mit `{slug}` getaggt, damit die **{display}**-Wiki-Seite alles an einem Ort sammelt. @@ -404,7 +404,7 @@ de: - πŸ“ **Notizen & Links:** Text einfΓΌgen oder URL pasten β†’ wird in deinem persΓΆnlichen Bucket abgelegt - 🎀 **Sprachnachrichten:** direkt im Chat aufnehmen β†’ wird transkribiert und abgelegt - πŸ“· **Fotos & PDFs:** beliebige Datei droppen β†’ Text wird extrahiert, zusammengefasst und abgelegt - - πŸ” **Frag mich was:** Nachricht mit `?` beginnen (z.B. `?wann lΓ€uft die KFZ-Versicherung ab`) β†’ ich durchsuche deine Captures und unsere Dokumente + - πŸ” **Frag mich was:** mit meinem Namen beginnen (z.B. `Archivist, wann lΓ€uft die KFZ-Versicherung ab?`) β†’ ich durchsuche deine Captures und unsere Dokumente **help** tippen fΓΌr diese Nachricht. @@ -418,7 +418,7 @@ de: - πŸ“Œ **Notizen:** mit πŸ“Œ auf eine Nachricht reagieren, dann merke ich sie mir (bist du allein im Raum, wird ein langer Text automatisch abgelegt) - 🎀 **Sprachnachrichten:** direkt im Chat aufnehmen β†’ wird transkribiert und abgelegt - πŸ“· **Fotos & PDFs:** beliebige Datei droppen β†’ Text wird extrahiert, zusammengefasst und abgelegt - - πŸ” **Frag mich was:** Nachricht mit `?` beginnen (z.B. `?haben wir die Zahnarzt-BestΓ€tigung bekommen`) β†’ ich durchsuche Captures und Dokumente + - πŸ” **Frag mich was:** mit meinem Namen beginnen (z.B. `Archivist, haben wir die Zahnarzt-BestΓ€tigung bekommen?`) β†’ ich durchsuche Captures und Dokumente **help** tippen fΓΌr diese Nachricht. diff --git a/tests/unit/stacklets/test_agent_name_trigger.py b/tests/unit/framework/test_name_trigger.py similarity index 84% rename from tests/unit/stacklets/test_agent_name_trigger.py rename to tests/unit/framework/test_name_trigger.py index 6e73349d..a6b76e28 100644 --- a/tests/unit/stacklets/test_agent_name_trigger.py +++ b/tests/unit/framework/test_name_trigger.py @@ -12,15 +12,9 @@ from __future__ import annotations -import sys -from pathlib import Path - import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent -sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "agent" / "runtime")) - -from name_trigger import addressed_by_name # noqa: E402 +from stack.name_trigger import addressed_by_name, strip_address class TestSpeakingToTheAgent: @@ -122,3 +116,20 @@ def test_a_blank_name_never_triggers(self, name): def test_an_empty_message_is_not_an_address(self): assert not addressed_by_name("", "Stacky") assert not addressed_by_name("> <@marge:simpson> quoted only\n", "Stacky") + + +class TestWhatIsLeftForTheBot: + """After the address, the bot hears the question, not its own name.""" + + @pytest.mark.parametrize("body, question", [ + ("Archivist, when does the car insurance renew?", "when does the car insurance renew?"), + ("archivist when does the car insurance renew?", "when does the car insurance renew?"), + ("hey Archivist: what did Lenny suggest?", "what did Lenny suggest?"), + ("what did Lenny suggest, Archivist?", "what did Lenny suggest?"), + ]) + def test_the_address_is_dropped(self, body, question): + assert strip_address(body, "Archivist") == question + + def test_a_message_about_the_bot_is_left_as_it_is(self): + body = "I asked the Archivist and it said no" + assert strip_address(body, "Archivist") == body diff --git a/tests/unit/stacklets/test_agent_runtime_shims.py b/tests/unit/stacklets/test_agent_runtime_shims.py index 9002df8d..a65874b6 100644 --- a/tests/unit/stacklets/test_agent_runtime_shims.py +++ b/tests/unit/stacklets/test_agent_runtime_shims.py @@ -49,6 +49,9 @@ def _load(drop: str | None = None): monkeypatch.setitem(sys.modules, name, module) for name in SHIMMED_MODULES: monkeypatch.delitem(sys.modules, name, raising=False) + # The container mounts lib/stack/name_trigger.py as `name_trigger`. + monkeypatch.setitem(sys.modules, "name_trigger", + importlib.import_module("stack.name_trigger")) importlib.import_module("sitecustomize") return mods @@ -121,7 +124,7 @@ def test_being_named_counts_as_a_mention(nanobot, monkeypatch): """Without this shim the agent ignores everyone who does not use a pill. Driven through nanobot's own gate rather than the matcher directly: - the matcher is specified in `test_agent_name_trigger.py`, and what + the matcher is specified in `tests/unit/framework/test_name_trigger.py`, and what is at stake here is that nanobot actually asks it. """ monkeypatch.setenv("AGENT_NAME", "Stacky") diff --git a/tests/unit/stacklets/test_microbot_source.py b/tests/unit/stacklets/test_microbot_source.py index 62b105a6..d55998a0 100644 --- a/tests/unit/stacklets/test_microbot_source.py +++ b/tests/unit/stacklets/test_microbot_source.py @@ -9,6 +9,7 @@ from __future__ import annotations import sys +from types import SimpleNamespace from pathlib import Path import pytest @@ -167,3 +168,50 @@ async def test_noop_when_unset(self, tmp_path): b._client = self._ProfileClient(current="whatever") await b._sync_display_name() assert b._client.set_calls == [] + + +class TestAddressedByName: + """People write "Archivist, when does the insurance renew?" rather than + picking the bot from a list. On the main timeline that is an address; + in a thread or a reply it is not, because there the conversation + already has one (for the archivist, a message there is a correction).""" + + def _event(self, body, relates=None): + content = {"body": body, "msgtype": "m.text"} + if relates: + content["m.relates_to"] = relates + return SimpleNamespace(body=body, source={"content": content}) + + def _named_bot(self, tmp_path): + b = _bot(tmp_path) + b.display_name = "Archivist" + return b + + def test_the_name_at_the_start_is_an_address(self, tmp_path): + assert self._named_bot(tmp_path)._is_bot_mentioned( + self._event("archivist, when does the car insurance renew?")) + + def test_talking_about_the_bot_is_not(self, tmp_path): + assert not self._named_bot(tmp_path)._is_bot_mentioned( + self._event("I asked the Archivist and it said no")) + + def test_in_a_thread_the_name_alone_is_not_an_address(self, tmp_path): + thread = {"rel_type": "m.thread", "event_id": "$root"} + assert not self._named_bot(tmp_path)._is_bot_mentioned( + self._event("Archivist, this is Marge's car", thread)) + + def test_in_a_reply_the_name_alone_is_not_an_address(self, tmp_path): + reply = {"m.in_reply_to": {"event_id": "$filed"}} + assert not self._named_bot(tmp_path)._is_bot_mentioned( + self._event("Archivist, this is Marge's car", reply)) + + def test_a_picked_mention_still_counts_in_a_thread(self, tmp_path): + b = self._named_bot(tmp_path) + event = self._event("Archivist: search this", {"rel_type": "m.thread", "event_id": "$r"}) + event.source["content"]["m.mentions"] = {"user_ids": [b.user_id]} + assert b._is_bot_mentioned(event) + + def test_the_question_is_what_is_left(self, tmp_path): + assert MicroBot.strip_mention( + "Archivist, when does the car insurance renew?", "@t:hs", + display_name="Archivist") == "when does the car insurance renew?"