fix: Zulip attachment handling — event attachments + all file types

hermes-zulip-plugin (Tanko/Mumuni):
  - Add _extract_event_attachments() to handle message.attachments from
    Zulip UI file uploads (previously only inline markdown links worked)
  - Add shared _cache_attachment() method for images/audio/documents
  - Refactor _extract_inline_media() to use _cache_attachment()
  - Fix connect() signature for hermes-agent 0.18.0 compat (add is_reconnect)

pi-zulip-extension (Abiba):
  - Extend attachment handling beyond images only: text files (decoded
    inline), PDFs (pdftotext extraction), binary files (metadata)
  - 50K char cap on text/PDF extraction to prevent context flooding
  - Classify attachments by extension (image/text/pdf/binary)

pi-mcp-extension (Abiba):
  - Detect bridge-side text truncation (… ellipsis marker)
  - Rebuild relay message display from structuredContent when truncated
  - Add rebuildRelayTextFromStructuredContent() helper

Config:
  - Add ZULIP_ROLE=router to ecosystem.abiba.config.cjs (was missing)
This commit is contained in:
Abiba
2026-07-05 16:03:32 +00:00
parent 7474ab1dc4
commit f205230e84
8 changed files with 2798 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
name: zulip-platform
label: Zulip
kind: platform
manifest_version: 1
version: 1.0.0
description: >
Zulip gateway adapter for Hermes Agent. Connects to a self-hosted or cloud
Zulip realm via the REST API — an event queue (register + long-poll) for
receiving and POST /messages for sending — relaying messages between Zulip
channels (streams)/topics and direct messages and the Hermes agent.
Supports topic-aware replies, native file uploads, channel-scoped
allowlists, @mention gating via message flags, and home-channel cron
delivery. No Zulip SDK required — uses aiohttp.
author: wachtelhund
requires_env:
- name: ZULIP_SITE
description: "Zulip realm URL (e.g. https://example.zulipchat.com)"
prompt: "Zulip site URL"
password: false
url: "https://zulip.com/api/api-keys"
- name: ZULIP_EMAIL
description: "Bot email address"
prompt: "Bot email"
password: false
- name: ZULIP_API_KEY
description: "Bot API key"
prompt: "Zulip bot API key"
password: true
optional_env:
- name: ZULIP_ALLOWED_USERS
description: "Comma-separated sender emails/ids allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"
password: false
- name: ZULIP_ALLOW_ALL_USERS
description: "Allow any Zulip user to trigger the bot (dev only)"
prompt: "Allow all users? (true/false)"
password: false
- name: ZULIP_HOME_CHANNEL
description: "Default target for cron / notification delivery (e.g. general/hermes)"
prompt: "Home channel (stream/topic)"
password: false
- name: ZULIP_REQUIRE_MENTION
description: "Require @bot mention in channels (default true). Set false for free-response everywhere."
prompt: "Require @mention? (true/false)"
password: false
- name: ZULIP_FREE_RESPONSE_CHANNELS
description: "Comma-separated stream ids/names where @mention is not required."
prompt: "Free-response streams (comma-separated)"
password: false
- name: ZULIP_ALLOWED_CHANNELS
description: "If set, the bot only responds in these stream ids/names (whitelist)."
prompt: "Allowed streams (comma-separated)"
password: false
- name: ZULIP_ALL_PUBLIC_STREAMS
description: "Subscribe the event queue to all public streams (default false)."
prompt: "Listen to all public streams? (true/false)"
password: false
- name: ZULIP_DEFAULT_TOPIC
description: "Topic used when a target has none (default 'general')."
prompt: "Default topic"
password: false
+13
View File
@@ -0,0 +1,13 @@
"""Zulip gateway plugin for Hermes Agent."""
try:
# Normal path: Hermes loads this as a package (``hermes_plugins.…``).
from .adapter import register
except ImportError: # pragma: no cover
# Imported as a top-level module (e.g. by a test collector that treats
# the repo root as a package). Fall back to an absolute import.
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from adapter import register
__all__ = ["register"]
+943
View File
@@ -0,0 +1,943 @@
"""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:<stream_id>:<topic> a stream message under a topic (chat_type=channel)
d:<user_id>,<user_id>,… 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:<id>:<topic> -> ("stream", <id-or-name>, <topic>)
<stream>/<topic> -> ("stream", <stream name>, <topic>) (friendly)
<stream> -> ("stream", <stream name>, 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_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 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,
)