Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f72da5b8d | ||
|
|
33453b8204 | ||
|
|
441255a07c | ||
|
|
5d9e36f657 | ||
|
|
bfc6e1877d | ||
|
|
e1b76376b1 | ||
|
|
aaaa11a912 | ||
|
|
40ce413fd8 | ||
|
|
15f94e3bcb | ||
|
|
3bb7698f88 | ||
|
|
0112c5bba7 | ||
|
|
1a6098f7fd | ||
|
|
15adb30bc7 | ||
|
|
faf9566332 | ||
|
|
b0aeb81caf |
@@ -34,3 +34,4 @@ deploy.log
|
|||||||
|
|
||||||
# PM2 ecosystem (local configs)
|
# PM2 ecosystem (local configs)
|
||||||
ecosystem.*.config.cjs
|
ecosystem.*.config.cjs
|
||||||
|
.agents/
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# CI Final Test
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# CI Pipeline Status
|
||||||
|
Status: Active
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -203,6 +203,22 @@ class ZulipAdapter(BasePlatformAdapter):
|
|||||||
logger.error("Zulip POST %s network error: %s", path, exc)
|
logger.error("Zulip POST %s network error: %s", path, exc)
|
||||||
return {"result": "error", "msg": str(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]:
|
async def _api_delete(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
import aiohttp
|
import aiohttp
|
||||||
url = f"{self._site}/api/v1/{path.lstrip('/')}"
|
url = f"{self._site}/api/v1/{path.lstrip('/')}"
|
||||||
@@ -326,6 +342,23 @@ class ZulipAdapter(BasePlatformAdapter):
|
|||||||
|
|
||||||
return SendResult(success=True, message_id=str(last_id) if last_id else None)
|
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]:
|
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||||
kind, to, topic = _parse_target(chat_id, self._default_topic)
|
kind, to, topic = _parse_target(chat_id, self._default_topic)
|
||||||
if kind == "direct":
|
if kind == "direct":
|
||||||
|
|||||||
@@ -1,26 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* PM2 Ecosystem Config — Zulip Gateway v3 (Resilience)
|
||||||
|
*
|
||||||
|
* Deploy: pm2 start /root/.pm2/ecosystem.config.cjs
|
||||||
|
* Status: pm2 status
|
||||||
|
* Logs: pm2 logs abiba-zulip
|
||||||
|
*/
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
|
// ── Router (main Zulip gateway) ──
|
||||||
name: "abiba-zulip",
|
name: "abiba-zulip",
|
||||||
script: "/usr/bin/pi",
|
script: "/bin/pi",
|
||||||
args: "--mode rpc --session-id zulip-service",
|
args: "--mode rpc --session-id zulip-service",
|
||||||
cwd: "/root",
|
cwd: "/root",
|
||||||
|
|
||||||
|
// Resilience hardening — up from pi defaults
|
||||||
|
max_restarts: 100, // Crash loops won't exhaust PM2 (was default 10)
|
||||||
|
min_uptime: "10s", // Must survive 10s to count as "alive"
|
||||||
|
max_memory_restart: "500M", // OOM protection — restart before swap thrash
|
||||||
|
restart_delay: 5000, // 5s cooldown between restarts
|
||||||
|
kill_timeout: 15000, // 15s SIGTERM grace before SIGKILL
|
||||||
|
listen_timeout: 30000, // 30s to bind health port
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
log_date_format: "YYYY-MM-DD HH:mm:ss Z",
|
||||||
|
error_file: "/root/.pm2/logs/abiba-zulip-error.log",
|
||||||
|
out_file: "/root/.pm2/logs/abiba-zulip-out.log",
|
||||||
|
merge_logs: true,
|
||||||
|
log_type: "json",
|
||||||
|
|
||||||
|
// Process management
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
instances: 1,
|
||||||
|
exec_mode: "fork",
|
||||||
|
|
||||||
|
// Environment
|
||||||
env: {
|
env: {
|
||||||
ZULIP_ROLE: "router",
|
ZULIP_ROLE: "router",
|
||||||
ZULIP_EXTENSION_ACTIVE: "true",
|
ZULIP_SITE: "https://chat.sysloggh.net",
|
||||||
NODE_OPTIONS: "--max-old-space-size=512",
|
ZULIP_EMAIL: "abiba-bot@chat.sysloggh.net",
|
||||||
|
ZULIP_API_KEY: "cKTDMZAPW08dk3zl05sStzO7HRztzyn8",
|
||||||
|
AGENT_NAME: "abiba",
|
||||||
|
AGENT_OWNER_EMAIL: "jerome@sysloggh.com",
|
||||||
|
NODE_ENV: "production",
|
||||||
},
|
},
|
||||||
// Prevent rapid crash-looping: restart with backoff, limit retries
|
|
||||||
min_uptime: "30s",
|
|
||||||
max_restarts: 15,
|
|
||||||
restart_delay: 10000,
|
|
||||||
kill_timeout: 5000,
|
|
||||||
autorestart: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "abiba-telegram",
|
// ── Supervisor (external watchdog) ──
|
||||||
script: "/usr/bin/pitg",
|
name: "zulip-watchdog",
|
||||||
|
script: "/root/.pi/agent/extensions/zulip/watchdog.js",
|
||||||
cwd: "/root",
|
cwd: "/root",
|
||||||
|
|
||||||
|
max_restarts: 10,
|
||||||
|
min_uptime: "3s",
|
||||||
|
restart_delay: 3000,
|
||||||
|
kill_timeout: 5000,
|
||||||
|
|
||||||
|
log_date_format: "YYYY-MM-DD HH:mm:ss Z",
|
||||||
|
error_file: "/root/.pm2/logs/zulip-watchdog-error.log",
|
||||||
|
out_file: "/root/.pm2/logs/zulip-watchdog-out.log",
|
||||||
|
merge_logs: true,
|
||||||
|
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
instances: 1,
|
||||||
|
exec_mode: "fork",
|
||||||
|
|
||||||
|
env: {
|
||||||
|
NODE_ENV: "production",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"lastChangelogVersion": "0.80.6",
|
||||||
|
"defaultProvider": "syslog-harness",
|
||||||
|
"defaultModel": "syslog-auto",
|
||||||
|
"defaultThinkingLevel": "high",
|
||||||
|
"extensions": [
|
||||||
|
"+extensions/mcp/index.ts",
|
||||||
|
"+extensions/zulip/index.js"
|
||||||
|
],
|
||||||
|
"theme": "dark"
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 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
@@ -0,0 +1,34 @@
|
|||||||
|
// 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>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Zulip Gateway Supervisor — external watchdog process.
|
||||||
|
*
|
||||||
|
* Monitors the router health endpoint every 30s. If 3 consecutive checks fail,
|
||||||
|
* restarts the abiba-zulip PM2 process gracefully.
|
||||||
|
*
|
||||||
|
* This is the pattern Hermes uses: an external supervisor that can recover
|
||||||
|
* the gateway even when the gateway process itself is hung (not just crashed).
|
||||||
|
*
|
||||||
|
* Deployed via PM2 as a separate process in ecosystem.config.cjs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const HEALTH_URL = "http://127.0.0.1:9200/health";
|
||||||
|
const CHECK_INTERVAL_MS = 30_000;
|
||||||
|
const MAX_FAILURES = 3;
|
||||||
|
const RESTART_GRACE_MS = 15_000;
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(HEALTH_URL, {
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.status === "ok" && data.zulip?.connected) {
|
||||||
|
if (failures > 0) {
|
||||||
|
console.log(`[watchdog] Router recovered after ${failures} failure(s)`);
|
||||||
|
}
|
||||||
|
failures = 0;
|
||||||
|
// Silent health log every 10 checks (~5 min) for monitoring
|
||||||
|
if (Math.random() < 0.1) {
|
||||||
|
console.log(`[watchdog] Router healthy (uptime: ${Math.round(process.uptime())}s)`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connected but degraded
|
||||||
|
console.warn(`[watchdog] Router degraded: status=${data.status}, connected=${data.zulip?.connected}`);
|
||||||
|
failures++;
|
||||||
|
} else {
|
||||||
|
console.warn(`[watchdog] Health check returned ${res.status}`);
|
||||||
|
failures++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
failures++;
|
||||||
|
console.warn(`[watchdog] Health check ${failures}/${MAX_FAILURES}: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures >= MAX_FAILURES) {
|
||||||
|
console.error(`[watchdog] ${MAX_FAILURES} consecutive failures — restarting abiba-zulip`);
|
||||||
|
|
||||||
|
const { execSync } = await import("node:child_process");
|
||||||
|
try {
|
||||||
|
execSync("pm2 restart abiba-zulip", { timeout: 30000, encoding: "utf-8" });
|
||||||
|
console.log("[watchdog] Restart command sent successfully");
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[watchdog] Restart failed: ${e.message}`);
|
||||||
|
|
||||||
|
// Fallback: try resurrect if restart fails (process may be deleted)
|
||||||
|
try {
|
||||||
|
execSync("pm2 resurrect", { timeout: 30000 });
|
||||||
|
console.log("[watchdog] PM2 resurrected (fallback)");
|
||||||
|
} catch (e2) {
|
||||||
|
console.error(`[watchdog] Resurrect also failed: ${e2.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
failures = 0;
|
||||||
|
// Wait for restart to fully initialize before checking again
|
||||||
|
await new Promise((r) => setTimeout(r, RESTART_GRACE_MS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[watchdog] Zulip gateway supervisor started");
|
||||||
|
console.log(`[watchdog] Monitoring ${HEALTH_URL} every ${CHECK_INTERVAL_MS / 1000}s`);
|
||||||
|
console.log(`[watchdog] Max failures before restart: ${MAX_FAILURES}`);
|
||||||
|
|
||||||
|
// Immediate first check, then periodic
|
||||||
|
check();
|
||||||
|
setInterval(check, CHECK_INTERVAL_MS);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -77,7 +77,7 @@ SELFTEST_TIMEOUT = 30 # seconds to wait for self-test response
|
|||||||
|
|
||||||
# Gen 4: Connection pool health
|
# Gen 4: Connection pool health
|
||||||
HEARTBEAT_INTERVAL = 300 # Log heartbeat every 5 minutes
|
HEARTBEAT_INTERVAL = 300 # Log heartbeat every 5 minutes
|
||||||
MAX_CONSECUTIVE_EMPTY_POLLS = 20 # Reset client after this many empty polls
|
MAX_CONSECUTIVE_EMPTY_POLLS = 500 # Reset client after this many empty polls (~25min at 3s interval). Raised from 20 to prevent idle bots from cycling reconnections.
|
||||||
CLIENT_REUSE_LIMIT = 500 # Create fresh client every N polls to prevent connection leaks
|
CLIENT_REUSE_LIMIT = 500 # Create fresh client every N polls to prevent connection leaks
|
||||||
POLL_SILENCE_WARN_INTERVAL = 60 # Warn if no events received for this many seconds
|
POLL_SILENCE_WARN_INTERVAL = 60 # Warn if no events received for this many seconds
|
||||||
MAX_ERROR_LOG_LEN = 80 # Truncate error response bodies to avoid HTML spam
|
MAX_ERROR_LOG_LEN = 80 # Truncate error response bodies to avoid HTML spam
|
||||||
@@ -99,6 +99,23 @@ def _build_auth_header(email: str, api_key: str) -> str:
|
|||||||
return f"Basic {token}"
|
return f"Basic {token}"
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_html(text: str) -> str:
|
||||||
|
"""Strip HTML tags and decode HTML entities from Zulip message content.
|
||||||
|
|
||||||
|
Zulip delivers message content as rendered HTML (e.g. <p>/approve</p>).
|
||||||
|
The gateway slash command parser expects plain text, so HTML tags
|
||||||
|
prevent command matching. This helper strips tags and decodes entities.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
# Strip HTML tags
|
||||||
|
text = re.sub(r"<[^>]+>", "", text)
|
||||||
|
# Decode common HTML entities
|
||||||
|
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
text = text.replace(""", '"').replace("'", "'").replace(" ", " ")
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
def _truncate(text: str, limit: int = MAX_ZULIP_MESSAGE) -> str:
|
def _truncate(text: str, limit: int = MAX_ZULIP_MESSAGE) -> str:
|
||||||
"""Truncate to Zulip's message limit with notice."""
|
"""Truncate to Zulip's message limit with notice."""
|
||||||
if len(text) <= limit:
|
if len(text) <= limit:
|
||||||
@@ -302,6 +319,9 @@ class ZulipAdapter(BasePlatformAdapter):
|
|||||||
# Gen 2: Try to resolve @all-bots user ID dynamically
|
# Gen 2: Try to resolve @all-bots user ID dynamically
|
||||||
await self._resolve_all_bots_user_id()
|
await self._resolve_all_bots_user_id()
|
||||||
|
|
||||||
|
# Gen 5: Ensure bot is subscribed to primary stream
|
||||||
|
await self._ensure_stream_subscriptions()
|
||||||
|
|
||||||
self._mark_connected()
|
self._mark_connected()
|
||||||
logger.info(
|
logger.info(
|
||||||
"[%s] Connected to %s as %s (queue=%s, bot_id=%s, all_bots_id=%s)",
|
"[%s] Connected to %s as %s (queue=%s, bot_id=%s, all_bots_id=%s)",
|
||||||
@@ -449,6 +469,10 @@ class ZulipAdapter(BasePlatformAdapter):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
# Gen 4: Heartbeat logging — prove poll loop is alive
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - self._last_heartbeat_time > HEARTBEAT_INTERVAL:
|
if now - self._last_heartbeat_time > HEARTBEAT_INTERVAL:
|
||||||
@@ -677,6 +701,64 @@ class ZulipAdapter(BasePlatformAdapter):
|
|||||||
# Gen 2: Dynamic @all-bots resolution
|
# 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:
|
async def _resolve_all_bots_user_id(self) -> None:
|
||||||
"""Try to resolve the @all-bots user ID from the Zulip server.
|
"""Try to resolve the @all-bots user ID from the Zulip server.
|
||||||
|
|
||||||
@@ -719,6 +801,8 @@ class ZulipAdapter(BasePlatformAdapter):
|
|||||||
sender_name = msg.get("sender_full_name", "Unknown")
|
sender_name = msg.get("sender_full_name", "Unknown")
|
||||||
sender_id = msg.get("sender_id")
|
sender_id = msg.get("sender_id")
|
||||||
content = msg.get("content", "")
|
content = msg.get("content", "")
|
||||||
|
# Strip Zulip HTML for slash command matching (zulip-approval-fix contract)
|
||||||
|
content = _strip_html(content)
|
||||||
|
|
||||||
# Echo-loop prevention: skip own messages
|
# Echo-loop prevention: skip own messages
|
||||||
if sender_email == self._bot_email:
|
if sender_email == self._bot_email:
|
||||||
@@ -1096,6 +1180,35 @@ class ZulipAdapter(BasePlatformAdapter):
|
|||||||
"detail": f"@all-bots user_id: {self._all_bots_user_id}",
|
"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
|
# Overall verdict
|
||||||
critical = ["connected", "queue_registered", "http_client", "poll_loop"]
|
critical = ["connected", "queue_registered", "http_client", "poll_loop"]
|
||||||
passed = sum(1 for c in checks.values() if c["status"])
|
passed = sum(1 for c in checks.values() if c["status"])
|
||||||
@@ -1311,7 +1424,7 @@ def check_requirements() -> bool:
|
|||||||
"""Check whether the Zulip adapter is installable."""
|
"""Check whether the Zulip adapter is installable."""
|
||||||
if not HTTPX_AVAILABLE:
|
if not HTTPX_AVAILABLE:
|
||||||
return False
|
return False
|
||||||
site = os.getenv("ZULIP_SITE", "").strip()
|
site = os.getenv("ZULIP_SITE", "").strip() or os.getenv("ZULIP_URL", "").strip()
|
||||||
email = os.getenv("ZULIP_EMAIL", "").strip()
|
email = os.getenv("ZULIP_EMAIL", "").strip()
|
||||||
api_key = os.getenv("ZULIP_API_KEY", "").strip()
|
api_key = os.getenv("ZULIP_API_KEY", "").strip()
|
||||||
return bool(site and email and api_key)
|
return bool(site and email and api_key)
|
||||||
@@ -1320,7 +1433,7 @@ def check_requirements() -> bool:
|
|||||||
def validate_config(config) -> bool:
|
def validate_config(config) -> bool:
|
||||||
"""Validate that the configured Zulip platform has credentials."""
|
"""Validate that the configured Zulip platform has credentials."""
|
||||||
extra = getattr(config, "extra", {}) or {}
|
extra = getattr(config, "extra", {}) or {}
|
||||||
site = extra.get("site") or os.getenv("ZULIP_SITE", "")
|
site = extra.get("site") or os.getenv("ZULIP_SITE", "") or os.getenv("ZULIP_URL", "")
|
||||||
email = extra.get("email") or os.getenv("ZULIP_EMAIL", "")
|
email = extra.get("email") or os.getenv("ZULIP_EMAIL", "")
|
||||||
api_key = extra.get("api_key") or os.getenv("ZULIP_API_KEY", "")
|
api_key = extra.get("api_key") or os.getenv("ZULIP_API_KEY", "")
|
||||||
return bool(site and email and api_key)
|
return bool(site and email and api_key)
|
||||||
@@ -1333,7 +1446,7 @@ def is_connected(config) -> bool:
|
|||||||
|
|
||||||
def _env_enablement() -> Optional[dict]:
|
def _env_enablement() -> Optional[dict]:
|
||||||
"""Seeds PlatformConfig.extra from env vars for env-only setups."""
|
"""Seeds PlatformConfig.extra from env vars for env-only setups."""
|
||||||
site = os.getenv("ZULIP_SITE", "").strip()
|
site = os.getenv("ZULIP_SITE", "").strip() or os.getenv("ZULIP_URL", "").strip()
|
||||||
email = os.getenv("ZULIP_EMAIL", "").strip()
|
email = os.getenv("ZULIP_EMAIL", "").strip()
|
||||||
api_key = os.getenv("ZULIP_API_KEY", "").strip()
|
api_key = os.getenv("ZULIP_API_KEY", "").strip()
|
||||||
if not (site and email and api_key):
|
if not (site and email and api_key):
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
#!/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()
|
||||||
Reference in New Issue
Block a user