diff --git a/hermes-zulip-plugin/src/adapter.py b/hermes-zulip-plugin/src/adapter.py index f302aaa..7542b9c 100644 --- a/hermes-zulip-plugin/src/adapter.py +++ b/hermes-zulip-plugin/src/adapter.py @@ -203,6 +203,22 @@ class ZulipAdapter(BasePlatformAdapter): logger.error("Zulip POST %s network error: %s", path, exc) return {"result": "error", "msg": str(exc)} + async def _api_patch(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: + import aiohttp + url = f"{self._site}/api/v1/{path.lstrip('/')}" + try: + async with self._session.patch( + url, data=payload, auth=self._auth(), + timeout=aiohttp.ClientTimeout(total=15), + ) as resp: + data = await resp.json() + if resp.status >= 400: + logger.debug("Zulip PATCH %s -> %s: %s", path, resp.status, str(data)[:200]) + return data + except (aiohttp.ClientError, asyncio.TimeoutError) as exc: + logger.debug("Zulip PATCH %s network error: %s", path, exc) + return {"result": "error", "msg": str(exc)} + async def _api_delete(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: import aiohttp url = f"{self._site}/api/v1/{path.lstrip('/')}" @@ -326,6 +342,23 @@ class ZulipAdapter(BasePlatformAdapter): return SendResult(success=True, message_id=str(last_id) if last_id else None) + async def edit_message( + self, + chat_id: str, + message_id: str, + content: str, + **kwargs, + ) -> SendResult: + """Edit a previously-sent message. Used by the gateway stream consumer + for progressive message updates during agent streaming.""" + formatted = self.format_message(content) + data = await self._api_patch(f"messages/{message_id}", {"content": formatted}) + if data.get("result") != "success": + msg = str(data.get("msg", "edit failed")) + logger.debug("Zulip edit_message(%s) -> %s", message_id, msg) + return SendResult(success=False, error=msg) + return SendResult(success=True, message_id=message_id) + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: kind, to, topic = _parse_target(chat_id, self._default_topic) if kind == "direct": diff --git a/plugins/platforms/zulip/adapter.py b/plugins/platforms/zulip/adapter.py index a6be243..a9c6877 100644 --- a/plugins/platforms/zulip/adapter.py +++ b/plugins/platforms/zulip/adapter.py @@ -99,6 +99,23 @@ def _build_auth_header(email: str, api_key: str) -> str: return f"Basic {token}" +def _strip_html(text: str) -> str: + """Strip HTML tags and decode HTML entities from Zulip message content. + + Zulip delivers message content as rendered HTML (e.g.
/approve
). + The gateway slash command parser expects plain text, so HTML tags + prevent command matching. This helper strips tags and decodes entities. + """ + if not text: + return text + # Strip HTML tags + text = re.sub(r"<[^>]+>", "", text) + # Decode common HTML entities + text = text.replace("&", "&").replace("<", "<").replace(">", ">") + text = text.replace(""", '"').replace("'", "'").replace(" ", " ") + return text.strip() + + def _truncate(text: str, limit: int = MAX_ZULIP_MESSAGE) -> str: """Truncate to Zulip's message limit with notice.""" if len(text) <= limit: @@ -784,6 +801,8 @@ class ZulipAdapter(BasePlatformAdapter): sender_name = msg.get("sender_full_name", "Unknown") sender_id = msg.get("sender_id") content = msg.get("content", "") + # Strip Zulip HTML for slash command matching (zulip-approval-fix contract) + content = _strip_html(content) # Echo-loop prevention: skip own messages if sender_email == self._bot_email: