Rewrite hermes-zulip-plugin as a proper Hermes platform plugin with
auto-discovery via plugin.yaml + __init__.py + register() pattern.
Features:
- Zulip event queue registration and polling for real-time messages
- DM-first architecture — private messages route into agent's session
- Placeholder→edit streaming UX (Thinking... → final response)
- Typing indicators via Zulip API
- Exponential backoff reconnection and queue re-registration
- Message deduplication with sliding window
- Config via config.yaml or env vars (ZULIP_EMAIL, ZULIP_API_KEY, ZULIP_SITE)
- Standalone sender for cron/notification delivery
- Plugin auto-discovery via @hermes_agent.plugins entry point
Architecture follows the proven ntfy adapter pattern:
plugins/platforms/{name}/plugin.yaml
plugins/platforms/{name}/__init__.py → exports register()
plugins/platforms/{name}/adapter.py → ZulipAdapter(BasePlatformAdapter)
Deployment: copy to ~/.hermes/plugins/platforms/zulip/ and restart gateway
Requirements: pip install zulip httpx
Closes issue #24
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
from .adapter import register
|
||||||
|
|
||||||
|
__all__ = ["register"]
|
||||||
@@ -0,0 +1,616 @@
|
|||||||
|
"""Zulip platform adapter (Hermes plugin).
|
||||||
|
|
||||||
|
Connects to a Zulip server via the Zulip Python SDK, registers an event
|
||||||
|
queue for real-time message delivery, and polls for incoming events.
|
||||||
|
DM-first architecture: private messages route directly into the Hermes
|
||||||
|
agent's session, maintaining conversation continuity with TUI/CLI.
|
||||||
|
|
||||||
|
Configuration in config.yaml::
|
||||||
|
|
||||||
|
platforms:
|
||||||
|
zulip:
|
||||||
|
enabled: true
|
||||||
|
extra:
|
||||||
|
email: "tanko-bot@chat.sysloggh.net"
|
||||||
|
api_key: "${ZULIP_API_KEY}"
|
||||||
|
site: "https://chat.sysloggh.net"
|
||||||
|
poll_interval_ms: 3000
|
||||||
|
|
||||||
|
Environment variables (env wins over config.yaml):
|
||||||
|
ZULIP_EMAIL Bot email (required)
|
||||||
|
ZULIP_API_KEY Bot API key (required)
|
||||||
|
ZULIP_SITE Server URL (required)
|
||||||
|
ZULIP_ALLOWED_USERS Comma-separated user emails allowed (optional)
|
||||||
|
ZULIP_ALLOW_ALL_USERS Allow any user (optional)
|
||||||
|
ZULIP_HOME_CHANNEL Default recipient for cron/notifications (optional)
|
||||||
|
ZULIP_HOME_CHANNEL_NAME Human label for home channel (optional)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import zulip
|
||||||
|
ZULIP_SDK_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
ZULIP_SDK_AVAILABLE = False
|
||||||
|
zulip = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
from gateway.config import Platform, PlatformConfig
|
||||||
|
from gateway.platforms.base import (
|
||||||
|
BasePlatformAdapter,
|
||||||
|
MessageEvent,
|
||||||
|
MessageType,
|
||||||
|
SendResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DEFAULT_POLL_INTERVAL_MS = 3000
|
||||||
|
MAX_MESSAGE_LENGTH = 10000 # Zulip max message body length
|
||||||
|
RECONNECT_BACKOFF = [2, 5, 10, 30, 60]
|
||||||
|
DEDUP_WINDOW_SECONDS = 300
|
||||||
|
DEDUP_MAX_SIZE = 1000
|
||||||
|
|
||||||
|
PLACEHOLDERS = [
|
||||||
|
":robot: _Processing your message..._",
|
||||||
|
":hourglass_flowing_sand: _Thinking..._",
|
||||||
|
":brain: _Generating response..._",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Plugin registration helpers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def check_requirements() -> bool:
|
||||||
|
"""Check whether the Zulip SDK is available and minimally configured."""
|
||||||
|
if not ZULIP_SDK_AVAILABLE:
|
||||||
|
return False
|
||||||
|
email = os.getenv("ZULIP_EMAIL", "").strip()
|
||||||
|
api_key = os.getenv("ZULIP_API_KEY", "").strip()
|
||||||
|
site = os.getenv("ZULIP_SITE", "").strip()
|
||||||
|
return bool(email and api_key and site)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config(config) -> bool:
|
||||||
|
"""Validate Zulip platform has email and site configured."""
|
||||||
|
extra = getattr(config, "extra", {}) or {}
|
||||||
|
email = extra.get("email") or os.getenv("ZULIP_EMAIL", "")
|
||||||
|
site = extra.get("site") or os.getenv("ZULIP_SITE", "")
|
||||||
|
return bool(email and site)
|
||||||
|
|
||||||
|
|
||||||
|
def is_connected(config) -> bool:
|
||||||
|
"""Check whether Zulip is configured (env or config.yaml)."""
|
||||||
|
extra = getattr(config, "extra", {}) or {}
|
||||||
|
email = os.getenv("ZULIP_EMAIL") or extra.get("email", "")
|
||||||
|
site = os.getenv("ZULIP_SITE") or extra.get("site", "")
|
||||||
|
return bool(email and site)
|
||||||
|
|
||||||
|
|
||||||
|
def _env_enablement() -> dict | None:
|
||||||
|
"""Seed PlatformConfig.extra from env vars during gateway config load.
|
||||||
|
|
||||||
|
Returns None when Zulip isn't minimally configured.
|
||||||
|
"""
|
||||||
|
email = os.getenv("ZULIP_EMAIL", "").strip()
|
||||||
|
api_key = os.getenv("ZULIP_API_KEY", "").strip()
|
||||||
|
site = os.getenv("ZULIP_SITE", "").strip()
|
||||||
|
if not (email and api_key and site):
|
||||||
|
return None
|
||||||
|
seed: dict = {"email": email, "site": site}
|
||||||
|
home = os.getenv("ZULIP_HOME_CHANNEL", "").strip()
|
||||||
|
if home:
|
||||||
|
seed["home_channel"] = {
|
||||||
|
"chat_id": home,
|
||||||
|
"name": os.getenv("ZULIP_HOME_CHANNEL_NAME", home),
|
||||||
|
}
|
||||||
|
return seed
|
||||||
|
|
||||||
|
|
||||||
|
async def _standalone_send(
|
||||||
|
pconfig,
|
||||||
|
chat_id: str,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
thread_id: Optional[str] = None,
|
||||||
|
media_files: Optional[List[str]] = None,
|
||||||
|
force_document: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Out-of-process send for cron / send_message_tool fallbacks."""
|
||||||
|
if not ZULIP_SDK_AVAILABLE:
|
||||||
|
return {"error": "zulip standalone send: zulip SDK not installed"}
|
||||||
|
extra = getattr(pconfig, "extra", {}) or {}
|
||||||
|
email = extra.get("email") or os.getenv("ZULIP_EMAIL", "")
|
||||||
|
api_key = extra.get("api_key") or os.getenv("ZULIP_API_KEY", "")
|
||||||
|
site = extra.get("site") or os.getenv("ZULIP_SITE", "")
|
||||||
|
if not (email and api_key and site):
|
||||||
|
return {"error": "zulip standalone send: ZULIP_EMAIL, ZULIP_API_KEY, ZULIP_SITE required"}
|
||||||
|
try:
|
||||||
|
client = zulip.Client(email=email, api_key=api_key, site=site)
|
||||||
|
result = client.send_message({
|
||||||
|
"type": "private",
|
||||||
|
"to": chat_id,
|
||||||
|
"content": message[:MAX_MESSAGE_LENGTH],
|
||||||
|
})
|
||||||
|
if result.get("result") == "success":
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"platform": "zulip",
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"message_id": str(result.get("id", "")),
|
||||||
|
}
|
||||||
|
return {"error": f"zulip send failed: {result.get('msg', 'unknown')}"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"zulip standalone send failed: {e}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Adapter ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class ZulipAdapter(BasePlatformAdapter):
|
||||||
|
"""Zulip platform adapter.
|
||||||
|
|
||||||
|
Connects to Zulip, registers a real-time event queue, and polls for
|
||||||
|
incoming messages. DMs are routed into the Hermes agent's session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH
|
||||||
|
|
||||||
|
def __init__(self, config: PlatformConfig):
|
||||||
|
platform = Platform("zulip")
|
||||||
|
super().__init__(config=config, platform=platform)
|
||||||
|
extra = config.extra or {}
|
||||||
|
|
||||||
|
# Core Zulip config — env overrides config.yaml
|
||||||
|
self._email: str = os.getenv("ZULIP_EMAIL") or extra.get("email", "")
|
||||||
|
self._api_key: str = os.getenv("ZULIP_API_KEY") or extra.get("api_key", "")
|
||||||
|
self._site: str = (os.getenv("ZULIP_SITE") or extra.get("site", "")).rstrip("/")
|
||||||
|
|
||||||
|
# Polling config
|
||||||
|
self._poll_interval: float = (
|
||||||
|
(extra.get("poll_interval_ms") or DEFAULT_POLL_INTERVAL_MS) / 1000.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# State
|
||||||
|
self._client: Optional["zulip.Client"] = None
|
||||||
|
self._queue_id: Optional[str] = None
|
||||||
|
self._last_event_id: int = -1
|
||||||
|
self._poll_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
|
# Message deduplication: event_id -> timestamp
|
||||||
|
self._seen_messages: Dict[str, float] = {}
|
||||||
|
|
||||||
|
# Pending replies for placeholder -> edit streaming.
|
||||||
|
# Maps chat_id -> {placeholder_msg_id, ...}
|
||||||
|
self._pending_replies: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
# ── Connection lifecycle ────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
"""Connect to Zulip by registering an event queue."""
|
||||||
|
if not ZULIP_SDK_AVAILABLE:
|
||||||
|
logger.warning(
|
||||||
|
"[%s] zulip SDK not installed. Run: pip install zulip", self.name
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
if not (self._email and self._api_key and self._site):
|
||||||
|
logger.warning(
|
||||||
|
"[%s] ZULIP_EMAIL, ZULIP_API_KEY, ZULIP_SITE not all configured",
|
||||||
|
self.name,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._client = zulip.Client(
|
||||||
|
email=self._email,
|
||||||
|
api_key=self._api_key,
|
||||||
|
site=self._site,
|
||||||
|
client="Hermes-Zulip-Plugin/1.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register event queue for message events
|
||||||
|
queue_result = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self._client.register(
|
||||||
|
event_types=["message"],
|
||||||
|
fetch_event_types=["message"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._queue_id = queue_result.get("queue_id")
|
||||||
|
self._last_event_id = queue_result.get("last_event_id", -1)
|
||||||
|
|
||||||
|
if not self._queue_id:
|
||||||
|
logger.error(
|
||||||
|
"[%s] Queue registration failed: %s",
|
||||||
|
self.name, queue_result,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._poll_task = asyncio.create_task(self._poll_loop())
|
||||||
|
self._mark_connected()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[%s] Connected — %s on %s, queue=%s last_event=%s",
|
||||||
|
self.name, self._email, self._site,
|
||||||
|
self._queue_id, self._last_event_id,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[%s] Failed to connect: %s", self.name, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def disconnect(self) -> bool:
|
||||||
|
"""Disconnect from Zulip."""
|
||||||
|
self._running = False
|
||||||
|
self._mark_disconnected()
|
||||||
|
if self._poll_task:
|
||||||
|
self._poll_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._poll_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._poll_task = None
|
||||||
|
if self._client and self._queue_id:
|
||||||
|
try:
|
||||||
|
await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None, lambda: self._client.deregister(self._queue_id)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._client = None
|
||||||
|
self._queue_id = None
|
||||||
|
self._seen_messages.clear()
|
||||||
|
self._pending_replies.clear()
|
||||||
|
logger.info("[%s] Disconnected", self.name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ── Polling loop ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _poll_loop(self) -> None:
|
||||||
|
"""Poll the Zulip event queue for new messages, with reconnect."""
|
||||||
|
backoff_idx = 0
|
||||||
|
loop_start: float = 0.0
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
loop_start = time.monotonic()
|
||||||
|
await self._poll_once()
|
||||||
|
if time.monotonic() - loop_start < 60.0:
|
||||||
|
backoff_idx = 0
|
||||||
|
await asyncio.sleep(self._poll_interval)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
logger.warning("[%s] Poll error: %s", self.name, e)
|
||||||
|
if "BAD_EVENT_QUEUE_ID" in str(e):
|
||||||
|
logger.info("[%s] Queue expired, re-registering...", self.name)
|
||||||
|
if await self._reregister_queue():
|
||||||
|
backoff_idx = 0
|
||||||
|
continue
|
||||||
|
delay = RECONNECT_BACKOFF[
|
||||||
|
min(backoff_idx, len(RECONNECT_BACKOFF) - 1)
|
||||||
|
]
|
||||||
|
logger.info("[%s] Retrying in %ds...", self.name, delay)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
backoff_idx += 1
|
||||||
|
|
||||||
|
async def _poll_once(self) -> None:
|
||||||
|
"""Retrieve and process one batch of events."""
|
||||||
|
if not self._client or not self._queue_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self._client.get_events(
|
||||||
|
queue_id=self._queue_id,
|
||||||
|
last_event_id=self._last_event_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not data or not isinstance(data, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
if data.get("result") == "error":
|
||||||
|
msg = data.get("msg", "")
|
||||||
|
if "BAD_EVENT_QUEUE_ID" in msg or "queue_id" in msg.lower():
|
||||||
|
raise RuntimeError(f"BAD_EVENT_QUEUE_ID: {msg}")
|
||||||
|
return
|
||||||
|
|
||||||
|
events = data.get("events", [])
|
||||||
|
if not events:
|
||||||
|
return
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
event_id = event.get("id", 0)
|
||||||
|
if event_id > self._last_event_id:
|
||||||
|
self._last_event_id = event_id
|
||||||
|
await self._on_event(event)
|
||||||
|
|
||||||
|
async def _reregister_queue(self) -> bool:
|
||||||
|
"""Re-register the event queue after expiry."""
|
||||||
|
try:
|
||||||
|
queue_result = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self._client.register(
|
||||||
|
event_types=["message"],
|
||||||
|
fetch_event_types=["message"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._queue_id = queue_result.get("queue_id")
|
||||||
|
self._last_event_id = queue_result.get("last_event_id", -1)
|
||||||
|
if self._queue_id:
|
||||||
|
logger.info(
|
||||||
|
"[%s] Queue re-registered: %s", self.name, self._queue_id
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[%s] Queue re-registration failed: %s", self.name, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── Event processing ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _on_event(self, event: Dict[str, Any]) -> None:
|
||||||
|
"""Process a single Zulip event."""
|
||||||
|
if event.get("type") != "message":
|
||||||
|
return
|
||||||
|
|
||||||
|
msg = event.get("message", {})
|
||||||
|
if not msg:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Skip own messages (echo loop prevention)
|
||||||
|
sender_email = msg.get("sender_email", "")
|
||||||
|
if sender_email == self._email:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Deduplicate
|
||||||
|
event_id = str(event.get("id", ""))
|
||||||
|
if self._is_duplicate(event_id):
|
||||||
|
return
|
||||||
|
|
||||||
|
# DM-first architecture (ADR-001): only process private messages
|
||||||
|
if msg.get("type") == "private":
|
||||||
|
await self._on_dm(msg)
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _on_dm(self, msg: Dict[str, Any]) -> None:
|
||||||
|
"""Process an incoming DM and dispatch to the gateway."""
|
||||||
|
sender_id = str(msg.get("sender_id", ""))
|
||||||
|
sender_email = str(msg.get("sender_email", "unknown"))
|
||||||
|
sender_name = str(msg.get("sender_full_name", sender_email))
|
||||||
|
content = str(msg.get("content", "")).strip()
|
||||||
|
|
||||||
|
if not content or not sender_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[%s] DM from %s (%s): %.60s",
|
||||||
|
self.name, sender_name, sender_email, content,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send a placeholder for streaming UX
|
||||||
|
placeholder_msg_id = await self._send_placeholder(sender_id)
|
||||||
|
|
||||||
|
# Register pending reply so send_message can edit the placeholder
|
||||||
|
self._pending_replies[sender_id] = {
|
||||||
|
"placeholder_msg_id": placeholder_msg_id,
|
||||||
|
"type": "private",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fire-and-forget typing indicator
|
||||||
|
asyncio.ensure_future(
|
||||||
|
self._send_typing_indicator(int(sender_id), "start")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build the session source
|
||||||
|
source = self.build_source(
|
||||||
|
chat_id=sender_id,
|
||||||
|
chat_name=sender_email,
|
||||||
|
chat_type="dm",
|
||||||
|
user_id=sender_email,
|
||||||
|
user_name=sender_name,
|
||||||
|
message_id=str(msg.get("id", "")),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build the MessageEvent for the gateway
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
message_event = MessageEvent(
|
||||||
|
text=content,
|
||||||
|
message_type=MessageType.TEXT,
|
||||||
|
source=source,
|
||||||
|
message_id=str(msg.get("id", "")),
|
||||||
|
raw_message=msg,
|
||||||
|
timestamp=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug("[%s] Dispatching DM from %s", self.name, sender_email)
|
||||||
|
await self.handle_message(message_event)
|
||||||
|
|
||||||
|
def _is_duplicate(self, msg_id: str) -> bool:
|
||||||
|
"""Dedup check with sliding window."""
|
||||||
|
now = time.time()
|
||||||
|
stale = [
|
||||||
|
mid for mid, ts in self._seen_messages.items()
|
||||||
|
if now - ts > DEDUP_WINDOW_SECONDS
|
||||||
|
]
|
||||||
|
for mid in stale:
|
||||||
|
del self._seen_messages[mid]
|
||||||
|
if len(self._seen_messages) > DEDUP_MAX_SIZE:
|
||||||
|
oldest = sorted(
|
||||||
|
self._seen_messages.keys(), key=lambda k: self._seen_messages[k]
|
||||||
|
)[:100]
|
||||||
|
for k in oldest:
|
||||||
|
del self._seen_messages[k]
|
||||||
|
if msg_id in self._seen_messages:
|
||||||
|
return True
|
||||||
|
self._seen_messages[msg_id] = now
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── Message delivery ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def send_message(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
reply_to: Optional[str] = None,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
edit_message_id: Optional[str] = None,
|
||||||
|
) -> SendResult:
|
||||||
|
"""Send a DM reply.
|
||||||
|
|
||||||
|
If a pending placeholder exists for this chat, edits it with the
|
||||||
|
final response (streaming UX). Otherwise sends a fresh message.
|
||||||
|
"""
|
||||||
|
# Check for pending placeholder to finalize
|
||||||
|
pending = self._pending_replies.pop(chat_id, None)
|
||||||
|
if pending and pending.get("placeholder_msg_id"):
|
||||||
|
return await self._edit_message(
|
||||||
|
pending["placeholder_msg_id"],
|
||||||
|
content[:MAX_MESSAGE_LENGTH],
|
||||||
|
chat_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await self._send_fresh(chat_id, content)
|
||||||
|
|
||||||
|
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
||||||
|
"""Send typing indicator. No-op; managed via event flow."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||||
|
"""Return basic info about a Zulip chat."""
|
||||||
|
return {"name": chat_id, "type": "dm"}
|
||||||
|
|
||||||
|
# ── Internal helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _send_placeholder(self, chat_id: str) -> Optional[str]:
|
||||||
|
"""Send a 'Thinking...' placeholder message. Returns message ID."""
|
||||||
|
if not self._client:
|
||||||
|
return None
|
||||||
|
placeholder = PLACEHOLDERS[
|
||||||
|
self._next_reply_id % len(PLACEHOLDERS)
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
result = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self._client.send_message({
|
||||||
|
"type": "private",
|
||||||
|
"to": chat_id,
|
||||||
|
"content": placeholder,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if result.get("result") == "success":
|
||||||
|
msg_id = str(result.get("id", ""))
|
||||||
|
logger.debug("[%s] Placeholder sent to %s (msg_id=%s)", self.name, chat_id, msg_id)
|
||||||
|
return msg_id
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("[%s] Placeholder send failed: %s", self.name, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _send_typing_indicator(self, user_id: int, operation: str) -> None:
|
||||||
|
"""Send typing start/stop via Zulip API."""
|
||||||
|
if not self._client:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self._client.send_typing_notification(
|
||||||
|
recipients=[user_id],
|
||||||
|
operation=operation,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _edit_message(
|
||||||
|
self, message_id: str, content: str, chat_id: str
|
||||||
|
) -> SendResult:
|
||||||
|
"""Edit an existing Zulip message (finalize a placeholder)."""
|
||||||
|
if not self._client:
|
||||||
|
return SendResult(success=False, error="Client not connected")
|
||||||
|
try:
|
||||||
|
await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self._client.update_message({
|
||||||
|
"message_id": message_id,
|
||||||
|
"content": content,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return SendResult(success=True, message_id=message_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"[%s] Edit failed, sending fresh: %s", self.name, e
|
||||||
|
)
|
||||||
|
return await self._send_fresh(chat_id, content)
|
||||||
|
|
||||||
|
async def _send_fresh(self, chat_id: str, content: str) -> SendResult:
|
||||||
|
"""Send a fresh DM to Zulip."""
|
||||||
|
if not self._client:
|
||||||
|
return SendResult(success=False, error="Client not connected")
|
||||||
|
try:
|
||||||
|
truncated = content[:MAX_MESSAGE_LENGTH]
|
||||||
|
result = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self._client.send_message({
|
||||||
|
"type": "private",
|
||||||
|
"to": chat_id,
|
||||||
|
"content": truncated,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if result.get("result") == "success":
|
||||||
|
return SendResult(
|
||||||
|
success=True,
|
||||||
|
message_id=str(result.get("id", "")),
|
||||||
|
)
|
||||||
|
return SendResult(
|
||||||
|
success=False,
|
||||||
|
error=result.get("msg", "Send failed"),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[%s] Send error: %s", self.name, e)
|
||||||
|
return SendResult(success=False, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Plugin registration ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def register(ctx) -> None:
|
||||||
|
"""Plugin entry point — called by the Hermes plugin system at startup."""
|
||||||
|
ctx.register_platform(
|
||||||
|
name="zulip",
|
||||||
|
label="Zulip",
|
||||||
|
adapter_factory=lambda cfg: ZulipAdapter(cfg),
|
||||||
|
check_fn=check_requirements,
|
||||||
|
validate_config=validate_config,
|
||||||
|
is_connected=is_connected,
|
||||||
|
required_env=["ZULIP_EMAIL", "ZULIP_API_KEY", "ZULIP_SITE"],
|
||||||
|
install_hint="pip install zulip httpx",
|
||||||
|
env_enablement_fn=_env_enablement,
|
||||||
|
cron_deliver_env_var="ZULIP_HOME_CHANNEL",
|
||||||
|
standalone_sender_fn=_standalone_send,
|
||||||
|
allowed_users_env="ZULIP_ALLOWED_USERS",
|
||||||
|
allow_all_env="ZULIP_ALLOW_ALL_USERS",
|
||||||
|
max_message_length=MAX_MESSAGE_LENGTH,
|
||||||
|
emoji="💬",
|
||||||
|
pii_safe=False,
|
||||||
|
allow_update_command=True,
|
||||||
|
platform_hint=(
|
||||||
|
"You are communicating via Zulip direct messages. "
|
||||||
|
"Your responses are delivered through Zulip's API. "
|
||||||
|
"Use markdown formatting for rich responses. "
|
||||||
|
f"Keep messages under {MAX_MESSAGE_LENGTH} characters (Zulip limit)."
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: zulip-platform
|
||||||
|
label: Zulip
|
||||||
|
kind: platform
|
||||||
|
version: 1.0.0
|
||||||
|
description: >
|
||||||
|
Zulip messaging gateway adapter for Hermes Agent.
|
||||||
|
Connects to any Zulip server, registers an event queue, and polls for
|
||||||
|
incoming messages. Handles DMs first (ADR-001), with @mention support
|
||||||
|
planned. Uses the Zulip Python SDK for API access.
|
||||||
|
|
||||||
|
DM-first architecture: private messages route directly into the Hermes
|
||||||
|
agent's session, so the same personality and conversation continuity
|
||||||
|
is maintained between TUI, CLI, and Zulip.
|
||||||
|
|
||||||
|
Features: event queue polling, typing indicators, placeholder→edit
|
||||||
|
streaming, exponential backoff reconnection, health endpoint.
|
||||||
|
author: Syslog Solution LLC
|
||||||
|
requires_env:
|
||||||
|
- name: ZULIP_EMAIL
|
||||||
|
description: "Zulip bot email address (e.g. tanko-bot@chat.sysloggh.net)"
|
||||||
|
prompt: "Zulip bot email"
|
||||||
|
password: false
|
||||||
|
- name: ZULIP_API_KEY
|
||||||
|
description: "Zulip bot API key"
|
||||||
|
prompt: "Zulip API key"
|
||||||
|
password: true
|
||||||
|
- name: ZULIP_SITE
|
||||||
|
description: "Zulip server URL (e.g. https://chat.sysloggh.net)"
|
||||||
|
prompt: "Zulip server URL"
|
||||||
|
password: false
|
||||||
|
optional_env:
|
||||||
|
- name: ZULIP_ALLOWED_USERS
|
||||||
|
description: "Comma-separated Zulip user IDs or emails allowed to DM the agent"
|
||||||
|
prompt: "Allowed users (comma-separated emails or IDs)"
|
||||||
|
password: false
|
||||||
|
- name: ZULIP_ALLOW_ALL_USERS
|
||||||
|
description: "Allow any user to DM the bot (disables allowlist)"
|
||||||
|
prompt: "Allow all users? (true/false)"
|
||||||
|
password: false
|
||||||
|
- name: ZULIP_HOME_CHANNEL
|
||||||
|
description: "Default recipient for cron / notification delivery"
|
||||||
|
prompt: "Home channel user email (or empty)"
|
||||||
|
password: false
|
||||||
|
- name: ZULIP_HOME_CHANNEL_NAME
|
||||||
|
description: "Human label for the home channel"
|
||||||
|
prompt: "Home channel display name"
|
||||||
|
password: false
|
||||||
@@ -1,3 +1,2 @@
|
|||||||
zulip>=0.9.0
|
zulip>=0.9.0
|
||||||
zulip-bots>=0.9.0
|
httpx>=0.27.0
|
||||||
pyyaml>=6.0
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
"""
|
|
||||||
Hermes Zulip Plugin — Core adapter for Hermes Python agents
|
|
||||||
|
|
||||||
Implements BasePlatformAdapter to connect Hermes agents (Tanko, Mumuni,
|
|
||||||
Koonimo, Koby) to the Sysloggh Zulip agent mesh.
|
|
||||||
|
|
||||||
Path: hermes-zulip-plugin/src/hermes_zulip/
|
|
||||||
Config: config.yaml (per-agent, deployed alongside plugin)
|
|
||||||
"""
|
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
# Core adapter implements BasePlatformAdapter for Zulip
|
|
||||||
# See ADR-005 for @mention detection, ADR-009 for error handling
|
|
||||||
# Full implementation in issue #5.
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
import re
|
|
||||||
import threading
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
class ZulipAdapter:
|
|
||||||
"""Zulip adapter for Hermes agents. Connects to Zulip, listens for
|
|
||||||
@mentions in #agent-hub, routes messages via BasePlatformAdapter."""
|
|
||||||
|
|
||||||
# Regex to strip Zulip mention artifacts from message bodies (ADR-008)
|
|
||||||
MENTION_CLEANER = re.compile(r'@\*\*[^*]+\*\*')
|
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any]) -> None:
|
|
||||||
self.config = config
|
|
||||||
self.connected = False
|
|
||||||
self._client = None
|
|
||||||
self._thread = None
|
|
||||||
self._stop_event = threading.Event()
|
|
||||||
|
|
||||||
async def connect(self) -> None:
|
|
||||||
"""Establish connection to Zulip and start the event loop."""
|
|
||||||
if self.connected:
|
|
||||||
logger.info("Already connected to Zulip.")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
from zulip import Client
|
|
||||||
server_url = self.config['zulip']['server_url']
|
|
||||||
email = self.config['zulip']['email']
|
|
||||||
api_key = self.config['zulip']['api_key']
|
|
||||||
|
|
||||||
self._client = Client(server_url=server_url, email=email, api_key=api_key)
|
|
||||||
self.connected = True
|
|
||||||
logger.info(f"Connected to Zulip: {server_url} as {email}")
|
|
||||||
|
|
||||||
# Start the event loop in a background thread
|
|
||||||
self._stop_event.clear()
|
|
||||||
self._thread = threading.Thread(target=self._event_loop, daemon=True)
|
|
||||||
self._thread.start()
|
|
||||||
logger.info("Zulip event loop started.")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to connect to Zulip: {e}")
|
|
||||||
self.connected = False
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
|
||||||
"""Disconnect from Zulip and stop the event loop."""
|
|
||||||
if not self.connected:
|
|
||||||
return
|
|
||||||
self._stop_event.set()
|
|
||||||
if self._thread:
|
|
||||||
self._thread.join(timeout=5)
|
|
||||||
self.connected = False
|
|
||||||
logger.info("Disconnected from Zulip.")
|
|
||||||
|
|
||||||
async def send_message(self, topic: str, content: str) -> Dict[str, Any]:
|
|
||||||
"""Send a message to Zulip with retry logic (ADR-009)."""
|
|
||||||
max_retries = self.config.get('error_handling', {}).get('retry_count', 3)
|
|
||||||
retry_delay = self.config.get('error_handling', {}).get('retry_delay_seconds', 5)
|
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
|
||||||
try:
|
|
||||||
result = self._client.send_message({
|
|
||||||
'type': 'stream',
|
|
||||||
'to': self.config['zulip']['stream'],
|
|
||||||
'subject': topic,
|
|
||||||
'content': content,
|
|
||||||
})
|
|
||||||
logger.info(f"Message sent to {self.config['zulip']['stream']} > {topic} (attempt {attempt + 1})")
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to send message (attempt {attempt + 1}): {e}")
|
|
||||||
if attempt < max_retries - 1:
|
|
||||||
time.sleep(retry_delay)
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def on_event(self, event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Process incoming Zulip events and route via BasePlatformAdapter."""
|
|
||||||
if event.get('type') != 'message':
|
|
||||||
return None
|
|
||||||
|
|
||||||
msg = event.get('message', {})
|
|
||||||
mentioned_users = msg.get('mentioned_users', False)
|
|
||||||
|
|
||||||
# Only process messages that mention this bot (ADR-005)
|
|
||||||
if not mentioned_users:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Strip Zulip formatting artifacts
|
|
||||||
body = msg.get('content', '')
|
|
||||||
clean_body = self.MENTION_CLEANER.sub('', body)
|
|
||||||
clean_body = clean_body.strip()
|
|
||||||
|
|
||||||
# Construct the MessageEvent for BasePlatformAdapter
|
|
||||||
return {
|
|
||||||
'type': 'MessageEvent',
|
|
||||||
'sender': msg.get('sender_full_name', 'Unknown'),
|
|
||||||
'body': clean_body,
|
|
||||||
'topic': msg.get('subject', 'Unknown Topic'),
|
|
||||||
'stream': msg.get('stream', 'Unknown Stream'),
|
|
||||||
'timestamp': msg.get('timestamp', 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _event_loop(self) -> None:
|
|
||||||
"""Background thread to listen for Zulip events."""
|
|
||||||
try:
|
|
||||||
self._client.call_on_each_message(
|
|
||||||
lambda event: self._process_event(event),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Event loop crashed: {e}")
|
|
||||||
self.connected = False
|
|
||||||
|
|
||||||
def _process_event(self, event: Dict[str, Any]) -> None:
|
|
||||||
"""Bridge the synchronous event to the async on_event handler."""
|
|
||||||
import asyncio
|
|
||||||
try:
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
if loop.is_running():
|
|
||||||
asyncio.create_task(self.on_event(event))
|
|
||||||
else:
|
|
||||||
asyncio.run(self.on_event(event))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing event: {e}")
|
|
||||||
Reference in New Issue
Block a user