# Core adapter implements BasePlatformAdapter for Zulip # See ADR-005 for @mention detection, ADR-009 for error handling # Full implementation in issue #5. import asyncio 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(site=server_url.rstrip('/'), 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: # Create an event loop for this background thread (Python 3.13+) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) """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}")