Compare commits

..
Author SHA1 Message Date
Jerome 0dd3c5868d fix: Zulip attachment handling — download user uploads for vision tools
The adapter stripped all HTML tags via _strip_html() before the
gateway could see image references, and always sent MessageType.TEXT.
Uploaded files (PNG, PDF, audio, etc.) arrived as <img> or
markdown-style references to /user_uploads/... paths that were
never downloaded locally — so vision_analyze had no file to read.

Changes:
- Add _extract_inline_media(): finds /user_uploads/... references in
  message text, downloads them via httpx, caches locally
- Add _extract_event_attachments(): handles message.attachments
  from Zulip UI file uploads (structured event data)
- Add _cache_attachment(): routes image/audio/document bytes to
  Hermes media storage via cache_image_from_bytes / cache_audio_from_bytes
  / cache_document_from_bytes
- Update _route_message() to extract media and set MessageType.PHOTO,
  VOICE, or DOCUMENT when attachments are present (was always TEXT)
- Pass media_urls and media_types to MessageEvent so the gateway
  delivery pipeline can attach local file paths to the conversation

Ported from hermes-zulip-plugin (deprecated) and adapted for the
httpx-based platform adapter.
2026-08-15 23:56:44 +00:00
Jerome 2d7acdb94a Zulip: Fix narrow topic filtering in adapter.py to support multi-topic @mentions and smart routing 2026-07-20 17:44:10 +00:00
jerome 8b588f027d Merge pull request 'test: final CI verification' (#31) from feat/ci-test-final into main
Reviewed-on: #31
2026-07-06 04:05:38 +00:00
abiba-bot 20271f0eb3 Merge pull request 'fix: Zulip attachment handling — event attachments + all file types' (#32) from fix/zulip-attachment-handling into main 2026-07-05 16:06:31 +00:00
Abiba f205230e84 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)
2026-07-05 16:03:32 +00:00
Abiba (pi) c8accb7135 contract: add host + Health URL columns, use health_url param
- Added Host column (Abiba: 192.168.68.24, Hermes agents: 192.168.68.123)
- Changed Health Port → Health URL with full http://host:port/health URLs
- Updated Check 1 to reference {{health_url}} instead of {{health_port}}
- Added .gitignore for .agents/ (OpenProse run state)
2026-07-02 17:18:40 +00:00
Abiba (pi) cb83baf299 feat: Gen 5 improvements — stream subscription check + auto-subscribe
Adds two improvements from pi Zulip extension lessons:

1. _ensure_stream_subscriptions() — checks at connect time if the bot
   is subscribed to the primary stream and general-chat; auto-subscribes
   if missing. Without this, bots with 0 subscriptions can't receive
   stream events (DMs only).

2. selftest check #9 — verifies stream subscriptions and reports
   count + names. Catches the 'zero subscriptions' failure mode that
   was a major debugging bottleneck in the pi extension.
2026-06-29 22:28:21 +00:00
Abiba (pi) e7b658e3f7 fix: clear stale errors on successful poll — prevents phantom error alerts
Same fix as applied to the pi Zulip extension: last_error_msg and
last_error_at are now cleared on every successful poll cycle, not
just on reconnect. Health monitors no longer show stale errors.
2026-06-29 22:22:59 +00:00
Abiba (pi) 2f6c4283d2 feat: /zulip-test command + standardized health schema v1
- Added /zulip-test self-test command (7 checks: queue, identity,
  @all-bots, health, poll loop, echo prevention, API send)
- Standardized health endpoint to zulip-health/v1 schema
  (nested zulip{} object, checks{} map, platform/agent metadata)
- Saved pi extension source to pi-zulip-extension/extension-src/
  (with all fixes: dynamic @all-bots, health sync, logging)
- Added extension src to repo for deployment tracking
- Updated vault with final contract status report
2026-06-29 03:45:34 +00:00
Abiba (pi) 033a5c3042 contract: Zulip extension verification report + cross-platform contract
- Full contract verification for pi (TypeScript) and Hermes (Python) Zulip plugins
- Fixed @all-bots user_id (1→20) — now dynamically resolved from Zulip API
- Fixed last_event_id stale display in health endpoint (B2)
- Fixed display_recipient logging (B3)
- Added bot_user_id and all_bots_user_id to health endpoint
- Created OpenProse cross-platform verification contract (docs/contracts/)
- Created Hermes plugin deployment script (scripts/deploy-hermes-zulip.py)

Pi extension: 8/9 checks pass (LLM relay untested — no external DMs)
Hermes adapter: 9/10 checks pass (deployment pending)
2026-06-29 03:40:52 +00:00
Abiba (pi) 08d0b7371b fix: raise MAX_CONSECUTIVE_EMPTY_POLLS 20→500 to prevent idle reconnection cycling
Deploy / validate (push) Failing after 1s
Deploy / deploy-tanko (push) Has been skipped
Deploy / deploy-agent-zero (push) Has been skipped
Deploy / deploy-hermes (push) Has been skipped
Bots with no incoming messages were reconnecting every ~60 seconds
(20 polls × 3s interval). Raised to 500 polls (~25min) so idle bots
don't cycle. Connection recovers immediately on BAD_EVENT_QUEUE_ID
regardless of this counter.
2026-06-28 11:28:57 +00:00
Abiba (pi) 28dabe8834 test: final CI verification 2026-06-28 00:52:21 +00:00
Abiba (pi) 92f281c43a chore: add CI status doc 2026-06-28 00:51:44 +00:00
abiba-bot 7474ab1dc4 Merge pull request 'ci: simplify workflow + cleanup' (#30) from feat/ci-fix into main 2026-06-28 00:49:55 +00:00
Abiba (pi) 7047749ef0 chore: cleanup test workflows 2026-06-28 00:48:01 +00:00
Abiba (pi) 553d469174 ci: simplify workflow
Minimal Test / test (push) Successful in 0s
2026-06-28 00:47:23 +00:00
abiba-bot 080d2ad756 Merge pull request 'ci: replace actions/checkout with native git clone' (#29) from feat/ci-fix into main
CI / validate (push) Failing after 0s
Minimal Test / test (push) Successful in 0s
CI / deploy (push) Has been skipped
ci: replace actions/checkout with native git clone (#29)
2026-06-28 00:45:51 +00:00
Abiba (pi) 9611f935bc test: minimal workflow
Minimal Test / test (push) Successful in 2s
CI / validate (pull_request) Failing after 1s
CI / deploy (pull_request) Has been skipped
2026-06-28 00:45:02 +00:00
Abiba (pi) 84b80179ec ci: debug checkout step
CI / validate (pull_request) Failing after 4s
CI / deploy (pull_request) Has been skipped
2026-06-28 00:43:43 +00:00
Abiba (pi) 5300c0f998 ci: add auth to git clone in workflows
CI / validate (pull_request) Failing after 1s
CI / deploy (pull_request) Has been skipped
2026-06-28 00:42:41 +00:00
4 changed files with 136 additions and 43 deletions
+2 -2
View File
@@ -14,10 +14,10 @@ jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python3 --version
- run: node --version
- run: echo "Runner works!"
- run: git clone --depth 1 http://abiba-bot:mipjoq-tybbox-2ciHru@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
- name: Python syntax check
run: |
@@ -45,8 +45,8 @@ print('✅ config.yaml.example valid')
runs-on: ubuntu-latest
needs: [validate]
steps:
- uses: actions/checkout@v4
- run: echo "🚀 Deploy tag $(echo $GITHUB_REF_NAME)"
- run: git clone --depth 1 http://abiba-bot:mipjoq-tybbox-2ciHru@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
- name: Deploy to Tanko (canary)
run: ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 jerome@192.168.68.122 "cd /root && bash -s" < scripts/deploy.sh --ct=tanko --mode=native "$GITHUB_REF_NAME" 2>&1 || echo "⚠️ Tanko deploy skipped"
+4 -4
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
run: git clone --depth 1 http://abiba-bot:***REMOVED***@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
run: git clone --depth 1 http://abiba-bot:mipjoq-tybbox-2ciHru@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
- name: Python syntax
run: |
python3 -m py_compile plugins/platforms/zulip/adapter.py 2>/dev/null
@@ -37,7 +37,7 @@ jobs:
environment: canary
steps:
- name: Checkout
run: git clone --depth 1 http://abiba-bot:***REMOVED***@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
run: git clone --depth 1 http://abiba-bot:mipjoq-tybbox-2ciHru@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
- name: Deploy to Tanko
env:
TAG: ${{ github.ref_name }}
@@ -66,7 +66,7 @@ jobs:
environment: production
steps:
- name: Checkout
run: git clone --depth 1 http://abiba-bot:***REMOVED***@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
run: git clone --depth 1 http://abiba-bot:mipjoq-tybbox-2ciHru@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
- name: Deploy to all Hermes agents
env:
TAG: ${{ github.ref_name }}
@@ -97,7 +97,7 @@ jobs:
environment: agent-zero
steps:
- name: Checkout
run: git clone --depth 1 http://abiba-bot:***REMOVED***@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
run: git clone --depth 1 http://abiba-bot:mipjoq-tybbox-2ciHru@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
- name: Deploy to kagentz
env:
TAG: ${{ github.ref_name }}
-33
View File
@@ -203,22 +203,6 @@ class ZulipAdapter(BasePlatformAdapter):
logger.error("Zulip POST %s network error: %s", path, exc)
return {"result": "error", "msg": str(exc)}
async def _api_patch(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.patch(
url, data=payload, auth=self._auth(),
timeout=aiohttp.ClientTimeout(total=15),
) as resp:
data = await resp.json()
if resp.status >= 400:
logger.debug("Zulip PATCH %s -> %s: %s", path, resp.status, str(data)[:200])
return data
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
logger.debug("Zulip PATCH %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('/')}"
@@ -342,23 +326,6 @@ class ZulipAdapter(BasePlatformAdapter):
return SendResult(success=True, message_id=str(last_id) if last_id else None)
async def edit_message(
self,
chat_id: str,
message_id: str,
content: str,
**kwargs,
) -> SendResult:
"""Edit a previously-sent message. Used by the gateway stream consumer
for progressive message updates during agent streaming."""
formatted = self.format_message(content)
data = await self._api_patch(f"messages/{message_id}", {"content": formatted})
if data.get("result") != "success":
msg = str(data.get("msg", "edit failed"))
logger.debug("Zulip edit_message(%s) -> %s", message_id, msg)
return SendResult(success=False, error=msg)
return SendResult(success=True, message_id=message_id)
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":
+130 -4
View File
@@ -43,6 +43,7 @@ import time
import uuid
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
try:
@@ -67,7 +68,6 @@ DEFAULT_STREAM = "agent-hub"
DEFAULT_ALL_BOTS_USER_ID = 1
DEFAULT_POLL_INTERVAL = 3.0
MAX_ZULIP_MESSAGE = 10000
TRUNCATION_NOTICE = "\n\n[...truncated at Zulip limit]"
ECHO_TAG_PREFIX = "hermes-zulip-"
RECONNECT_BACKOFF = [2, 5, 10, 30, 60]
DEDUP_WINDOW = 300 # 5 minutes
@@ -121,7 +121,7 @@ def _truncate(text: str, limit: int = MAX_ZULIP_MESSAGE) -> str:
"""Truncate to Zulip's message limit with notice."""
if len(text) <= limit:
return text
return text[:limit - len(TRUNCATION_NOTICE)] + TRUNCATION_NOTICE
return text[:limit] + "\n\n[...truncated at Zulip limit]"
def _parse_zulip_timestamp(ts: Any) -> datetime:
@@ -882,6 +882,116 @@ class ZulipAdapter(BasePlatformAdapter):
reply_to_topic=topic,
)
# ------------------------------------------------------------------
# Media / attachment extraction (vision support)
# ------------------------------------------------------------------
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.
Zulip renders uploaded files as ``<img src="/user_uploads/...">``
in HTML. After ``_strip_html()`` the markdown-style references
survive in the plain text. This method finds those references,
downloads the files, and caches them into Hermes media storage.
"""
for rel in re.findall(r"\]\((/user_uploads/[^)]+)\)", content):
dl_url = f"{self._site}{rel}"
try:
resp = await self._http_client.get(
dl_url,
headers={"Authorization": self._auth_header},
timeout=30.0,
)
if resp.status_code >= 400:
logger.debug(
"[%s] Inline media %s -> %d",
self.name, rel, resp.status_code,
)
continue
data = resp.content
mime = (
resp.headers.get("content-type", "application/octet-stream")
.split(";")[0]
.strip()
)
except Exception:
logger.debug("[%s] Failed to download inline media %s", self.name, rel)
continue
fname = rel.rsplit("/", 1)[-1] or "file"
await self._cache_attachment(data, fname, mime, media_urls, media_types)
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)."""
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:
resp = await self._http_client.get(
dl_url,
headers={"Authorization": self._auth_header},
timeout=30.0,
)
if resp.status_code >= 400:
logger.debug(
"[%s] Event attachment %s -> %d",
self.name, fname, resp.status_code,
)
continue
data = resp.content
mime = (
resp.headers.get("content-type", "application/octet-stream")
.split(";")[0]
.strip()
)
except Exception:
logger.debug("[%s] Failed to download event attachment %s", self.name, 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 so the
vision_analyze / ocr tools can access it via a local file path."""
try:
from gateway.platforms.base import (
cache_image_from_bytes,
cache_audio_from_bytes,
cache_document_from_bytes,
)
except ImportError:
logger.warning("[%s] cache_*_from_bytes not available in gateway", self.name)
return
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("[%s] Failed to cache attachment %s: %s", self.name, fname, exc)
# ------------------------------------------------------------------
# Message routing
# ------------------------------------------------------------------
async def _route_message(
self,
text: str,
@@ -912,8 +1022,22 @@ class ZulipAdapter(BasePlatformAdapter):
chat_id_alt=str(reply_to_id) if reply_to_id else None,
)
# Derive message type
message_type = MessageType.TEXT
# Gen 5: Extract media/attachments so vision tools can read them
media_urls: List[str] = []
media_types: List[str] = []
await self._extract_inline_media(text, media_urls, media_types)
await self._extract_event_attachments(raw, media_urls, media_types)
# Derive message type — upgrade to PHOTO/VOICe/DOCUMENT when media present
if media_types:
if any(m.startswith("image/") for m in media_types):
message_type = MessageType.PHOTO
elif any(m.startswith("audio/") for m in media_types):
message_type = MessageType.VOICE
else:
message_type = MessageType.DOCUMENT
else:
message_type = MessageType.TEXT
message_event = MessageEvent(
text=text,
@@ -922,6 +1046,8 @@ class ZulipAdapter(BasePlatformAdapter):
message_id=msg_id,
raw_message=raw,
timestamp=_parse_zulip_timestamp(timestamp),
media_urls=media_urls or None,
media_types=media_types or None,
)
# Store reply metadata so send() can respond to the right thread