Compare commits

..
Author SHA1 Message Date
Abiba (pi) 0d3a4bdfac fix: raise MAX_CONSECUTIVE_EMPTY_POLLS 20→500 to prevent idle reconnection cycling
Deploy / validate (push) Failing after 4s
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) f02e4b7147 test: final CI verification 2026-06-28 00:52:21 +00:00
Abiba (pi) 1ff6e1e6ac chore: add CI status doc 2026-06-28 00:51:44 +00:00
abiba-bot cde8bd3bad Merge pull request 'ci: simplify workflow + cleanup' (#30) from feat/ci-fix into main 2026-06-28 00:49:55 +00:00
Abiba (pi) 06e159870e chore: cleanup test workflows 2026-06-28 00:48:01 +00:00
Abiba (pi) 241db268a5 ci: simplify workflow 2026-06-28 00:47:23 +00:00
abiba-bot 512af04303 Merge pull request 'ci: replace actions/checkout with native git clone' (#29) from feat/ci-fix into main
ci: replace actions/checkout with native git clone (#29)
2026-06-28 00:45:51 +00:00
Abiba (pi) 7326217643 test: minimal workflow 2026-06-28 00:45:02 +00:00
Abiba (pi) 2d91d57e8b ci: debug checkout step 2026-06-28 00:43:43 +00:00
Abiba (pi) 32fc6fc66b ci: add auth to git clone in workflows 2026-06-28 00:42:41 +00:00
10 changed files with 10 additions and 2041 deletions
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
- run: python3 --version
- run: node --version
- run: echo "Runner works!"
- 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:***REDACTED***@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
- name: Python syntax check
run: |
@@ -46,7 +46,7 @@ print('✅ config.yaml.example valid')
needs: [validate]
steps:
- run: echo "🚀 Deploy tag $(echo $GITHUB_REF_NAME)"
- 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:***REDACTED***@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:***REDACTED***@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:***REDACTED***@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:***REDACTED***@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:***REDACTED***@192.168.68.17:3000/SyslogSolution/zulip-platform-plugins.git .
- name: Deploy to kagentz
env:
TAG: ${{ github.ref_name }}
-1
View File
@@ -34,4 +34,3 @@ deploy.log
# PM2 ecosystem (local configs)
ecosystem.*.config.cjs
.agents/
-129
View File
@@ -1,129 +0,0 @@
# Zulip Extension Contract Verification Report
**Date**: 2026-06-29 | **Tester**: Abiba (pi agent) | **Scope**: pi + Hermes Zulip plugins
---
## Pi Zulip Extension (`~/.pi/agent/extensions/zulip/`)
### Test Matrix
| # | Test | Result | Detail |
|---|------|--------|--------|
| 1 | Queue registration | ✅ PASS | Registers with Zulip, gets queue_id |
| 2 | Event polling | ✅ PASS | Polls every 3s, receives events |
| 3 | Own-message filtering | ✅ PASS | Correctly skips own sent messages |
| 4 | Health endpoint (:9200) | ✅ PASS | Responds with connected state, stats |
| 5 | Event reception (DM) | ✅ PASS | `idle_seconds` resets on event arrival |
| 6 | @mention detection | ✅ PASS | Bot user_id 21 resolved correctly |
| 7 | Streaming placeholder | ⚠️ UNTESTED* | Code path verified, no live DM to trigger |
| 8 | LLM response relay | ⚠️ UNTESTED* | Requires external user DM |
| 9 | Zulip commands (/status, etc) | ⚠️ UNTESTED* | Requires external user DM |
**Untested: No external user has DMed the bot since last restart.*
### Bugs Found
| # | Severity | Issue | Fix |
|---|----------|-------|-----|
| B1 | **HIGH** | `all_bots_user_id: 1` in config.yaml — actual Zulip value is **20** | ✅ **FIXED** — updated to 20 |
| B2 | MEDIUM | `last_event_id` always shows -1 in health endpoint — object property captured by value, not reference | Fix: use getter or update property in poll() |
| B3 | LOW | `display_recipient` logged as `[object Object]` — should use `JSON.stringify` | Fix: change log line |
| B4 | MEDIUM | PM2 restarts=4 in <30min — indicates crash/restart loop | Investigate cause |
| B5 | LOW | No `/zulip-test` self-test command exists | Add loopback test |
---
## Hermes Zulip Plugin (`plugins/platforms/zulip/`)
### Test Matrix
| # | Test | Result | Detail |
|---|------|--------|--------|
| 1 | Import & structure | ✅ PASS | `ZulipAdapter(BasePlatformAdapter)` OK |
| 2 | Requirements check | ✅ PASS | Env detection works |
| 3 | Adapter instantiation | ✅ PASS | Config parsed correctly |
| 4 | Self-test (pre-connect) | ✅ PASS | Reports degraded (expected, pre-connect) |
| 5 | Connect to Zulip | ✅ PASS | Queue registered, bot_id=21 resolved |
| 6 | Self-test (post-connect) | ✅ PASS | 7/8 checks pass (connected flag mock issue) |
| 7 | Health stats | ✅ PASS | Poll stats, dedup, silence tracking all work |
| 8 | @all-bots resolution | ✅ PASS | **Dynamically resolves to 20** (correct!) |
| 9 | Disconnect | ✅ PASS | Clean shutdown |
| 10 | Deployed as service | ❌ FAIL | Not deployed, no systemd service |
### Bugs Found
| # | Severity | Issue | Fix |
|---|----------|-------|-----|
| H1 | **CRITICAL** | Hermes plugin NOT deployed — no systemd service, no running instance | Create systemd service + deploy |
| H2 | MEDIUM | `httpx` not available globally — needs `pip install --break-system-packages` | Add to requirements or venv |
| H3 | LOW | selftest `connected` flag relies on `_mark_connected()` mock issue | Minor, doesn't affect real Hermes Gateway |
---
## Cross-Platform Contract Gaps
| # | Gap | Severity | Recommendation |
|---|-----|----------|----------------|
| G1 | **No unified contract test** — pi and Hermes tested independently | HIGH | Create cross-platform `.prose.md` contract |
| G2 | **@all-bots ID inconsistency** — pi hardcoded 1, Hermes dynamic-resolved to 20 | HIGH (fixed B1) | Make pi extension also dynamic-resolve |
| G3 | **No agent identity verification** — no way to confirm "this bot is Abiba" | MEDIUM | Add `/whoami` or identity stamp |
| G4 | **No cross-bot communication test** — can't verify @all-bots works end-to-end | MEDIUM | Schedule coordinated test when all bots online |
| G5 | **Health check format differs** — pi returns flat JSON, Hermes returns nested stats | LOW | Standardize health endpoint schema |
---
## Immediate Actions
1. **✅ DONE** — Fixed `all_bots_user_id: 1 → 20` in pi config
2. **TODO** — Restart pi Zulip service to pick up config change: `pm2 restart abiba-zulip`
3. **TODO** — Deploy Hermes Zulip plugin as systemd service
4. **TODO** — Fix `last_event_id` health display bug (B2)
5. **TODO** — Create OpenProse cross-platform contract for automated validation
---
## Contract Upgrade Recommendations
### 1. Dynamic @all-bots Resolution (both platforms)
Pi extension should resolve `all_bots_user_id` from Zulip API (like Hermes does) instead of hardcoding.
### 2. Standardized Health Schema
```json
{
"status": "ok",
"platform": "pi",
"agent": "abiba",
"zulip": {
"connected": true,
"queue_id": "...",
"last_event_id": 2,
"bot_user_id": 21,
"all_bots_user_id": 20,
"messages_processed": 5,
"silence_seconds": 12
},
"uptime_seconds": 3600,
"checks": {
"queue": "ok",
"poll_loop": "ok",
"self_test": "ok"
}
}
```
### 3. Built-in Self-Test Command
Add `/zulip-test` that:
- Sends a test DM to owner
- Verifies event reception
- Reports round-trip time
- Validates all code paths
### 4. Cross-Platform Contract (OpenProse)
Create a `.prose.md` contract that:
- Verifies both pi and Hermes plugins connect to Zulip
- Validates @mention and @all-bots routing
- Runs on schedule (daily) or on-demand
- Reports to knowledge graph
### 5. Queue Health Monitoring
The `last_event_id=-1` issue is actually a Zulip server behavior (fresh instance). Add monitoring that alerts if `last_event_id` doesn't advance after external messages are sent.
@@ -1,98 +0,0 @@
---
kind: responsibility
name: zulip-platform-verification
version: 1.0.0
description: >
Cross-platform Zulip agent verification contract. Validates both pi (TypeScript
extension) and Hermes (Python BasePlatformAdapter) plugins can connect to Zulip,
receive DMs, filter own messages, resolve @all-bots, and relay LLM responses.
Designed to run on schedule (daily) or on-demand by any Syslog agent.
author: Abiba (pi agent)
---
# Zulip Platform Verification Contract
## Platforms Under Contract
| Platform | Agent | Host | Bot Email | Health URL |
|----------|-------|------|-----------|------------|
| pi | Abiba | `192.168.68.24` | abiba-bot@chat.sysloggh.net | `http://192.168.68.24:9200/health` |
| Hermes | Tanko | `192.168.68.123` | tanko-bot@chat.sysloggh.net | `http://192.168.68.123:9201/health` |
| Hermes | Mumuni | `192.168.68.123` | mumuni-bot@chat.sysloggh.net | `http://192.168.68.123:9202/health` |
| Hermes | Koonimo | `192.168.68.123` | koonimo-bot@chat.sysloggh.net | `http://192.168.68.123:9203/health` |
| Hermes | Koby | `192.168.68.123` | koby-bot@chat.sysloggh.net | `http://192.168.68.123:9204/health` |
## Parameters
- zulip_site: string — Zulip server URL (default: "https://chat.sysloggh.net")
- agents: array — List of agent configs to verify (default: all)
- test_message: string — Content to send in loopback DM (default: "🔬 Contract self-test")
## Checks (per agent)
### Check 1: Health Endpoint
- GET {{health_url}} → expect 200, `{ status: "ok", connected: true }`
### Check 2: Queue Registered
- Health response must include `queue_id` (non-null string)
### Check 3: Bot Identity Resolved
- Health response must include `bot_user_id` (positive integer, not null)
### Check 4: @all-bots Resolved
- Health response must include `all_bots_user_id` (positive integer, not null)
- Must match the actual Zulip @all-bots user (resolve from `/api/v1/users`)
### Check 5: Poll Loop Active
- `idle_seconds` < 300 (5 min) OR `stuck` = false
- `silence_seconds` < 600 (10 min) for Hermes adapter
### Check 6: Event Reception (Loopback)
- Send test DM via Zulip API from external account
- Verify `idle_seconds` resets to <10s within 5s of DM
- OR verify `messages_processed` increments (for external-source DMs)
### Check 7: Self-Test (if available)
- Hermes: POST to adapter's `selftest()` method → expect verdict "healthy"
- pi: Run `/zulip-test` command via RPC → expect "All checks passed"
### Check 8: Echo-Loop Prevention
- Health endpoint must include mechanism to identify bot email
- Own-sent messages must not trigger processing
## Returns
```json
{
"verdict": "healthy" | "degraded" | "critical",
"timestamp": "ISO-8601",
"agents": {
"abiba": {
"platform": "pi",
"verdict": "healthy",
"checks": { ... },
"details": { ... }
},
"tanko": {
"platform": "hermes",
"verdict": "degraded",
"checks": { ... },
"details": { ... }
}
},
"summary": {
"total": 5,
"healthy": 4,
"degraded": 1,
"critical": 0
}
}
```
## Maintains
- Each agent is independently verified; one failure doesn't block others
- @all-bots user_id is cross-validated against Zulip API (catches config drift)
- Report is saved to vault at `/root/vault/contract-reports/zulip-verification-{date}.md`
- Alert sent to owner if any agent is critical or >2 are degraded
- Contract is self-documenting — results include raw health snapshots
@@ -1,24 +0,0 @@
# pi Zulip extension — Abiba configuration (flat format for custom parser)
# Deploy to: ~/.pi/agent/extensions/config.yaml
zulip.site: https://chat.sysloggh.net
zulip.email: abiba-bot@chat.sysloggh.net
zulip.api_key: YOUR_ZULIP_API_KEY_HERE
zulip.stream: agent-hub
zulip.all_bots_user_id: 20
agent.name: abiba
agent.display_name: Abiba
agent.zulip_bot_name: abiba-bot
agent.owner_email: jerome@sysloggh.com
agent.private_topic: abiba
health_port: 9200
monitoring.health_endpoint_enabled: true
monitoring.log_level: info
error_handling.timeout_seconds: 30
error_handling.retry_count: 3
error_handling.retry_delay_seconds: 5
error_handling.graceful_message: "Abiba encountered an error processing your request. Please try again later."
-34
View File
@@ -1,34 +0,0 @@
// Type declarations for zulip-js (no @types available)
declare module "zulip-js" {
interface ZulipClient {
queues: {
register(params: {
event_types: string[];
narrow?: Array<Array<string | number>>;
}): Promise<{ queue_id: string; last_event_id: number }>;
};
users: {
me: {
get(): Promise<any>;
};
};
messages: {
store: {
send(rawContent: string): Promise<any>;
};
};
events: {
retrieve(params: {
queue_id: string;
last_event_id: number;
dont_block?: boolean;
}): Promise<{ events: any[]; result?: string; msg?: string }>;
};
}
export default function zulip(config: {
username: string;
apiKey: string;
realm: string;
}): Promise<ZulipClient>;
}
File diff suppressed because it is too large Load Diff
+4 -229
View File
@@ -1,25 +1,10 @@
"""
Zulip platform adapter (Hermes plugin) — Gen 6.
Zulip platform adapter (Hermes plugin) — Gen 4.
Connects a Hermes agent to Zulip via event queue polling. DMs and @mentions
are routed to the agent via the Hermes Gateway's handle_message() interface.
Replies use a placeholder->edit streaming pattern for UX feedback.
Gen 6 Improvements (2026-07-07):
1. known_issues persistence — add_known_issue(), get_known_issues(),
clear_known_issues() methods. Issues tracked in health stats for
external logging to knowledge graph across generations.
2. simulate_dm_latency() — offline-verifiable DM processing pipeline
timing test. Measures event parse, placeholder pick, auth, dedup,
and truncation to verify dm_response_time_ms < 10000 without live Zulip.
3. Placeholder collision fix — switched from hash-based pick to
random.choice() to avoid identical placeholders on rapid-fire calls.
Gen 5 Improvements (2026-06-29):
1. Bot user ID resolution — fetches from /users/me when /register doesn't return it
2. Stream subscription auto-fix — ensures bot is subscribed to primary streams
on connect, critical for receiving @mentions in stream topics
Gen 4 Improvements (2026-06-27):
1. HTTP client recreation on reconnect — new httpx.AsyncClient() after queue expiry
or sustained failures to prevent CLOSE-WAIT socket leaks
@@ -53,7 +38,6 @@ import base64
import json
import logging
import os
import random
import re
import time
import uuid
@@ -242,9 +226,6 @@ class ZulipAdapter(BasePlatformAdapter):
# --- Gen 3: Periodic @all-bots refresh ---
self._all_bots_refresh_task: Optional[asyncio.Task] = None
# --- Gen 6: known_issues persistence ---
self._known_issues: List[str] = []
# --- Gen 3: Health stats callback ---
self._health_callback = None
self._health_callback_interval: int = 300 # 5 min
@@ -321,9 +302,6 @@ class ZulipAdapter(BasePlatformAdapter):
# Gen 2: Try to resolve @all-bots user ID dynamically
await self._resolve_all_bots_user_id()
# Gen 5: Ensure bot is subscribed to primary stream
await self._ensure_stream_subscriptions()
self._mark_connected()
logger.info(
"[%s] Connected to %s as %s (queue=%s, bot_id=%s, all_bots_id=%s)",
@@ -471,10 +449,6 @@ class ZulipAdapter(BasePlatformAdapter):
except Exception:
pass
# Clear stale errors on successful poll cycle
self._health_stats["last_error_msg"] = None
self._health_stats["last_error_at"] = None
# Gen 4: Heartbeat logging — prove poll loop is alive
now = time.time()
if now - self._last_heartbeat_time > HEARTBEAT_INTERVAL:
@@ -703,64 +677,6 @@ class ZulipAdapter(BasePlatformAdapter):
# Gen 2: Dynamic @all-bots resolution
# ------------------------------------------------------------------
async def _ensure_stream_subscriptions(self) -> None:
"""Ensure the bot is subscribed to the primary streams.
Checks current subscriptions and subscribes to the configured primary
stream (and general-chat) if missing. Without subscription, the bot
receives DMs but no stream events — @mentions in streams are invisible.
This is a Gen 5 improvement based on lessons from the pi Zulip extension,
where the bot had zero stream subscriptions and couldn't receive
@mentions in stream topics.
"""
try:
# Check current subscriptions
resp, _ = await self._api_call(
"GET", "/api/v1/users/me/subscriptions",
)
if not resp:
return
subscribed = resp.get("subscriptions", [])
subscribed_names = [s.get("name", "") for s in subscribed if isinstance(s, dict)]
# Streams the bot should be subscribed to
required_streams = [self._stream, "general chat"]
missing = [s for s in required_streams if s not in subscribed_names]
if not missing:
logger.info(
"[%s] Stream subscriptions OK: %s",
self.name, ", ".join(subscribed_names),
)
return
# Subscribe to missing streams
import json as _json
payload = {"subscriptions": _json.dumps(
[{"name": s} for s in missing]
)}
sub_resp, _ = await self._api_call(
"POST", "/api/v1/users/me/subscriptions",
data=payload,
)
if sub_resp and sub_resp.get("result") == "success":
subscribed_str = ", ".join(missing)
logger.info(
"[%s] Subscribed to: %s", self.name, subscribed_str,
)
self._health_stats["subscriptions_fixed"] = (
self._health_stats.get("subscriptions_fixed", 0) + 1
)
else:
logger.warning(
"[%s] Failed to subscribe to %s",
self.name, ", ".join(missing),
)
except Exception as e:
logger.warning("[%s] Subscription check failed: %s", self.name, e)
async def _resolve_all_bots_user_id(self) -> None:
"""Try to resolve the @all-bots user ID from the Zulip server.
@@ -1180,35 +1096,6 @@ class ZulipAdapter(BasePlatformAdapter):
"detail": f"@all-bots user_id: {self._all_bots_user_id}",
}
# 9. Stream subscriptions (verifies bot is subscribed to at least one stream)
try:
from urllib.parse import urlencode
import json as _json
key_str = f"{self._email}:{self._api_key}"
auth_b64 = base64.b64encode(key_str.encode()).decode()
resp, _ = await self._api_call(
"GET", "/api/v1/users/me/subscriptions",
)
if resp and "subscriptions" in resp:
stream_count = len(resp["subscriptions"])
stream_names = [s.get("name", "?") for s in resp["subscriptions"]][:5]
checks["stream_subscriptions"] = {
"status": stream_count > 0,
"detail": f"{stream_count} stream(s): {', '.join(stream_names)}"
if stream_count > 0
else "No stream subscriptions — bot will not receive stream events",
}
else:
checks["stream_subscriptions"] = {
"status": False,
"detail": "Failed to check subscriptions",
}
except Exception as sub_err:
checks["stream_subscriptions"] = {
"status": False,
"detail": f"Subscription check failed: {sub_err}",
}
# Overall verdict
critical = ["connected", "queue_registered", "http_client", "poll_loop"]
passed = sum(1 for c in checks.values() if c["status"])
@@ -1282,7 +1169,6 @@ class ZulipAdapter(BasePlatformAdapter):
- Message counts (total, DM, mention)
- Send counts and errors
- Dedup map size
- Known issues (Gen 6)
- Timestamps of last activity
Suitable for periodic logging to RA-H OS knowledge graph.
@@ -1310,7 +1196,6 @@ class ZulipAdapter(BasePlatformAdapter):
stats["consecutive_empty_polls"] = self._consecutive_empty_polls
stats["client_pool_resets"] = self._client_pool_reset_count
stats["silence_seconds"] = round(time.time() - self._last_event_received_time)
stats["known_issues"] = list(self._known_issues) # Gen 6
# Compute error rate
total_polls = stats.get("poll_count", 0) or 1
@@ -1320,113 +1205,6 @@ class ZulipAdapter(BasePlatformAdapter):
return stats
# ------------------------------------------------------------------
# Gen 6: known_issues persistence
# ------------------------------------------------------------------
def add_known_issue(self, issue: str) -> None:
"""Record a discovered issue for tracking across generations.
Known issues persist in the adapter instance's lifetime and are
included in health stats for external logging to knowledge graph.
"""
if issue not in self._known_issues:
self._known_issues.append(issue)
logger.warning("[%s] Known issue recorded: %s", self.name, issue)
def get_known_issues(self) -> List[str]:
"""Return all tracked known issues."""
return list(self._known_issues)
def clear_known_issues(self) -> None:
"""Clear resolved issues from the tracker."""
if self._known_issues:
logger.info(
"[%s] Cleared %d known issues",
self.name, len(self._known_issues),
)
self._known_issues.clear()
# ------------------------------------------------------------------
# Gen 6: Simulated DM latency test (offline-verifiable)
# ------------------------------------------------------------------
async def simulate_dm_latency(self) -> Dict[str, Any]:
"""Simulate DM processing pipeline and measure round-trip timing.
Creates a mock DM event and routes it through the full processing
pipeline (parse → route → send placeholder) without a live Zulip
connection. Measures:
- Processing time (event parse → handle_message)
- Send pipeline time (placeholder generation)
- Total simulated round-trip
Returns timing breakdown for offline verification of the
dm_response_time_ms < 10000 postcondition.
"""
import time as _time
result = {"test": "simulated_dm_latency", "steps": {}}
# Step 1: Simulate message parse
t0 = _time.perf_counter()
mock_event = {
"id": 99999,
"type": "message",
"message": {
"id": 88888,
"type": "private",
"sender_email": "test@sysloggh.net",
"sender_full_name": "Test User",
"sender_id": 42,
"content": "Hello, this is a test message for latency measurement.",
"timestamp": _time.time(),
"mentioned_users": [],
},
}
t1 = _time.perf_counter()
result["steps"]["event_parse"] = round((t1 - t0) * 1000, 2)
# Step 2: Simulate placeholder pick
t0 = _time.perf_counter()
placeholder = self._pick_placeholder()
t1 = _time.perf_counter()
result["steps"]["placeholder_pick"] = round((t1 - t0) * 1000, 2)
result["placeholder"] = placeholder
# Step 3: Simulate auth header (typically done once, measure for completeness)
t0 = _time.perf_counter()
if self._email and self._api_key:
_build_auth_header(self._email, self._api_key)
t1 = _time.perf_counter()
result["steps"]["auth_header"] = round((t1 - t0) * 1000, 2)
# Step 4: Simulate dedup check
t0 = _time.perf_counter()
is_dup = self._is_duplicate("99999:88888")
t1 = _time.perf_counter()
result["steps"]["dedup_check"] = round((t1 - t0) * 1000, 2)
result["is_duplicate"] = is_dup
# Step 5: Simulate truncation (worst case)
t0 = _time.perf_counter()
_truncate("x" * 15000)
t1 = _time.perf_counter()
result["steps"]["truncation"] = round((t1 - t0) * 1000, 2)
# Aggregate
total_ms = sum(result["steps"].values())
result["total_simulated_ms"] = round(total_ms, 2)
result["dm_response_time_ms_pass"] = total_ms < 10000
logger.info(
"[%s] Simulated DM latency: %.2fms (%s)",
self.name, total_ms,
"PASS" if result["dm_response_time_ms_pass"] else "FAIL",
)
return result
# ------------------------------------------------------------------
# Zulip API helpers
# ------------------------------------------------------------------
@@ -1497,12 +1275,9 @@ class ZulipAdapter(BasePlatformAdapter):
# ------------------------------------------------------------------
def _pick_placeholder(self) -> str:
"""Pick a random placeholder message for streaming UX.
Gen 6: Switched from hash-based to random.choice() to avoid
collision when called twice in the same second.
"""
return random.choice(PLACEHOLDERS)
"""Pick a random placeholder message for streaming UX."""
idx = hash(str(time.time())) % len(PLACEHOLDERS)
return PLACEHOLDERS[idx]
def _is_duplicate(self, msg_id: str) -> bool:
"""Deduplication using message IDs — O(1) lookup, no cleanup.
-155
View File
@@ -1,155 +0,0 @@
#!/usr/bin/env python3
"""
Deploy Hermes Zulip plugin as a systemd service.
Creates service file, installs dependencies, deploys plugin.
Usage: python3 deploy-hermes-zulip.py --agent tanko --email tanko-bot@chat.sysloggh.net --api-key KEY
"""
import argparse, os, sys, subprocess, json
SYSTEMD_TEMPLATE = """[Unit]
Description=Hermes Zulip Gateway {agent_name} ({bot_email})
After=network-online.target
Wants=network-online.target
Documentation=https://git.sysloggh.net/SyslogSolution/zulip-platform-plugins
[Service]
Type=simple
User=root
WorkingDirectory=/opt/hermes-zulip-{agent_name}
Environment=ZULIP_SITE=https://chat.sysloggh.net
Environment=ZULIP_EMAIL={bot_email}
Environment=ZULIP_API_KEY={api_key}
Environment=ZULIP_AGENT_NAME={agent_name}
Environment=ZULIP_STREAM=agent-hub
Environment=ZULIP_POLL_INTERVAL=3
Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/bin/python3 -m hermes_zulip.adapter_standalone
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=hermes-zulip-{agent_name}
[Install]
WantedBy=multi-user.target
"""
PLUGIN_FILES = [
"plugins/platforms/zulip/__init__.py",
"plugins/platforms/zulip/adapter.py",
"plugins/platforms/zulip/plugin.yaml",
]
def main():
parser = argparse.ArgumentParser(description="Deploy Hermes Zulip plugin")
parser.add_argument("--agent", required=True, help="Agent name (e.g. tanko)")
parser.add_argument("--email", required=True, help="Bot email")
parser.add_argument("--api-key", required=True, help="Zulip API key")
parser.add_argument("--port", type=int, default=None, help="Health port (auto-assigned)")
args = parser.parse_args()
agent = args.agent.lower()
port = args.port or (9201 + hash(agent) % 10)
repo_dir = "/root/zulip-platform-plugins"
deploy_dir = f"/opt/hermes-zulip-{agent}"
print(f"=== Deploying Hermes Zulip Plugin: {agent} ===")
print(f" Bot: {args.email}")
print(f" Port: {port}")
print(f" Deploy dir: {deploy_dir}")
print()
# 1. Ensure httpx is installed
print("[1/5] Installing httpx...")
subprocess.run(
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--quiet", "httpx"],
check=False,
)
print(" ✅ httpx installed")
# 2. Create deploy directory
print(f"[2/5] Setting up {deploy_dir}...")
os.makedirs(deploy_dir, exist_ok=True)
# 3. Copy plugin files
print("[3/5] Copying plugin files...")
for f in PLUGIN_FILES:
src = os.path.join(repo_dir, f)
dst = os.path.join(deploy_dir, os.path.basename(f))
if os.path.exists(src):
subprocess.run(["cp", src, dst], check=True)
print(f"{os.path.basename(f)}")
else:
print(f" ⚠️ {f} not found, skipping")
# 4. Create standalone adapter launcher
print("[4/5] Creating adapter launcher...")
launcher = os.path.join(deploy_dir, "adapter_standalone.py")
with open(launcher, "w") as f:
f.write(f'''"""Standalone launcher for Hermes Zulip adapter — {agent}."""
import asyncio, os, sys, logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
sys.path.insert(0, "{deploy_dir}")
from adapter import ZulipAdapter, check_requirements
async def main():
if not check_requirements():
print("Missing Zulip credentials. Set ZULIP_SITE, ZULIP_EMAIL, ZULIP_API_KEY.")
sys.exit(1)
config = type("Config", (), {{"extra": {{}}}})()
adapter = ZulipAdapter(config)
ok = await adapter.connect()
if not ok:
print("Failed to connect to Zulip")
sys.exit(1)
print(f"Connected as {{adapter._email}} (queue={{adapter._queue_id}})")
# Keep alive
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
pass
finally:
await adapter.disconnect()
if __name__ == "__main__":
asyncio.run(main())
''')
print(" ✅ adapter_standalone.py")
# 5. Create and enable systemd service
print("[5/5] Installing systemd service...")
service_name = f"hermes-zulip-{agent}"
service_content = SYSTEMD_TEMPLATE.format(
agent_name=agent.title(),
bot_email=args.email,
api_key=args.api_key,
port=port,
)
service_path = f"/etc/systemd/system/{service_name}.service"
with open(service_path, "w") as f:
f.write(service_content)
subprocess.run(["systemctl", "daemon-reload"], check=True)
subprocess.run(["systemctl", "enable", service_name], check=True)
print(f" ✅ Service installed: {service_name}")
print(f" Service file: {service_path}")
print()
print(f" To start: systemctl start {service_name}")
print(f" To check: systemctl status {service_name}")
print(f" Logs: journalctl -u {service_name} -f")
if __name__ == "__main__":
main()