Просмотр исходного кода

Process inbound message edits as corrections

A message_edited event now re-runs the admission gates against the new
body: a message currently being processed is cancelled (🚫) and
redispatched with the edited text, a queued follow-up is rewritten in
place, and a never-dispatched message (a forgotten @mention, say) gets
a fresh turn. Already-answered messages stay answered.

Configured via CHATTO_EDIT_DISPATCH (default: on) with a 300 s
CHATTO_EDIT_WINDOW; the shared admission pipeline moves into
_admit_and_build so posted and edited paths gate identically.
Paul Klumpp 1 неделя назад
Родитель
Сommit
54a010ebdc
9 измененных файлов с 704 добавлено и 137 удалено
  1. 11 0
      AGENTS.md
  2. 76 110
      PLAN.md
  3. 31 0
      README.md
  4. 253 25
      adapter.py
  5. 5 0
      after-install.md
  6. 38 0
      platform_config.py
  7. 8 0
      plugin.yaml
  8. 255 2
      test_adapter.py
  9. 27 0
      test_platform_config.py

+ 11 - 0
AGENTS.md

@@ -83,6 +83,17 @@ changes must keep them, or consciously renegotiate the docs.**
 - **Threads stay threads.** An inbound thread reply keeps its thread context;
   a fresh room reply opens a thread under the incoming message unless
   `auto_thread` is disabled or the room is a DM.
+- **Edits are corrections, not new traffic.** An inbound `message_edited`
+  re-runs the admission gates against the new body: a message currently being
+  processed is cancelled (🚫) and redispatched with the edited text; a queued
+  follow-up is rewritten in place; a never-dispatched message (a forgotten
+  @mention, say) gets a fresh turn if the gates pass now; an already-answered
+  message stays answered — the `_dispatched_ids` ring exists so edits cannot
+  re-animate old conversations. Edits older than `CHATTO_EDIT_WINDOW` seconds
+  (default 300) are dropped, as are edited DM-command texts (`/join`, `/leave`
+  ran once and must not run again); `CHATTO_EDIT_DISPATCH=false` turns the
+  whole behavior off. Own edit echoes (the streaming path) are filtered by
+  actor before any of this.
 - **Processing reactions.** 👀 while working, then ✅, ❌, or 🚫 (cancelled) —
   driven by `on_processing_start`/`on_processing_complete` and the `reactions`
   setting, never applied to the bot's own events.

+ 76 - 110
PLAN.md

@@ -1,125 +1,91 @@
-# Plan: BasePlatformAdapter-Overrides nachziehen (Punkte 1–8)
+# Plan: Eingehende Edits verarbeiten (feat/edit-dispatch)
 
-Referenz: `gateway/platforms/base.py` aus dem Hermes-Checkout (HEAD `7b25941`,
-identisch mit github.com/nousresearch/hermes-agent `main`).
+Referenz: `gateway/platforms/base.py` aus dem Hermes-Checkout; chattolib
+0.4.20.post1 (`MessageEditedPayload`, realtime protocol v1).
 
-Jeder Punkt: was die Basis anbietet, was chattolib kann, was implementiert wird.
+Ein `message_edited`-Event wird je nach Zustand der Original-Nachricht zu
+einer Korrektur, einem Nachtrag oder nichts. Drei Pfade:
 
----
-
-## 1. `edit_message` — base.py:4035, Default `SendResult(success=False, "Not supported")`
-
-chattolib: `client.update_message(room_id, event_id, body=...)`.
-
-Aufrufer: `gateway/stream_consumer.py:412` (Zeilen 1451, 1824, 2233) und
-`gateway/run.py:4530/28592/29493`. Solange `False` zurückkommt, wird jedes
-Streaming-Update als *neue* Nachricht gesendet.
-
-- Signatur exakt wie Basis, inkl. `finalize: bool = False` (für Chatto ein
-  No-op — kein Rich-Card-Lifecycle, `REQUIRES_EDIT_FINALIZE` bleibt aus).
-- Content länger als `MAX_MESSAGE_LENGTH` → `success=False` zurückgeben statt
-  still zu kürzen, damit der Aufrufer auf `send()` (das splittet) zurückfällt.
-- Fehler von chattolib → `success=False, retryable=True`.
-- Editierte ID via `_mark_seen()`, damit das eigene `message_edited`-Event
-  nicht zurückläuft.
-
-## 2. `delete_message` — base.py:4064, Default `False`
-
-chattolib: `client.delete_message(room_id, event_id) -> bool`.
-
-Genutzt vom Fresh-Final-Cleanup des Stream-Consumers und von
-`_schedule_ephemeral_delete` (EphemeralReply-TTL). Durchreichen, Exceptions zu
-`False`.
-
-## 3. Eingehende Attachments → `MessageEvent.media_urls` / `media_types`
-
-Kein Override, sondern eine Lücke: `_dispatch_message_posted` verwirft
-Nachrichten mit leerem `body` — ein reines Bild/PDF an Hermes verschwindet.
-
-- `Message.attachments` (`list[MessageAttachment]`) trägt `asset_url.url`
-  (vorsignierte URL), `filename`, `content_type` — kein `get_asset`-Roundtrip
-  nötig.
-- Bytes laden (httpx, vendored), gegen `validate_inbound_media_size` prüfen,
-  durch `cache_media_bytes(data, filename=…, mime_type=…)` schicken — das ist
-  der geteilte Funnel aller Adapter (base.py:2212).
-- `media_urls` = lokale Cache-Pfade, `media_types` = MIME, `message_type` nach
-  derselben Präzedenz wie Teams/Signal: DOCUMENT > PHOTO > VIDEO > AUDIO > TEXT.
-- Early-Return nur noch, wenn *weder* Body *noch* Attachments da sind.
+1. **Mid-run-Korrektur** – Nachricht wird gerade verarbeitet: Lauf canceln
+   (Muster `/stop`, `cancel_session_processing`), mit korrigiertem Text neu
+   dispatchen. 🚫 entsteht über den CANCELLED-Outcome-Hook (base.py:6345),
+   👀 wieder über den Neustart.
+2. **Nachtrag** – Nachricht wurde nie dispatcht (z. B. Mention nachgetragen):
+   Admission-Gates gegen den neuen Body, dann normaler Turn.
+3. **Schon beantwortet** – ignorieren. `_dispatched_ids` ist die Sperre gegen
+   die Retrospektive-Maschine.
 
-## 4. `create_handoff_thread` — base.py:4008, Default `None`
+Konsistenzregel: Gates gelten in beide Richtungen — wer die Mention beim
+Berichtigen herauseditiert, fällt durchs Gate und wird ignoriert.
 
-Seed-Message in den Parent-Room posten, deren ID ist der Thread-Root
-(dasselbe Muster wie Slack, adapter.py:2278 dort). Best-effort
-`follow_thread`. Bei DM-Räumen `None` (Chatto-DMs können keine Threads).
-
-## 5. `send_document` / `send_video` / `send_voice` — base.py:4718 / 4691 / 4545
-
-Defaults schicken „⚠️ Couldn't deliver …" als Text. Die Upload-Mechanik gibt es
-schon (`_upload_asset`, chunked), sie hängt nur an `send_image_file`.
-
-- Gemeinsamen Helper `_send_local_attachment()` aus `send_image_file`
-  herausziehen; die vier Methoden werden dünne Wrapper.
-- `validate_media_delivery_path()` bleibt der Gate vor jedem Upload.
-- Fällt der Upload aus, greift weiter der Textkommentar der Basis-Semantik
-  (nie den Host-Pfad in den Chat schreiben).
-
-## 6. `send_multiple_images` — base.py:4398
-
-Default sendet einzeln. Chatto-Nachrichten tragen mehrere Attachments:
-alle hochladen, *ein* `post_message` mit `attachment_asset_ids=[…]`.
-Bei ≤1 Bild oder Upload-Fehler auf `super()` zurückfallen.
-`file://`-URIs entquoten, `http(s)://` vorher herunterladen.
+---
 
-## 7. Reaction-Events → `_reaction_handler` (`set_reaction_handler`, base.py:3691)
+## Konfiguration (Drei-Namen-Schema)
+
+- `CHATTO_EDIT_DISPATCH` (bool, **default true**) — Master-Schalter.
+  Default an: das Verhalten ist Teil des README-Versprechens.
+- `CHATTO_EDIT_WINDOW` (int Sekunden, default 300) — max. Alter eines Edits
+  via `Message.updated_at − created_at` (types.py:673/678). Neuer "int"-Kind
+  in der ConfigField-Maschinerie (`_get_env_or_extra_int`); unparsebare
+  Werte fallen mit Warnung aufs Default.
+- plugin.yaml: beide als optional_env mit `(default: …)`-Angabe.
+
+## Adapter
+
+- `_dispatched_ids`: Ring-Puffer (SEEN_CAP-Muster) — gefüllt vor jedem
+  `handle_message`, both paths. Retro-Sperre.
+- `_processing: dict[session_key, message_id]` — geschrieben in
+  `on_processing_start`, geräumt in `on_processing_complete` (feuert bei
+  Cancellation mit CANCELLED, base.py:6345-6350). Liefert Pfadwahl UND den
+  Cancel-Schlüssel.
+- `_session_key_for(source)`: ruft `gateway.session.build_session_key` mit
+  exakt denselben Extras wie `handle_message` (base.py:5578) — ohne das
+  trifft `cancel_session_processing` die falsche Session.
+- `_admit_and_build(...)`: die Admission-Pipeline aus `_dispatch_message_posted`
+  ausgezogen (User-Cache → Auth → Room-Kind → DM-Commands → Mention-Gates →
+  🫥-Ack → Thread-Anker → Source/Event → Media). Beide Pfade teilen sie;
+  Flag `allow_dm_commands=False` für Edits — ein editiertes `/join` läuft
+  weder doppelt noch zum Agenten durch.
+- `_dispatch_message_edited(payload)`: Echo-Filter (actor == me, im Event-Zweig)
+  → Respond-Room-Gate → Hydration (`deleted_at` skip) → Fenster → **billige
+  Retro-Triage vor der Pipeline** (schon erledigt ⇒ keine API-Kosten, kein
+  zweites 🫥) → `_admit_and_build` → Pfadentscheidung:
+  - inflight ⇒ `cancel_session_processing(discard_pending=False)` — geparkte
+    Folgenachrichten überleben; der neue Turn drainiert sie am Ende selbst.
+  - queued (`_pending_messages[session_key].message_id == id`) ⇒ Text in
+    place umschreiben, kein zweiter Dispatch.
+  - sonst nie dispatcht ⇒ frischer Turn.
+- Einbau in `_handle_realtime_event` als eigener Zweig; `message_edited`
+  fliegt aus der Known-Kinds-Liste.
 
-`reaction_added`/`reaction_removed` werden in `_handle_realtime_event` heute
-explizit verworfen. `ReactionPayload` (`room_id`, `message_event_id`, `emoji`)
-ist fertig dekodiert.
+## Tests
 
-- Normalisiertes Dict exakt im Slack-Format: `platform`, `event_name`
-  (`reaction:added` / `reaction:removed`), `reaction`, `user_id`,
-  `item_user_id`, `item_type`, `channel_id`, `message_ts`, `event_ts`,
-  `raw_event`.
-- Eigene Reaktionen (Lifecycle 👀/✅/❌) rausfiltern, sonst Endlosschleife.
-- Handler-Aufruf non-blocking (try/except), `getattr`-Guard wie bei Slack.
-- Nebenbei: `message_edited`/`message_retracted` in die Known-Kinds-Liste,
-  damit sie nicht als `unknown event kind` geloggt werden.
+`test_adapter.py::TestEditDispatch` (14 Fälle): Default on + Fenster 300 ·
+Echo ignoriert · Feature aus · Read-only-Room ohne API-Call · Mention
+nachgetragen ⇒ Turn · weiter ohne Mention ⇒ still · schon beantwortet ⇒
+ignoriert · gelöscht ⇒ ignoriert · Edit außerhalb des Fensters ⇒ ignoriert ·
+frischer Edit in DM ⇒ Turn · Mid-run ⇒ Cancel + Redispatch mit neuem Text ·
+Completion-Hook räumt `_processing` ab (Re-Arming) · queued Follow-up wird
+in place korrigiert (kein Cancel, kein zweiter Dispatch) · DM-Command-Edit
+läuft weder Command noch Agent.
 
-## 8. `format_message` — base.py:7235, Default = Identität
+`test_platform_config.py`: `_get_env_or_extra_int` (Env-Präferenz, Extra int
+und str, Junk ⇒ Default, YAML-bool ⇒ Default, beides fehlt ⇒ Default).
 
-Bewusst minimal: Chatto rendert Markdown nativ, es gibt nichts zu escapen.
-Implementiert wird nur, was messbar schiefgeht — CRLF-Normalisierung und das
-Kappen von >2 Leerzeilen am Stück. Zusätzlich der `hasattr`-Aufruf in `send()`
-(adapter.py:681) durch einen direkten Aufruf ersetzt, der heute effektiv ein
-No-op ist. **Dünnster Punkt der Liste** — mehr wäre Spekulation über
-Chatto-Renderer-Details, die sich real-testen lässt.
+Lauf: `PYTHONPATH=/path/to/hermes-agent uv run --with pyyaml pytest -q`
 
----
-
-## Tests
+## Doku
 
-Alle in `test_adapter.py`, gleicher Stil (AsyncMock-Client, keine Netzwerk-IO):
-
-- `TestMessageEditing`: die beiden `xfail(strict=True)`-Marker entfernen,
-  ergänzen: Overlong-Content → `success=False`, chattolib-Fehler → `retryable`.
-- `TestInboundAttachments`: Bild-Attachment → `media_urls`/`media_types`
-  gesetzt, `message_type == PHOTO`; Body leer + Attachment → nicht verworfen;
-  Body leer + kein Attachment → weiterhin verworfen; Download-Fehler → Event
-  trotzdem zugestellt.
-- `TestHandoffThread`: Room → Seed-ID; DM → `None`.
-- `TestNativeSends`: `send_document`/`send_video`/`send_voice` laden hoch und
-  posten mit `attachment_asset_ids`; unsicherer Pfad → Fallback-Text.
-- `TestSendMultipleImages`: zwei lokale Dateien → *ein* `post_message` mit zwei
-  Asset-IDs.
-- `TestReactionForwarding`: Fremd-Reaktion → Handler mit korrektem Dict;
-  Selbst-Reaktion → kein Handler-Aufruf; Handler-Exception → kein Absturz.
-- `TestFormatMessage`: CRLF und Leerzeilen.
-
-Lauf: `HERMES_ROOT=/tmp/hermes uv run --group dev --with pyyaml pytest -q`
-(das Plugin hängt nicht am Agent; `HERMES_ROOT` zeigt auf ein Checkout).
+README.md: Verhaltenstabelle (neue Zeile „Editing your messages"),
+Capability-Matrix („inbound edit processing"), Env-Beispiele, Env-Tabelle,
+config.yaml-Beispiel, Usage Note „Editing Your Messages" mit Pflichtenheft-
+Tabelle. AGENTS.md: Behavioural-Contract-Bullet. after-install.md: Satz im
+Test-Schritt (First-run-Verhalten).
 
 ## Nicht in Scope
 
-`send_draft`/`supports_draft_streaming` (Telegram-Draft-API, kein Chatto-Pendant),
-Streaming-TTS, `enforces_own_access_policy` (Security-Entscheidung, gehört
-separat besprochen — siehe Punkt 9 der Analyse).
+Anhänge im Edit berücksichtigen (queued rewrite toucht nur Text; Media bleibt
+vom Original). Mehrere schnelle Edits debouncen — `fetch_message` liefert
+ohnehin den neuesten Stand. Retraction (`message_retracted`) verarbeiten.
+Edits an Nachrichten in anderen Sessions gezielt in deren Queues korrigieren
+(der Pending-Match läuft über die Session-Key-Gleichheit).

+ 31 - 0
README.md

@@ -16,6 +16,7 @@ Before setup, here's the part most people want to know: how Hermes behaves once
 | **Rooms** | Hermes responds to every room message by default. With `CHATTO_REQUIRE_MENTION=true`, channel-kind rooms answer only `@mentions`; group-kind rooms always respond. |
 | **Threads** | If you reply in a thread, Hermes keeps the thread context isolated from the parent room. The bot auto-follows threads it participates in. |
 | **Processing indicators** | Hermes adds a 👀 reaction when it starts processing a message, and replaces it with ✅ on success, ❌ on failure, or 🚫 when processing was cancelled. |
+| **Editing your messages** | An edit within 5 minutes of posting counts as a correction: while Hermes is still working on the original, he restarts with the edited text (🚫 → 👀); if he had ignored or not yet answered the message, the gates are re-checked against the new text — adding a forgotten `@mention` this way works. Edits to messages Hermes has already answered change nothing. Disable with `CHATTO_EDIT_DISPATCH=false`. |
 | **Typing indicators** | Hermes broadcasts persistent typing indicators while it's working, so users know the bot is active. |
 | **Message splitting** | Long responses (>10000 chars) are automatically split into multiple messages. |
 
@@ -32,6 +33,7 @@ Capabilities natively implemented by the Chatto plugin adapter:
 | threads | yes |
 | reactions | yes |
 | message editing | yes |
+| inbound edit processing | yes (correction / late-mention / ignored, see behavior table) |
 | message deletion | yes |
 | typing indicators | yes |
 | processing notifications | yes (👀/✅/❌/🚫) |
@@ -118,6 +120,16 @@ CHATTO_PASSWORD=your-password
 
 # Optional: 👀/✅/❌/🚫 processing reactions (default: true)
 # CHATTO_REACTIONS=true
+
+# Optional: treat edits of chat messages as corrections (default: true).
+# A message being processed restarts with the edited text; a message the bot
+# ignored (e.g. missing @mention) is re-checked against its new text;
+# already-answered messages stay answered.
+# CHATTO_EDIT_DISPATCH=true
+
+# Optional: how long after posting an edit may still land, in seconds
+# (default: 300). Older edits are ignored.
+# CHATTO_EDIT_WINDOW=300
 ```
 
 Secrets (`CHATTO_TOKEN`, `CHATTO_PASSWORD`) belong in `~/.hermes/.env`, never in `config.yaml`.
@@ -138,6 +150,8 @@ gateway:
         require_mention: false     # only respond to @mentions in channels
         allowed_users: []          # empty = deny all (or set allow_all_users)
         allow_all_users: true
+        edit_dispatch: true        # process message edits as corrections
+        edit_window: 300           # seconds after posting an edit may land
 ```
 
 > **Note:** Environment variables override `config.yaml` values.
@@ -158,6 +172,8 @@ gateway:
 | `CHATTO_FREE_RESPONSE_CHANNELS` | No | — | Room IDs that answer without a mention even when `require_mention` is enabled |
 | `CHATTO_AUTO_THREAD` | No | `true` | Auto-create threads for replies in rooms (`true`/`false`) |
 | `CHATTO_REACTIONS` | No | `true` | 👀/✅/❌/🚫 processing reactions (`true`/`false`) |
+| `CHATTO_EDIT_DISPATCH` | No | `true` | Treat edits of chat messages as corrections: a message being processed restarts with the edited text, an ignored message is re-checked against its new text, already-answered messages stay answered (`true`/`false`) |
+| `CHATTO_EDIT_WINDOW` | No | `300` | How long after posting an edit may still land, in seconds; older edits are ignored |
 
 \* Either login+password or `CHATTO_TOKEN` is required.
 
@@ -208,6 +224,8 @@ Disable the lifecycle reactions with `CHATTO_REACTIONS=false`.
 
 The adapter supports editing existing messages (via chattolib's `update_message`) and deleting them (via `delete_message`). Streaming replies are delivered as incremental edits to a single message.
 
+Inbound edits are processed too — see [Editing Your Messages](#editing-your-messages) under Usage Notes.
+
 ### Typing Indicators
 
 While the agent works, the adapter broadcasts a persistent typing indicator per room, refreshing it until the response is sent.
@@ -268,6 +286,19 @@ The list gates inbound replies only. DMs always get a response so `/join` stays
 
 With `CHATTO_REQUIRE_MENTION=true`, Hermes answers channel-kind rooms only when the message contains an `@mention` of the bot's login or display name. In group-kind rooms and DMs every message gets a response. A channel message aimed at someone *else* is acknowledged with a 🫥 reaction instead of an answer.
 
+### Editing Your Messages
+
+Editing a message you sent is treated as a correction, not as new traffic. What an edit does depends on where the original message stands:
+
+| Original message | Effect of the edit |
+|------------------|--------------------|
+| Being processed right now (👀) | The running turn is cancelled (🚫) and restarts immediately with the edited text (👀 again). |
+| Queued behind another turn in the same conversation | The queued message is updated in place to the edited wording — no stale answer later. |
+| Ignored so far (e.g. missing `@mention` in a channel) | The admission gates are re-checked against the new text; if it passes now — mention added, for example — the message is answered for real. |
+| Already answered (✅/❌) | Nothing. Answered messages stay answered, so old conversations cannot be re-animated by editing them. |
+
+Two limits apply: only edits within `CHATTO_EDIT_WINDOW` seconds of posting are processed (default 300), and edits whose text is a membership command (`/join`, `/leave`) never run those commands again. The whole behavior can be turned off with `CHATTO_EDIT_DISPATCH=false`.
+
 ### Allowed Users
 
 By default, if neither `CHATTO_ALLOWED_USERS` nor `CHATTO_ALLOW_ALL_USERS` is set, the bot denies all users as a safety measure. Configure one of:

+ 253 - 25
adapter.py

@@ -48,6 +48,7 @@ from gateway.platforms.base import (
     get_inbound_media_max_bytes,
     validate_inbound_media_size,
 )
+from gateway.session import build_session_key
 
 # Chattolib imports (vendored)
 # Using vendored chattolib from vendor/chattolib/
@@ -72,10 +73,12 @@ try:
         stream_events,
     )
     from chattolib.realtime_types import (
+        MessageEditedPayload,
         MessagePostedPayload,
         ReactionPayload,
     )
     from chattolib.types import (
+        Message,
         PresenceStatus,
         Room,
         RoomKind,
@@ -275,6 +278,15 @@ class ChattoAdapter(BasePlatformAdapter):
         # Event IDs already processed — chattolib may redeliver events across
         # reconnects, so every inbound event is checked against this list.
         self._seen: list[str] = []
+        # Message IDs this adapter has handed to the gateway (posted or
+        # edit-re-dispatched). Edits of anything on this list never start a
+        # fresh turn — that is the lock against re-answering settled
+        # conversations by editing old messages.
+        self._dispatched_ids: list[str] = []
+        # session_key -> message ID currently being processed there. Written
+        # by on_processing_start, cleared by on_processing_complete; an edit
+        # landing on the recorded ID is a mid-run correction.
+        self._processing: dict[str, str] = {}
         self._joined_room_ids: list[str] = []
         # Rooms the server force-joined everyone into (Room.universal) — used
         # only for [universal] tags in the joined-rooms log line, never for
@@ -916,12 +928,195 @@ class ChattoAdapter(BasePlatformAdapter):
         message_body = message.body or ""
         logger.debug("message: %s", message)
 
-        attachments = list(message.attachments or [])
         # A message carrying only an image/PDF has an empty body — dropping it
         # here is what made attachments sent to Hermes disappear silently.
+        attachments = list(message.attachments or [])
         if not message_body and not attachments:
             return
 
+        event = await self._admit_and_build(
+            client,
+            room_id=payload.room_id,
+            message=message,
+            source_message_id=payload.message_event_id,
+            thread_root_event_id=payload.thread_root_event_id or None,
+        )
+        if event is None:
+            return
+
+        self._remember_dispatched(event.message_id or "")
+        logger.info("Chatto: dispatching message to Hermes")
+        await self.handle_message(event)
+        return
+
+    def _remember_dispatched(self, message_id: str) -> None:
+        """Record a message ID as handed to the gateway, capped like _seen."""
+        if not message_id:
+            return
+        self._dispatched_ids.append(message_id)
+        while len(self._dispatched_ids) > ChattoConstants.SEEN_CAP:
+            self._dispatched_ids.remove(self._dispatched_ids[0])
+
+    def _edit_is_fresh(self, message: Message) -> bool:
+        """Whether this edit is young enough to still be processed.
+
+        Age is measured against the posting time, so an edit to an hours-old
+        message cannot resurrect a settled conversation even when it arrives
+        right now.
+        """
+        if message.created_at is None or message.updated_at is None:
+            return True
+        age_seconds = (message.updated_at - message.created_at).total_seconds()
+        return age_seconds <= self.chatto_config.edit_window.value
+
+    def _session_key_for(self, source) -> str:
+        """The gateway's own session key for this source.
+
+        Built with exactly the inputs ``handle_message`` uses, so lookups in
+        ``_processing`` and calls to ``cancel_session_processing`` hit the
+        same session the gateway is running.
+        """
+        return build_session_key(
+            source,
+            group_sessions_per_user=self.config.extra.get(
+                "group_sessions_per_user", True
+            ),
+            thread_sessions_per_user=self.config.extra.get(
+                "thread_sessions_per_user", False
+            ),
+        )
+
+    async def _dispatch_message_edited(self, payload: MessageEditedPayload) -> None:
+        """Route an inbound edit according to the edit-dispatch contract.
+
+        Three outcomes for the edited message:
+
+        - currently being processed → cancel that turn and re-dispatch with
+          the corrected text (the cancelled turn reports 🚫 via its
+          CANCELLED outcome hook),
+        - never dispatched (e.g. a forgotten @mention added later) → re-run
+          the admission gates against the new body and answer for real,
+        - already answered → stay answered.
+
+        Edits whose text parses as a DM membership command are dropped: the
+        command ran when the message was posted and must not run again.
+        """
+        if not self.chatto_config.edit_dispatch.value:
+            return
+        # Read-only memberships cost no API call, mirroring the posted path.
+        if not self._is_respond_room(payload.room_id):
+            logger.debug("Chatto: edit from read-only room %s ignored", payload.room_id)
+            return
+
+        try:
+            client = await self._require_client()
+        except RuntimeError:
+            logger.warning("Chatto: dropping edit - no client available")
+            return
+        logger.debug("Chatto WS: 'message_edited' payload:%s", payload)
+
+        message = await payload.fetch_message(client=client)
+        if message is None or message.deleted_at:
+            return
+        message_body = message.body or ""
+        if not message_body and not message.attachments:
+            return
+        if not self._edit_is_fresh(message):
+            logger.debug(
+                "Chatto: edit of msg %s outside the edit window",
+                payload.message_event_id,
+            )
+            return
+
+        # Cheap triage before the admission pipeline: an edit to an
+        # already-settled message (dispatched, neither running nor queued)
+        # must not cost get_room/media calls or re-fire acknowledgements.
+        was_dispatched = payload.message_event_id in self._dispatched_ids
+        if (
+            was_dispatched
+            and payload.message_event_id not in self._processing.values()
+            and not any(
+                getattr(pending, "message_id", None) == payload.message_event_id
+                for pending in self._pending_messages.values()
+            )
+        ):
+            logger.debug(
+                "Chatto: edit of already-answered msg %s ignored",
+                payload.message_event_id,
+            )
+            return
+
+        event = await self._admit_and_build(
+            client,
+            room_id=payload.room_id,
+            message=message,
+            source_message_id=payload.message_event_id,
+            thread_root_event_id=message.thread_root_event_id or None,
+            allow_dm_commands=False,
+        )
+        if event is None:
+            return
+
+        session_key = self._session_key_for(event.source)
+        if self._processing.get(session_key) == payload.message_event_id:
+            logger.info(
+                "Chatto: msg %s edited mid-run - restarting the turn",
+                payload.message_event_id,
+            )
+            # The cancelled task's completion hook clears _processing and
+            # reports 🚫 before this coroutine moves on, because cancel
+            # awaits the task. Queued follow-ups must survive.
+            await self.cancel_session_processing(
+                session_key, release_guard=True, discard_pending=False
+            )
+        elif payload.message_event_id not in self._dispatched_ids:
+            logger.info(
+                "Chatto: msg %s was never dispatched - edit starts a fresh turn",
+                payload.message_event_id,
+            )
+        else:
+            pending = self._pending_messages.get(session_key)
+            if pending is not None and pending.message_id == payload.message_event_id:
+                # Still queued behind the running turn: correct it in place
+                # instead of answering stale wording later.
+                pending.text = event.text
+                logger.info(
+                    "Chatto: queued msg %s updated to its edited text",
+                    payload.message_event_id,
+                )
+                return
+            logger.debug(
+                "Chatto: edit of already-answered msg %s ignored",
+                payload.message_event_id,
+            )
+            return
+
+        self._remember_dispatched(event.message_id or "")
+        logger.info("Chatto: dispatching edited message to Hermes")
+        await self.handle_message(event)
+
+    async def _admit_and_build(
+        self,
+        client: ChattoClient,
+        *,
+        room_id: str,
+        message: Message,
+        source_message_id: str,
+        thread_root_event_id: str | None,
+        allow_dm_commands: bool = True,
+    ) -> MessageEvent | None:
+        """Run one hydrated inbound message through the admission pipeline.
+
+        Shared by the posted and the edited path: user resolution, auth,
+        mention gates, thread anchoring and media caching all behave
+        identically for both. Returns ``None`` for anything that must not
+        reach the agent. DM membership commands are executed here (side
+        effect) unless ``allow_dm_commands`` is False — edits pass False so
+        a corrected command line neither runs twice nor leaks to the agent.
+
+        The caller owns dispatching: a non-None result still needs
+        ``handle_message()``.
+        """
         if message.actor_id in self._user_cache:
             # try the user cache.
             user = self._user_cache.get(message.actor_id)
@@ -929,41 +1124,44 @@ class ChattoAdapter(BasePlatformAdapter):
             # get the user and update cache.
             directory_member = await client.get_user(user_id=message.actor_id)
             if directory_member is None:
-                return
+                return None
             user = directory_member.user
             if user is None:
-                return
+                return None
             self._user_cache[user.id] = user
 
         if user is None:
-            return
+            return None
 
         if not self._check_auth(user):
-            return
+            return None
 
         # Todo: use a function that either reads from cache or gets room kind again.
         if self._room_kinds.get(message.room_id) is None:
             room_viewer_state = await client.get_room(message.room_id)
             if room_viewer_state is None:
-                return
+                return None
             if room_viewer_state.room is None:
-                return
+                return None
             self._room_kinds[message.room_id] = (
                 room_viewer_state.room.kind or RoomKind.UNSPECIFIED
             )
 
         room_kind = self._room_kinds.get(message.room_id)
+        message_body = message.body or ""
 
         logger.debug("message_body: %s room_kind: %s", message_body, room_kind)
 
         # Membership commands ride in over DMs only: they change what the bot
         # listens to and must never reach the agent pipeline or the mention
         # gates.
-        if room_kind == RoomKind.DM and await self._handle_dm_command(
-            message.room_id,
-            message_body,
-        ):
-            return
+        if room_kind == RoomKind.DM:
+            if allow_dm_commands:
+                if await self._handle_dm_command(room_id, message_body):
+                    return None
+            elif message_body.startswith("/"):
+                logger.debug("Chatto: edited DM command %r not re-run", message_body)
+                return None
 
         # require_mention deliberately gates channels only: in a channel the bot
         # is one of many listeners and must be addressed, whereas a DM is already
@@ -983,7 +1181,7 @@ class ChattoAdapter(BasePlatformAdapter):
                     "Discarding message. Bot was not mentionend but require_mention is '%s'.",
                     self.chatto_config.require_mention.value,
                 )
-                return
+                return None
 
         logger.debug("mentioned: %s", mentioned)
 
@@ -1001,25 +1199,25 @@ class ChattoAdapter(BasePlatformAdapter):
             logger.info("Chatto: message addresses someone else, acknowledging only")
             if self.chatto_config.reactions.value:
                 await self.add_reaction(message.room_id, message.id, "🫥")
-            return
+            return None
 
         # Thread anchoring — if the incoming message is inside a Chatto thread, we
         # keep that thread by default; otherwise leave thread_id unset so
         # replies land at the root.
         thread_id = (
-            payload.thread_root_event_id or None
-        )  # we could also take "payload.room_id" but then, we're in a thread already.
+            thread_root_event_id or None
+        )  # we could also take the room id but then, we're in a thread already.
         if not thread_id and room_kind != RoomKind.DM:
             thread_id = message.id
 
         source = self.build_source(
-            chat_id=payload.room_id,
+            chat_id=room_id,
             chat_name=self._room_names.get(message.room_id),
             chat_type=chat_type_for_room_kind(room_kind),
             user_id=message.actor_id,
             user_name=user.login,  # use login, because display_name is changeable by anyone.
             thread_id=thread_id,
-            message_id=payload.message_event_id,
+            message_id=source_message_id,
             role_authorized=True,
         )
 
@@ -1037,12 +1235,13 @@ class ChattoAdapter(BasePlatformAdapter):
 
         # Attachments — download and hand the local cache paths to the gateway,
         # which runs vision enrichment / document extraction off media_urls.
+        attachments = list(message.attachments or [])
         (
             message_event.media_urls,
             message_event.media_types,
             media_kinds,
         ) = await self._cache_attachments(
-            payload.room_id,
+            room_id,
             attachments,
         )
         if media_kinds:
@@ -1054,9 +1253,7 @@ class ChattoAdapter(BasePlatformAdapter):
             message_event.message_type = MessageType.TEXT
 
         logger.debug("Chatto: MessageEvent: %s", message_event)
-        logger.info("Chatto: dispatching message to Hermes")
-        await self.handle_message(message_event)
-        return
+        return message_event
 
     async def _forward_reaction(
         self,
@@ -1121,6 +1318,18 @@ class ChattoAdapter(BasePlatformAdapter):
 
             await self._dispatch_message_posted(event_payload)
 
+        elif (edited_payload := event.get("message_edited")) is not None:
+            # Same self-event filter: our own streaming edits echo back here.
+            # Redeliveries of edits we made are also caught by _mark_seen in
+            # edit_message().
+            actor_id = event.actor_id
+            if actor_id and self.me and actor_id == self.me.id:
+                return
+
+            await self._dispatch_message_edited(
+                cast(MessageEditedPayload, edited_payload)
+            )
+
         elif event.kind in ("reaction_added", "reaction_removed"):
             reaction_payload = event.get(event.kind)
             if reaction_payload is not None:
@@ -1140,7 +1349,6 @@ class ChattoAdapter(BasePlatformAdapter):
             "user_typing",
             "notification_created",
             "new_direct_message_notification",
-            "message_edited",
             "message_retracted",
         ):
             logger.debug(
@@ -1875,10 +2083,20 @@ class ChattoAdapter(BasePlatformAdapter):
         return chat_id, message_id
 
     async def on_processing_start(self, event: MessageEvent) -> None:
-        """Add an 👀 (eyes) reaction to the incoming message.
+        """Record the turn as open, then add an 👀 (eyes) reaction.
+
+        The record is what lets an edit of this very message be recognized as
+        a mid-run correction. It must be maintained even when reactions are
+        disabled — tracking and decorating are independent concerns.
 
         BasePlatformAdapter override
         """
+        session_key = (
+            self._session_key_for(event.source) if event.source is not None else ""
+        )
+        if session_key and event.message_id:
+            self._processing[session_key] = str(event.message_id)
+
         if not self.chatto_config.reactions.value:
             return
 
@@ -1900,10 +2118,20 @@ class ChattoAdapter(BasePlatformAdapter):
         event: MessageEvent,
         outcome: ProcessingOutcome,
     ) -> None:
-        """Swap the 👀 reaction for ✅ (success) or ❌ (failure).
+        """Close the turn's record, then swap 👀 for ✅/❌/🚫.
+
+        Fires for every outcome, including CANCELLED (mid-run edit
+        correction) — this is what re-arms `_processing` before the corrected
+        turn is dispatched.
 
         BasePlatformAdapter override
         """
+        session_key = (
+            self._session_key_for(event.source) if event.source is not None else ""
+        )
+        if session_key and event.message_id:
+            self._processing.pop(session_key, None)
+
         if not self.chatto_config.reactions.value:
             return
         chat_id, message_id = self._event_room_and_message_id(event)

+ 5 - 0
after-install.md

@@ -67,3 +67,8 @@ can live in either file. Environment variables take precedence over
    Send any message in a room the bot has joined. By default it answers every
    room message; only with `CHATTO_REQUIRE_MENTION=true` do channel-kind rooms
    wait for an @mention (group rooms and DMs answer regardless).
+
+   Editing works as a correction: fix a typo in your sent message within
+   5 minutes and Hermes restarts his answer with the corrected text. If he had
+   ignored the message because an `@mention` was missing, editing the mention
+   in makes him answer after all.

+ 38 - 0
platform_config.py

@@ -185,6 +185,32 @@ def _get_env_or_extra_list(env_var: str, extra_val: list[str] | None) -> list[st
     return []
 
 
+def _get_env_or_extra_int(env_var: str, extra_val: Any, default: int) -> int:
+    """Get an integer value from environment variable or extra config.
+
+    Unparseable values fall back to the default with a warning rather than
+    raising — a typo in an env var must not keep the plugin from starting.
+    """
+    raw = os.getenv(env_var)
+    if raw is None or not raw.strip():
+        raw = extra_val
+    if isinstance(raw, bool):  # a YAML true/false is not a number
+        raw = None
+    elif isinstance(raw, (int, float)):
+        return int(raw)
+    elif isinstance(raw, str) and raw.strip():
+        try:
+            return int(raw.strip())
+        except ValueError:
+            logger.warning(
+                "Chatto: %s=%r is not an integer, using default %d",
+                env_var,
+                raw,
+                default,
+            )
+    return default
+
+
 T = TypeVar("T")
 
 
@@ -281,6 +307,8 @@ class ConfigField(Generic[T]):
             value: Any = _get_env_or_extra_list(self.env_name, raw)
         elif self.kind == "bool":
             value = _get_env_or_extra_truthy(self.env_name, raw, bool(self.default))
+        elif self.kind == "int":
+            value = _get_env_or_extra_int(self.env_name, raw, int(self.default))
         elif self.kind == "str_opt":
             value = _get_env_or_extra_str_opt(self.env_name, raw, self.default)
         else:
@@ -319,6 +347,16 @@ class ChattoConfiguration:
     auto_thread = ConfigField("bool", default=True)
     allow_all_users = ConfigField("bool", default=False)
     reactions = ConfigField("bool", default=True)
+    # Inbound edits: a message_edited event re-runs the admission gates against
+    # the new body. A message currently being processed is cancelled and
+    # re-dispatched with the corrected text; a message that never passed the
+    # gates (e.g. a forgotten @mention) gets a fresh turn; an already-answered
+    # message stays answered.
+    edit_dispatch = ConfigField("bool", default=True)
+    # How long after posting an edit may still land, in seconds — edits to
+    # hours-old messages must not resurrect old conversations. Parsed by
+    # ConfigField's "int" kind; unparseable input falls back to the default.
+    edit_window = ConfigField("int", default=300)
 
     def __init__(self, pconfig: PlatformConfig):
         """Resolve every declared ConfigField against env vars and

+ 8 - 0
plugin.yaml

@@ -61,4 +61,12 @@ optional_env:
   - name: CHATTO_REACTIONS
     description: "Add 👀/✅/❌/🚫 reactions to messages during processing (default: true)"
     prompt: "Enable message reactions? (true/false)"
+    password: false
+  - name: CHATTO_EDIT_DISPATCH
+    description: "Treat edits of chat messages as corrections: a message being processed is restarted with the edited text, a message the bot ignored (e.g. missing @mention) is re-checked against its new text. Already-answered messages stay answered, and edits older than CHATTO_EDIT_WINDOW are ignored. (default: true)"
+    prompt: "Process message edits as corrections? (true/false)"
+    password: false
+  - name: CHATTO_EDIT_WINDOW
+    description: "How long after posting an edit may still land, in seconds; older edits are ignored (default: 300)"
+    prompt: "Edit window in seconds (or empty for 300)"
     password: false

+ 255 - 2
test_adapter.py

@@ -16,6 +16,7 @@ All network calls are mocked — no real HTTP or WebSocket connections.
 import asyncio
 import os
 import sys
+from datetime import UTC, datetime, timedelta
 from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
@@ -57,6 +58,7 @@ from gateway.platforms.base import (
     BasePlatformAdapter,
     CachedMedia,
     MessageType,
+    ProcessingOutcome,
     SendResult,
     get_inbound_media_max_bytes,
 )
@@ -178,15 +180,23 @@ def _make_attachment(filename, content_type, url="https://cdn.example.com/a"):
     )
 
 
-def _make_message(body="hi", attachments=None, message_id="msg-1", room_id="room-1"):
+def _make_message(
+    body="hi",
+    attachments=None,
+    message_id="msg-1",
+    room_id="room-1",
+    created_at=None,
+    updated_at=None,
+):
     """Build a real chattolib Message, as fetch_message() would return."""
     return Message(
         id=message_id,
         room_id=room_id,
-        created_at=None,
+        created_at=created_at,
         actor_id="user-1",
         body=body,
         attachments=list(attachments or []),
+        updated_at=updated_at,
     )
 
 
@@ -1113,6 +1123,249 @@ class TestRequireMention:
         adapter.handle_message.assert_called_once()
 
 
+# -- Inbound edits (edit-dispatch) --
+
+
+def _make_edited_event(room_id="room-1", message_event_id="msg-1", actor_id="human-1"):
+    """A message_edited envelope whose payload the caller stubs."""
+    event = MagicMock()
+    event.id = "evt-edit-1"
+    event.kind = "message_edited"
+    event.actor_id = actor_id
+    payload = MagicMock()
+    payload.room_id = room_id
+    payload.message_event_id = message_event_id
+    # RealtimeEvent.get() only yields the payload for its own kind.
+    event.get = MagicMock(
+        side_effect=lambda kind: payload if kind == "message_edited" else None
+    )
+    return event, payload
+
+
+class TestEditDispatch:
+    """message_edited events become corrections, late mentions or nothing."""
+
+    def _adapter(self):
+        adapter = _make_adapter()
+        adapter.chatto_config.allow_all_users.value = True
+        adapter.me = _make_user("bot-user-id", "hermes_bot")
+        adapter._user_cache["user-1"] = _make_user("user-1", "alice")
+        adapter.handle_message = AsyncMock()
+        return adapter
+
+    async def _edit(
+        self,
+        adapter,
+        body,
+        *,
+        message_id="msg-1",
+        room_id="room-1",
+        created_at=None,
+        updated_at=None,
+    ):
+        event, payload = _make_edited_event(
+            room_id=room_id, message_event_id=message_id
+        )
+        payload.fetch_message = AsyncMock(
+            return_value=_make_message(
+                body=body,
+                message_id=message_id,
+                room_id=room_id,
+                created_at=created_at,
+                updated_at=updated_at,
+            )
+        )
+        await adapter._handle_realtime_event(event)
+        return payload
+
+    async def _post_original(self, adapter, body, *, message_id, room_id):
+        """Dispatch the original posted message so its turn exists."""
+        payload = _make_posted_payload(room_id=room_id, message_event_id=message_id)
+        payload.fetch_message = AsyncMock(
+            return_value=_make_message(
+                body=body, message_id=message_id, room_id=room_id
+            )
+        )
+        await adapter._dispatch_message_posted(payload)
+
+    def test_edit_dispatch_defaults_on_with_five_minute_window(self):
+        adapter = self._adapter()
+        assert adapter.chatto_config.edit_dispatch.value is True
+        assert adapter.chatto_config.edit_window.value == 300
+
+    async def test_own_edit_echo_is_ignored(self):
+        """Our streaming edits echo back as message_edited — never inbound traffic."""
+        adapter = self._adapter()
+        event, payload = _make_edited_event(actor_id="bot-user-id")
+        payload.fetch_message = AsyncMock()
+
+        await adapter._handle_realtime_event(event)
+
+        payload.fetch_message.assert_not_awaited()
+        adapter.handle_message.assert_not_called()
+
+    async def test_disabled_feature_drops_edits(self):
+        adapter = self._adapter()
+        adapter.chatto_config.edit_dispatch.value = False
+
+        await self._edit(adapter, "corrected")
+
+        adapter.handle_message.assert_not_called()
+
+    async def test_read_only_room_costs_no_api_call(self):
+        """Respond-room gating sits before fetch_message, like for posts."""
+        adapter = self._adapter()
+        adapter.chatto_config.respond_rooms.value = ["other-room"]
+
+        fetch = await self._edit(adapter, "corrected")
+        fetch.fetch_message.assert_not_awaited()  # type: ignore[union-attr]
+        adapter.handle_message.assert_not_called()
+
+    async def test_mention_added_by_edit_starts_a_turn(self):
+        adapter = self._adapter()
+        adapter._room_kinds["room-1"] = RoomKind.CHANNEL
+        adapter.chatto_config.require_mention.value = True
+
+        await self._edit(adapter, "@hermes_bot corrected text")
+
+        adapter.handle_message.assert_called_once()
+        assert (
+            adapter.handle_message.call_args.args[0].text
+            == "@hermes_bot corrected text"
+        )
+        assert "msg-1" in adapter._dispatched_ids
+
+    async def test_edit_without_the_mention_stays_silent(self):
+        """Gates run against the new body — no mention in, no answer out."""
+        adapter = self._adapter()
+        adapter._room_kinds["room-1"] = RoomKind.CHANNEL
+        adapter.chatto_config.require_mention.value = True
+
+        await self._edit(adapter, "still no mention")
+
+        adapter.handle_message.assert_not_called()
+
+    async def test_already_answered_message_stays_answered(self):
+        """The retro-lock: editing an old answered message must not re-animate it."""
+        adapter = self._adapter()
+        adapter._dispatched_ids.append("msg-1")
+
+        await self._edit(adapter, "late fix")
+
+        adapter.handle_message.assert_not_called()
+
+    async def test_deleted_message_is_ignored(self):
+        adapter = self._adapter()
+        now = datetime.now(UTC)
+        event, payload = _make_edited_event()
+        payload.fetch_message = AsyncMock(
+            return_value=_make_message(body="gone", created_at=now, updated_at=now)
+        )
+        payload.fetch_message.return_value.deleted_at = now
+
+        await adapter._handle_realtime_event(event)
+
+        adapter.handle_message.assert_not_called()
+
+    async def test_stale_edit_outside_the_window_is_ignored(self):
+        adapter = self._adapter()
+        now = datetime.now(UTC)
+
+        await self._edit(
+            adapter,
+            "very late fix",
+            created_at=now,
+            updated_at=now + timedelta(seconds=400),
+        )
+
+        adapter.handle_message.assert_not_called()
+
+    async def test_fresh_edit_within_the_window_passes_gates(self):
+        adapter = self._adapter()
+        adapter._room_kinds["dm-room"] = RoomKind.DM
+        now = datetime.now(UTC)
+
+        await self._edit(
+            adapter,
+            "typo fixed",
+            room_id="dm-room",
+            created_at=now,
+            updated_at=now + timedelta(seconds=5),
+        )
+
+        adapter.handle_message.assert_called_once()
+
+    async def test_midrun_edit_restarts_the_turn(self):
+        adapter = self._adapter()
+        adapter._room_kinds["dm-room"] = RoomKind.DM
+        await self._post_original(
+            adapter, "orignal text", message_id="msg-run", room_id="dm-room"
+        )
+        first_event = adapter.handle_message.call_args.args[0]
+        await adapter.on_processing_start(first_event)
+        adapter.cancel_session_processing = AsyncMock()
+
+        await self._edit(
+            adapter, "original text", message_id="msg-run", room_id="dm-room"
+        )
+
+        adapter.cancel_session_processing.assert_awaited_once()
+        assert adapter.handle_message.await_count == 2
+        assert adapter.handle_message.await_args.args[0].text == "original text"
+
+    async def test_completion_hook_rearms_the_processing_map(self):
+        """After any outcome (incl. CANCELLED) the session accepts new turns."""
+        adapter = self._adapter()
+        adapter._room_kinds["dm-room"] = RoomKind.DM
+        await self._post_original(
+            adapter, "hello", message_id="msg-run", room_id="dm-room"
+        )
+        event = adapter.handle_message.call_args.args[0]
+
+        await adapter.on_processing_start(event)
+        session_key = adapter._session_key_for(event.source)
+        assert adapter._processing.get(session_key) == "msg-run"
+
+        await adapter.on_processing_complete(event, ProcessingOutcome.CANCELLED)
+        assert session_key not in adapter._processing
+
+    async def test_queued_followup_gets_corrected_in_place(self):
+        """A still-queued message answers later with its edited wording."""
+        from types import SimpleNamespace
+
+        adapter = self._adapter()
+        adapter._room_kinds["dm-room"] = RoomKind.DM
+        await self._post_original(
+            adapter, "queue me", message_id="msg-other", room_id="dm-room"
+        )
+        other_event = adapter.handle_message.call_args.args[0]
+        session_key = adapter._session_key_for(other_event.source)
+        pending = SimpleNamespace(message_id="msg-run", text="stale wording")
+        adapter._pending_messages[session_key] = pending
+        adapter._dispatched_ids.extend(["msg-other", "msg-run"])
+        adapter.cancel_session_processing = AsyncMock()
+
+        await self._edit(
+            adapter, "fixed wording", message_id="msg-run", room_id="dm-room"
+        )
+
+        assert pending.text == "fixed wording"
+        adapter.cancel_session_processing.assert_not_awaited()
+        # No second dispatch: the queued original carries the correction.
+        assert adapter.handle_message.await_count == 1
+
+    async def test_dm_command_edit_neither_reruns_nor_dispatches(self):
+        """/join ran when posted; its edited copy must not run twice or leak."""
+        adapter = self._adapter()
+        adapter._room_kinds["dm-room"] = RoomKind.DM
+        adapter._handle_dm_command = AsyncMock(return_value=True)
+
+        await self._edit(adapter, "/leave", room_id="dm-room")
+
+        adapter._handle_dm_command.assert_not_called()
+        adapter.handle_message.assert_not_called()
+
+
 # -- Inbound attachments --
 
 

+ 27 - 0
test_platform_config.py

@@ -7,6 +7,7 @@ sys.path.insert(0, os.environ.get("HERMES_ROOT", "/opt/hermes"))
 sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
 
 from platform_config import (
+    _get_env_or_extra_int,
     _get_env_or_extra_list,
     _get_env_or_extra_str,
     _get_env_or_extra_str_opt,
@@ -94,3 +95,29 @@ class TestPlatformConfigHelpers:
     def test_get_env_or_extra_list_returns_empty_for_none(self, monkeypatch):
         monkeypatch.delenv("CHATTO_TEST", raising=False)
         assert _get_env_or_extra_list("CHATTO_TEST", None) == []
+
+    def test_get_env_or_extra_int_prefers_env(self, monkeypatch):
+        monkeypatch.setenv("CHATTO_TEST", " 45 ")
+        assert _get_env_or_extra_int("CHATTO_TEST", 300, default=300) == 45
+
+    def test_get_env_or_extra_int_uses_extra_when_no_env(self, monkeypatch):
+        monkeypatch.delenv("CHATTO_TEST", raising=False)
+        assert _get_env_or_extra_int("CHATTO_TEST", 60, default=300) == 60
+
+    def test_get_env_or_extra_int_parses_extra_string(self, monkeypatch):
+        """config.yaml values arrive as strings when set via env-style files."""
+        monkeypatch.delenv("CHATTO_TEST", raising=False)
+        assert _get_env_or_extra_int("CHATTO_TEST", "90", default=300) == 90
+
+    def test_get_env_or_extra_int_falls_back_to_default_for_junk(self, monkeypatch):
+        monkeypatch.setenv("CHATTO_TEST", "soon-ish")
+        assert _get_env_or_extra_int("CHATTO_TEST", None, default=300) == 300
+
+    def test_get_env_or_extra_int_treats_yaml_bool_as_missing(self, monkeypatch):
+        """A YAML true/false is not a number — silently take the default."""
+        monkeypatch.delenv("CHATTO_TEST", raising=False)
+        assert _get_env_or_extra_int("CHATTO_TEST", True, default=300) == 300
+
+    def test_get_env_or_extra_int_defaults_when_both_missing(self, monkeypatch):
+        monkeypatch.delenv("CHATTO_TEST", raising=False)
+        assert _get_env_or_extra_int("CHATTO_TEST", None, default=300) == 300