Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions gateway/platforms/bluebubbles.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,22 @@ def _redact(text: str) -> str:
return text


# Characters that render as tofu boxes on iMessage because Apple Color Emoji
# has no glyphs for them. Strips:
# * Block Elements (U+2580..U+259F) — streaming-cursor artifacts like ▉ that
# the agent's stream renderer can leak into the final message.
# * Private Use Area (U+E000..U+F8FF) — Slack custom workspace emoji live
# here and get carried through when Slack history ends up in model context.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add regression coverage for this helper via format_message(): Block Elements, BMP and supplementary PUA code points, preserved standard emoji, mixed text, and trailing whitespace. The current test changes cover only URL normalization.

# * Supplementary Private Use Areas (U+F0000..U+FFFFD, U+100000..U+10FFFD).
_IMSG_TOFU_RE = re.compile(
"[\u2580-\u259f\ue000-\uf8ff\U000f0000-\U000ffffd\U00100000-\U0010fffd]"
)


def _sanitize_for_imessage(text: str) -> str:
return _IMSG_TOFU_RE.sub("", text or "").rstrip()


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -223,8 +239,12 @@ async def disconnect(self) -> None:
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"
# Use 127.0.0.1 literal rather than "localhost": on macOS with modern
# Node.js the BlueBubbles Server resolves "localhost" to ::1 (IPv6
# first), but the aiohttp TCPSite binds 127.0.0.1 only, so webhook
# dispatches fail with ECONNREFUSED on same-host installs.
if host in ("0.0.0.0", "127.0.0.1", "localhost", "::", "::1"):
host = "127.0.0.1"
return f"http://{host}:{self.webhook_port}{self.webhook_path}"

@property
Expand Down Expand Up @@ -674,7 +694,7 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
return info

def format_message(self, content: str) -> str:
return strip_markdown(content)
return _sanitize_for_imessage(strip_markdown(content))

# ------------------------------------------------------------------
# Inbound attachment downloading (from #4588)
Expand Down
56 changes: 51 additions & 5 deletions tests/gateway/test_bluebubbles.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,50 @@ def test_strip_markdown_links(self, monkeypatch):
adapter = _make_adapter(monkeypatch)
assert adapter.format_message("[click here](http://example.com)") == "click here"


class TestBlueBubblesIMessageSanitization:
"""format_message strips iMessage-tofu characters (Block Elements, PUAs)
while preserving ordinary emoji and leading/trailing whitespace layout."""

def test_strips_block_elements(self, monkeypatch):
"""Block Elements (U+2580..U+259F) like the ▉ cursor artifact are removed."""
adapter = _make_adapter(monkeypatch)
assert adapter.format_message("hello▉ world") == "hello world"

def test_strips_bmp_pua(self, monkeypatch):
"""BMP Private Use Area (U+E000..U+F8FF) — Slack custom-emoji chars."""
adapter = _make_adapter(monkeypatch)
assert adapter.format_message("hi \ue028 there") == "hi there"

def test_strips_supplementary_pua(self, monkeypatch):
"""Supplementary Private Use Areas (U+F0000..U+FFFFD, U+100000..U+10FFFD)."""
adapter = _make_adapter(monkeypatch)
assert adapter.format_message("a\U000f1234b") == "ab"
assert adapter.format_message("a\U0010cdefb") == "ab"

def test_preserves_ordinary_emoji(self, monkeypatch):
"""Everyday emoji must survive sanitization untouched."""
adapter = _make_adapter(monkeypatch)
assert adapter.format_message("hey 😀") == "hey 😀"
assert adapter.format_message("love ❤️") == "love ❤️"

def test_rstrips_trailing_whitespace(self, monkeypatch):
"""Trailing whitespace left after stripping tofu chars is cleaned up."""
adapter = _make_adapter(monkeypatch)
# tofu char at the very end leaves trailing whitespace that must be removed
assert adapter.format_message("done▉ ") == "done"
assert adapter.format_message("done \uE028 ") == "done"

def test_sanitize_helper_direct(self):
"""_sanitize_for_imessage strips the tofu ranges and rstrips."""
from gateway.platforms.bluebubbles import _sanitize_for_imessage

assert _sanitize_for_imessage("▉block▉") == "block"
assert _sanitize_for_imessage("\ue000pua\uf8ff") == "pua"
assert _sanitize_for_imessage("\U000f0000sup\U0010fffd") == "sup"
assert _sanitize_for_imessage("ok 😀 ") == "ok 😀"
assert _sanitize_for_imessage(None) == ""

def test_init_normalizes_webhook_path(self, monkeypatch):
adapter = _make_adapter(monkeypatch, webhook_path="bluebubbles-webhook")
assert adapter.webhook_path == "/bluebubbles-webhook"
Expand Down Expand Up @@ -418,19 +462,21 @@ def test_download_returns_none_without_client(self, monkeypatch):


class TestBlueBubblesWebhookUrl:
"""_webhook_url property normalises local hosts to 'localhost'."""
"""_webhook_url property normalises local hosts to '127.0.0.1'."""

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
# Default webhook_host is 0.0.0.0 → normalized to 127.0.0.1
assert "127.0.0.1" 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", "::"])
@pytest.mark.parametrize(
"host", ["0.0.0.0", "127.0.0.1", "localhost", "::", "::1"]
)
def test_local_hosts_normalized(self, monkeypatch, host):
adapter = _make_adapter(monkeypatch, webhook_host=host)
assert adapter._webhook_url.startswith("http://localhost:")
assert adapter._webhook_url.startswith("http://127.0.0.1:")

def test_custom_host_preserved(self, monkeypatch):
adapter = _make_adapter(monkeypatch, webhook_host="192.168.1.50")
Expand Down