From 0fd82293936012c2dd22ce0ac52876024f24ec81 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 23:44:43 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(mattermost):=20add=20'auto'=20reply=20?= =?UTF-8?q?mode=20=E2=80=94=20flat=20DMs,=20threaded=20channels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces MATTERMOST_REPLY_MODE=auto alongside the existing 'thread' and 'off' modes. When set to 'auto', the adapter checks the channel type before deciding whether to thread: - DMs (channel_type 'D'): flat replies (no root_id) — avoids the 'Invalid RootId parameter' 400 error that occurs when the adapter sends root_id in DM channels where no thread root exists. - Channels / groups ('O', 'P', 'G'): threaded replies (root_id set to the user's original post) — keeps conversations organized in busy channels without cluttering the main timeline. Implementation: - New _should_thread(chat_id, reply_to) async method replaces all inline 'if reply_to and self._reply_mode == "thread"' checks. - _channel_type_cache dict avoids repeated /channels/{id} API calls for the same channel within a session. - get_chat_info() populates the cache on first lookup. - All four send paths (send, _send_url_as_file, _send_local_file, send_multiple_images) updated to use _should_thread. Existing 'thread' and 'off' modes are unchanged. --- plugins/platforms/mattermost/adapter.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/plugins/platforms/mattermost/adapter.py b/plugins/platforms/mattermost/adapter.py index bb6dc9b81f248..a94dce81b2673 100644 --- a/plugins/platforms/mattermost/adapter.py +++ b/plugins/platforms/mattermost/adapter.py @@ -90,7 +90,7 @@ def __init__(self, config: PlatformConfig): self._reconnect_task: Optional[asyncio.Task] = None self._closing = False - # Reply mode: "thread" to nest replies, "off" for flat messages. + # Reply mode: "thread" to nest replies, "off" for flat messages, "auto" for smart (flat DMs, thread channels). self._reply_mode: str = ( config.extra.get("reply_mode", "") or os.getenv("MATTERMOST_REPLY_MODE", "off") @@ -98,6 +98,7 @@ def __init__(self, config: PlatformConfig): # Dedup cache (prevent reprocessing) self._dedup = MessageDeduplicator() + self._channel_type_cache: Dict[str, str] = {} # ------------------------------------------------------------------ # HTTP helpers @@ -250,6 +251,19 @@ async def disconnect(self) -> None: logger.info("Mattermost: disconnected") + async def _should_thread(self, chat_id: str, reply_to: Optional[str]) -> bool: + if not reply_to: + return False + if self._reply_mode == "thread": + return True + if self._reply_mode == "auto": + ch_type = self._channel_type_cache.get(chat_id) + if not ch_type: + info = await self.get_chat_info(chat_id) + ch_type = info["type"] + return ch_type != "dm" + return False + async def _resolve_root_id(self, post_id: str) -> str: """Resolve a post_id to the thread root_id for Mattermost. @@ -287,7 +301,7 @@ async def send( "message": chunk, } # Thread support: reply_to is the root post ID. - if reply_to and self._reply_mode == "thread": + if await self._should_thread(chat_id, reply_to): # Ensure root_id points to the thread root, not a reply. # Mattermost rejects non-root post IDs as root_id. resolved_root = await self._resolve_root_id(reply_to) @@ -308,6 +322,8 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: ch_type = _CHANNEL_TYPE_MAP.get(data.get("type", "O"), "channel") display_name = data.get("display_name") or data.get("name") or chat_id + + self._channel_type_cache[chat_id] = ch_type return {"name": display_name, "type": ch_type} # ------------------------------------------------------------------ @@ -470,7 +486,7 @@ async def _send_url_as_file( "message": caption or "", "file_ids": [file_id], } - if reply_to and self._reply_mode == "thread": + if await self._should_thread(chat_id, reply_to): payload["root_id"] = await self._resolve_root_id(reply_to) data = await self._api_post("posts", payload) @@ -509,7 +525,7 @@ async def _send_local_file( "message": caption or "", "file_ids": [file_id], } - if reply_to and self._reply_mode == "thread": + if await self._should_thread(chat_id, reply_to): payload["root_id"] = await self._resolve_root_id(reply_to) data = await self._api_post("posts", payload) From 228f2ba7ae2ae6129ecd193fcea00df014b8cea5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 01:49:06 -0700 Subject: [PATCH 2/3] fix(docker): set S6_KEEP_ENV=1 to preserve container environment s6-overlay v3 strips the container's environment before execing the main program (CMD). This causes all Kubernetes-injected env vars (MATTERMOST_TOKEN, MATTERMOST_URL, HERMES_HOME, HOME, API keys) to vanish by the time the hermes gateway process starts. The gateway then fails to detect any messaging platforms because os.getenv() returns empty for every credential. v2026.5.16 used tini as PID 1 (no env stripping), so this was never an issue. The migration to s6-overlay on main introduced the regression. Fix: ENV S6_KEEP_ENV=1 in the Dockerfile, per the s6-overlay docs. This preserves the full container environment for the main program and all cont-init.d scripts. --- Dockerfile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Dockerfile b/Dockerfile index be4e8848bb5aa..47278b8770c6c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -188,6 +188,12 @@ COPY --chmod=0755 docker/cont-init.d/02-reconcile-profiles /etc/cont-init.d/02-r # ---------- Runtime ---------- ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist ENV HERMES_HOME=/opt/data +# s6-overlay v3 strips the container environment by default before +# execing the main program. The main-wrapper.sh CMD needs all K8s env +# vars (MATTERMOST_TOKEN, API keys, etc.) to survive into the hermes +# process. S6_KEEP_ENV=1 preserves them. See: +# https://github.com/just-containers/s6-overlay#customizing-s6-overlay-behaviour +ENV S6_KEEP_ENV=1 # Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported # the venv bin onto PATH; Architecture B's main-wrapper.sh does the # same for the container's main process, but `docker exec` and our From 2062283ce43505702961cad2caff182c0bca3808 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 03:04:04 -0700 Subject: [PATCH 3/3] fix(docker): inject venv PATH into /etc/profile.d for login shells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal tool's init_session() spawns `bash -l -c` (login shell) to capture an environment snapshot. Login shells source /etc/profile which resets PATH to the system default, dropping the venv bin dir. Subsequent terminal commands use the snapshot, so `python3` resolves to /usr/bin/python3 (system Python, no pip, no google-api-python-client) instead of /opt/hermes/.venv/bin/python3. On v2026.5.16 (tini), entrypoint.sh ran `source .venv/bin/activate` in the same process tree, so the activated PATH survived into every child — including login shells. With s6-overlay, /init is a C binary that doesn't carry shell state, and main-wrapper.sh only activates the venv for the hermes process itself. Fix: write /etc/profile.d/hermes-venv.sh so login shells prepend the venv to PATH. Works alongside the existing ENV PATH for non-login shells and docker exec. --- Dockerfile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Dockerfile b/Dockerfile index 47278b8770c6c..6157ab145403c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -201,6 +201,11 @@ ENV S6_KEEP_ENV=1 # bin globally so `docker exec hermes ...` and any # subprocess that doesn't activate the venv first still find hermes. ENV PATH="/opt/hermes/.venv/bin:/opt/data/.local/bin:${PATH}" +# Login shells (bash -l) reset PATH from /etc/profile, dropping the venv. +# The terminal tool's init_session spawns bash -l to snapshot the env. +# Without this, python3 resolves to /usr/bin/python3 (no google deps, no pip). +RUN echo 'export PATH="/opt/hermes/.venv/bin:/opt/data/.local/bin:${PATH}"' \ + > /etc/profile.d/hermes-venv.sh RUN mkdir -p /opt/data VOLUME [ "/opt/data" ]