Parcourir la source

Merge branch 'review-baseadapter-overrides' of paul/hermes-chatto-plugin into main

Paul Klumpp il y a 1 semaine
Parent
commit
a9c081477d
100 fichiers modifiés avec 1638 ajouts et 408 suppressions
  1. 4 0
      .gitignore
  2. 125 0
      PLAN.md
  3. 100 64
      VENDORING.md
  4. 4 1
      __init__.py
  5. 655 126
      adapter.py
  6. 151 98
      platform_config.py
  7. 10 0
      pyproject.toml
  8. 468 114
      test_adapter.py
  9. 14 3
      test_platform_config.py
  10. 105 0
      uv.lock
  11. 0 0
      vendor/common/anyio-4.14.2.dist-info/INSTALLER
  12. 0 0
      vendor/common/anyio-4.14.2.dist-info/METADATA
  13. 0 0
      vendor/common/anyio-4.14.2.dist-info/RECORD
  14. 0 0
      vendor/common/anyio-4.14.2.dist-info/REQUESTED
  15. 0 0
      vendor/common/anyio-4.14.2.dist-info/WHEEL
  16. 0 0
      vendor/common/anyio-4.14.2.dist-info/entry_points.txt
  17. 0 0
      vendor/common/anyio-4.14.2.dist-info/licenses/LICENSE
  18. 0 0
      vendor/common/anyio-4.14.2.dist-info/scm_file_list.json
  19. 0 0
      vendor/common/anyio-4.14.2.dist-info/scm_version.json
  20. 0 0
      vendor/common/anyio-4.14.2.dist-info/top_level.txt
  21. 0 0
      vendor/common/anyio/__init__.py
  22. 0 0
      vendor/common/anyio/_backends/__init__.py
  23. 0 0
      vendor/common/anyio/_backends/_asyncio.py
  24. 0 0
      vendor/common/anyio/_backends/_trio.py
  25. 0 0
      vendor/common/anyio/_core/__init__.py
  26. 0 0
      vendor/common/anyio/_core/_asyncio_selector_thread.py
  27. 0 0
      vendor/common/anyio/_core/_contextmanagers.py
  28. 0 0
      vendor/common/anyio/_core/_eventloop.py
  29. 0 0
      vendor/common/anyio/_core/_exceptions.py
  30. 0 0
      vendor/common/anyio/_core/_fileio.py
  31. 0 0
      vendor/common/anyio/_core/_resources.py
  32. 0 0
      vendor/common/anyio/_core/_signals.py
  33. 0 0
      vendor/common/anyio/_core/_sockets.py
  34. 0 0
      vendor/common/anyio/_core/_streams.py
  35. 0 0
      vendor/common/anyio/_core/_subprocesses.py
  36. 0 0
      vendor/common/anyio/_core/_synchronization.py
  37. 0 0
      vendor/common/anyio/_core/_tasks.py
  38. 0 0
      vendor/common/anyio/_core/_tempfile.py
  39. 0 0
      vendor/common/anyio/_core/_testing.py
  40. 0 0
      vendor/common/anyio/_core/_typedattr.py
  41. 0 0
      vendor/common/anyio/abc/__init__.py
  42. 0 0
      vendor/common/anyio/abc/_eventloop.py
  43. 0 0
      vendor/common/anyio/abc/_resources.py
  44. 0 0
      vendor/common/anyio/abc/_sockets.py
  45. 0 0
      vendor/common/anyio/abc/_streams.py
  46. 0 0
      vendor/common/anyio/abc/_subprocesses.py
  47. 0 0
      vendor/common/anyio/abc/_tasks.py
  48. 0 0
      vendor/common/anyio/abc/_testing.py
  49. 0 0
      vendor/common/anyio/from_thread.py
  50. 0 0
      vendor/common/anyio/functools.py
  51. 0 0
      vendor/common/anyio/itertools.py
  52. 0 0
      vendor/common/anyio/lowlevel.py
  53. 0 0
      vendor/common/anyio/py.typed
  54. 0 0
      vendor/common/anyio/pytest_plugin.py
  55. 0 0
      vendor/common/anyio/streams/__init__.py
  56. 0 0
      vendor/common/anyio/streams/buffered.py
  57. 0 0
      vendor/common/anyio/streams/file.py
  58. 0 0
      vendor/common/anyio/streams/memory.py
  59. 0 0
      vendor/common/anyio/streams/stapled.py
  60. 0 0
      vendor/common/anyio/streams/text.py
  61. 0 0
      vendor/common/anyio/streams/tls.py
  62. 0 0
      vendor/common/anyio/to_interpreter.py
  63. 0 0
      vendor/common/anyio/to_process.py
  64. 0 0
      vendor/common/anyio/to_thread.py
  65. 1 1
      vendor/common/bin/httpx
  66. 1 1
      vendor/common/bin/idna
  67. 0 0
      vendor/common/certifi-2026.7.22.dist-info/INSTALLER
  68. 0 0
      vendor/common/certifi-2026.7.22.dist-info/METADATA
  69. 0 0
      vendor/common/certifi-2026.7.22.dist-info/RECORD
  70. 0 0
      vendor/common/certifi-2026.7.22.dist-info/REQUESTED
  71. 0 0
      vendor/common/certifi-2026.7.22.dist-info/WHEEL
  72. 0 0
      vendor/common/certifi-2026.7.22.dist-info/licenses/LICENSE
  73. 0 0
      vendor/common/certifi-2026.7.22.dist-info/top_level.txt
  74. 0 0
      vendor/common/certifi/__init__.py
  75. 0 0
      vendor/common/certifi/__main__.py
  76. 0 0
      vendor/common/certifi/cacert.pem
  77. 0 0
      vendor/common/certifi/core.py
  78. 0 0
      vendor/common/certifi/py.typed
  79. 0 0
      vendor/common/certifi/tests/__init__.py
  80. 0 0
      vendor/common/certifi/tests/test_certify.py
  81. 0 0
      vendor/common/chattolib-0.4.20.post1.dist-info/INSTALLER
  82. 0 0
      vendor/common/chattolib-0.4.20.post1.dist-info/METADATA
  83. 0 0
      vendor/common/chattolib-0.4.20.post1.dist-info/RECORD
  84. 0 0
      vendor/common/chattolib-0.4.20.post1.dist-info/REQUESTED
  85. 0 0
      vendor/common/chattolib-0.4.20.post1.dist-info/WHEEL
  86. 0 0
      vendor/common/chattolib-0.4.20.post1.dist-info/licenses/LICENSE
  87. 0 0
      vendor/common/chattolib-0.4.20.post1.dist-info/licenses/LICENSES/Apache-2.0.txt
  88. 0 0
      vendor/common/chattolib-0.4.20.post1.dist-info/licenses/LICENSES/MPL-2.0.txt
  89. 0 0
      vendor/common/chattolib-0.4.20.post1.dist-info/top_level.txt
  90. 0 0
      vendor/common/chattolib/__init__.py
  91. 0 0
      vendor/common/chattolib/_pb/__init__.py
  92. 0 0
      vendor/common/chattolib/_pb/buf/__init__.py
  93. 0 0
      vendor/common/chattolib/_pb/buf/validate/__init__.py
  94. 0 0
      vendor/common/chattolib/_pb/buf/validate/validate_pb2.py
  95. 0 0
      vendor/common/chattolib/_pb/chatto/__init__.py
  96. 0 0
      vendor/common/chattolib/_pb/chatto/admin/__init__.py
  97. 0 0
      vendor/common/chattolib/_pb/chatto/admin/v1/__init__.py
  98. 0 0
      vendor/common/chattolib/_pb/chatto/admin/v1/diagnostics_connect.py
  99. 0 0
      vendor/common/chattolib/_pb/chatto/admin/v1/diagnostics_pb2.py
  100. 0 0
      vendor/common/chattolib/_pb/chatto/admin/v1/event_log_connect.py

+ 4 - 0
.gitignore

@@ -11,6 +11,10 @@ chattolib_vendor/
 __pycache__/
 *.py[cod]
 *$py.class
+.venv/
+myvenv/
+*.egg-info/
+.pytest_cache/
 
 # OS
 .DS_Store

+ 125 - 0
PLAN.md

@@ -0,0 +1,125 @@
+# Plan: BasePlatformAdapter-Overrides nachziehen (Punkte 1–8)
+
+Referenz: `gateway/platforms/base.py` aus dem Hermes-Checkout (HEAD `7b25941`,
+identisch mit github.com/nousresearch/hermes-agent `main`).
+
+Jeder Punkt: was die Basis anbietet, was chattolib kann, was implementiert wird.
+
+---
+
+## 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.
+
+## 4. `create_handoff_thread` — base.py:4008, Default `None`
+
+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)
+
+`reaction_added`/`reaction_removed` werden in `_handle_realtime_event` heute
+explizit verworfen. `ReactionPayload` (`room_id`, `message_event_id`, `emoji`)
+ist fertig dekodiert.
+
+- 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.
+
+## 8. `format_message` — base.py:7235, Default = Identität
+
+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.
+
+---
+
+## Tests
+
+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).
+
+## 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).

+ 100 - 64
VENDORING.md

@@ -1,91 +1,127 @@
 # Vendoring chattolib
 
-This plugin includes a **vendored copy of chattolib** in the `chattolib_vendor/` directory.
-This means you don't need to install chattolib separately — it's bundled with the plugin.
+This plugin ships a **vendored copy of chattolib and all its dependencies** in
+`vendor/`. Nothing has to be installed separately — the plugin brings its own
+dependency tree.
 
 ## Why Vendoring?
 
 - **No dependencies to install**: `hermes plugin install` works immediately
-- **No lazy-import complexity**: No need for `ensure()` or `lazy_import` mechanisms
-- **Isolated**: The vendored chattolib won't conflict with any system-installed version
-- **Offline-friendly**: Works in air-gapped environments
+- **No lazy-import complexity**: no `ensure()` or `lazy_import` mechanisms
+- **Isolated**: the vendored copy cannot conflict with a system-installed one
+- **Offline-friendly**: works in air-gapped environments
 
-## Updating the Vendored chattolib
+## Layout
 
-To update to a new version of chattolib:
+Three of the vendored packages contain compiled extension modules and are
+therefore platform-specific: `pyqwest` (~15 MB, the HTTP core used by
+`connectrpc`), `protobuf` and `protobuf-py-ext`. Everything else is pure
+Python. The tree is split accordingly, so the shared part is stored once:
 
-```bash
-# Run the vendoring script
-./vendor_chattolib.sh [version]
+```
+vendor/
+├── __init__.py
+├── common/                    # pure-Python packages (~8 MB), all platforms
+│   ├── chattolib/
+│   ├── connectrpc/
+│   ├── httpx/ httpcore/ h11/ anyio/ idna/ certifi/
+│   └── opentelemetry/ protobuf/ typing_extensions.py
+└── platform/                  # compiled extensions, ~16 MB per platform
+    ├── linux-x86_64/          #   pyqwest/, protobuf_ext/, google/
+    ├── linux-aarch64/
+    └── macos-arm64/
+```
 
-# Example: update to 0.5.0
-./vendor_chattolib.sh 0.5.0
+`vendor_path.py` derives the platform tag from `platform.system()` /
+`platform.machine()` at import time and puts both directories on `sys.path`.
+Both `adapter.py` and `platform_config.py` call `setup_vendor_path()` before
+importing anything from `chattolib`.
 
-# Or use default (latest known stable)
-./vendor_chattolib.sh
-```
+### sys.path order — and why it differs per directory
 
-The script will:
-1. Download the chattolib wheel from PyPI
-2. Extract it to `chattolib_vendor/`
-3. Also download the `websockets` dependency (needed for realtime features)
+| Directory | Position | Why |
+| --- | --- | --- |
+| `platform/<tag>` | **prepended** | The compiled extensions must match the vendored chattolib. A host copy of `protobuf` at a different version would break the generated `_pb` code. |
+| `common` | **appended** | The Hermes agent ships most of this itself — `httpx` (same pin, 0.28.1), `certifi`, `anyio`, `httpcore`, `h11`, `idna`, `typing_extensions`. A plugin must not shadow the host's pinned versions; Hermes pins `certifi` exactly, and prepending would substitute our CA bundle process-wide. |
 
-## Development without Vendoring
+So the host's pure-Python packages win, and our copies act as a fallback —
+which keeps out-of-process use (the cron sender, `hermes_standalone_sender_fn`)
+working in an environment without Hermes.
 
-If you're developing and want to use your system-installed chattolib instead:
+The packages Hermes does *not* provide — `chattolib`, `connectrpc`, `pyqwest`,
+`protobuf`, `protobuf-py`, `protobuf-py-ext` — always come from `vendor/`. They
+are also the bulk of it: `pyqwest` alone is ~15 MB per platform, so dropping
+the packages Hermes already ships would save only ~2.7 MB of ~59 MB and buy a
+dependency on Hermes' exact pins. Not worth it.
+
+chattolib requires `httpx>=0.27` and `protobuf>=5.28`; connectrpc requires
+`protobuf-py==0.1.1` and `pyqwest>=0.5.1`. If a future Hermes release pins
+`httpx` below 0.27, the appended fallback no longer helps — the host copy would
+be found first and be too old.
+
+If the current platform was not vendored, the import fails with an explicit
+message naming the available platforms — rather than a cryptic `dlopen` error
+about an invalid Mach-O/ELF file.
+
+## Updating or rebuilding
 
 ```bash
-pip install chattolib[realtime]
-```
+# Default platform set: linux-x86_64, linux-aarch64, macos-arm64
+./vendor_chattolib.sh
 
-The adapter will automatically fall back to the system-installed version if the vendored copy is not found.
+# A single platform
+PLATFORMS="linux-x86_64" ./vendor_chattolib.sh
 
-## Structure
+# Add another one (see the table below for valid tags)
+PLATFORMS="linux-x86_64 linux-aarch64 macos-arm64 windows-amd64" ./vendor_chattolib.sh
 
-```
-chattolib_vendor/
-├── __init__.py           # Marker file
-├── chattolib/           # The chattolib package
-│   ├── __init__.py
-│   ├── src/
-│   │   └── chattolib/
-│   │       ├── _pb/        # Protobuf generated files
-│   │       ├── _transport.py
-│   │       ├── client.py
-│   │       ├── realtime.py
-│   │       ├── types.py
-│   │       └── ...
-│   └── ...
-└── websockets/          # Dependency for realtime features
-    └── ...
+# Pin a specific chattolib version
+CHATTOLIB_SPEC="chattolib==0.4.20" ./vendor_chattolib.sh
 ```
 
-## License
+The script wipes `vendor/`, installs the dependency tree once per platform via
+`uv pip install --python-platform …`, splits the result with `vendor_split.py`,
+and verifies that `import chattolib` works on the machine you ran it on.
+Afterwards: `git add vendor/`.
+
+### Supported platform tags
+
+| Tag                  | uv `--python-platform`         | vendored by default |
+| -------------------- | ------------------------------ | ------------------- |
+| `linux-x86_64`       | `x86_64-unknown-linux-gnu`     | ✅                  |
+| `linux-aarch64`      | `aarch64-unknown-linux-gnu`    | ✅                  |
+| `macos-arm64`        | `aarch64-apple-darwin`         | ✅                  |
+| `linux-x86_64-musl`  | `x86_64-unknown-linux-musl`    | —                   |
+| `linux-aarch64-musl` | `aarch64-unknown-linux-musl`   | —                   |
+| `macos-x86_64`       | `x86_64-apple-darwin`          | —                   |
+| `windows-amd64`      | `x86_64-pc-windows-msvc`       | —                   |
+
+All seven are available as binary wheels upstream; only the default three are
+committed, to keep the repository at a reasonable size (~59 MB for `vendor/`).
 
-The vendored chattolib is licensed under **MPL-2.0 + Apache-2.0** (see `chattolib/LICENSE`).
-The original source is available at: https://github.com/chattocorp/chatto
+### Python versions
 
-## Manual Vendoring (Alternative)
+The script resolves wheels for **Python 3.11** (`PYTHON_VERSION`), which is the
+project minimum. At that version the three binary packages still provide
+`cp310-abi3` wheels, and the stable ABI is forward compatible — so one build
+covers 3.11 through 3.14. Resolving for 3.12+ would instead pull
+version-specific wheels (`cp312-…`) that only work on that exact version.
 
-If the script doesn't work for your environment:
+**If you raise `PYTHON_VERSION`, check that the resulting `.so` files are still
+named `*.abi3.so`.** If they are not, the vendored tree silently becomes
+Python-version-specific.
+
+## Development without vendoring
 
 ```bash
-# Create directory
-mkdir -p chattolib_vendor
-
-# Download and extract chattolib
-pip download chattolib==0.4.19 --no-deps -d /tmp
-cd /tmp
-unzip chattolib-*.whl -d chattolib_extracted
-cp -r chattolib_extracted/chattolib* ../chattolib_vendor/
-
-# Download websockets
-pip download websockets -d /tmp
-cd /tmp
-unzip websockets-*.whl -d websockets_extracted
-mkdir -p ../chattolib_vendor/websockets
-cp -r websockets_extracted/websockets* ../chattolib_vendor/websockets/
-
-# Add __init__.py
-echo "# Vendored chattolib" > chattolib_vendor/__init__.py
+pip install chattolib
 ```
+
+A system-installed chattolib is only used if `vendor/` is absent — the vendored
+copy is deliberately prepended to `sys.path` and therefore wins.
+
+## License
+
+The vendored chattolib is licensed under **MPL-2.0 + Apache-2.0** (see
+`vendor/common/chattolib-*.dist-info/`). Upstream source:
+https://github.com/chattocorp/chatto

+ 4 - 1
__init__.py

@@ -1,4 +1,7 @@
-from .adapter import register
+try:
+    from .adapter import register
+except ImportError:  # pragma: no cover - loaded outside a package (tests)
+    from adapter import register
 
 __all__ = ["register"]
 

Fichier diff supprimé car celui-ci est trop grand
+ 655 - 126
adapter.py


+ 151 - 98
platform_config.py

@@ -4,27 +4,29 @@ Chatto Platform Config
 
 """
 
-import sys
 import os
-from pathlib import Path
 
-# 1. Den absoluten Pfad zum 'vendor'-Ordner in diesem Plugin ermitteln
-current_dir = Path(__file__).parent
-vendor_dir = current_dir / "vendor"
+# Put the vendored dependencies for THIS platform on sys.path before importing
+# anything from chattolib. Imported relatively as part of the plugin package and
+# absolutely when this module is loaded standalone (e.g. by the tests).
+try:
+    from .vendor_path import setup_vendor_path
+except ImportError:  # pragma: no cover - depends on how the module is loaded
+    from vendor_path import setup_vendor_path
 
-# 2. Den vendor-Ordner an den Anfang des Suchpfads (sys.path) setzen
-if str(vendor_dir) not in sys.path:
-    sys.path.insert(0, str(vendor_dir))
+setup_vendor_path()
 
-from dataclasses import dataclass
 import logging
-import os
-from typing import Any, Dict, Optional
+from typing import Any, Dict, Generic, Optional, TypeVar
 
 from gateway.config import PlatformConfig
 import utils
 
-from .vendor.chattolib.client import ChattoClient
+# Absolute import — the vendor dir is on sys.path (see above) and chattolib's
+# own modules import each other absolutely ("from chattolib.x import y").
+# Importing it relatively as well would load a *second* copy of every module
+# under a different name, so isinstance() checks across the two would fail.
+from chattolib.client import ChattoClient
 
 logger = logging.getLogger(__name__)
 
@@ -45,15 +47,8 @@ class ChattoConstants:
 
     INSTALL_HINT = "Requires a Chatto server. See https://docs.chatto.run"
 
-    EXTRA_ENV_MAPPING = {
-        "base_url": "CHATTO_BASE_URL",
-        "home_channel": "CHATTO_HOME_CHANNEL",
-        "require_mention": "CHATTO_REQUIRE_MENTION",
-        "free_response_channels": "CHATTO_FREE_RESPONSE_CHANNELS",
-        "auto_thread": "CHATTO_AUTO_THREAD",
-        "allow_all_users": "CHATTO_ALLOW_ALL_USERS",
-        "allowed_users": "CHATTO_ALLOWED_USERS",
-    }
+    # NOTE: env var names live on the ConfigFields below (ConfigField.env_name),
+    # so there is exactly one source of truth for them.
 
     MAX_MESSAGE_LENGTH = 10000
     SEEN_CAP = 500
@@ -130,9 +125,9 @@ def _get_env_or_extra_truthy(env_var: str, extra_val: Optional[str | bool], defa
     return utils.is_truthy_value(_get_env_or_extra_str(env_var, extra_val, str(default)), default)
 
 
-def _split_str_to_list(mystring: str) -> list:
-    logger.info("mystring: %s", mystring)
-    return list(c for c in mystring.split(","))
+def _split_str_to_list(mystring: str) -> list[str]:
+    """Split a comma-separated string, trimming whitespace and dropping empties."""
+    return [part.strip() for part in mystring.split(",") if part.strip()]
 
 
 def _get_env_or_extra_list(env_var: str, extra_val: Optional[list[str]]) -> list[str]:
@@ -142,106 +137,164 @@ def _get_env_or_extra_list(env_var: str, extra_val: Optional[list[str]]) -> list
         return _split_str_to_list(env_value)
 
     if extra_val is not None:
-        #logger.info("extra_val is1: " + str(type(extra_val)))
-        #logger.info("extra_val is2: " + str(extra_val))
         if isinstance(extra_val, list):
-            return list(
-                c for c in extra_val
-            )
+            return [str(c).strip() for c in extra_val if str(c).strip()]
         if isinstance(extra_val, str):
             return _split_str_to_list(extra_val)
 
     return []
 
-from typing import TypeVar, Generic, Any
 
-# 1. Define a generic Type Variable
-T = TypeVar('T')
+T = TypeVar("T")
 
-# 2. Inherit from Generic[T]
-class ConfigField(Generic[T]):
-    """Repräsentiert ein einzelnes Konfigurationsfeld mit IDE-Support."""
-# 3. Type 'value' as T (or T | None since it starts as None)
-    value: T
-    field_name: str = ""
-    env_name: str = ""
 
-    # 4. Hint that the init argument should match type T
-    # We add `| Any` as a fallback because complex types like `list[str]` 
-    # can sometimes confuse older type checkers when used with `type[T]`
-    def __init__(self, type_: type[T] | Any):
-        self._type = type_
+class ConfigValue(Generic[T]):
+    """The resolved value of a single config field, bound to one configuration
+    instance.
+
+    Access the payload via ``.value``.  ``__bool__``/``__eq__`` delegate to it,
+    so a forgotten ``.value`` (``if config.allow_all_users:``) still evaluates
+    the actual setting instead of the always-truthy wrapper object.
+    """
+
+    __slots__ = ("value", "field_name", "env_name")
 
-    # 5. Ensure the IDE knows only type T can be assigned
-    def __set__(self, instance: Any, value: T) -> None:
+    def __init__(self, value: T, field_name: str, env_name: str) -> None:
         self.value = value
-        self._type = type(value)
-        logger.info("Chatto: configuration field '%s' set to '%s'", self.field_name, str(value))
+        self.field_name = field_name
+        self.env_name = env_name
+
+    def __bool__(self) -> bool:
+        return bool(self.value)
+
+    def __eq__(self, other: Any) -> bool:
+        if isinstance(other, ConfigValue):
+            return self.value == other.value
+        return self.value == other
+
+    def __hash__(self) -> int:
+        return hash(self.value)
+
+    def __contains__(self, item: Any) -> bool:
+        return item in self.value  # type: ignore[operator]
+
+    def __iter__(self):
+        return iter(self.value)  # type: ignore[call-overload]
 
     def __str__(self) -> str:
         return str(self.value)
 
-    def __set_name__(self, owner: Any, name: str) -> None:
-        temp: str = str(name).lower().replace("chatto_", "", 1)
-        self.field_name = temp
-        self.env_name = f"CHATTO_{temp.upper()}"
+    def __repr__(self) -> str:
+        return f"{self.field_name}={self.value!r}"
 
 
-@dataclass
-class ChattoConfiguration:
-    """
-    Chatto Platform Config
+class ConfigField(Generic[T]):
+    """Declarative descriptor for one config field.
 
-    Some constants and getting from environment/config.yaml
+    Declares *how* a field is read (kind, env var name, default); the resolved
+    payload lives per configuration instance in ``instance._values``, never on
+    the descriptor itself.  Reading a field on the class (rather than on an
+    instance) yields the descriptor, so ``ChattoConfiguration.token.env_name``
+    keeps working for the registration hooks.
     """
-    base_url = ConfigField(str)
-    token = ConfigField(str | None)
-    login = ConfigField(str)
-    password = ConfigField(str)
-    channels = ConfigField(str)
-    channels_list = ConfigField(list[str])
-    home_channel = ConfigField(str)
-    allowed_users = ConfigField(list[str])
-    require_mention = ConfigField(bool)
-    free_response_channels_list = ConfigField(list[str])
-    auto_thread = ConfigField(bool)
-    allow_all_users = ConfigField(bool)
-    reactions = ConfigField(bool)
 
-    def __init__(self, pconfig: PlatformConfig):
-        """PlatformConfig from Hermes provides our own configuration within the "extra" 
-        object. But here, we allow overriding via environment variables again.
+    field_name: str = ""
+    env_name: str = ""
 
-        We take our Configuration from this typed Class, because it is easier access than using extra.get["base_url"].
-        """
-        self.base_url.value = _get_env_or_extra_str(self.base_url.env_name,
-            pconfig.extra.get(self.base_url.field_name), ChattoClient.DEFAULT_BASE_URL)
+    def __init__(
+        self,
+        kind: str,
+        *,
+        default: Any = None,
+        config_key: Optional[str] = None,
+        doc: str = "",
+    ) -> None:
+        # kind: "str" | "str_opt" | "bool" | "list"
+        self.kind = kind
+        self.default = default
+        # config_key: the name used in config.yaml's "extra" block and (upper-cased,
+        # CHATTO_-prefixed) as the env var, where it differs from the attribute name.
+        self._config_key = config_key
+        self.__doc__ = doc
 
-        self.token.value = _get_env_or_extra_str_opt(self.token.env_name, pconfig.extra.get(self.token.field_name))
-        self.login.value = _get_env_or_extra_str(self.login.env_name, pconfig.extra.get(self.login.field_name))
-        self.password.value = _get_env_or_extra_str(self.password.env_name, pconfig.extra.get(self.password.field_name))
-        
-        self.channels.value = str(_get_env_or_extra_list(self.channels.env_name, pconfig.extra.get(self.channels.field_name)))
-        self.channels_list.value = _get_env_or_extra_list(self.channels.env_name, pconfig.extra.get(self.channels.field_name))
+    def __set_name__(self, owner: Any, name: str) -> None:
+        self.field_name = str(name).lower().replace("chatto_", "", 1)
+        self.config_key = self._config_key or self.field_name
+        self.env_name = f"CHATTO_{self.config_key.upper()}"
+
+    def __get__(self, instance: Any, owner: Any = None) -> "ConfigValue[T]":
+        if instance is None:
+            return self  # type: ignore[return-value]
+        return instance._values[self.field_name]
+
+    def __set__(self, instance: Any, value: Any) -> None:
+        raise AttributeError(
+            f"Chatto: config field '{self.field_name}' is read-only; "
+            f"set '{self.field_name}.value' if you really need to override it."
+        )
+
+    def resolve(self, extra: Dict[str, Any]) -> ConfigValue:
+        """Read this field from the environment, then from ``extra``, then the default."""
+        raw = extra.get(self.config_key)
+
+        if self.kind == "list":
+            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 == "str_opt":
+            value = _get_env_or_extra_str_opt(self.env_name, raw, self.default)
+        else:
+            value = _get_env_or_extra_str(self.env_name, raw, self.default)
 
-        self.home_channel.value = _get_env_or_extra_str(self.home_channel.env_name, pconfig.extra.get(self.home_channel.field_name))
+        return ConfigValue(value, self.field_name, self.env_name)
 
-        self.allowed_users.value = _get_env_or_extra_list(self.allowed_users.env_name, pconfig.extra.get(self.allowed_users.field_name))
-        logger.info("self.allowed_users.value: %s", self.allowed_users.value)
 
-        self.require_mention.value = _get_env_or_extra_truthy(self.require_mention.env_name, pconfig.extra.get(self.require_mention.field_name), False)
-        logger.info("self.require_mention.value: %s", self.require_mention.value)
+class ChattoConfiguration:
+    """Chatto platform configuration.
 
-        # free_response_channels: room IDs where the bot responds without being mentioned via "@botname" when require_mention is true.
-        self.free_response_channels_list.value = _get_env_or_extra_list(self.free_response_channels_list.env_name,
-                                                                   pconfig.extra.get(self.free_response_channels_list.field_name))
+    Every field is resolved once per instance, in this order:
+    environment variable → ``PlatformConfig.extra`` → declared default.
+    """
+
+    base_url = ConfigField("str", default=ChattoClient.DEFAULT_BASE_URL)
+    token = ConfigField("str_opt")
+    login = ConfigField("str")
+    password = ConfigField("str")
+    channels_list = ConfigField("list", config_key="channels")
+    home_channel = ConfigField("str")
+    allowed_users = ConfigField("list")
+    require_mention = ConfigField("bool", default=False)
+    # free_response_channels: room IDs where the bot responds without being
+    # mentioned via "@botname" even when require_mention is true.
+    free_response_channels_list = ConfigField("list", config_key="free_response_channels")
+    # Auto-thread: by default, Chatto creates a thread for replies to room
+    # messages (not DMs, not already in a thread). This keeps conversations
+    # organized in the room. Can be disabled via extra.auto_thread=false.
+    auto_thread = ConfigField("bool", default=True)
+    allow_all_users = ConfigField("bool", default=False)
+    reactions = ConfigField("bool", default=True)
 
-        # Auto-thread: by default, Chatto creates a thread for replies to room
-        # messages (not DMs, not already in a thread). This keeps conversations
-        # organized in the room. Can be disabled via extra.auto_thread=false.
-        self.auto_thread.value = _get_env_or_extra_truthy(self.auto_thread.env_name, pconfig.extra.get(self.auto_thread.field_name), True)
+    def __init__(self, pconfig: PlatformConfig):
+        """Resolve every declared ConfigField against env vars and
+        ``PlatformConfig.extra`` (which Hermes pre-populates from config.yaml).
+        """
+        extra: Dict[str, Any] = getattr(pconfig, "extra", None) or {}
 
-        self.allow_all_users.value = _get_env_or_extra_truthy(self.allow_all_users.env_name, pconfig.extra.get(self.allow_all_users.field_name))
-        self.reactions.value = _get_env_or_extra_truthy(self.reactions.env_name, pconfig.extra.get(self.reactions.field_name), True)
+        self._values: Dict[str, ConfigValue] = {
+            field.field_name: field.resolve(extra) for field in self.fields()
+        }
 
-        logger.info("ChattoConfiguration: %s", self)
+        logger.debug("ChattoConfiguration: %s", self)
+
+    @classmethod
+    def fields(cls) -> list[ConfigField]:
+        """All declared config fields, in declaration order."""
+        return [v for v in vars(cls).values() if isinstance(v, ConfigField)]
+
+    def __str__(self) -> str:
+        redacted = {"token", "password"}
+        parts = [
+            f"{name}={'***' if name in redacted and cv.value else cv.value!r}"
+            for name, cv in self._values.items()
+        ]
+        return "ChattoConfiguration(" + ", ".join(parts) + ")"

+ 10 - 0
pyproject.toml

@@ -24,3 +24,13 @@ dev = [
 
 [tool.setuptools.packages.find]
 where = ["."]
+
+[tool.pytest.ini_options]
+# importlib mode: the plugin root is itself a package (__init__.py), so pytest's
+# default "prepend" mode tries to import the test modules as part of it, which
+# fails because the checkout directory name is not a valid Python identifier.
+addopts = "--import-mode=importlib"
+asyncio_mode = "auto"
+# Only our own tests — vendor/ ships the upstream test suites of some packages.
+testpaths = ["test_adapter.py", "test_platform_config.py"]
+norecursedirs = ["vendor", "myvenv", ".venv"]

+ 468 - 114
test_adapter.py

@@ -25,9 +25,11 @@ import pytest_asyncio
 # Import chattolib types for tests - using vendored chattolib from adapter
 
 # -- Path setup --
+# The Hermes agent itself is not a dependency of this plugin; point HERMES_ROOT
+# at a checkout to run these tests outside a deployed agent.
 PLUGIN_ROOT = os.path.abspath(os.path.dirname(__file__))
 sys.path.insert(0, PLUGIN_ROOT)
-sys.path.insert(0, "/opt/hermes")
+sys.path.insert(0, os.environ.get("HERMES_ROOT", "/opt/hermes"))
 sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
 
 from adapter import (
@@ -36,9 +38,24 @@ from adapter import (
     hermes_validate_config as validate_config,
     register,
 )
+from chattolib.realtime_types import ReactionPayload
+from chattolib.types import (
+    AssetUrl,
+    Message,
+    MessageAttachment,
+    Room,
+    RoomKind,
+    User,
+)
 from platform_config import ChattoConstants
 from gateway.config import PlatformConfig
-from gateway.platforms.base import SendResult, MessageEvent, MessageType
+from gateway.platforms.base import (
+    CachedMedia,
+    MessageEvent,
+    MessageType,
+    SendResult,
+    get_inbound_media_max_bytes,
+)
 
 _EMOJI_TO_SHORTCODE = ChattoConstants.EMOJI_TO_SHORTCODE
 _MAX_MESSAGE_LENGTH = ChattoConstants.MAX_MESSAGE_LENGTH
@@ -72,9 +89,9 @@ class _MockPluginContext:
 
 
 def _ensure_chatto_registered():
-    """Register chatto in the platform registry so Platform('chatto') works."""
+    """Register the platform so Platform(PLATFORM_NAME) resolves."""
     from gateway.platform_registry import platform_registry
-    if not platform_registry.is_registered("chatto"):
+    if not platform_registry.is_registered(ChattoConstants.PLATFORM_NAME):
         ctx = _MockPluginContext()
         register(ctx)
 
@@ -105,6 +122,53 @@ def _make_config(**extra_overrides):
     return PlatformConfig(enabled=True, extra=extra)
 
 
+def _make_room(room_id, name, kind):
+    """Build a real chattolib Room, as the client would return."""
+    return Room(id=room_id, name=name, kind=kind, description="",
+                archived=False, group_id="", universal=kind != RoomKind.DM)
+
+
+def _make_user(user_id, login):
+    """Build a real chattolib User, as the member directory would return."""
+    return User(id=user_id, login=login, display_name=login.replace("_", " ").title())
+
+
+def _make_attachment(filename, content_type, url="https://cdn.example.com/a"):
+    """Build a real MessageAttachment carrying a (pre-signed) asset URL."""
+    return MessageAttachment(
+        id="asset-" + filename,
+        filename=filename,
+        content_type=content_type,
+        asset_url=AssetUrl(url=url),
+    )
+
+
+def _make_message(body="hi", attachments=None, message_id="msg-1", room_id="room-1"):
+    """Build a real chattolib Message, as fetch_message() would return."""
+    return Message(
+        id=message_id,
+        room_id=room_id,
+        created_at=None,
+        actor_id="user-1",
+        body=body,
+        attachments=list(attachments or []),
+    )
+
+
+def _make_posted_payload(room_id="room-1", message_event_id="msg-1"):
+    """A message_posted payload whose fetch_message() the caller stubs."""
+    payload = MagicMock()
+    payload.room_id = room_id
+    payload.message_event_id = message_event_id
+    payload.thread_root_event_id = None
+    return payload
+
+
+def _cached(path, media_type, kind):
+    """The CachedMedia that cache_media_bytes() would return for an attachment."""
+    return CachedMedia(path=path, media_type=media_type, kind=kind, display_name="f")
+
+
 def _make_adapter(**extra_overrides):
     """Create a ChattoAdapter with mocked config."""
     _clear_chatto_env()
@@ -145,22 +209,30 @@ class TestAdapterInstantiation:
         cfg = _make_config()
         adapter = ChattoAdapter(cfg)
         assert adapter is not None
-        assert adapter.platform.name == "chatto"
+        # Platform members created dynamically from a plugin name carry the
+        # name upper-cased; the registered identity is the value.
+        assert adapter.platform.value == ChattoConstants.PLATFORM_NAME
 
     def test_adapter_max_message_length(self):
+        """The framework chunks via max_message_length_for_chat(), which reads
+        the adapter-scalar MAX_MESSAGE_LENGTH and silently falls back to 4096
+        when it is missing."""
         cfg = _make_config()
         adapter = ChattoAdapter(cfg)
         assert adapter.MAX_MESSAGE_LENGTH == _MAX_MESSAGE_LENGTH
+        assert adapter.max_message_length_for_chat("room-1") == _MAX_MESSAGE_LENGTH
 
     def test_adapter_splits_long_messages(self):
         cfg = _make_config()
         adapter = ChattoAdapter(cfg)
         assert adapter.splits_long_messages is True
 
-    def test_adapter_supports_threads(self):
+    def test_adapter_threads_enabled_by_default(self):
+        """There is no capability flag for threads — Chatto threading is driven
+        by the auto_thread setting, which defaults to on."""
         cfg = _make_config()
         adapter = ChattoAdapter(cfg)
-        assert adapter.supports_threads() is True
+        assert adapter.chatto_config.auto_thread.value is True
 
 
 # -- Registration and requirements --
@@ -171,9 +243,10 @@ class TestRegistration:
     def test_register_called(self):
         ctx = _MockPluginContext()
         register(ctx)
-        assert "chatto" in ctx.registered_names
-        assert ctx.registered_kwargs["name"] == "chatto"
-        assert ctx.registered_kwargs["label"] == "Chatto"
+        assert ChattoConstants.PLATFORM_NAME in ctx.registered_names
+        assert ctx.registered_kwargs["name"] == ChattoConstants.PLATFORM_NAME
+        assert ctx.registered_kwargs["label"] == ChattoConstants.PLATFORM_LABEL
+        assert ctx.registered_kwargs["max_message_length"] == _MAX_MESSAGE_LENGTH
 
     def test_check_requirements(self):
         assert check_requirements() is True
@@ -299,113 +372,405 @@ class TestMessageEditing:
 
     async def test_edit_message(self, adapter):
         result = await adapter.edit_message("room-1", "msg-1", "New content")
-        assert result is True
+        assert result.success is True
         adapter._chatto_client.update_message.assert_called_once()
+        call_kwargs = adapter._chatto_client.update_message.call_args.kwargs
+        assert call_kwargs["room_id"] == "room-1"
+        assert call_kwargs["event_id"] == "msg-1"
+        assert call_kwargs["body"] == "New content"
+
+    async def test_edit_message_marks_own_edit_seen(self, adapter):
+        """The edit echoes back as message_edited — it must not look inbound."""
+        mock_msg = MagicMock()
+        mock_msg.id = "msg-1"
+        adapter._chatto_client.update_message.return_value = mock_msg
+        await adapter.edit_message("room-1", "msg-1", "New content")
+        assert adapter._is_seen("msg-1") is True
+
+    async def test_edit_message_too_long_refuses(self, adapter):
+        """Overlong content must fall back to send() (which splits), not be
+        silently truncated into a lossy edit."""
+        result = await adapter.edit_message(
+            "room-1", "msg-1", "x" * (_MAX_MESSAGE_LENGTH + 1),
+        )
+        assert result.success is False
+        adapter._chatto_client.update_message.assert_not_called()
+
+    async def test_edit_message_empty_content(self, adapter):
+        result = await adapter.edit_message("room-1", "msg-1", "")
+        assert result.success is False
+        adapter._chatto_client.update_message.assert_not_called()
+
+    async def test_edit_message_error_is_retryable(self, adapter):
+        from chattolib.exceptions import ChattoError
+
+        adapter._chatto_client.update_message.side_effect = ChattoError("boom")
+        result = await adapter.edit_message("room-1", "msg-1", "New content")
+        assert result.success is False
+        assert result.retryable is True
 
     async def test_delete_message(self, adapter):
         result = await adapter.delete_message("room-1", "msg-1")
         assert result is True
         adapter._chatto_client.delete_message.assert_called_once()
+        call_kwargs = adapter._chatto_client.delete_message.call_args.kwargs
+        assert call_kwargs["room_id"] == "room-1"
+        assert call_kwargs["event_id"] == "msg-1"
+
+    async def test_delete_message_missing_ids(self, adapter):
+        assert await adapter.delete_message("", "msg-1") is False
+        assert await adapter.delete_message("room-1", "") is False
+        adapter._chatto_client.delete_message.assert_not_called()
+
+    async def test_delete_message_error_returns_false(self, adapter):
+        from chattolib.exceptions import ChattoError
+
+        adapter._chatto_client.delete_message.side_effect = ChattoError("nope")
+        assert await adapter.delete_message("room-1", "msg-1") is False
 
 
-# -- User lookup --
+# -- Outgoing text formatting --
 
-class TestUserLookup:
-    """Test user lookup functionality."""
+class TestFormatMessage:
+    """format_message() only fixes what renders wrong in Chatto."""
+
+    def test_normalises_crlf(self):
+        adapter = _make_adapter()
+        assert adapter.format_message("a\r\nb\rc") == "a\nb\nc"
+
+    def test_collapses_excess_blank_lines(self):
+        adapter = _make_adapter()
+        assert adapter.format_message("a\n\n\n\n\n\nb") == "a\n\n\nb"
+
+    def test_leaves_markdown_untouched(self):
+        adapter = _make_adapter()
+        text = "**bold** `code`\n\n```py\nx = 1\n```\n- item"
+        assert adapter.format_message(text) == text
+
+    def test_empty_content(self):
+        adapter = _make_adapter()
+        assert adapter.format_message("") == ""
+
+
+# -- Handoff threads --
+
+class TestHandoffThread:
+    """create_handoff_thread() anchors a handoff on a seed message."""
 
     @pytest_asyncio.fixture
     def adapter(self):
-        _clear_chatto_env()
-        cfg = _make_config()
-        adapter = ChattoAdapter(cfg)
-        adapter._chatto_client = MagicMock()
-        adapter._token = "test-token"
-        adapter._user_cache = {}
+        adapter = _make_adapter()
+        adapter._chatto_client.post_message = AsyncMock()
+        adapter._chatto_client.follow_thread = AsyncMock()
         return adapter
 
-    async def test_get_user_calls_chattolib(self, adapter):
-        # Import chattolib types for testing (try vendored first)
-        try:
-            from chattolib_vendor.chattolib.types import User, GetUserResponse
-        except ImportError:
-            try:
-                from chattolib.types import User, GetUserResponse
-            except ImportError:
-                # Fallback to mocked types if chattolib not installed
-                from unittest.mock import MagicMock
-                User = MagicMock
-                GetUserResponse = MagicMock
-        mock_user = User(id="user-1", login="testuser", display_name="Test User")
-        adapter._chatto_client.get_user.return_value = GetUserResponse(user=mock_user)
-        result = await adapter.get_user("user-1")
-        assert result is not None
-        assert result["id"] == "user-1"
-        assert result["login"] == "testuser"
-
-    async def test_get_user_caching(self, adapter):
-        # Import chattolib types for testing (try vendored first)
-        try:
-            from chattolib_vendor.chattolib.types import User, GetUserResponse
-        except ImportError:
-            try:
-                from chattolib.types import User, GetUserResponse
-            except ImportError:
-                # Fallback to mocked types if chattolib not installed
-                from unittest.mock import MagicMock
-                User = MagicMock
-                GetUserResponse = MagicMock
-        mock_user = User(id="user-1", login="testuser", display_name="Test User")
-        adapter._chatto_client.get_user.return_value = GetUserResponse(user=mock_user)
-        result1 = await adapter.get_user("user-1")
-        result2 = await adapter.get_user("user-1")
-        assert result1 == result2
-        assert adapter._chatto_client.get_user.call_count == 1
-
-
-# -- Presence and Custom Status --
-
-class TestPresence:
-    """Test presence functionality."""
+    async def test_returns_seed_message_id(self, adapter):
+        mock_msg = MagicMock()
+        mock_msg.id = "seed-1"
+        adapter._chatto_client.post_message.return_value = mock_msg
+        adapter._room_kinds["room-1"] = RoomKind.CHANNEL
+
+        result = await adapter.create_handoff_thread("room-1", "Refactor run")
+
+        assert result == "seed-1"
+        assert adapter._chatto_client.post_message.call_args.kwargs["room_id"] == "room-1"
+        adapter._chatto_client.follow_thread.assert_called_once_with("room-1", "seed-1")
+        # Our own seed must not come back in as inbound traffic.
+        assert adapter._is_seen("seed-1") is True
+
+    async def test_dm_has_no_threads(self, adapter):
+        adapter._room_kinds["dm-1"] = RoomKind.DM
+        assert await adapter.create_handoff_thread("dm-1", "x") is None
+        adapter._chatto_client.post_message.assert_not_called()
+
+    async def test_seed_post_failure(self, adapter):
+        adapter._chatto_client.post_message.side_effect = RuntimeError("down")
+        adapter._room_kinds["room-1"] = RoomKind.CHANNEL
+        assert await adapter.create_handoff_thread("room-1", "x") is None
+
+
+# -- Native file / video / audio delivery --
+
+class TestNativeSends:
+    """send_document/_video/_voice upload instead of apologising in text."""
 
     @pytest_asyncio.fixture
     def adapter(self):
-        _clear_chatto_env()
-        cfg = _make_config()
-        adapter = ChattoAdapter(cfg)
-        adapter._chatto_client = MagicMock()
-        adapter._chatto_client.update_presence = AsyncMock()
-        adapter._token = "test-token"
+        adapter = _make_adapter()
+        adapter._chatto_client.post_message = AsyncMock()
+        adapter._upload_asset = AsyncMock(return_value="asset-1")
+        adapter.validate_media_delivery_path = MagicMock(side_effect=lambda p: p)
+        mock_msg = MagicMock()
+        mock_msg.id = "msg-1"
+        adapter._chatto_client.post_message.return_value = mock_msg
         return adapter
 
-    async def test_set_presence(self, adapter):
-        result = await adapter.set_presence("online")
-        assert result is True
-        adapter._chatto_client.update_presence.assert_called_once()
+    @pytest.mark.parametrize(
+        "method,arg_name",
+        [
+            ("send_document", "file_path"),
+            ("send_video", "video_path"),
+            ("send_voice", "audio_path"),
+            ("send_image_file", "image_path"),
+        ],
+    )
+    async def test_uploads_and_attaches(self, adapter, method, arg_name):
+        result = await getattr(adapter, method)(
+            "room-1", **{arg_name: "/tmp/thing.bin"}, caption="here you go",
+        )
+        assert result.success is True
+        adapter._upload_asset.assert_called_once_with("room-1", "/tmp/thing.bin")
+        call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
+        assert call_kwargs["attachment_asset_ids"] == ["asset-1"]
+        assert call_kwargs["body"] == "here you go"
 
+    async def test_unsafe_path_falls_back_to_notice(self, adapter):
+        adapter.validate_media_delivery_path = MagicMock(return_value=None)
+        adapter.send = AsyncMock(return_value=SendResult(success=True))
+        await adapter.send_document("room-1", "/etc/shadow")
+        adapter._upload_asset.assert_not_called()
+        # Never echo the host path into chat.
+        sent_text = adapter.send.call_args.args[1]
+        assert "/etc/shadow" not in sent_text
 
-class TestCustomStatus:
-    """Test custom status functionality."""
+    async def test_upload_failure_falls_back_to_notice(self, adapter):
+        adapter._upload_asset = AsyncMock(return_value=None)
+        adapter.send = AsyncMock(return_value=SendResult(success=True))
+        await adapter.send_video("room-1", "/tmp/clip.mp4", caption="a clip")
+        sent_text = adapter.send.call_args.args[1]
+        assert sent_text.startswith("a clip\n")
+        assert "/tmp/clip.mp4" not in sent_text
+
+
+# -- Batched image delivery --
+
+class TestSendMultipleImages:
+    """A batch of images belongs in ONE Chatto message."""
 
     @pytest_asyncio.fixture
     def adapter(self):
-        _clear_chatto_env()
-        cfg = _make_config()
-        adapter = ChattoAdapter(cfg)
-        adapter._chatto_client = MagicMock()
-        adapter._chatto_client.update_custom_status = AsyncMock()
-        adapter._chatto_client.delete_custom_status = AsyncMock()
-        adapter._token = "test-token"
+        adapter = _make_adapter()
+        adapter._chatto_client.post_message = AsyncMock()
+        mock_msg = MagicMock()
+        mock_msg.id = "msg-1"
+        adapter._chatto_client.post_message.return_value = mock_msg
+        adapter._upload_asset = AsyncMock(side_effect=["asset-1", "asset-2"])
+        adapter.validate_media_delivery_path = MagicMock(side_effect=lambda p: p)
         return adapter
 
-    async def test_set_custom_status(self, adapter):
-        result = await adapter.set_custom_status("Processing...")
-        assert result is True
-        adapter._chatto_client.update_custom_status.assert_called_once()
+    async def test_bundles_into_single_message(self, adapter):
+        await adapter.send_multiple_images(
+            "room-1", [("/tmp/a.png", "first"), ("/tmp/b.png", "second")],
+        )
+        adapter._chatto_client.post_message.assert_called_once()
+        call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
+        assert call_kwargs["attachment_asset_ids"] == ["asset-1", "asset-2"]
+        assert call_kwargs["body"] == "first\nsecond"
+
+    async def test_single_image_uses_base_path(self, adapter):
+        """One image is not a batch — leave it to the base implementation.
+
+        Also pins the send_image_file signature: the base class calls it with
+        ``image_path=`` as a keyword, so a renamed parameter degrades every
+        native image send to a text notice.
+        """
+        adapter.send_image_file = AsyncMock(return_value=SendResult(success=True))
+        await adapter.send_multiple_images("room-1", [("file:///tmp/a.png", "only")])
+        adapter._upload_asset.assert_not_called()
+        adapter.send_image_file.assert_called_once()
+        assert adapter.send_image_file.call_args.kwargs["image_path"] == "/tmp/a.png"
+
+    async def test_partial_upload_failure_still_sends_the_rest(self, adapter):
+        adapter._upload_asset = AsyncMock(side_effect=["asset-1", None])
+        await adapter.send_multiple_images(
+            "room-1", [("/tmp/a.png", "first"), ("/tmp/b.png", "second")],
+        )
+        call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
+        assert call_kwargs["attachment_asset_ids"] == ["asset-1"]
+
+    async def test_file_uri_is_unquoted(self, adapter):
+        await adapter.send_multiple_images(
+            "room-1",
+            [("file:///tmp/a%20b.png", ""), ("/tmp/c.png", "")],
+        )
+        first_path = adapter._upload_asset.call_args_list[0].args[1]
+        assert first_path == "/tmp/a b.png"
 
-    async def test_clear_custom_status(self, adapter):
-        result = await adapter.clear_custom_status()
-        assert result is True
-        adapter._chatto_client.delete_custom_status.assert_called_once()
+
+# -- Reaction event forwarding --
+
+class TestReactionForwarding:
+    """Human reactions reach the gateway's reaction hook surface."""
+
+    @pytest_asyncio.fixture
+    def adapter(self):
+        adapter = _make_adapter()
+        adapter.me = _make_user("bot-user-id", "hermes_bot")
+        return adapter
+
+    def _event(self, kind, actor_id="human-1"):
+        event = MagicMock()
+        event.id = "evt-1"
+        event.kind = kind
+        event.actor_id = actor_id
+        payload = ReactionPayload(
+            room_id="room-1", message_event_id="msg-1", emoji="thumbsup",
+        )
+        # RealtimeEvent.get() only yields the payload for its own kind.
+        event.get = MagicMock(side_effect=lambda k: payload if k == kind else None)
+        return event
+
+    async def test_forwards_added_reaction(self, adapter):
+        handler = AsyncMock()
+        adapter.set_reaction_handler(handler)
+
+        await adapter._handle_realtime_event(self._event("reaction_added"))
+
+        handler.assert_called_once()
+        payload = handler.call_args.args[0]
+        assert payload["event_name"] == "reaction:added"
+        assert payload["reaction"] == "thumbsup"
+        assert payload["channel_id"] == "room-1"
+        assert payload["message_ts"] == "msg-1"
+        assert payload["user_id"] == "human-1"
+        assert payload["item_type"] == "message"
+
+    async def test_forwards_removed_reaction(self, adapter):
+        handler = AsyncMock()
+        adapter.set_reaction_handler(handler)
+        await adapter._handle_realtime_event(self._event("reaction_removed"))
+        assert handler.call_args.args[0]["event_name"] == "reaction:removed"
+
+    async def test_ignores_own_lifecycle_reactions(self, adapter):
+        """👀/✅/❌ are ours — forwarding them would feed the agent its own markers."""
+        handler = AsyncMock()
+        adapter.set_reaction_handler(handler)
+        await adapter._handle_realtime_event(
+            self._event("reaction_added", actor_id="bot-user-id"),
+        )
+        handler.assert_not_called()
+
+    async def test_no_handler_registered_is_harmless(self, adapter):
+        await adapter._handle_realtime_event(self._event("reaction_added"))
+
+    async def test_handler_exception_does_not_propagate(self, adapter):
+        adapter.set_reaction_handler(AsyncMock(side_effect=RuntimeError("hook boom")))
+        await adapter._handle_realtime_event(self._event("reaction_added"))
+
+
+# -- Inbound attachments --
+
+class TestInboundAttachments:
+    """Messages carrying files must reach the agent, body or not."""
+
+    @pytest_asyncio.fixture
+    def adapter(self):
+        adapter = _make_adapter()
+        adapter.chatto_config.allow_all_users.value = True
+        adapter.me = _make_user("bot-user-id", "hermes_bot")
+        adapter._room_kinds["room-1"] = RoomKind.DM
+        adapter._user_cache["user-1"] = _make_user("user-1", "alice")
+        adapter.handle_message = AsyncMock()
+        adapter._download_attachment_bytes = AsyncMock(return_value=b"\x89PNG\r\n\x1a\nrest")
+        return adapter
+
+    async def test_image_attachment_becomes_media_url(self, adapter):
+        payload = _make_posted_payload()
+        adapter._chatto_client.get_room = AsyncMock()
+        message = _make_message(
+            body="look at this",
+            attachments=[_make_attachment("shot.png", "image/png")],
+        )
+        payload.fetch_message = AsyncMock(return_value=message)
+
+        with patch("adapter.cache_media_bytes", return_value=_cached("/cache/shot.png", "image/png", "image")):
+            await adapter._dispatch_message_posted(payload)
+
+        event = adapter.handle_message.call_args.args[0]
+        assert event.media_urls == ["/cache/shot.png"]
+        assert event.media_types == ["image/png"]
+        assert event.message_type == MessageType.PHOTO
+
+    async def test_attachment_only_message_is_not_dropped(self, adapter):
+        """The empty-body early return is what silently ate file uploads."""
+        payload = _make_posted_payload()
+        message = _make_message(
+            body="", attachments=[_make_attachment("report.pdf", "application/pdf")],
+        )
+        payload.fetch_message = AsyncMock(return_value=message)
+
+        with patch("adapter.cache_media_bytes", return_value=_cached("/cache/report.pdf", "application/pdf", "document")):
+            await adapter._dispatch_message_posted(payload)
+
+        adapter.handle_message.assert_called_once()
+        event = adapter.handle_message.call_args.args[0]
+        assert event.message_type == MessageType.DOCUMENT
+        assert event.media_urls == ["/cache/report.pdf"]
+
+    async def test_empty_message_without_attachments_is_dropped(self, adapter):
+        payload = _make_posted_payload()
+        payload.fetch_message = AsyncMock(return_value=_make_message(body=""))
+        await adapter._dispatch_message_posted(payload)
+        adapter.handle_message.assert_not_called()
+
+    async def test_download_failure_still_delivers_the_text(self, adapter):
+        adapter._download_attachment_bytes = AsyncMock(side_effect=RuntimeError("404"))
+        payload = _make_posted_payload()
+        payload.fetch_message = AsyncMock(return_value=_make_message(
+            body="see attached", attachments=[_make_attachment("a.png", "image/png")],
+        ))
+
+        await adapter._dispatch_message_posted(payload)
+
+        event = adapter.handle_message.call_args.args[0]
+        assert event.text == "see attached"
+        assert event.media_urls == []
+        assert event.message_type == MessageType.TEXT
+
+    async def test_attachment_without_asset_url_is_skipped(self, adapter):
+        """Videos are announced before transcoding finishes."""
+        payload = _make_posted_payload()
+        att = _make_attachment("clip.mp4", "video/mp4")
+        att.asset_url = None
+        payload.fetch_message = AsyncMock(return_value=_make_message(
+            body="clip", attachments=[att],
+        ))
+
+        await adapter._dispatch_message_posted(payload)
+
+        event = adapter.handle_message.call_args.args[0]
+        assert event.media_urls == []
+        adapter._download_attachment_bytes.assert_not_called()
+
+    async def test_document_wins_over_image(self, adapter):
+        """Mixed batches classify as DOCUMENT — that gates context injection."""
+        assert adapter._message_type_for_media_kinds(["image", "document"]) is MessageType.DOCUMENT
+        assert adapter._message_type_for_media_kinds(["image"]) is MessageType.PHOTO
+        assert adapter._message_type_for_media_kinds(["video"]) is MessageType.VIDEO
+        assert adapter._message_type_for_media_kinds(["audio"]) is MessageType.AUDIO
+        assert adapter._message_type_for_media_kinds([]) is MessageType.TEXT
+
+    async def test_oversized_attachment_is_rejected(self, adapter):
+        """The gateway media cap must bound what a hostile upload can buffer."""
+        import httpx
+
+        big = get_inbound_media_max_bytes() + 1
+        transport = httpx.MockTransport(lambda request: httpx.Response(
+            200, headers={"content-length": str(big)}, content=b"x",
+        ))
+        real_adapter = _make_adapter()
+        real_client_cls = httpx.AsyncClient
+
+        with patch("httpx.AsyncClient", lambda **kw: real_client_cls(transport=transport)):
+            with pytest.raises(ValueError):
+                await real_adapter._download_attachment_bytes("https://chat.example.com/a.png")
+
+
+# NOTE: there are deliberately no tests for get_user(), set_presence() or
+# set_custom_status() on the adapter. Those are not adapter responsibilities —
+# callers use the chattolib client directly, which exposes them (client.get_user,
+# client.update_presence, client.update_custom_status). The adapter only touches
+# presence in connect()/disconnect().
 
 
 # -- Room operations --
@@ -419,44 +784,33 @@ class TestRoomOperations:
         cfg = _make_config()
         adapter = ChattoAdapter(cfg)
         adapter._chatto_client = MagicMock()
+        # AsyncMock, not MagicMock: the adapter awaits these, and awaiting a
+        # plain MagicMock raises TypeError, which create_room()/start_dm()
+        # swallow into a None return.
+        adapter._chatto_client.create_room = AsyncMock()
+        adapter._chatto_client.start_dm = AsyncMock()
         adapter._token = "test-token"
         adapter._room_names = {}
         adapter._room_kinds = {}
         return adapter
 
     async def test_create_room(self, adapter):
-        # Import chattolib types for testing (try vendored first)
-        try:
-            from chattolib_vendor.chattolib.types import Room
-        except ImportError:
-            try:
-                from chattolib.types import Room
-            except ImportError:
-                from unittest.mock import MagicMock
-                Room = MagicMock
-        mock_room = Room(id="room-123", name="Test Room", kind="ROOM_KIND_GROUP",
-                        description="", archived=False, group_id="", universal=True)
-        adapter._chatto_client.create_room.return_value = mock_room
+        adapter._chatto_client.create_room.return_value = _make_room(
+            "room-123", "Test Room", RoomKind.CHANNEL,
+        )
         result = await adapter.create_room("Test Room", "A test room")
         assert result == "room-123"
         adapter._chatto_client.create_room.assert_called_once()
+        assert adapter._room_names["room-123"] == "Test Room"
 
     async def test_start_dm(self, adapter):
-        # Import chattolib types for testing (try vendored first)
-        try:
-            from chattolib_vendor.chattolib.types import Room
-        except ImportError:
-            try:
-                from chattolib.types import Room
-            except ImportError:
-                from unittest.mock import MagicMock
-                Room = MagicMock
-        mock_room = Room(id="dm-123", name="DM with user", kind="ROOM_KIND_DM",
-                        description="", archived=False, group_id="", universal=False)
-        adapter._chatto_client.start_dm.return_value = mock_room
+        adapter._chatto_client.start_dm.return_value = _make_room(
+            "dm-123", "DM with user", RoomKind.DM,
+        )
         result = await adapter.start_dm("user-123")
         assert result == "dm-123"
         adapter._chatto_client.start_dm.assert_called_once()
+        assert adapter._room_kinds["dm-123"] == RoomKind.DM
 
 
 # -- Constants --

+ 14 - 3
test_platform_config.py

@@ -6,11 +6,12 @@ import pytest
 
 PLUGIN_ROOT = os.path.abspath(os.path.dirname(__file__))
 sys.path.insert(0, PLUGIN_ROOT)
-sys.path.insert(0, "/opt/hermes")
+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_str,
+    _get_env_or_extra_str_opt,
     _get_env_or_extra_truthy,
     _split_str_to_list,
     _get_env_or_extra_list,
@@ -28,9 +29,19 @@ class TestPlatformConfigHelpers:
         monkeypatch.delenv("CHATTO_TEST", raising=False)
         assert _get_env_or_extra_str("CHATTO_TEST", " extra-value ") == "extra-value"
 
-    def test_get_env_or_extra_str_returns_none_when_missing(self, monkeypatch):
+    def test_get_env_or_extra_str_returns_empty_when_missing(self, monkeypatch):
+        """The non-optional helper coerces a missing value to "" — use
+        _get_env_or_extra_str_opt() when None has to stay distinguishable."""
         monkeypatch.delenv("CHATTO_TEST", raising=False)
-        assert _get_env_or_extra_str("CHATTO_TEST", None) is None
+        assert _get_env_or_extra_str("CHATTO_TEST", None) == ""
+
+    def test_get_env_or_extra_str_opt_returns_none_when_missing(self, monkeypatch):
+        monkeypatch.delenv("CHATTO_TEST", raising=False)
+        assert _get_env_or_extra_str_opt("CHATTO_TEST", None) is None
+
+    def test_get_env_or_extra_str_opt_prefers_env(self, monkeypatch):
+        monkeypatch.setenv("CHATTO_TEST", " env-value ")
+        assert _get_env_or_extra_str_opt("CHATTO_TEST", "extra-value") == "env-value"
 
     def test_get_env_or_extra_truthy_parses_truthy_values(self, monkeypatch):
         monkeypatch.setenv("CHATTO_TEST", " yes ")

+ 105 - 0
uv.lock

@@ -0,0 +1,105 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "hermes-chatto-plugin"
+version = "1.0.0"
+source = { editable = "." }
+
+[package.dev-dependencies]
+dev = [
+    { name = "pytest" },
+    { name = "pytest-asyncio" },
+]
+
+[package.metadata]
+
+[package.metadata.requires-dev]
+dev = [
+    { name = "pytest", specifier = ">=9.0" },
+    { name = "pytest-asyncio", specifier = ">=0.24" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.21.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+    { name = "iniconfig" },
+    { name = "packaging" },
+    { name = "pluggy" },
+    { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
+]
+
+[[package]]
+name = "pytest-asyncio"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "pytest" },
+    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
+]

+ 0 - 0
vendor/anyio-4.14.2.dist-info/INSTALLER → vendor/common/anyio-4.14.2.dist-info/INSTALLER


+ 0 - 0
vendor/anyio-4.14.2.dist-info/METADATA → vendor/common/anyio-4.14.2.dist-info/METADATA


+ 0 - 0
vendor/anyio-4.14.2.dist-info/RECORD → vendor/common/anyio-4.14.2.dist-info/RECORD


+ 0 - 0
vendor/anyio-4.14.2.dist-info/REQUESTED → vendor/common/anyio-4.14.2.dist-info/REQUESTED


+ 0 - 0
vendor/anyio-4.14.2.dist-info/WHEEL → vendor/common/anyio-4.14.2.dist-info/WHEEL


+ 0 - 0
vendor/anyio-4.14.2.dist-info/entry_points.txt → vendor/common/anyio-4.14.2.dist-info/entry_points.txt


+ 0 - 0
vendor/anyio-4.14.2.dist-info/licenses/LICENSE → vendor/common/anyio-4.14.2.dist-info/licenses/LICENSE


+ 0 - 0
vendor/anyio-4.14.2.dist-info/scm_file_list.json → vendor/common/anyio-4.14.2.dist-info/scm_file_list.json


+ 0 - 0
vendor/anyio-4.14.2.dist-info/scm_version.json → vendor/common/anyio-4.14.2.dist-info/scm_version.json


+ 0 - 0
vendor/anyio-4.14.2.dist-info/top_level.txt → vendor/common/anyio-4.14.2.dist-info/top_level.txt


+ 0 - 0
vendor/anyio/__init__.py → vendor/common/anyio/__init__.py


+ 0 - 0
vendor/anyio/_backends/__init__.py → vendor/common/anyio/_backends/__init__.py


+ 0 - 0
vendor/anyio/_backends/_asyncio.py → vendor/common/anyio/_backends/_asyncio.py


+ 0 - 0
vendor/anyio/_backends/_trio.py → vendor/common/anyio/_backends/_trio.py


+ 0 - 0
vendor/anyio/_core/__init__.py → vendor/common/anyio/_core/__init__.py


+ 0 - 0
vendor/anyio/_core/_asyncio_selector_thread.py → vendor/common/anyio/_core/_asyncio_selector_thread.py


+ 0 - 0
vendor/anyio/_core/_contextmanagers.py → vendor/common/anyio/_core/_contextmanagers.py


+ 0 - 0
vendor/anyio/_core/_eventloop.py → vendor/common/anyio/_core/_eventloop.py


+ 0 - 0
vendor/anyio/_core/_exceptions.py → vendor/common/anyio/_core/_exceptions.py


+ 0 - 0
vendor/anyio/_core/_fileio.py → vendor/common/anyio/_core/_fileio.py


+ 0 - 0
vendor/anyio/_core/_resources.py → vendor/common/anyio/_core/_resources.py


+ 0 - 0
vendor/anyio/_core/_signals.py → vendor/common/anyio/_core/_signals.py


+ 0 - 0
vendor/anyio/_core/_sockets.py → vendor/common/anyio/_core/_sockets.py


+ 0 - 0
vendor/anyio/_core/_streams.py → vendor/common/anyio/_core/_streams.py


+ 0 - 0
vendor/anyio/_core/_subprocesses.py → vendor/common/anyio/_core/_subprocesses.py


+ 0 - 0
vendor/anyio/_core/_synchronization.py → vendor/common/anyio/_core/_synchronization.py


+ 0 - 0
vendor/anyio/_core/_tasks.py → vendor/common/anyio/_core/_tasks.py


+ 0 - 0
vendor/anyio/_core/_tempfile.py → vendor/common/anyio/_core/_tempfile.py


+ 0 - 0
vendor/anyio/_core/_testing.py → vendor/common/anyio/_core/_testing.py


+ 0 - 0
vendor/anyio/_core/_typedattr.py → vendor/common/anyio/_core/_typedattr.py


+ 0 - 0
vendor/anyio/abc/__init__.py → vendor/common/anyio/abc/__init__.py


+ 0 - 0
vendor/anyio/abc/_eventloop.py → vendor/common/anyio/abc/_eventloop.py


+ 0 - 0
vendor/anyio/abc/_resources.py → vendor/common/anyio/abc/_resources.py


+ 0 - 0
vendor/anyio/abc/_sockets.py → vendor/common/anyio/abc/_sockets.py


+ 0 - 0
vendor/anyio/abc/_streams.py → vendor/common/anyio/abc/_streams.py


+ 0 - 0
vendor/anyio/abc/_subprocesses.py → vendor/common/anyio/abc/_subprocesses.py


+ 0 - 0
vendor/anyio/abc/_tasks.py → vendor/common/anyio/abc/_tasks.py


+ 0 - 0
vendor/anyio/abc/_testing.py → vendor/common/anyio/abc/_testing.py


+ 0 - 0
vendor/anyio/from_thread.py → vendor/common/anyio/from_thread.py


+ 0 - 0
vendor/anyio/functools.py → vendor/common/anyio/functools.py


+ 0 - 0
vendor/anyio/itertools.py → vendor/common/anyio/itertools.py


+ 0 - 0
vendor/anyio/lowlevel.py → vendor/common/anyio/lowlevel.py


+ 0 - 0
vendor/anyio/py.typed → vendor/common/anyio/py.typed


+ 0 - 0
vendor/anyio/pytest_plugin.py → vendor/common/anyio/pytest_plugin.py


+ 0 - 0
vendor/anyio/streams/__init__.py → vendor/common/anyio/streams/__init__.py


+ 0 - 0
vendor/anyio/streams/buffered.py → vendor/common/anyio/streams/buffered.py


+ 0 - 0
vendor/anyio/streams/file.py → vendor/common/anyio/streams/file.py


+ 0 - 0
vendor/anyio/streams/memory.py → vendor/common/anyio/streams/memory.py


+ 0 - 0
vendor/anyio/streams/stapled.py → vendor/common/anyio/streams/stapled.py


+ 0 - 0
vendor/anyio/streams/text.py → vendor/common/anyio/streams/text.py


+ 0 - 0
vendor/anyio/streams/tls.py → vendor/common/anyio/streams/tls.py


+ 0 - 0
vendor/anyio/to_interpreter.py → vendor/common/anyio/to_interpreter.py


+ 0 - 0
vendor/anyio/to_process.py → vendor/common/anyio/to_process.py


+ 0 - 0
vendor/anyio/to_thread.py → vendor/common/anyio/to_thread.py


+ 1 - 1
vendor/bin/httpx → vendor/common/bin/httpx

@@ -1,4 +1,4 @@
-#!/home/paul/dev2/chatto-stuff/hermes-chatto-plugin/.venv/bin/python3
+#!/opt/homebrew/opt/python@3.14/bin/python3.14
 # -*- coding: utf-8 -*-
 import sys
 from httpx import main

+ 1 - 1
vendor/bin/idna → vendor/common/bin/idna

@@ -1,4 +1,4 @@
-#!/home/paul/dev2/chatto-stuff/hermes-chatto-plugin/.venv/bin/python3
+#!/opt/homebrew/opt/python@3.14/bin/python3.14
 # -*- coding: utf-8 -*-
 import sys
 from idna.cli import main

+ 0 - 0
vendor/certifi-2026.7.22.dist-info/INSTALLER → vendor/common/certifi-2026.7.22.dist-info/INSTALLER


+ 0 - 0
vendor/certifi-2026.7.22.dist-info/METADATA → vendor/common/certifi-2026.7.22.dist-info/METADATA


+ 0 - 0
vendor/certifi-2026.7.22.dist-info/RECORD → vendor/common/certifi-2026.7.22.dist-info/RECORD


+ 0 - 0
vendor/certifi-2026.7.22.dist-info/REQUESTED → vendor/common/certifi-2026.7.22.dist-info/REQUESTED


+ 0 - 0
vendor/certifi-2026.7.22.dist-info/WHEEL → vendor/common/certifi-2026.7.22.dist-info/WHEEL


+ 0 - 0
vendor/certifi-2026.7.22.dist-info/licenses/LICENSE → vendor/common/certifi-2026.7.22.dist-info/licenses/LICENSE


+ 0 - 0
vendor/certifi-2026.7.22.dist-info/top_level.txt → vendor/common/certifi-2026.7.22.dist-info/top_level.txt


+ 0 - 0
vendor/certifi/__init__.py → vendor/common/certifi/__init__.py


+ 0 - 0
vendor/certifi/__main__.py → vendor/common/certifi/__main__.py


+ 0 - 0
vendor/certifi/cacert.pem → vendor/common/certifi/cacert.pem


+ 0 - 0
vendor/certifi/core.py → vendor/common/certifi/core.py


+ 0 - 0
vendor/certifi/py.typed → vendor/common/certifi/py.typed


+ 0 - 0
vendor/certifi/tests/__init__.py → vendor/common/certifi/tests/__init__.py


+ 0 - 0
vendor/certifi/tests/test_certify.py → vendor/common/certifi/tests/test_certify.py


+ 0 - 0
vendor/chattolib-0.4.20.post1.dist-info/INSTALLER → vendor/common/chattolib-0.4.20.post1.dist-info/INSTALLER


+ 0 - 0
vendor/chattolib-0.4.20.post1.dist-info/METADATA → vendor/common/chattolib-0.4.20.post1.dist-info/METADATA


+ 0 - 0
vendor/chattolib-0.4.20.post1.dist-info/RECORD → vendor/common/chattolib-0.4.20.post1.dist-info/RECORD


+ 0 - 0
vendor/chattolib-0.4.20.post1.dist-info/REQUESTED → vendor/common/chattolib-0.4.20.post1.dist-info/REQUESTED


+ 0 - 0
vendor/chattolib-0.4.20.post1.dist-info/WHEEL → vendor/common/chattolib-0.4.20.post1.dist-info/WHEEL


+ 0 - 0
vendor/chattolib-0.4.20.post1.dist-info/licenses/LICENSE → vendor/common/chattolib-0.4.20.post1.dist-info/licenses/LICENSE


+ 0 - 0
vendor/chattolib-0.4.20.post1.dist-info/licenses/LICENSES/Apache-2.0.txt → vendor/common/chattolib-0.4.20.post1.dist-info/licenses/LICENSES/Apache-2.0.txt


+ 0 - 0
vendor/chattolib-0.4.20.post1.dist-info/licenses/LICENSES/MPL-2.0.txt → vendor/common/chattolib-0.4.20.post1.dist-info/licenses/LICENSES/MPL-2.0.txt


+ 0 - 0
vendor/chattolib-0.4.20.post1.dist-info/top_level.txt → vendor/common/chattolib-0.4.20.post1.dist-info/top_level.txt


+ 0 - 0
vendor/chattolib/__init__.py → vendor/common/chattolib/__init__.py


+ 0 - 0
vendor/chattolib/_pb/__init__.py → vendor/common/chattolib/_pb/__init__.py


+ 0 - 0
vendor/chattolib/_pb/buf/__init__.py → vendor/common/chattolib/_pb/buf/__init__.py


+ 0 - 0
vendor/chattolib/_pb/buf/validate/__init__.py → vendor/common/chattolib/_pb/buf/validate/__init__.py


+ 0 - 0
vendor/chattolib/_pb/buf/validate/validate_pb2.py → vendor/common/chattolib/_pb/buf/validate/validate_pb2.py


+ 0 - 0
vendor/chattolib/_pb/chatto/__init__.py → vendor/common/chattolib/_pb/chatto/__init__.py


+ 0 - 0
vendor/chattolib/_pb/chatto/admin/__init__.py → vendor/common/chattolib/_pb/chatto/admin/__init__.py


+ 0 - 0
vendor/chattolib/_pb/chatto/admin/v1/__init__.py → vendor/common/chattolib/_pb/chatto/admin/v1/__init__.py


+ 0 - 0
vendor/chattolib/_pb/chatto/admin/v1/diagnostics_connect.py → vendor/common/chattolib/_pb/chatto/admin/v1/diagnostics_connect.py


+ 0 - 0
vendor/chattolib/_pb/chatto/admin/v1/diagnostics_pb2.py → vendor/common/chattolib/_pb/chatto/admin/v1/diagnostics_pb2.py


+ 0 - 0
vendor/chattolib/_pb/chatto/admin/v1/event_log_connect.py → vendor/common/chattolib/_pb/chatto/admin/v1/event_log_connect.py


Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff