feat: DM-First v2 architecture - Tanko live on CT 112
CI / validate (pull_request) Failing after 1s

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:
kagentz-bot
2026-06-21 07:50:30 -04:00
parent b8ae43f652
commit 705c29a4e0
14 changed files with 1552 additions and 260 deletions
+32 -18
View File
@@ -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")