"""Zulip gateway adapter for Hermes Agent. Connects to a Zulip realm (self-hosted or zulipchat.com) and relays messages between Zulip channels (streams) / direct messages and the Hermes agent. Both transports use Zulip's REST API over ``aiohttp`` (already a Hermes dependency — no ``zulip`` SDK required): * **Receiving** — register an event queue (``POST /api/v1/register``) and long-poll it (``GET /api/v1/events``). The queue delivers ``message`` events; the bot's ``mentioned`` flag drives channel gating. A ``BAD_EVENT_QUEUE_ID`` (expired queue) transparently re-registers. * **Sending** — ``POST /api/v1/messages`` (stream + topic, or direct) and ``POST /api/v1/user_uploads`` for file attachments. REST is also used out-of-process by cron delivery via :func:`_standalone_send`. Authentication is HTTP Basic with the bot's email + API key. Chat ids are encoded so replies route back to the right place: s:: a stream message under a topic (chat_type=channel) d:,,… a direct message (chat_type=dm) Environment variables (set in ``~/.hermes/.env``): ZULIP_SITE Realm URL (e.g. https://example.zulipchat.com) ZULIP_EMAIL Bot email ZULIP_API_KEY Bot API key Optional: ZULIP_ALLOWED_USERS Comma-separated sender emails/ids allowed to talk to the bot ZULIP_ALLOW_ALL_USERS Allow any user (dev only) ZULIP_HOME_CHANNEL Default target for cron delivery (e.g. "general/hermes" or "s:5:hermes") ZULIP_REQUIRE_MENTION Require @bot mention in channels (default true) ZULIP_FREE_RESPONSE_CHANNELS Stream ids/names where @mention is not required ZULIP_ALLOWED_CHANNELS If set, the bot only responds in these stream ids/names ZULIP_ALL_PUBLIC_STREAMS Subscribe the queue to all public streams (default false) ZULIP_DEFAULT_TOPIC Topic used when a target has none (default "general") """ from __future__ import annotations import asyncio import json import logging import os import re from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator from gateway.platforms.base import ( BasePlatformAdapter, MessageEvent, MessageType, SendResult, ) logger = logging.getLogger(__name__) # Identifier used in config.yaml (gateway.platforms.zulip) and by # ``Platform("zulip")``. Registered at import-time via register(). PLATFORM_NAME = "zulip" # Zulip's hard message limit is 10000 chars; 9000 leaves headroom for the # chunk indicator and any markdown the agent adds. MAX_MESSAGE_LENGTH = 9000 # Long-poll client timeout. Zulip holds GET /events open and emits a # heartbeat well within this window, so a timeout just means "re-poll". _LONGPOLL_TIMEOUT = 90.0 _RECONNECT_BASE_DELAY = 2.0 _RECONNECT_MAX_DELAY = 60.0 _RECONNECT_JITTER = 0.2 def check_zulip_requirements() -> bool: """Return True if the Zulip adapter can be used.""" site = os.getenv("ZULIP_SITE", "") or os.getenv("ZULIP_URL", "") email = os.getenv("ZULIP_EMAIL", "") api_key = os.getenv("ZULIP_API_KEY", "") if not site: logger.debug("Zulip: ZULIP_SITE not set") return False if not email or not api_key: logger.debug("Zulip: ZULIP_EMAIL and ZULIP_API_KEY required") return False try: import aiohttp # noqa: F401 return True except ImportError: logger.warning("Zulip: aiohttp not installed") return False def _parse_target(chat_id: str, default_topic: str = "general") -> Tuple[str, Any, Optional[str]]: """Decode a chat id / home-channel string into a send target. Returns ``(kind, to, topic)`` where *kind* is ``"stream"`` or ``"direct"``. Accepted forms: d:1,2,3 -> ("direct", [1, 2, 3], None) s:: -> ("stream", , ) / -> ("stream", , ) (friendly) -> ("stream", , default_topic) """ cid = (chat_id or "").strip() if cid.startswith("d:"): ids: List[Any] = [] for part in cid[2:].split(","): part = part.strip() if not part: continue ids.append(int(part) if part.isdigit() else part) return "direct", ids, None if cid.startswith("s:"): rest = cid[2:] stream, _, topic = rest.partition(":") stream = stream.strip() to: Any = int(stream) if stream.isdigit() else stream return "stream", to, (topic or default_topic) if "/" in cid: stream, topic = cid.split("/", 1) return "stream", stream.strip(), (topic.strip() or default_topic) return "stream", cid, default_topic class ZulipAdapter(BasePlatformAdapter): """Gateway adapter for Zulip (self-hosted or zulipchat.com).""" def __init__(self, config: PlatformConfig): super().__init__(config, Platform(PLATFORM_NAME)) extra = config.extra or {} self._site: str = ( extra.get("site") or os.getenv("ZULIP_SITE", "") or os.getenv("ZULIP_URL", "") ).rstrip("/") self._email: str = extra.get("email") or os.getenv("ZULIP_EMAIL", "") self._api_key: str = config.token or os.getenv("ZULIP_API_KEY", "") self._bot_user_id: Optional[int] = None self._bot_email: str = "" self._bot_full_name: str = "" self._session: Any = None self._queue_id: Optional[str] = None self._last_event_id: int = -1 self._poll_task: Optional[asyncio.Task] = None self._closing = False self._default_topic: str = ( extra.get("default_topic") or os.getenv("ZULIP_DEFAULT_TOPIC", "general") ) self._dedup = MessageDeduplicator() # ------------------------------------------------------------------ # HTTP helpers # ------------------------------------------------------------------ def _auth(self): import aiohttp return aiohttp.BasicAuth(self._email, self._api_key) async def _api_get(self, path: str, params: Optional[Dict[str, Any]] = None, timeout: float = 30.0) -> Dict[str, Any]: import aiohttp url = f"{self._site}/api/v1/{path.lstrip('/')}" try: async with self._session.get( url, params=params, auth=self._auth(), timeout=aiohttp.ClientTimeout(total=timeout), ) as resp: data = await resp.json() if resp.status >= 400: logger.debug("Zulip GET %s -> %s: %s", path, resp.status, str(data)[:200]) data.setdefault("_http_status", resp.status) return data # aiohttp raises a bare asyncio.TimeoutError (NOT a ClientError) on # timeout — catch both so callers always get the error dict, never an # unhandled exception. except (aiohttp.ClientError, asyncio.TimeoutError) as exc: logger.error("Zulip GET %s network error: %s", path, exc) return {"result": "error", "msg": str(exc)} async def _api_post(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.post( url, data=payload, auth=self._auth(), timeout=aiohttp.ClientTimeout(total=30), ) as resp: data = await resp.json() if resp.status >= 400: logger.error("Zulip POST %s -> %s: %s", path, resp.status, str(data)[:200]) return data except (aiohttp.ClientError, asyncio.TimeoutError) as exc: 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('/')}" try: async with self._session.delete( url, params=params, auth=self._auth(), timeout=aiohttp.ClientTimeout(total=15), ) as resp: return await resp.json() except (aiohttp.ClientError, asyncio.TimeoutError) as exc: logger.debug("Zulip DELETE %s network error: %s", path, exc) return {"result": "error", "msg": str(exc)} async def _upload(self, file_data: bytes, filename: str, content_type: str) -> Optional[str]: """Upload a file, returning its absolute URL (or None).""" import aiohttp url = f"{self._site}/api/v1/user_uploads" form = aiohttp.FormData() form.add_field("file", file_data, filename=filename, content_type=content_type) try: async with self._session.post( url, data=form, auth=self._auth(), timeout=aiohttp.ClientTimeout(total=60), ) as resp: data = await resp.json() if resp.status >= 400: logger.error("Zulip upload -> %s: %s", resp.status, str(data)[:200]) return None rel = data.get("url") or data.get("uri") if not rel: return None return rel if rel.startswith("http") else f"{self._site}{rel}" except (aiohttp.ClientError, asyncio.TimeoutError) as exc: logger.error("Zulip upload network error: %s", exc) return None # ------------------------------------------------------------------ # Required overrides # ------------------------------------------------------------------ async def connect(self, is_reconnect: bool = False) -> bool: import aiohttp if not self._site or not self._email or not self._api_key: logger.error("Zulip: ZULIP_SITE, ZULIP_EMAIL and ZULIP_API_KEY must be set") return False self._session = aiohttp.ClientSession() self._closing = False me = await self._api_get("users/me") if me.get("result") != "success" or "user_id" not in me: logger.error( "Zulip: failed to authenticate — check ZULIP_SITE, ZULIP_EMAIL " "and ZULIP_API_KEY (%s)", str(me.get("msg", ""))[:160], ) await self._session.close() return False self._bot_user_id = me["user_id"] self._bot_email = me.get("email", self._email) self._bot_full_name = me.get("full_name", "") logger.info( "Zulip: authenticated as %s (#%s) on %s", self._bot_full_name or self._bot_email, self._bot_user_id, self._site, ) self._poll_task = asyncio.create_task(self._poll_loop()) self._mark_connected() return True async def disconnect(self) -> None: self._closing = True if self._poll_task and not self._poll_task.done(): self._poll_task.cancel() try: await self._poll_task except (asyncio.CancelledError, Exception): pass # Best-effort: release the server-side event queue. Zulip deletes a # queue via DELETE /api/v1/events?queue_id=… (there is no POST form). if self._queue_id and self._session and not self._session.closed: try: await self._api_delete("events", {"queue_id": self._queue_id}) except Exception: pass if self._session and not self._session.closed: await self._session.close() logger.info("Zulip: disconnected") async def send( self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: if not content: return SendResult(success=True) kind, to, topic = _parse_target(chat_id, self._default_topic) formatted = self.format_message(content) chunks = self.truncate_message(formatted, MAX_MESSAGE_LENGTH) last_id = None for chunk in chunks: payload: Dict[str, Any] = {"content": chunk} if kind == "direct": # "private" is the backward-compatible alias accepted by every # Zulip version (the newer "direct" alias only landed in 7.0). payload["type"] = "private" payload["to"] = json.dumps(to) else: payload["type"] = "stream" payload["to"] = str(to) payload["topic"] = topic or self._default_topic data = await self._api_post("messages", payload) if data.get("result") != "success": return SendResult(success=False, error=str(data.get("msg", "send failed"))) last_id = data.get("id") 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": return {"name": "Direct message", "type": "dm", "chat_id": chat_id} name = f"{to} > {topic}" if topic else str(to) return {"name": name, "type": "channel", "chat_id": chat_id} # ------------------------------------------------------------------ # Optional overrides — files are uploaded then linked into a message # ------------------------------------------------------------------ async def send_image( self, chat_id: str, image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: return await self._send_url_as_file(chat_id, image_url, caption, "image") async def send_image_file( self, chat_id: str, image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: return await self._send_local_file(chat_id, image_path, caption) async def send_document( self, chat_id: str, file_path: str, caption: Optional[str] = None, file_name: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: return await self._send_local_file(chat_id, file_path, caption, file_name) async def send_voice( self, chat_id: str, audio_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: return await self._send_local_file(chat_id, audio_path, caption) async def send_video( self, chat_id: str, video_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: return await self._send_local_file(chat_id, video_path, caption) def format_message(self, content: str) -> str: """Zulip renders CommonMark. Convert inline image markdown into bare links — Zulip auto-previews image URLs, and files are uploaded separately. """ return re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", r"\2", content) # ------------------------------------------------------------------ # File helpers # ------------------------------------------------------------------ async def _send_url_as_file( self, chat_id: str, url: str, caption: Optional[str], kind: str = "file", ) -> SendResult: from tools.url_safety import is_safe_url if not is_safe_url(url): logger.warning("Zulip: blocked unsafe URL (SSRF protection)") return await self.send(chat_id, f"{caption or ''}\n{url}".strip()) import aiohttp fname = url.rsplit("/", 1)[-1].split("?")[0] or f"{kind}.png" try: async with self._session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp: if resp.status >= 400: return await self.send(chat_id, f"{caption or ''}\n{url}".strip()) file_data = await resp.read() ct = resp.content_type or "application/octet-stream" except (aiohttp.ClientError, asyncio.TimeoutError) as exc: logger.warning("Zulip: failed to download %s: %s", url[:80], exc) return await self.send(chat_id, f"{caption or ''}\n{url}".strip()) return await self._upload_and_send(chat_id, file_data, fname, ct, caption, fallback=url) async def _send_local_file( self, chat_id: str, file_path: str, caption: Optional[str], file_name: Optional[str] = None, ) -> SendResult: import mimetypes p = Path(file_path) if not p.exists(): logger.warning("Zulip: local file not found, skipping: %s", file_path) return SendResult(success=True, message_id=None) fname = file_name or p.name ct = mimetypes.guess_type(fname)[0] or "application/octet-stream" return await self._upload_and_send(chat_id, p.read_bytes(), fname, ct, caption) async def _upload_and_send( self, chat_id: str, data: bytes, fname: str, ct: str, caption: Optional[str], fallback: Optional[str] = None, ) -> SendResult: url = await self._upload(data, fname, ct) if not url: if fallback: return await self.send(chat_id, f"{caption or ''}\n{fallback}".strip()) return SendResult(success=False, error="File upload failed") body = f"{caption + chr(10) if caption else ''}[{fname}]({url})" return await self.send(chat_id, body) # ------------------------------------------------------------------ # Event queue (register + long-poll) # ------------------------------------------------------------------ async def _register_queue(self) -> bool: all_public = os.getenv("ZULIP_ALL_PUBLIC_STREAMS", "").lower() in {"1", "true", "yes", "on"} payload = { "event_types": json.dumps(["message"]), "apply_markdown": "false", "all_public_streams": "true" if all_public else "false", } data = await self._api_post("register", payload) if data.get("result") != "success" or "queue_id" not in data: logger.error("Zulip: failed to register event queue: %s", str(data.get("msg", ""))[:160]) return False self._queue_id = data["queue_id"] self._last_event_id = data.get("last_event_id", -1) logger.info("Zulip: event queue registered (%s)", self._queue_id) return True async def _poll_loop(self) -> None: delay = _RECONNECT_BASE_DELAY while not self._closing: try: if not self._queue_id: if not await self._register_queue(): raise RuntimeError("queue registration failed") await self._poll_once() delay = _RECONNECT_BASE_DELAY except asyncio.CancelledError: return except Exception as exc: if self._closing: return logger.warning("Zulip poll error: %s — retrying in %.0fs", exc, delay) import random await asyncio.sleep(delay + delay * _RECONNECT_JITTER * random.random()) delay = min(delay * 2, _RECONNECT_MAX_DELAY) async def _get_events(self) -> Optional[Dict[str, Any]]: """Long-poll ``GET /events``. Returns the parsed response, or ``None`` on the expected long-poll timeout (no events within the window — caller just re-polls). Kept separate from ``_api_get`` so the long-poll timeout isn't swallowed into an error dict the way ordinary calls need. """ import aiohttp params = {"queue_id": self._queue_id, "last_event_id": self._last_event_id} try: async with self._session.get( f"{self._site}/api/v1/events", params=params, auth=self._auth(), timeout=aiohttp.ClientTimeout(total=_LONGPOLL_TIMEOUT), ) as resp: return await resp.json() except asyncio.TimeoutError: return None except aiohttp.ClientError as exc: raise RuntimeError(f"events poll network error: {exc}") async def _poll_once(self) -> None: data = await self._get_events() if data is None: return # long-poll window elapsed — just poll again if data.get("result") != "success": if data.get("code") == "BAD_EVENT_QUEUE_ID": logger.info("Zulip: event queue expired — re-registering") self._queue_id = None return # Transient server error — let the loop back off. raise RuntimeError(str(data.get("msg", "events poll failed"))) for event in data.get("events", []): eid = event.get("id") if isinstance(eid, int): self._last_event_id = max(self._last_event_id, eid) if event.get("type") == "message": # Guard each event so one failing message can't drop the rest # of the batch. last_event_id is already advanced above, so a # dropped message is not reprocessed on the next poll. try: await self._handle_message_event(event) except Exception: logger.exception("Zulip: error handling message event %s", eid) async def _handle_message_event(self, event: Dict[str, Any]) -> None: message = event.get("message") or {} flags = event.get("flags") or [] sender_id = message.get("sender_id") # Ignore our own messages (Zulip echoes them back on the queue). if sender_id is not None and sender_id == self._bot_user_id: return msg_id = message.get("id") if msg_id is None or self._dedup.is_duplicate(str(msg_id)): return mtype = message.get("type") # "stream" or "private" content = message.get("content", "") or "" sender_name = message.get("sender_full_name", "") or message.get("sender_email", "") if mtype == "private": chat_type = "dm" recipients = message.get("display_recipient") or [] user_ids = sorted( int(r["id"]) for r in recipients if isinstance(r, dict) and "id" in r ) chat_id = "d:" + ",".join(str(u) for u in user_ids) topic = None else: chat_type = "channel" stream_id = message.get("stream_id") topic = message.get("subject", "") or message.get("topic", "") chat_id = f"s:{stream_id}:{topic}" # Mention-gating for streams. stream_name = message.get("display_recipient", "") extra = self.config.extra or {} allowed_raw = extra.get("allowed_channels") if allowed_raw is None: allowed_raw = os.getenv("ZULIP_ALLOWED_CHANNELS", "") allowed = _split_csv(allowed_raw) if allowed and str(stream_id) not in allowed and str(stream_name) not in allowed: return require_mention = os.getenv("ZULIP_REQUIRE_MENTION", "true").lower() not in {"false", "0", "no"} free = _split_csv(os.getenv("ZULIP_FREE_RESPONSE_CHANNELS", "")) is_free = str(stream_id) in free or str(stream_name) in free has_mention = "mentioned" in flags or "wildcard_mentioned" in flags if require_mention and not is_free and not has_mention: return # Strip the bot's @-mention so the agent sees clean input. if self._bot_full_name: content = re.sub( r"@\*\*" + re.escape(self._bot_full_name) + r"\*\*", "", content, ).strip() msg_type = MessageType.COMMAND if content.startswith("/") else MessageType.TEXT media_urls: List[str] = [] media_types: List[str] = [] await self._extract_inline_media(content, media_urls, media_types) await self._extract_event_attachments(message, media_urls, media_types) if media_types and msg_type == MessageType.TEXT: if any(m.startswith("image/") for m in media_types): msg_type = MessageType.PHOTO elif any(m.startswith("audio/") for m in media_types): msg_type = MessageType.VOICE else: msg_type = MessageType.DOCUMENT source = self.build_source( chat_id=chat_id, chat_type=chat_type, user_id=str(sender_id) if sender_id is not None else None, user_name=sender_name, chat_topic=topic, ) from gateway.platforms.base import resolve_channel_prompt channel_prompt = resolve_channel_prompt(self.config.extra, chat_id, None) await self.handle_message(MessageEvent( text=content, message_type=msg_type, source=source, raw_message=message, message_id=str(msg_id), media_urls=media_urls or None, media_types=media_types or None, channel_prompt=channel_prompt, )) async def _extract_event_attachments( self, message: Dict[str, Any], media_urls: List[str], media_types: List[str], ) -> None: """Download files from Zulip's ``message.attachments`` field (UI file uploads that Zulip sends as structured event data rather than inline markdown links).""" import aiohttp atts = message.get("attachments") or [] for att in atts: path_id = att.get("path_id") fname = att.get("name") or "file" if not path_id: continue dl_url = f"{self._site}/api/v1/user_uploads/{path_id}" try: async with self._session.get( dl_url, auth=self._auth(), timeout=aiohttp.ClientTimeout(total=30), ) as resp: if resp.status >= 400: logger.debug("Zulip: event attachment %s -> %s", fname, resp.status) continue data = await resp.read() mime = resp.content_type or "application/octet-stream" except (aiohttp.ClientError, asyncio.TimeoutError): logger.debug("Zulip: failed to download event attachment %s", fname) continue await self._cache_attachment(data, fname, mime, media_urls, media_types) async def _cache_attachment( self, data: bytes, fname: str, mime: str, media_urls: List[str], media_types: List[str], ) -> None: """Cache a downloaded attachment into Hermes media storage.""" from gateway.platforms.base import ( cache_image_from_bytes, cache_audio_from_bytes, cache_document_from_bytes, ) ext = Path(fname).suffix try: if mime.startswith("image/"): media_urls.append(cache_image_from_bytes(data, ext or ".png")) media_types.append(mime) elif mime.startswith("audio/"): media_urls.append(cache_audio_from_bytes(data, ext or ".ogg")) media_types.append(mime) else: media_urls.append(cache_document_from_bytes(data, fname)) media_types.append(mime) except Exception as exc: logger.warning("Zulip: failed to cache attachment %s: %s", fname, exc) async def _extract_inline_media( self, content: str, media_urls: List[str], media_types: List[str], ) -> None: """Download Zulip ``/user_uploads/...`` attachments referenced in a message so vision/transcription tools can read them locally.""" import aiohttp for rel in re.findall(r"\]\((/user_uploads/[^)]+)\)", content): dl_url = f"{self._site}{rel}" try: async with self._session.get( dl_url, auth=self._auth(), timeout=aiohttp.ClientTimeout(total=30), ) as resp: if resp.status >= 400: continue data = await resp.read() mime = resp.content_type or "application/octet-stream" except (aiohttp.ClientError, asyncio.TimeoutError): continue fname = rel.rsplit("/", 1)[-1] or "file" await self._cache_attachment(data, fname, mime, media_urls, media_types) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _split_csv(value: Any) -> set: if isinstance(value, list): return {str(v).strip() for v in value if str(v).strip()} return {v.strip() for v in str(value or "").split(",") if v.strip()} # --------------------------------------------------------------------------- # Out-of-process cron delivery (standalone REST send) # --------------------------------------------------------------------------- async def _standalone_send( pconfig, chat_id: str, message: str, *, thread_id: Optional[str] = None, media_files: Optional[list] = None, force_document: bool = False, ) -> Dict[str, Any]: """Send via the Zulip REST API without a live gateway adapter. Used by ``tools/send_message_tool`` for cron jobs running outside the gateway process. Reads ``ZULIP_API_KEY`` from ``pconfig.token`` (env fallback); site + email come from ``pconfig.extra`` or env. """ try: import aiohttp except ImportError: return {"error": "aiohttp not installed. Run: pip install aiohttp"} extra = getattr(pconfig, "extra", {}) or {} site = (extra.get("site") or os.getenv("ZULIP_SITE", "") or os.getenv("ZULIP_URL", "")).rstrip("/") email = (extra.get("email") or os.getenv("ZULIP_EMAIL", "")).strip() api_key = (getattr(pconfig, "token", None) or os.getenv("ZULIP_API_KEY", "")).strip() if not site or not email or not api_key: return {"error": "Zulip standalone send: ZULIP_SITE, ZULIP_EMAIL and ZULIP_API_KEY must be set"} default_topic = extra.get("default_topic") or os.getenv("ZULIP_DEFAULT_TOPIC", "general") kind, to, topic = _parse_target(chat_id, default_topic) try: from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp proxy = resolve_proxy_url(platform_env_var="ZULIP_PROXY") sess_kw, req_kw = proxy_kwargs_for_aiohttp(proxy) auth = aiohttp.BasicAuth(email, api_key) async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60), **sess_kw) as session: body = message # Upload any media and append links to the message body. for media in (media_files or []): file_path = media.get("path") if isinstance(media, dict) else media if not file_path or not os.path.exists(file_path): continue form = aiohttp.FormData() with open(file_path, "rb") as fh: form.add_field("file", fh.read(), filename=os.path.basename(file_path)) async with session.post( f"{site}/api/v1/user_uploads", data=form, auth=auth, **req_kw, ) as up: if up.status >= 400: continue up_data = await up.json() rel = up_data.get("url") or up_data.get("uri") if rel: full = rel if rel.startswith("http") else f"{site}{rel}" body = f"{body}\n[{os.path.basename(file_path)}]({full})".strip() payload: Dict[str, Any] = {"content": body or "(no content)"} if kind == "direct": # "private" is the backward-compatible alias accepted by every # Zulip version (the newer "direct" alias only landed in 7.0). payload["type"] = "private" payload["to"] = json.dumps(to) else: payload["type"] = "stream" payload["to"] = str(to) payload["topic"] = topic or default_topic async with session.post( f"{site}/api/v1/messages", data=payload, auth=auth, **req_kw, ) as resp: data = await resp.json() if data.get("result") != "success": return {"error": f"Zulip API error: {str(data.get('msg', ''))[:300]}"} return { "success": True, "platform": "zulip", "chat_id": chat_id, "message_id": data.get("id"), } except aiohttp.ClientError as exc: return {"error": f"Zulip send failed (network): {exc}"} except Exception as exc: # noqa: BLE001 return {"error": f"Zulip send failed: {exc}"} # --------------------------------------------------------------------------- # Interactive setup wizard # --------------------------------------------------------------------------- def interactive_setup() -> None: """Guide the user through Zulip bot setup.""" from hermes_cli.config import get_env_value, save_env_value from hermes_cli.cli_output import ( prompt, prompt_yes_no, print_header, print_info, print_success, ) print_header("Zulip") if get_env_value("ZULIP_API_KEY") and get_env_value("ZULIP_EMAIL"): print_info("Zulip: already configured") if not prompt_yes_no("Reconfigure Zulip?", False): return print_info("Create a bot in Zulip: Settings → Personal → Bots → Add a new bot") print_info(" Choose 'Generic bot', then copy its email + API key.") print() site = prompt("Zulip site URL (e.g. https://example.zulipchat.com)") if site: save_env_value("ZULIP_SITE", site.rstrip("/")) email = prompt("Bot email") if email: save_env_value("ZULIP_EMAIL", email) api_key = prompt("Bot API key", password=True) if not api_key: return save_env_value("ZULIP_API_KEY", api_key) print_success("Zulip credentials saved") print() print_info("🔒 Security: restrict who can use your bot") allowed = prompt("Allowed sender emails/ids (comma-separated, empty for open access)") if allowed: save_env_value("ZULIP_ALLOWED_USERS", allowed.replace(" ", "")) print_success("Zulip allowlist configured") else: print_info("⚠️ No allowlist set — anyone who can message the bot can use it!") print() print_info("📬 Home channel: where Hermes delivers cron results / notifications.") print_info(" Form: 'stream-name/topic' (e.g. general/hermes).") home = prompt("Home channel (leave empty to set later with /set-home)") if home: save_env_value("ZULIP_HOME_CHANNEL", home) # --------------------------------------------------------------------------- # YAML -> env config bridge / connected probe # --------------------------------------------------------------------------- def _apply_yaml_config(yaml_cfg: dict, z_cfg: dict) -> dict | None: extras: Dict[str, Any] = {} site = z_cfg.get("site") or z_cfg.get("url") if site: extras["site"] = str(site).rstrip("/") if not os.getenv("ZULIP_SITE"): os.environ["ZULIP_SITE"] = str(site).rstrip("/") if z_cfg.get("email"): extras["email"] = str(z_cfg["email"]) if not os.getenv("ZULIP_EMAIL"): os.environ["ZULIP_EMAIL"] = str(z_cfg["email"]) if z_cfg.get("default_topic"): extras["default_topic"] = str(z_cfg["default_topic"]) if "require_mention" in z_cfg and not os.getenv("ZULIP_REQUIRE_MENTION"): os.environ["ZULIP_REQUIRE_MENTION"] = str(z_cfg["require_mention"]).lower() if "all_public_streams" in z_cfg and not os.getenv("ZULIP_ALL_PUBLIC_STREAMS"): os.environ["ZULIP_ALL_PUBLIC_STREAMS"] = str(z_cfg["all_public_streams"]).lower() for key, env in ( ("free_response_channels", "ZULIP_FREE_RESPONSE_CHANNELS"), ("allowed_channels", "ZULIP_ALLOWED_CHANNELS"), ): val = z_cfg.get(key) if val is not None and not os.getenv(env): if isinstance(val, list): val = ",".join(str(v) for v in val) os.environ[env] = str(val) return extras or None def _env_enablement() -> Optional[dict]: site = os.getenv("ZULIP_SITE", "") or os.getenv("ZULIP_URL", "") if not site: return None extras: Dict[str, Any] = {"site": site.rstrip("/")} if os.getenv("ZULIP_EMAIL"): extras["email"] = os.getenv("ZULIP_EMAIL") return extras def _is_connected(config) -> bool: import hermes_cli.gateway as gateway_mod site = (gateway_mod.get_env_value("ZULIP_SITE") or gateway_mod.get_env_value("ZULIP_URL") or "").strip() email = (gateway_mod.get_env_value("ZULIP_EMAIL") or "").strip() api_key = (gateway_mod.get_env_value("ZULIP_API_KEY") or "").strip() return bool(site and email and api_key) # --------------------------------------------------------------------------- # Plugin registration entry point # --------------------------------------------------------------------------- def _build_adapter(config): return ZulipAdapter(config) def register(ctx) -> None: """Plugin entry point — called by the Hermes plugin system.""" ctx.register_platform( name=PLATFORM_NAME, label="Zulip", adapter_factory=_build_adapter, check_fn=check_zulip_requirements, is_connected=_is_connected, required_env=["ZULIP_SITE", "ZULIP_EMAIL", "ZULIP_API_KEY"], install_hint="pip install aiohttp", setup_fn=interactive_setup, apply_yaml_config_fn=_apply_yaml_config, env_enablement_fn=_env_enablement, allowed_users_env="ZULIP_ALLOWED_USERS", allow_all_env="ZULIP_ALLOW_ALL_USERS", cron_deliver_env_var="ZULIP_HOME_CHANNEL", standalone_sender_fn=_standalone_send, max_message_length=MAX_MESSAGE_LENGTH, emoji="🗨️", platform_hint=( "You are on Zulip. Messages live under a channel (stream) + topic, " "or are direct messages. Use CommonMark/Zulip Markdown for formatting " "(**bold**, *italic*, `code`, ```code blocks```, lists, links, " "spoilers, LaTeX). Keep replies on-topic; the gateway keeps your " "reply in the same stream/topic or DM it arrived on." ), allow_update_command=True, )