mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 07:51:45 +08:00
Compare commits
2 Commits
fix/plugin
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e57057b9ef | ||
|
|
fabf7206b8 |
@@ -207,9 +207,17 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
|||||||
self.webhook_port,
|
self.webhook_port,
|
||||||
self.webhook_path,
|
self.webhook_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Register webhook with BlueBubbles server
|
||||||
|
# This is required for the server to know where to send events
|
||||||
|
await self._register_webhook()
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
async def disconnect(self) -> None:
|
||||||
|
# Unregister webhook before cleaning up
|
||||||
|
await self._unregister_webhook()
|
||||||
|
|
||||||
if self.client:
|
if self.client:
|
||||||
await self.client.aclose()
|
await self.client.aclose()
|
||||||
self.client = None
|
self.client = None
|
||||||
@@ -218,6 +226,105 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
|||||||
self._runner = None
|
self._runner = None
|
||||||
self._mark_disconnected()
|
self._mark_disconnected()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _webhook_url(self) -> str:
|
||||||
|
"""Compute the external webhook URL for BlueBubbles registration."""
|
||||||
|
host = self.webhook_host
|
||||||
|
if host in ("0.0.0.0", "127.0.0.1", "localhost", "::"):
|
||||||
|
host = "localhost"
|
||||||
|
return f"http://{host}:{self.webhook_port}{self.webhook_path}"
|
||||||
|
|
||||||
|
async def _find_registered_webhooks(self, url: str) -> list:
|
||||||
|
"""Return list of BB webhook entries matching *url*."""
|
||||||
|
try:
|
||||||
|
res = await self._api_get("/api/v1/webhook")
|
||||||
|
data = res.get("data")
|
||||||
|
if isinstance(data, list):
|
||||||
|
return [wh for wh in data if wh.get("url") == url]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _register_webhook(self) -> bool:
|
||||||
|
"""Register this webhook URL with the BlueBubbles server.
|
||||||
|
|
||||||
|
BlueBubbles requires webhooks to be registered via API before
|
||||||
|
it will send events. Checks for an existing registration first
|
||||||
|
to avoid duplicates (e.g. after a crash without clean shutdown).
|
||||||
|
"""
|
||||||
|
if not self.client:
|
||||||
|
return False
|
||||||
|
|
||||||
|
webhook_url = self._webhook_url
|
||||||
|
|
||||||
|
# Crash resilience — reuse an existing registration if present
|
||||||
|
existing = await self._find_registered_webhooks(webhook_url)
|
||||||
|
if existing:
|
||||||
|
logger.info(
|
||||||
|
"[bluebubbles] webhook already registered: %s", webhook_url
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"url": webhook_url,
|
||||||
|
"events": ["new-message", "updated-message", "message"],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = await self._api_post("/api/v1/webhook", payload)
|
||||||
|
status = res.get("status", 0)
|
||||||
|
if 200 <= status < 300:
|
||||||
|
logger.info(
|
||||||
|
"[bluebubbles] webhook registered with server: %s",
|
||||||
|
webhook_url,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"[bluebubbles] webhook registration returned status %s: %s",
|
||||||
|
status,
|
||||||
|
res.get("message"),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"[bluebubbles] failed to register webhook with server: %s",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _unregister_webhook(self) -> bool:
|
||||||
|
"""Unregister this webhook URL from the BlueBubbles server.
|
||||||
|
|
||||||
|
Removes *all* matching registrations to clean up any duplicates
|
||||||
|
left by prior crashes.
|
||||||
|
"""
|
||||||
|
if not self.client:
|
||||||
|
return False
|
||||||
|
|
||||||
|
webhook_url = self._webhook_url
|
||||||
|
removed = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
for wh in await self._find_registered_webhooks(webhook_url):
|
||||||
|
wh_id = wh.get("id")
|
||||||
|
if wh_id:
|
||||||
|
res = await self.client.delete(
|
||||||
|
self._api_url(f"/api/v1/webhook/{wh_id}")
|
||||||
|
)
|
||||||
|
res.raise_for_status()
|
||||||
|
removed = True
|
||||||
|
if removed:
|
||||||
|
logger.info(
|
||||||
|
"[bluebubbles] webhook unregistered: %s", webhook_url
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
"[bluebubbles] failed to unregister webhook (non-critical): %s",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return removed
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Chat GUID resolution
|
# Chat GUID resolution
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -826,3 +933,4 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
|||||||
asyncio.create_task(self.mark_read(session_chat_id))
|
asyncio.create_task(self.mark_read(session_chat_id))
|
||||||
|
|
||||||
return web.Response(text="ok")
|
return web.Response(text="ok")
|
||||||
|
|
||||||
|
|||||||
@@ -359,3 +359,257 @@ class TestBlueBubblesAttachmentDownload:
|
|||||||
adapter._download_attachment("att-guid", {"mimeType": "image/png"})
|
adapter._download_attachment("att-guid", {"mimeType": "image/png"})
|
||||||
)
|
)
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Webhook registration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBlueBubblesWebhookUrl:
|
||||||
|
"""_webhook_url property normalises local hosts to 'localhost'."""
|
||||||
|
|
||||||
|
def test_default_host(self, monkeypatch):
|
||||||
|
adapter = _make_adapter(monkeypatch)
|
||||||
|
# Default webhook_host is 0.0.0.0 → normalized to localhost
|
||||||
|
assert "localhost" in adapter._webhook_url
|
||||||
|
assert str(adapter.webhook_port) in adapter._webhook_url
|
||||||
|
assert adapter.webhook_path in adapter._webhook_url
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("host", ["0.0.0.0", "127.0.0.1", "localhost", "::"])
|
||||||
|
def test_local_hosts_normalized(self, monkeypatch, host):
|
||||||
|
adapter = _make_adapter(monkeypatch, webhook_host=host)
|
||||||
|
assert adapter._webhook_url.startswith("http://localhost:")
|
||||||
|
|
||||||
|
def test_custom_host_preserved(self, monkeypatch):
|
||||||
|
adapter = _make_adapter(monkeypatch, webhook_host="192.168.1.50")
|
||||||
|
assert "192.168.1.50" in adapter._webhook_url
|
||||||
|
|
||||||
|
|
||||||
|
class TestBlueBubblesWebhookRegistration:
|
||||||
|
"""Tests for _register_webhook, _unregister_webhook, _find_registered_webhooks."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mock_client(get_response=None, post_response=None, delete_ok=True):
|
||||||
|
"""Build a tiny mock httpx.AsyncClient."""
|
||||||
|
|
||||||
|
async def mock_get(*args, **kwargs):
|
||||||
|
class R:
|
||||||
|
status_code = 200
|
||||||
|
def raise_for_status(self):
|
||||||
|
pass
|
||||||
|
def json(self):
|
||||||
|
return get_response or {"status": 200, "data": []}
|
||||||
|
return R()
|
||||||
|
|
||||||
|
async def mock_post(*args, **kwargs):
|
||||||
|
class R:
|
||||||
|
status_code = 200
|
||||||
|
def raise_for_status(self):
|
||||||
|
pass
|
||||||
|
def json(self):
|
||||||
|
return post_response or {"status": 200, "data": {}}
|
||||||
|
return R()
|
||||||
|
|
||||||
|
async def mock_delete(*args, **kwargs):
|
||||||
|
class R:
|
||||||
|
status_code = 200 if delete_ok else 500
|
||||||
|
def raise_for_status(self_inner):
|
||||||
|
if not delete_ok:
|
||||||
|
raise Exception("delete failed")
|
||||||
|
return R()
|
||||||
|
|
||||||
|
return type(
|
||||||
|
"MockClient", (),
|
||||||
|
{"get": mock_get, "post": mock_post, "delete": mock_delete},
|
||||||
|
)()
|
||||||
|
|
||||||
|
# -- _find_registered_webhooks --
|
||||||
|
|
||||||
|
def test_find_registered_webhooks_returns_matches(self, monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
adapter = _make_adapter(monkeypatch)
|
||||||
|
url = adapter._webhook_url
|
||||||
|
adapter.client = self._mock_client(
|
||||||
|
get_response={"status": 200, "data": [
|
||||||
|
{"id": 1, "url": url, "events": ["new-message"]},
|
||||||
|
{"id": 2, "url": "http://other:9999/hook", "events": ["message"]},
|
||||||
|
]}
|
||||||
|
)
|
||||||
|
result = asyncio.get_event_loop().run_until_complete(
|
||||||
|
adapter._find_registered_webhooks(url)
|
||||||
|
)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["id"] == 1
|
||||||
|
|
||||||
|
def test_find_registered_webhooks_empty_when_none(self, monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
adapter = _make_adapter(monkeypatch)
|
||||||
|
adapter.client = self._mock_client(
|
||||||
|
get_response={"status": 200, "data": []}
|
||||||
|
)
|
||||||
|
result = asyncio.get_event_loop().run_until_complete(
|
||||||
|
adapter._find_registered_webhooks(adapter._webhook_url)
|
||||||
|
)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_find_registered_webhooks_handles_api_error(self, monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
adapter = _make_adapter(monkeypatch)
|
||||||
|
adapter.client = self._mock_client()
|
||||||
|
|
||||||
|
# Override _api_get to raise
|
||||||
|
async def bad_get(path):
|
||||||
|
raise ConnectionError("server down")
|
||||||
|
adapter._api_get = bad_get
|
||||||
|
|
||||||
|
result = asyncio.get_event_loop().run_until_complete(
|
||||||
|
adapter._find_registered_webhooks(adapter._webhook_url)
|
||||||
|
)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
# -- _register_webhook --
|
||||||
|
|
||||||
|
def test_register_fresh(self, monkeypatch):
|
||||||
|
"""No existing webhook → POST creates one."""
|
||||||
|
import asyncio
|
||||||
|
adapter = _make_adapter(monkeypatch)
|
||||||
|
adapter.client = self._mock_client(
|
||||||
|
get_response={"status": 200, "data": []},
|
||||||
|
post_response={"status": 200, "data": {"id": 42}},
|
||||||
|
)
|
||||||
|
ok = asyncio.get_event_loop().run_until_complete(
|
||||||
|
adapter._register_webhook()
|
||||||
|
)
|
||||||
|
assert ok is True
|
||||||
|
|
||||||
|
def test_register_accepts_201(self, monkeypatch):
|
||||||
|
"""BB might return 201 Created — must still succeed."""
|
||||||
|
import asyncio
|
||||||
|
adapter = _make_adapter(monkeypatch)
|
||||||
|
adapter.client = self._mock_client(
|
||||||
|
get_response={"status": 200, "data": []},
|
||||||
|
post_response={"status": 201, "data": {"id": 43}},
|
||||||
|
)
|
||||||
|
ok = asyncio.get_event_loop().run_until_complete(
|
||||||
|
adapter._register_webhook()
|
||||||
|
)
|
||||||
|
assert ok is True
|
||||||
|
|
||||||
|
def test_register_reuses_existing(self, monkeypatch):
|
||||||
|
"""Crash resilience — existing registration is reused, no POST needed."""
|
||||||
|
import asyncio
|
||||||
|
adapter = _make_adapter(monkeypatch)
|
||||||
|
url = adapter._webhook_url
|
||||||
|
adapter.client = self._mock_client(
|
||||||
|
get_response={"status": 200, "data": [
|
||||||
|
{"id": 7, "url": url, "events": ["new-message"]},
|
||||||
|
]},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track whether POST was called
|
||||||
|
post_called = False
|
||||||
|
orig_api_post = adapter._api_post
|
||||||
|
async def tracking_post(path, payload):
|
||||||
|
nonlocal post_called
|
||||||
|
post_called = True
|
||||||
|
return await orig_api_post(path, payload)
|
||||||
|
adapter._api_post = tracking_post
|
||||||
|
|
||||||
|
ok = asyncio.get_event_loop().run_until_complete(
|
||||||
|
adapter._register_webhook()
|
||||||
|
)
|
||||||
|
assert ok is True
|
||||||
|
assert not post_called, "Should reuse existing, not POST again"
|
||||||
|
|
||||||
|
def test_register_returns_false_without_client(self, monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
adapter = _make_adapter(monkeypatch)
|
||||||
|
adapter.client = None
|
||||||
|
ok = asyncio.get_event_loop().run_until_complete(
|
||||||
|
adapter._register_webhook()
|
||||||
|
)
|
||||||
|
assert ok is False
|
||||||
|
|
||||||
|
def test_register_returns_false_on_server_error(self, monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
adapter = _make_adapter(monkeypatch)
|
||||||
|
adapter.client = self._mock_client(
|
||||||
|
get_response={"status": 200, "data": []},
|
||||||
|
post_response={"status": 500, "message": "internal error"},
|
||||||
|
)
|
||||||
|
ok = asyncio.get_event_loop().run_until_complete(
|
||||||
|
adapter._register_webhook()
|
||||||
|
)
|
||||||
|
assert ok is False
|
||||||
|
|
||||||
|
# -- _unregister_webhook --
|
||||||
|
|
||||||
|
def test_unregister_removes_matching(self, monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
adapter = _make_adapter(monkeypatch)
|
||||||
|
url = adapter._webhook_url
|
||||||
|
adapter.client = self._mock_client(
|
||||||
|
get_response={"status": 200, "data": [
|
||||||
|
{"id": 10, "url": url},
|
||||||
|
]},
|
||||||
|
)
|
||||||
|
ok = asyncio.get_event_loop().run_until_complete(
|
||||||
|
adapter._unregister_webhook()
|
||||||
|
)
|
||||||
|
assert ok is True
|
||||||
|
|
||||||
|
def test_unregister_removes_all_duplicates(self, monkeypatch):
|
||||||
|
"""Multiple orphaned registrations for same URL — all get removed."""
|
||||||
|
import asyncio
|
||||||
|
adapter = _make_adapter(monkeypatch)
|
||||||
|
url = adapter._webhook_url
|
||||||
|
deleted_ids = []
|
||||||
|
|
||||||
|
async def mock_delete(*args, **kwargs):
|
||||||
|
# Extract ID from URL
|
||||||
|
url_str = args[0] if args else ""
|
||||||
|
deleted_ids.append(url_str)
|
||||||
|
class R:
|
||||||
|
status_code = 200
|
||||||
|
def raise_for_status(self):
|
||||||
|
pass
|
||||||
|
return R()
|
||||||
|
|
||||||
|
adapter.client = self._mock_client(
|
||||||
|
get_response={"status": 200, "data": [
|
||||||
|
{"id": 1, "url": url},
|
||||||
|
{"id": 2, "url": url},
|
||||||
|
{"id": 3, "url": "http://other/hook"},
|
||||||
|
]},
|
||||||
|
)
|
||||||
|
adapter.client.delete = mock_delete
|
||||||
|
|
||||||
|
ok = asyncio.get_event_loop().run_until_complete(
|
||||||
|
adapter._unregister_webhook()
|
||||||
|
)
|
||||||
|
assert ok is True
|
||||||
|
assert len(deleted_ids) == 2
|
||||||
|
|
||||||
|
def test_unregister_returns_false_without_client(self, monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
adapter = _make_adapter(monkeypatch)
|
||||||
|
adapter.client = None
|
||||||
|
ok = asyncio.get_event_loop().run_until_complete(
|
||||||
|
adapter._unregister_webhook()
|
||||||
|
)
|
||||||
|
assert ok is False
|
||||||
|
|
||||||
|
def test_unregister_handles_api_failure_gracefully(self, monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
adapter = _make_adapter(monkeypatch)
|
||||||
|
adapter.client = self._mock_client()
|
||||||
|
|
||||||
|
async def bad_get(path):
|
||||||
|
raise ConnectionError("server down")
|
||||||
|
adapter._api_get = bad_get
|
||||||
|
|
||||||
|
ok = asyncio.get_event_loop().run_until_complete(
|
||||||
|
adapter._unregister_webhook()
|
||||||
|
)
|
||||||
|
assert ok is False
|
||||||
|
|||||||
@@ -135,8 +135,9 @@ Without the Private API, basic text messaging and media still work.
|
|||||||
### Messages not arriving
|
### Messages not arriving
|
||||||
- Check that the webhook is registered in BlueBubbles Server → Settings → API → Webhooks
|
- Check that the webhook is registered in BlueBubbles Server → Settings → API → Webhooks
|
||||||
- Verify the webhook URL is reachable from the Mac
|
- Verify the webhook URL is reachable from the Mac
|
||||||
- Check `hermes gateway logs` for webhook errors
|
- Check `hermes logs gateway` for webhook errors (or `hermes logs -f` to follow in real-time)
|
||||||
|
|
||||||
### "Private API helper not connected"
|
### "Private API helper not connected"
|
||||||
- Install the Private API helper: [docs.bluebubbles.app](https://docs.bluebubbles.app/helper-bundle/installation)
|
- Install the Private API helper: [docs.bluebubbles.app](https://docs.bluebubbles.app/helper-bundle/installation)
|
||||||
- Basic messaging works without it — only reactions, typing, and read receipts require it
|
- Basic messaging works without it — only reactions, typing, and read receipts require it
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user