Architecture change: DM-First (v2) replaces @mention-in-stream (v1) as primary agent interaction channel. See ADR-001-dm-topology and ADR-002-dm-routing. Key changes: - New DM-first Zulip adapter (adapter_dm.py) deployed on CT 112 - Registers empty narrow to receive ALL events - Demuxes DMs (type: 'private') vs stream events - Replies to DMs back in private thread, stream replies to #agent-hub - Uses blocking long-poll (dont_block=False) to eliminate rate limiting - Rewritten ARCHITECTURE.md, CONTEXT.md, PRD.md for v2 DM-First - New ADRs: ADR-001 (DM Topology), ADR-002 (DM Routing) - Archived old ADRs: 001, 002, 005 (topic-topology, mention-routing, mention-detection) - Updated ADRs: 003 (agent-naming), 004 (per-agent-bots), 006 (all-bots-model) - Updated PI extension (index.ts) with DM-aware patterns - Tanko agent confirmed responding in both DM and @all-bots in #agent-hub
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
# See ADR-005 for @mention detection, ADR-009 for error handling
|
||||
# Full implementation in issue #5.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
@@ -34,10 +33,12 @@ class ZulipAdapter:
|
||||
self.connected = False
|
||||
self._client = None
|
||||
self._thread = None
|
||||
self._loop = None
|
||||
self._stop_event = threading.Event()
|
||||
self.message_handler = None # Callback: async (MessageEvent) -> str | None
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Establish connection to Zulip and start the event loop."""
|
||||
def connect(self) -> None:
|
||||
"""Establish connection to Zulip and start the event loop (synchronous)."""
|
||||
if self.connected:
|
||||
logger.info("Already connected to Zulip.")
|
||||
return
|
||||
@@ -63,8 +64,8 @@ class ZulipAdapter:
|
||||
self.connected = False
|
||||
raise
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from Zulip and stop the event loop."""
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from Zulip and stop the event loop (synchronous)."""
|
||||
if not self.connected:
|
||||
return
|
||||
self._stop_event.set()
|
||||
@@ -73,8 +74,8 @@ class ZulipAdapter:
|
||||
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)."""
|
||||
def send_message(self, topic: str, content: str) -> Dict[str, Any]:
|
||||
"""Send a message to Zulip with retry logic (ADR-009) — synchronous."""
|
||||
max_retries = self.config.get('error_handling', {}).get('retry_count', 3)
|
||||
retry_delay = self.config.get('error_handling', {}).get('retry_delay_seconds', 5)
|
||||
|
||||
@@ -95,18 +96,28 @@ class ZulipAdapter:
|
||||
else:
|
||||
raise
|
||||
|
||||
async def on_event(self, event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Process incoming Zulip events and route via BasePlatformAdapter."""
|
||||
def on_event(self, event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Process incoming Zulip events — synchronous (no I/O)."""
|
||||
event_type = str(event.get("type", "unknown"))
|
||||
logger.info(f"[ZULIP_EVENT] Processing: {event_type}")
|
||||
|
||||
if event.get('type') != 'message':
|
||||
logger.debug(f"Ignored non-message event: {event.get('type')}")
|
||||
return None
|
||||
|
||||
msg = event.get('message', {})
|
||||
mentioned_users = msg.get('mentioned_users', False)
|
||||
mentioned_users = msg.get('mentioned_users', None)
|
||||
logger.info(f"[DEBUG] raw mentioned_users type={type(mentioned_users).__name__} value={mentioned_users}")
|
||||
logger.info(f"[DEBUG] msg keys: {list(msg.keys())}")
|
||||
logger.info(f"[DEBUG] sender_id={msg.get('sender_id')} stream={msg.get('stream')}")
|
||||
|
||||
# Only process messages that mention this bot (ADR-005)
|
||||
if not mentioned_users:
|
||||
logger.info(f"[DEBUG] mentioned_users is falsy, skipping")
|
||||
return None
|
||||
|
||||
logger.info(f"[DEBUG] mentioned_users IS truthy, proceeding to parse")
|
||||
|
||||
# Strip Zulip formatting artifacts
|
||||
body = msg.get('content', '')
|
||||
clean_body = self.MENTION_CLEANER.sub('', body)
|
||||
@@ -144,7 +155,6 @@ class ZulipAdapter:
|
||||
logger.error(f"Event loop crashed: {e}")
|
||||
self.connected = False
|
||||
finally:
|
||||
loop.close()
|
||||
logger.info("Event loop closed.")
|
||||
|
||||
def _process_event(self, event: Dict[str, Any]) -> None:
|
||||
@@ -159,11 +169,15 @@ class ZulipAdapter:
|
||||
is_mentioned = "mentioned" in flags
|
||||
logger.info(f"[DEBUG] is_mentioned (via flags)={is_mentioned}")
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
asyncio.create_task(self.on_event(event))
|
||||
else:
|
||||
asyncio.run(self.on_event(event))
|
||||
message_event = self.on_event(event)
|
||||
if message_event and self.message_handler:
|
||||
reply = self.message_handler(message_event)
|
||||
if reply:
|
||||
self._client.send_message({
|
||||
'type': 'stream',
|
||||
'to': message_event['topic'],
|
||||
'subject': message_event['topic'],
|
||||
'content': reply,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing event: {e}")
|
||||
logger.error(f"Error in _process_event: {e}")
|
||||
@@ -0,0 +1,685 @@
|
||||
"""
|
||||
Zulip Platform Adapter for Hermes Agent — DM-First Version
|
||||
|
||||
Supports:
|
||||
- Direct Messages (DM): Users DM the bot user directly
|
||||
- Stream/Topic: Bot listens to configured stream/topic for @all-bots broadcasts
|
||||
- Reply routing: DMs reply in DM thread, stream messages reply in-stream
|
||||
|
||||
Architecture:
|
||||
- Async adapter using Zulip SDK's long-poll get_events() pattern
|
||||
- Event queue registered with no narrow (receives all events)
|
||||
- _handle_event() demuxes based on message.type: private vs stream
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
SendResult,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
)
|
||||
from gateway.config import Platform
|
||||
|
||||
|
||||
class ZulipAdapter(BasePlatformAdapter):
|
||||
"""Async Zulip adapter implementing the BasePlatformAdapter interface.
|
||||
|
||||
Connects to a Zulip server and uses long-poll streaming via
|
||||
get_events() to receive messages in real-time. Supports both
|
||||
DMs (private messages) and stream/topic messages.
|
||||
"""
|
||||
|
||||
def __init__(self, config, **kwargs):
|
||||
platform = Platform("zulip")
|
||||
super().__init__(config=config, platform=platform)
|
||||
|
||||
extra = getattr(config, "extra", {}) or {}
|
||||
|
||||
# Connection settings (env vars override config.yaml)
|
||||
self.email = os.getenv("ZULIP_EMAIL") or extra.get("email", "")
|
||||
self.api_key = os.getenv("ZULIP_API_KEY") or extra.get("api_key", "")
|
||||
self.site = os.getenv("ZULIP_SITE") or extra.get("site", "")
|
||||
self.stream = os.getenv("ZULIP_STREAM") or extra.get("stream", "agent-hub")
|
||||
self.topic = os.getenv("ZULIP_TOPIC") or extra.get("topic", "")
|
||||
|
||||
# Auth
|
||||
self.allowed_users: list = extra.get("allowed_users", [])
|
||||
self._allowed_users_set: set = {u.lower() for u in self.allowed_users if isinstance(u, str)}
|
||||
self._allow_all = extra.get("allow_all_users", False)
|
||||
|
||||
# Runtime state
|
||||
self._client = None
|
||||
self._recv_task: Optional[asyncio.Task] = None
|
||||
self._queue_id: Optional[str] = None
|
||||
self._last_event_id: Optional[int] = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Zulip"
|
||||
|
||||
# ── Connection lifecycle ──────────────────────────────────────────────
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to Zulip and start the event queue."""
|
||||
if not self.email or not self.api_key or not self.site:
|
||||
logger.error("Zulip: email, api_key, and site must be configured")
|
||||
self._set_fatal_error(
|
||||
"config_missing",
|
||||
"ZULIP_EMAIL, ZULIP_API_KEY, and ZULIP_SITE must be set",
|
||||
retryable=False,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
import zulip
|
||||
self._client = zulip.Client(
|
||||
email=self.email,
|
||||
api_key=self.api_key,
|
||||
site=self.site,
|
||||
client="Hermes Agent / ZulipPlugin",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Zulip: failed to create client — %s", e)
|
||||
self._set_fatal_error("client_init_failed", str(e), retryable=True)
|
||||
return False
|
||||
|
||||
# Verify connectivity by fetching own user
|
||||
try:
|
||||
me = await asyncio.get_event_loop().run_in_executor(
|
||||
None, lambda: self._client.get_profile()
|
||||
)
|
||||
if me.get("result") != "success":
|
||||
logger.error("Zulip: server returned error on profile: %s", me.get("msg", "unknown"))
|
||||
self._set_fatal_error("auth_failed", str(me.get("msg", "unknown")), retryable=True)
|
||||
self._client = None
|
||||
return False
|
||||
logger.info("Zulip: authenticated as %s (user_id=%s) on %s",
|
||||
me.get("full_name", self.email), me.get("user_id", "?"), self.site)
|
||||
except Exception as e:
|
||||
logger.error("Zulip: authentication failed — %s", e)
|
||||
self._set_fatal_error("auth_failed", str(e), retryable=True)
|
||||
self._client = None
|
||||
return False
|
||||
|
||||
# Register the event queue with NO narrow to receive ALL events (DMs + streams)
|
||||
try:
|
||||
queue_data = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: self._client.register(
|
||||
event_types=["message"],
|
||||
narrow=[], # Empty narrow = ALL events
|
||||
include_subscribers=False,
|
||||
)
|
||||
)
|
||||
if queue_data.get("result") != "success":
|
||||
logger.error("Zulip: failed to register event queue: %s", queue_data.get("msg", "unknown"))
|
||||
self._set_fatal_error("queue_registration_failed", str(queue_data.get("msg", "unknown")), retryable=True)
|
||||
self._client = None
|
||||
return False
|
||||
|
||||
self._queue_id = queue_data["queue_id"]
|
||||
self._last_event_id = queue_data.get("last_event_id", -1)
|
||||
logger.info("Zulip: event queue registered (queue_id=%s, last_event_id=%s)",
|
||||
self._queue_id[:8], self._last_event_id)
|
||||
except Exception as e:
|
||||
logger.error("Zulip: event queue registration failed: %s", e)
|
||||
self._set_fatal_error("queue_registration_failed", str(e), retryable=True)
|
||||
self._client = None
|
||||
return False
|
||||
|
||||
# Subscribe to the configured stream
|
||||
try:
|
||||
await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: self._client.add_subscriptions([{"name": self.stream}])
|
||||
)
|
||||
logger.info("Zulip: subscribed to stream %s", self.stream)
|
||||
except Exception as e:
|
||||
logger.warning("Zulip: could not subscribe to stream %s: %s", self.stream, e)
|
||||
|
||||
# Start the receive loop
|
||||
self._recv_task = asyncio.create_task(self._receive_loop())
|
||||
|
||||
self._mark_connected()
|
||||
logger.info("Zulip: connected to %s as %s (stream=%s, topic=%s, DMs=enabled)",
|
||||
self.site, self.email, self.stream, self.topic or "*")
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from Zulip and clean up resources."""
|
||||
self._mark_disconnected()
|
||||
|
||||
if self._recv_task and not self._recv_task.done():
|
||||
self._recv_task.cancel()
|
||||
try:
|
||||
await self._recv_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._recv_task = None
|
||||
|
||||
# Deregister the event queue
|
||||
if self._client and self._queue_id:
|
||||
try:
|
||||
await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: self._client.deregister(self._queue_id)
|
||||
)
|
||||
logger.info("Zulip: deregistered queue %s", self._queue_id[:8])
|
||||
except Exception as e:
|
||||
logger.warning("Zulip: failed to deregister queue: %s", e)
|
||||
|
||||
self._client = None
|
||||
self._queue_id = None
|
||||
self._last_event_id = None
|
||||
|
||||
# ── Sending ───────────────────────────────────────────────────────────
|
||||
|
||||
async def send(
|
||||
self,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a message.
|
||||
|
||||
chat_id format:
|
||||
- "dm:user@email.com" → send as private message to that user
|
||||
- "stream_name:topic_name" → send to stream/topic
|
||||
- "stream_name" → send to stream with default topic
|
||||
"""
|
||||
if not self._client:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
# Detect DM vs stream
|
||||
is_dm = chat_id.startswith("dm:")
|
||||
|
||||
if is_dm:
|
||||
target_email = chat_id[3:] # Remove "dm:" prefix
|
||||
message_data = {
|
||||
"type": "private",
|
||||
"to": [target_email],
|
||||
"content": content,
|
||||
}
|
||||
else:
|
||||
# Parse chat_id into stream:topic
|
||||
if ":" in chat_id:
|
||||
parts = chat_id.split(":", 1)
|
||||
target_stream = parts[0]
|
||||
target_topic = parts[1]
|
||||
else:
|
||||
target_stream = self.stream
|
||||
target_topic = self.topic or "general"
|
||||
|
||||
if reply_to:
|
||||
content = f"> @**{reply_to}** wrote…\n\n{content}"
|
||||
|
||||
message_data = {
|
||||
"type": "stream",
|
||||
"to": target_stream,
|
||||
"subject": target_topic,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
try:
|
||||
result = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: self._client.send_message(message_data)
|
||||
)
|
||||
if result.get("result") == "success":
|
||||
return SendResult(
|
||||
success=True,
|
||||
message_id=str(result.get("id", ""))
|
||||
)
|
||||
else:
|
||||
return SendResult(
|
||||
success=False,
|
||||
error=result.get("msg", "Unknown error")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Zulip: send failed: %s", e)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
||||
"""Zulip has no typing indicator — no-op."""
|
||||
pass
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
"""Return info about a chat.
|
||||
|
||||
chat_id format: "dm:user@email.com" or "stream_name:topic_name"
|
||||
"""
|
||||
if chat_id.startswith("dm:"):
|
||||
user_email = chat_id[3:]
|
||||
return {
|
||||
"name": f"DM with {user_email}",
|
||||
"type": "dm",
|
||||
"chat_id": chat_id,
|
||||
"user_email": user_email,
|
||||
}
|
||||
elif ":" in chat_id:
|
||||
parts = chat_id.split(":", 1)
|
||||
stream_name = parts[0]
|
||||
topic_name = parts[1]
|
||||
return {
|
||||
"name": f"#{stream_name} > {topic_name}",
|
||||
"type": "channel",
|
||||
"stream": stream_name,
|
||||
"topic": topic_name,
|
||||
}
|
||||
return {
|
||||
"name": f"#{chat_id}",
|
||||
"type": "channel",
|
||||
"stream": chat_id,
|
||||
"topic": self.topic or "general",
|
||||
}
|
||||
|
||||
# ── Receive loop ─────────────────────────────────────────────────────
|
||||
|
||||
async def _receive_loop(self) -> None:
|
||||
"""Main receive loop — polls Zulip events and dispatches messages."""
|
||||
try:
|
||||
while self._client and self._queue_id:
|
||||
try:
|
||||
response = 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,
|
||||
dont_block=False,
|
||||
)
|
||||
)
|
||||
|
||||
if response.get("result") != "success":
|
||||
if "bad event queue" in response.get("msg", "").lower():
|
||||
logger.warning("Zulip: event queue expired, re-registering...")
|
||||
await self._reconnect_queue()
|
||||
else:
|
||||
logger.warning("Zulip: get_events error: %s", response.get("msg", "unknown"))
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
events = response.get("events", [])
|
||||
for event in events:
|
||||
self._last_event_id = event.get("id", self._last_event_id)
|
||||
await self._handle_event(event)
|
||||
|
||||
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("Zulip: receive loop error: %s", e, exc_info=True)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Zulip: receive loop terminated: %s", e)
|
||||
finally:
|
||||
if self.is_connected:
|
||||
logger.warning("Zulip: connection lost, marking disconnected")
|
||||
self._set_fatal_error("connection_lost", "Zulip event queue disconnected", retryable=True)
|
||||
await self._notify_fatal_error()
|
||||
|
||||
async def _reconnect_queue(self) -> None:
|
||||
"""Re-register the event queue after expiry."""
|
||||
if not self._client:
|
||||
return
|
||||
|
||||
try:
|
||||
queue_data = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: self._client.register(
|
||||
event_types=["message"],
|
||||
narrow=[],
|
||||
include_subscribers=False,
|
||||
)
|
||||
)
|
||||
if queue_data.get("result") == "success":
|
||||
self._queue_id = queue_data["queue_id"]
|
||||
self._last_event_id = queue_data.get("last_event_id", -1)
|
||||
logger.info("Zulip: re-registered event queue (queue_id=%s)", self._queue_id[:8])
|
||||
else:
|
||||
logger.error("Zulip: failed to re-register queue: %s", queue_data.get("msg", "unknown"))
|
||||
except Exception as e:
|
||||
logger.error("Zulip: re-registration failed: %s", e)
|
||||
|
||||
async def _handle_event(self, event: Dict[str, Any]) -> None:
|
||||
"""Process a single Zulip event and dispatch as MessageEvent.
|
||||
|
||||
Handles both:
|
||||
- Private messages (DMs) — chat_id: "dm:sender_email"
|
||||
- Stream messages — chat_id: "stream_name:topic_name"
|
||||
"""
|
||||
if event.get("type") != "message":
|
||||
return
|
||||
|
||||
msg = event.get("message", {})
|
||||
if not msg:
|
||||
return
|
||||
|
||||
# Self-message filter: ignore our own messages
|
||||
sender_email = msg.get("sender_email", "")
|
||||
if sender_email.lower() == self.email.lower():
|
||||
logger.debug("Zulip: ignoring own message")
|
||||
return
|
||||
|
||||
# Determine message type: private (DM) or stream
|
||||
zulip_msg_type = msg.get("type", "")
|
||||
content = msg.get("content", "")
|
||||
sender_full_name = msg.get("sender_full_name", sender_email)
|
||||
|
||||
# ── HANDLE PRIVATE MESSAGES (DMs) ──
|
||||
if zulip_msg_type == "private":
|
||||
# Extract the other participant(s)
|
||||
# Zulip private messages have display_recipient as a list of user dicts
|
||||
recipients = msg.get("display_recipient", [])
|
||||
chat_id = f"dm:{sender_email}"
|
||||
chat_name = f"DM with {sender_full_name}"
|
||||
|
||||
# Auth check: DM policy
|
||||
if not self._allow_all:
|
||||
if self._allowed_users_set and sender_email.lower() not in self._allowed_users_set:
|
||||
logger.debug("Zulip: ignoring DM from unauthorized user %s", sender_email)
|
||||
return
|
||||
|
||||
logger.info("Zulip: DM from %s (%s)", sender_full_name, sender_email)
|
||||
|
||||
if not self._message_handler:
|
||||
return
|
||||
|
||||
source = self.build_source(
|
||||
chat_id=chat_id,
|
||||
chat_name=chat_name,
|
||||
chat_type="dm",
|
||||
user_id=sender_email,
|
||||
user_name=sender_full_name,
|
||||
)
|
||||
|
||||
event_obj = MessageEvent(
|
||||
text=content,
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
message_id=str(msg.get("id", int(time.time() * 1000))),
|
||||
timestamp=__import__("datetime").datetime.now(),
|
||||
)
|
||||
|
||||
await self.handle_message(event_obj)
|
||||
return
|
||||
|
||||
# ── HANDLE STREAM MESSAGES ──
|
||||
# Only process messages from our configured stream/topic
|
||||
display_recipient = msg.get("display_recipient", "")
|
||||
stream_name = display_recipient if isinstance(display_recipient, str) else ""
|
||||
subject = msg.get("subject", "")
|
||||
|
||||
if stream_name != self.stream:
|
||||
return
|
||||
if self.topic and subject.lower() != self.topic.lower():
|
||||
return
|
||||
|
||||
# Build chat_id (stream:topic format for send routing)
|
||||
chat_id = f"{stream_name}:{subject}" if subject else stream_name
|
||||
chat_name = f"#{stream_name} > {subject}" if subject else f"#{stream_name}"
|
||||
|
||||
# Auth check
|
||||
if not self._allow_all:
|
||||
if self._allowed_users_set and sender_email.lower() not in self._allowed_users_set:
|
||||
logger.debug("Zulip: ignoring stream message from unauthorized user %s", sender_email)
|
||||
return
|
||||
|
||||
logger.debug("Zulip: stream message from %s in %s", sender_full_name, chat_id)
|
||||
|
||||
if not self._message_handler:
|
||||
return
|
||||
|
||||
source = self.build_source(
|
||||
chat_id=chat_id,
|
||||
chat_name=chat_name,
|
||||
chat_type="channel",
|
||||
user_id=sender_email,
|
||||
user_name=sender_full_name,
|
||||
)
|
||||
|
||||
event_obj = MessageEvent(
|
||||
text=content,
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
message_id=str(msg.get("id", int(time.time() * 1000))),
|
||||
timestamp=__import__("datetime").datetime.now(),
|
||||
)
|
||||
|
||||
await self.handle_message(event_obj)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin registration helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_requirements() -> bool:
|
||||
"""Check if Zulip is configured."""
|
||||
email = os.getenv("ZULIP_EMAIL", "")
|
||||
api_key = os.getenv("ZULIP_API_KEY", "")
|
||||
site = os.getenv("ZULIP_SITE", "")
|
||||
return bool(email and api_key and site)
|
||||
|
||||
|
||||
def validate_config(config) -> bool:
|
||||
"""Validate that the platform config has enough info to connect."""
|
||||
extra = getattr(config, "extra", {}) or {}
|
||||
email = os.getenv("ZULIP_EMAIL") or extra.get("email", "")
|
||||
api_key = os.getenv("ZULIP_API_KEY") or extra.get("api_key", "")
|
||||
site = os.getenv("ZULIP_SITE") or extra.get("site", "")
|
||||
return bool(email and api_key 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", "")
|
||||
api_key = os.getenv("ZULIP_API_KEY") or extra.get("api_key", "")
|
||||
site = os.getenv("ZULIP_SITE") or extra.get("site", "")
|
||||
return bool(email and api_key and site)
|
||||
|
||||
|
||||
def _env_enablement() -> dict | None:
|
||||
"""Seed PlatformConfig.extra from env vars during gateway config load."""
|
||||
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,
|
||||
"api_key": api_key,
|
||||
"site": site,
|
||||
}
|
||||
stream = os.getenv("ZULIP_STREAM", "").strip()
|
||||
if stream:
|
||||
seed["stream"] = stream
|
||||
topic = os.getenv("ZULIP_TOPIC", "").strip()
|
||||
if topic:
|
||||
seed["topic"] = topic
|
||||
home = os.getenv("ZULIP_HOME_CHANNEL") or (f"{stream}:{topic}" if stream and topic else stream or "")
|
||||
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]:
|
||||
"""Send a Zulip message independently, used by cron delivery."""
|
||||
extra = getattr(pconfig, "extra", {}) or {}
|
||||
email = os.getenv("ZULIP_EMAIL") or extra.get("email", "")
|
||||
api_key = os.getenv("ZULIP_API_KEY") or extra.get("api_key", "")
|
||||
site = os.getenv("ZULIP_SITE") or extra.get("site", "")
|
||||
if not (email and api_key and site):
|
||||
return {"error": "Zulip standalone send: ZULIP_EMAIL, ZULIP_API_KEY, and ZULIP_SITE must be configured"}
|
||||
|
||||
try:
|
||||
import zulip
|
||||
client = zulip.Client(
|
||||
email=email,
|
||||
api_key=api_key,
|
||||
site=site,
|
||||
client="Hermes Agent / ZulipPlugin (cron)",
|
||||
)
|
||||
|
||||
if chat_id.startswith("dm:"):
|
||||
target_email = chat_id[3:]
|
||||
result = client.send_message({
|
||||
"type": "private",
|
||||
"to": [target_email],
|
||||
"content": message,
|
||||
})
|
||||
else:
|
||||
stream = os.getenv("ZULIP_STREAM") or extra.get("stream", "agent-hub")
|
||||
topic = os.getenv("ZULIP_TOPIC") or extra.get("topic", "")
|
||||
|
||||
if ":" in chat_id:
|
||||
parts = chat_id.split(":", 1)
|
||||
target_stream = parts[0]
|
||||
target_topic = parts[1]
|
||||
else:
|
||||
target_stream = stream
|
||||
target_topic = topic or "general"
|
||||
|
||||
result = client.send_message({
|
||||
"type": "stream",
|
||||
"to": target_stream,
|
||||
"subject": target_topic,
|
||||
"content": message,
|
||||
})
|
||||
|
||||
if result.get("result") == "success":
|
||||
return {"success": True, "message_id": str(result.get("id", ""))}
|
||||
return {"error": result.get("msg", "Unknown error")}
|
||||
except Exception as e:
|
||||
return {"error": f"Zulip standalone send failed: {e}"}
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""Plugin entry point: called by the Hermes plugin system."""
|
||||
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",
|
||||
setup_fn=interactive_setup,
|
||||
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=10000,
|
||||
emoji="💬",
|
||||
pii_safe=False,
|
||||
allow_update_command=True,
|
||||
platform_hint=(
|
||||
"You are chatting via Zulip — an open-source team chat platform. "
|
||||
"Zulip supports Markdown formatting natively (bold **text**, italic *text*, "
|
||||
"lists, code blocks with ```, links [text](url), and @mentions). "
|
||||
"Messages are organized by stream + topic. Keep responses clear and "
|
||||
"conversational — the full Markdown formatting is supported."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def interactive_setup() -> None:
|
||||
"""Interactive `hermes gateway setup` flow for Zulip."""
|
||||
from hermes_cli.setup import (
|
||||
prompt,
|
||||
prompt_yes_no,
|
||||
save_env_value,
|
||||
get_env_value,
|
||||
print_header,
|
||||
print_info,
|
||||
print_warning,
|
||||
print_success,
|
||||
)
|
||||
|
||||
print_header("Zulip")
|
||||
existing = get_env_value("ZULIP_EMAIL")
|
||||
if existing:
|
||||
print_info(f"Zulip: already configured (email: {existing})")
|
||||
if not prompt_yes_no("Reconfigure Zulip?", False):
|
||||
return
|
||||
|
||||
print_info("Connect Hermes to a Zulip team chat server.")
|
||||
print_info(" Requires the 'zulip' Python package (pip install zulip).")
|
||||
print()
|
||||
|
||||
email = prompt("Zulip bot email (e.g. tanko-bot@chat.sysloggh.net)", default=existing or "")
|
||||
if not email:
|
||||
print_warning("Email is required — skipping Zulip setup")
|
||||
return
|
||||
save_env_value("ZULIP_EMAIL", email.strip())
|
||||
|
||||
api_key = prompt("Zulip bot API key", password=True)
|
||||
if not api_key:
|
||||
print_warning("API key is required — skipping Zulip setup")
|
||||
return
|
||||
save_env_value("ZULIP_API_KEY", api_key)
|
||||
|
||||
site = prompt("Zulip server URL (e.g. https://chat.sysloggh.net)",
|
||||
default=get_env_value("ZULIP_SITE") or "")
|
||||
if not site:
|
||||
print_warning("Server URL is required — skipping Zulip setup")
|
||||
return
|
||||
save_env_value("ZULIP_SITE", site.strip())
|
||||
|
||||
stream = prompt("Stream to join (default: agent-hub)",
|
||||
default=get_env_value("ZULIP_STREAM") or "agent-hub")
|
||||
if stream:
|
||||
save_env_value("ZULIP_STREAM", stream.strip())
|
||||
|
||||
topic = prompt("Topic to listen on (optional)",
|
||||
default=get_env_value("ZULIP_TOPIC") or "")
|
||||
if topic:
|
||||
save_env_value("ZULIP_TOPIC", topic.strip())
|
||||
|
||||
print()
|
||||
print_info("🔒 Access control: restrict who can message the bot")
|
||||
allow_all = prompt_yes_no("Allow all users to talk to the bot?", False)
|
||||
if allow_all:
|
||||
save_env_value("ZULIP_ALLOW_ALL_USERS", "true")
|
||||
save_env_value("ZULIP_ALLOWED_USERS", "")
|
||||
print_warning("⚠️ Open access — any user in the stream can command the bot.")
|
||||
else:
|
||||
save_env_value("ZULIP_ALLOW_ALL_USERS", "false")
|
||||
allowed = prompt(
|
||||
"Allowed user emails (comma-separated)",
|
||||
default=get_env_value("ZULIP_ALLOWED_USERS") or "",
|
||||
)
|
||||
if allowed:
|
||||
save_env_value("ZULIP_ALLOWED_USERS", allowed.replace(" ", ""))
|
||||
print_success("Allowlist configured")
|
||||
else:
|
||||
save_env_value("ZULIP_ALLOWED_USERS", "")
|
||||
print_info("No users allowed — bot will ignore all messages until you add users.")
|
||||
|
||||
print()
|
||||
print_success("Zulip configuration saved to ~/.hermes/.env")
|
||||
print_info("Restart the gateway for changes to take effect: hermes gateway restart")
|
||||
Reference in New Issue
Block a user