Files
hermes-agent/tests/gateway/test_bluebubbles.py

658 lines
24 KiB
Python
Raw Normal View History

"""Tests for the BlueBubbles iMessage gateway adapter."""
import pytest
from gateway.config import Platform, PlatformConfig
def _make_adapter(monkeypatch, **extra):
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret")
from gateway.platforms.bluebubbles import BlueBubblesAdapter
cfg = PlatformConfig(
enabled=True,
extra={
"server_url": "http://localhost:1234",
"password": "secret",
**extra,
},
)
return BlueBubblesAdapter(cfg)
class TestBlueBubblesConfigLoading:
def test_apply_env_overrides_bluebubbles(self, monkeypatch):
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret")
monkeypatch.setenv("BLUEBUBBLES_WEBHOOK_PORT", "9999")
from gateway.config import GatewayConfig, _apply_env_overrides
config = GatewayConfig()
_apply_env_overrides(config)
assert Platform.BLUEBUBBLES in config.platforms
bc = config.platforms[Platform.BLUEBUBBLES]
assert bc.enabled is True
assert bc.extra["server_url"] == "http://localhost:1234"
assert bc.extra["password"] == "secret"
assert bc.extra["webhook_port"] == 9999
def test_home_channel_set_from_env(self, monkeypatch):
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret")
monkeypatch.setenv("BLUEBUBBLES_HOME_CHANNEL", "user@example.com")
from gateway.config import GatewayConfig, _apply_env_overrides
config = GatewayConfig()
_apply_env_overrides(config)
hc = config.platforms[Platform.BLUEBUBBLES].home_channel
assert hc is not None
assert hc.chat_id == "user@example.com"
def test_not_connected_without_password(self, monkeypatch):
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
monkeypatch.delenv("BLUEBUBBLES_PASSWORD", raising=False)
from gateway.config import GatewayConfig, _apply_env_overrides
config = GatewayConfig()
_apply_env_overrides(config)
assert Platform.BLUEBUBBLES not in config.get_connected_platforms()
class TestBlueBubblesHelpers:
def test_check_requirements(self, monkeypatch):
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret")
from gateway.platforms.bluebubbles import check_bluebubbles_requirements
assert check_bluebubbles_requirements() is True
def test_format_message_strips_markdown(self, monkeypatch):
adapter = _make_adapter(monkeypatch)
assert adapter.format_message("**Hello** `world`") == "Hello world"
def test_strip_markdown_headers(self, monkeypatch):
adapter = _make_adapter(monkeypatch)
assert adapter.format_message("## Heading\ntext") == "Heading\ntext"
def test_strip_markdown_links(self, monkeypatch):
adapter = _make_adapter(monkeypatch)
assert adapter.format_message("[click here](http://example.com)") == "click here"
def test_init_normalizes_webhook_path(self, monkeypatch):
adapter = _make_adapter(monkeypatch, webhook_path="bluebubbles-webhook")
assert adapter.webhook_path == "/bluebubbles-webhook"
def test_init_preserves_leading_slash(self, monkeypatch):
adapter = _make_adapter(monkeypatch, webhook_path="/my-hook")
assert adapter.webhook_path == "/my-hook"
def test_server_url_normalized(self, monkeypatch):
adapter = _make_adapter(monkeypatch, server_url="http://localhost:1234/")
assert adapter.server_url == "http://localhost:1234"
def test_server_url_adds_scheme(self, monkeypatch):
adapter = _make_adapter(monkeypatch, server_url="localhost:1234")
assert adapter.server_url == "http://localhost:1234"
class TestBlueBubblesWebhookParsing:
def test_webhook_prefers_chat_guid_over_message_guid(self, monkeypatch):
adapter = _make_adapter(monkeypatch)
payload = {
"guid": "MESSAGE-GUID",
"chatGuid": "iMessage;-;user@example.com",
"chatIdentifier": "user@example.com",
}
record = adapter._extract_payload_record(payload) or {}
chat_guid = adapter._value(
record.get("chatGuid"),
payload.get("chatGuid"),
record.get("chat_guid"),
payload.get("chat_guid"),
payload.get("guid"),
)
assert chat_guid == "iMessage;-;user@example.com"
def test_webhook_can_fall_back_to_sender_when_chat_fields_missing(self, monkeypatch):
adapter = _make_adapter(monkeypatch)
payload = {
"data": {
"guid": "MESSAGE-GUID",
"text": "hello",
"handle": {"address": "user@example.com"},
"isFromMe": False,
}
}
record = adapter._extract_payload_record(payload) or {}
chat_guid = adapter._value(
record.get("chatGuid"),
payload.get("chatGuid"),
record.get("chat_guid"),
payload.get("chat_guid"),
payload.get("guid"),
)
chat_identifier = adapter._value(
record.get("chatIdentifier"),
record.get("identifier"),
payload.get("chatIdentifier"),
payload.get("identifier"),
)
sender = (
adapter._value(
record.get("handle", {}).get("address")
if isinstance(record.get("handle"), dict)
else None,
record.get("sender"),
record.get("from"),
record.get("address"),
)
or chat_identifier
or chat_guid
)
if not (chat_guid or chat_identifier) and sender:
chat_identifier = sender
assert chat_identifier == "user@example.com"
fix(gateway/bluebubbles): fall back to data.chats[0].guid when chatGuid missing BlueBubbles v1.9+ webhook payloads for new-message events do not always include a top-level chatGuid field on the message data object. Instead, the chat GUID is nested under data.chats[0].guid. The adapter currently checks five top-level fallback locations (record and payload, snake_case and camelCase, plus payload.guid) but never looks inside the chats array. When none of those top-level fields contain the GUID, the adapter falls through to using the sender's phone/email as the session chat ID. This causes two observable bugs when a user is a participant in both a DM and a group chat with the bot: 1. DM and group sessions merge. Every message from that user ends up with the same session_chat_id (their own address), so the bot cannot distinguish which thread the message came from. 2. Outbound routing becomes ambiguous. _resolve_chat_guid() iterates all chats and returns the first one where the address appears as a participant; group chats typically sort ahead of DMs by activity, so replies and cron messages intended for the DM can land in a group. This was observed in production: a user's morning brief cron delivered to a group chat with his spouse instead of his DM thread. The fix adds a single fallback that extracts chat_guid from record["chats"][0]["guid"] when the top-level fields are empty. The chats array is included in every new-message webhook payload in BB v1.9.9 (verified against a live server). It is backwards compatible: if a future BB version starts including chatGuid at the top level, that still wins. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:30:58 -05:00
def test_webhook_extracts_chat_guid_from_chats_array_dm(self, monkeypatch):
"""BB v1.9+ webhook payloads omit top-level chatGuid; GUID is in chats[0].guid."""
adapter = _make_adapter(monkeypatch)
payload = {
"type": "new-message",
"data": {
"guid": "MESSAGE-GUID",
"text": "hello",
"handle": {"address": "+15551234567"},
"isFromMe": False,
"chats": [
{"guid": "any;-;+15551234567", "chatIdentifier": "+15551234567"}
],
},
}
record = adapter._extract_payload_record(payload) or {}
chat_guid = adapter._value(
record.get("chatGuid"),
payload.get("chatGuid"),
record.get("chat_guid"),
payload.get("chat_guid"),
payload.get("guid"),
)
if not chat_guid:
_chats = record.get("chats") or []
if _chats and isinstance(_chats[0], dict):
chat_guid = _chats[0].get("guid") or _chats[0].get("chatGuid")
assert chat_guid == "any;-;+15551234567"
def test_webhook_extracts_chat_guid_from_chats_array_group(self, monkeypatch):
"""Group chat GUIDs contain ;+; and must be extracted from chats array."""
adapter = _make_adapter(monkeypatch)
payload = {
"type": "new-message",
"data": {
"guid": "MESSAGE-GUID",
"text": "hello everyone",
"handle": {"address": "+15551234567"},
"isFromMe": False,
"isGroup": True,
"chats": [{"guid": "any;+;chat-uuid-abc123"}],
},
}
record = adapter._extract_payload_record(payload) or {}
chat_guid = adapter._value(
record.get("chatGuid"),
payload.get("chatGuid"),
record.get("chat_guid"),
payload.get("chat_guid"),
payload.get("guid"),
)
if not chat_guid:
_chats = record.get("chats") or []
if _chats and isinstance(_chats[0], dict):
chat_guid = _chats[0].get("guid") or _chats[0].get("chatGuid")
assert chat_guid == "any;+;chat-uuid-abc123"
def test_extract_payload_record_accepts_list_data(self, monkeypatch):
adapter = _make_adapter(monkeypatch)
payload = {
"type": "new-message",
"data": [
{
"text": "hello",
"chatGuid": "iMessage;-;user@example.com",
"chatIdentifier": "user@example.com",
}
],
}
record = adapter._extract_payload_record(payload)
assert record == payload["data"][0]
def test_extract_payload_record_dict_data(self, monkeypatch):
adapter = _make_adapter(monkeypatch)
payload = {"data": {"text": "hello", "chatGuid": "iMessage;-;+1234"}}
record = adapter._extract_payload_record(payload)
assert record["text"] == "hello"
def test_extract_payload_record_fallback_to_message(self, monkeypatch):
adapter = _make_adapter(monkeypatch)
payload = {"message": {"text": "hello"}}
record = adapter._extract_payload_record(payload)
assert record["text"] == "hello"
class TestBlueBubblesGuidResolution:
def test_raw_guid_returned_as_is(self, monkeypatch):
"""If target already contains ';' it's a raw GUID — return unchanged."""
adapter = _make_adapter(monkeypatch)
import asyncio
result = asyncio.get_event_loop().run_until_complete(
adapter._resolve_chat_guid("iMessage;-;user@example.com")
)
assert result == "iMessage;-;user@example.com"
def test_empty_target_returns_none(self, monkeypatch):
adapter = _make_adapter(monkeypatch)
import asyncio
result = asyncio.get_event_loop().run_until_complete(
adapter._resolve_chat_guid("")
)
assert result is None
class TestBlueBubblesAttachmentDownload:
"""Verify _download_attachment routes to the correct cache helper."""
def test_download_image_uses_image_cache(self, monkeypatch):
"""Image MIME routes to cache_image_from_bytes."""
adapter = _make_adapter(monkeypatch)
import asyncio
import httpx
# Mock the HTTP client response
class MockResponse:
status_code = 200
content = b"\x89PNG\r\n\x1a\n"
def raise_for_status(self):
pass
async def mock_get(*args, **kwargs):
return MockResponse()
adapter.client = type("MockClient", (), {"get": mock_get})()
cached_path = None
def mock_cache_image(data, ext):
nonlocal cached_path
cached_path = f"/tmp/test_image{ext}"
return cached_path
monkeypatch.setattr(
"gateway.platforms.bluebubbles.cache_image_from_bytes",
mock_cache_image,
)
att_meta = {"mimeType": "image/png", "transferName": "photo.png"}
result = asyncio.get_event_loop().run_until_complete(
adapter._download_attachment("att-guid-123", att_meta)
)
assert result == "/tmp/test_image.png"
def test_download_audio_uses_audio_cache(self, monkeypatch):
"""Audio MIME routes to cache_audio_from_bytes."""
adapter = _make_adapter(monkeypatch)
import asyncio
class MockResponse:
status_code = 200
content = b"fake-audio-data"
def raise_for_status(self):
pass
async def mock_get(*args, **kwargs):
return MockResponse()
adapter.client = type("MockClient", (), {"get": mock_get})()
cached_path = None
def mock_cache_audio(data, ext):
nonlocal cached_path
cached_path = f"/tmp/test_audio{ext}"
return cached_path
monkeypatch.setattr(
"gateway.platforms.bluebubbles.cache_audio_from_bytes",
mock_cache_audio,
)
att_meta = {"mimeType": "audio/mpeg", "transferName": "voice.mp3"}
result = asyncio.get_event_loop().run_until_complete(
adapter._download_attachment("att-guid-456", att_meta)
)
assert result == "/tmp/test_audio.mp3"
def test_download_document_uses_document_cache(self, monkeypatch):
"""Non-image/audio MIME routes to cache_document_from_bytes."""
adapter = _make_adapter(monkeypatch)
import asyncio
class MockResponse:
status_code = 200
content = b"fake-doc-data"
def raise_for_status(self):
pass
async def mock_get(*args, **kwargs):
return MockResponse()
adapter.client = type("MockClient", (), {"get": mock_get})()
cached_path = None
def mock_cache_doc(data, filename):
nonlocal cached_path
cached_path = f"/tmp/{filename}"
return cached_path
monkeypatch.setattr(
"gateway.platforms.bluebubbles.cache_document_from_bytes",
mock_cache_doc,
)
att_meta = {"mimeType": "application/pdf", "transferName": "report.pdf"}
result = asyncio.get_event_loop().run_until_complete(
adapter._download_attachment("att-guid-789", att_meta)
)
assert result == "/tmp/report.pdf"
def test_download_returns_none_without_client(self, monkeypatch):
"""No client → returns None gracefully."""
adapter = _make_adapter(monkeypatch)
adapter.client = None
import asyncio
result = asyncio.get_event_loop().run_until_complete(
adapter._download_attachment("att-guid", {"mimeType": "image/png"})
)
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
fix(gateway/bluebubbles): embed password in registered webhook URL for inbound auth When BlueBubbles posts webhook events to the adapter, it uses the exact URL registered via /api/v1/webhook — and BB's registration API does not support custom headers. The adapter currently registers the bare URL (no credentials), but then requires password auth on inbound POSTs, rejecting every webhook with HTTP 401. This is masked on fresh BB installs by a race condition: the webhook might register once with a prior (possibly patched) URL and keep working until the first restart. On v0.9.0, _unregister_webhook runs on clean shutdown, so the next startup re-registers with the bare URL and the 401s begin. Users see the bot go silent with no obvious cause. Root cause: there's no way to pass auth credentials from BB to the webhook handler except via the URL itself. BB accepts query params and preserves them on outbound POSTs. ## Fix Introduce `_webhook_register_url` — the URL handed to BB's registration API, with the configured password appended as a `?password=<value>` query param. The existing webhook auth handler already accepts this form (it reads `request.query.get("password")`), so no change to the receive side is needed. The bare `_webhook_url` is still used for logging and for binding the local listener, so credentials don't leak into log output. Only the registration/find/unregister paths use the password-bearing form. ## Notes - Password is URL-encoded via urllib.parse.quote, handling special characters (&, *, @, etc.) that would otherwise break parsing. - Storing the password in BB's webhook table is not a new disclosure: anyone with access to that table already has the BB admin password (same credential used for every other API call). - If `self.password` is empty (no auth configured), the register URL is the bare URL — preserves current behavior for unauthenticated local-only setups. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:39:51 -05:00
def test_register_url_embeds_password(self, monkeypatch):
"""_webhook_register_url should append ?password=... for inbound auth."""
adapter = _make_adapter(monkeypatch, password="secret123")
assert adapter._webhook_register_url.endswith("?password=secret123")
assert adapter._webhook_register_url.startswith(adapter._webhook_url)
def test_register_url_url_encodes_password(self, monkeypatch):
"""Passwords with special characters must be URL-encoded."""
adapter = _make_adapter(monkeypatch, password="W9fTC&L5JL*@")
assert "password=W9fTC%26L5JL%2A%40" in adapter._webhook_register_url
def test_register_url_omits_query_when_no_password(self, monkeypatch):
"""If no password is configured, the register URL should be the bare URL."""
monkeypatch.delenv("BLUEBUBBLES_PASSWORD", raising=False)
from gateway.platforms.bluebubbles import BlueBubblesAdapter
cfg = PlatformConfig(
enabled=True,
extra={"server_url": "http://localhost:1234", "password": ""},
)
adapter = BlueBubblesAdapter(cfg)
assert adapter._webhook_register_url == 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_register_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_register_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_register_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