feat(gateway): add BlueBubbles iMessage platform adapter (#6437)
Adds Apple iMessage as a gateway platform via BlueBubbles macOS server.
Architecture:
- Webhook-based inbound (event-driven, no polling/dedup needed)
- Email/phone → chat GUID resolution for user-friendly addressing
- Private API safety (checks helper_connected before tapback/typing)
- Inbound attachment downloading (images, audio, documents cached locally)
- Markdown stripping for clean iMessage delivery
- Smart progress suppression for platforms without message editing
Based on PR #5869 by @benjaminsehl (webhook architecture, GUID resolution,
Private API safety, progress suppression) with inbound attachment downloading
from PR #4588 by @1960697431 (attachment cache routing).
Integration points: Platform enum, env config, adapter factory, auth maps,
cron delivery, send_message routing, channel directory, platform hints,
toolset definition, setup wizard, status display.
27 tests covering config, adapter, webhook parsing, GUID resolution,
attachment download routing, toolset consistency, and prompt hints.
2026-04-08 23:54:03 -07:00
|
|
|
"""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"
|
|
|
|
|
|
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"
|
|
|
|
|
|
feat(gateway): add BlueBubbles iMessage platform adapter (#6437)
Adds Apple iMessage as a gateway platform via BlueBubbles macOS server.
Architecture:
- Webhook-based inbound (event-driven, no polling/dedup needed)
- Email/phone → chat GUID resolution for user-friendly addressing
- Private API safety (checks helper_connected before tapback/typing)
- Inbound attachment downloading (images, audio, documents cached locally)
- Markdown stripping for clean iMessage delivery
- Smart progress suppression for platforms without message editing
Based on PR #5869 by @benjaminsehl (webhook architecture, GUID resolution,
Private API safety, progress suppression) with inbound attachment downloading
from PR #4588 by @1960697431 (attachment cache routing).
Integration points: Platform enum, env config, adapter factory, auth maps,
cron delivery, send_message routing, channel directory, platform hints,
toolset definition, setup wizard, status display.
27 tests covering config, adapter, webhook parsing, GUID resolution,
attachment download routing, toolset consistency, and prompt hints.
2026-04-08 23:54:03 -07:00
|
|
|
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
|
2026-04-10 03:18:03 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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
|
|
|
|
|
|
2026-04-10 03:18:03 -07:00
|
|
|
|
|
|
|
|
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)
|
2026-04-14 10:58:07 -07:00
|
|
|
url = adapter._webhook_register_url
|
2026-04-10 03:18:03 -07:00
|
|
|
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)
|
2026-04-14 10:58:07 -07:00
|
|
|
url = adapter._webhook_register_url
|
2026-04-10 03:18:03 -07:00
|
|
|
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)
|
2026-04-14 10:58:07 -07:00
|
|
|
url = adapter._webhook_register_url
|
2026-04-10 03:18:03 -07:00
|
|
|
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
|