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:
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user