feat(bluebubbles): support group mention gating
This commit is contained in:
@ -1722,6 +1722,22 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
"webhook_path": os.getenv("BLUEBUBBLES_WEBHOOK_PATH", "/bluebubbles-webhook"),
|
||||
"send_read_receipts": os.getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true").lower() in {"true", "1", "yes"},
|
||||
})
|
||||
bluebubbles_require_mention = os.getenv("BLUEBUBBLES_REQUIRE_MENTION")
|
||||
if bluebubbles_require_mention is not None:
|
||||
config.platforms[Platform.BLUEBUBBLES].extra["require_mention"] = (
|
||||
bluebubbles_require_mention.lower() in {"true", "1", "yes", "on"}
|
||||
)
|
||||
bluebubbles_mention_patterns = os.getenv("BLUEBUBBLES_MENTION_PATTERNS")
|
||||
if bluebubbles_mention_patterns:
|
||||
try:
|
||||
parsed_patterns = json.loads(bluebubbles_mention_patterns)
|
||||
except Exception:
|
||||
parsed_patterns = [
|
||||
part.strip()
|
||||
for part in bluebubbles_mention_patterns.replace("\n", ",").split(",")
|
||||
if part.strip()
|
||||
]
|
||||
config.platforms[Platform.BLUEBUBBLES].extra["mention_patterns"] = parsed_patterns
|
||||
bluebubbles_home = os.getenv("BLUEBUBBLES_HOME_CHANNEL")
|
||||
if bluebubbles_home and Platform.BLUEBUBBLES in config.platforms:
|
||||
config.platforms[Platform.BLUEBUBBLES].home_channel = HomeChannel(
|
||||
|
||||
@ -44,6 +44,15 @@ DEFAULT_WEBHOOK_PORT = 8645
|
||||
DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook"
|
||||
MAX_TEXT_LENGTH = 4000
|
||||
|
||||
# BlueBubbles/iMessage does not expose a stable bot mention identity like
|
||||
# Slack (<@U...>), Telegram (@botname), or Matrix (MXID). When users opt into
|
||||
# group mention gating without custom aliases, use conservative Hermes wake
|
||||
# words so `require_mention: true` is a one-line enablement path.
|
||||
DEFAULT_MENTION_PATTERNS = [
|
||||
r"(?<![\w@])@?hermes\s+agent\b[,:\-]?",
|
||||
r"(?<![\w@])@?hermes\b[,:\-]?",
|
||||
]
|
||||
|
||||
# Tapback reaction codes (BlueBubbles associatedMessageType values)
|
||||
_TAPBACK_ADDED = {
|
||||
2000: "love", 2001: "like", 2002: "dislike",
|
||||
@ -127,6 +136,16 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
if not str(self.webhook_path).startswith("/"):
|
||||
self.webhook_path = f"/{self.webhook_path}"
|
||||
self.send_read_receipts = bool(extra.get("send_read_receipts", True))
|
||||
self.require_mention = self._bool_setting(
|
||||
extra.get("require_mention"),
|
||||
os.getenv("BLUEBUBBLES_REQUIRE_MENTION"),
|
||||
default=False,
|
||||
)
|
||||
self._mention_patterns = self._compile_mention_patterns(
|
||||
extra.get("mention_patterns")
|
||||
if "mention_patterns" in extra
|
||||
else os.getenv("BLUEBUBBLES_MENTION_PATTERNS")
|
||||
)
|
||||
self.client: Optional[httpx.AsyncClient] = None
|
||||
self._runner = None
|
||||
self._private_api_enabled: Optional[bool] = None
|
||||
@ -141,6 +160,77 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
sep = "&" if "?" in path else "?"
|
||||
return f"{self.server_url}{path}{sep}password={quote(self.password, safe='')}"
|
||||
|
||||
@staticmethod
|
||||
def _bool_setting(*values: Any, default: bool = False) -> bool:
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
text = str(value).strip().lower()
|
||||
if not text:
|
||||
continue
|
||||
return text in {"true", "1", "yes", "on"}
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _coerce_mention_patterns(raw: Any) -> List[str]:
|
||||
if raw is None:
|
||||
return list(DEFAULT_MENTION_PATTERNS)
|
||||
if isinstance(raw, list):
|
||||
values = raw
|
||||
elif isinstance(raw, str):
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except Exception:
|
||||
parsed = None
|
||||
if isinstance(parsed, list):
|
||||
values = parsed
|
||||
else:
|
||||
values = [
|
||||
part.strip()
|
||||
for line in text.splitlines()
|
||||
for part in line.split(",")
|
||||
if part.strip()
|
||||
]
|
||||
else:
|
||||
values = [raw]
|
||||
return [str(value).strip() for value in values if str(value).strip()]
|
||||
|
||||
def _compile_mention_patterns(self, raw: Any) -> List[re.Pattern]:
|
||||
compiled: List[re.Pattern] = []
|
||||
for pattern in self._coerce_mention_patterns(raw):
|
||||
try:
|
||||
compiled.append(re.compile(pattern, re.IGNORECASE))
|
||||
except re.error as exc:
|
||||
logger.warning("[%s] Invalid BlueBubbles mention pattern %r: %s", self.name, pattern, exc)
|
||||
if compiled:
|
||||
logger.info("[%s] Loaded %d BlueBubbles mention pattern(s)", self.name, len(compiled))
|
||||
return compiled
|
||||
|
||||
def _message_matches_mention_patterns(self, text: str) -> bool:
|
||||
if not text or not self._mention_patterns:
|
||||
return False
|
||||
return any(pattern.search(text) for pattern in self._mention_patterns)
|
||||
|
||||
def _clean_mention_text(self, text: str) -> str:
|
||||
"""Strip a leading BlueBubbles wake word before dispatch.
|
||||
|
||||
Custom mention patterns are regular expressions, so stripping only a
|
||||
leading match avoids deleting ordinary words later in the prompt.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
for pattern in self._mention_patterns:
|
||||
match = pattern.match(text.lstrip())
|
||||
if match:
|
||||
cleaned = text.lstrip()[match.end():].lstrip(" ,:-")
|
||||
return cleaned or text
|
||||
return text
|
||||
|
||||
async def _api_get(self, path: str) -> Dict[str, Any]:
|
||||
assert self.client is not None
|
||||
res = await self.client.get(self._api_url(path))
|
||||
@ -921,6 +1011,13 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
|
||||
session_chat_id = chat_guid or chat_identifier
|
||||
is_group = bool(record.get("isGroup")) or (";+;" in (chat_guid or ""))
|
||||
if is_group and self.require_mention:
|
||||
if not self._message_matches_mention_patterns(text):
|
||||
logger.debug(
|
||||
"[bluebubbles] ignoring group message (require_mention=true, no mention pattern matched)"
|
||||
)
|
||||
return web.Response(text="ok")
|
||||
text = self._clean_mention_text(text)
|
||||
source = self.build_source(
|
||||
chat_id=session_chat_id,
|
||||
chat_name=chat_identifier or sender,
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
"""Tests for the BlueBubbles iMessage gateway adapter."""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
@ -25,6 +28,8 @@ class TestBlueBubblesConfigLoading:
|
||||
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
|
||||
monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret")
|
||||
monkeypatch.setenv("BLUEBUBBLES_WEBHOOK_PORT", "9999")
|
||||
monkeypatch.setenv("BLUEBUBBLES_REQUIRE_MENTION", "true")
|
||||
monkeypatch.setenv("BLUEBUBBLES_MENTION_PATTERNS", r'["(?i)^amos\\b"]')
|
||||
from gateway.config import GatewayConfig, _apply_env_overrides
|
||||
|
||||
config = GatewayConfig()
|
||||
@ -35,6 +40,8 @@ class TestBlueBubblesConfigLoading:
|
||||
assert bc.extra["server_url"] == "http://localhost:1234"
|
||||
assert bc.extra["password"] == "secret"
|
||||
assert bc.extra["webhook_port"] == 9999
|
||||
assert bc.extra["require_mention"] is True
|
||||
assert bc.extra["mention_patterns"] == ["(?i)^amos\\b"]
|
||||
|
||||
def test_home_channel_set_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
|
||||
@ -130,6 +137,131 @@ class TestBlueBubblesHelpers:
|
||||
adapter = _make_adapter(monkeypatch, server_url="localhost:1234")
|
||||
assert adapter.server_url == "http://localhost:1234"
|
||||
|
||||
def test_default_mention_patterns_match_hermes_variants(self, monkeypatch):
|
||||
adapter = _make_adapter(monkeypatch, require_mention=True)
|
||||
|
||||
assert adapter.require_mention is True
|
||||
assert adapter._message_matches_mention_patterns("Hermes, summarize this")
|
||||
assert adapter._message_matches_mention_patterns("@Hermes agent help")
|
||||
assert not adapter._message_matches_mention_patterns("casual family chatter")
|
||||
assert not adapter._message_matches_mention_patterns("antihermes should not match")
|
||||
|
||||
def test_custom_mention_patterns_override_defaults(self, monkeypatch):
|
||||
adapter = _make_adapter(
|
||||
monkeypatch,
|
||||
require_mention=True,
|
||||
mention_patterns=[r"(?<![\w@])@?amos\b[,:\-]?"],
|
||||
)
|
||||
|
||||
assert adapter._message_matches_mention_patterns("Amos what is next?")
|
||||
assert not adapter._message_matches_mention_patterns("Hermes what is next?")
|
||||
|
||||
def test_clean_mention_text_strips_leading_wake_word(self, monkeypatch):
|
||||
adapter = _make_adapter(monkeypatch, require_mention=True)
|
||||
|
||||
assert adapter._clean_mention_text("Hermes, summarize this") == "summarize this"
|
||||
assert adapter._clean_mention_text("Hermes agent: summarize this") == "summarize this"
|
||||
assert adapter._clean_mention_text("please ask Hermes about this") == "please ask Hermes about this"
|
||||
|
||||
|
||||
class _FakeBlueBubblesRequest:
|
||||
def __init__(self, payload, password="secret"):
|
||||
self.query = {"password": password}
|
||||
self.headers = {}
|
||||
self._body = json.dumps(payload).encode("utf-8")
|
||||
|
||||
async def read(self):
|
||||
return self._body
|
||||
|
||||
|
||||
class TestBlueBubblesMentionGating:
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_message_without_mention_is_acknowledged_and_skipped(self, monkeypatch):
|
||||
adapter = _make_adapter(
|
||||
monkeypatch,
|
||||
require_mention=True,
|
||||
send_read_receipts=False,
|
||||
)
|
||||
handled = []
|
||||
|
||||
async def fake_handle_message(event):
|
||||
handled.append(event)
|
||||
|
||||
monkeypatch.setattr(adapter, "handle_message", fake_handle_message)
|
||||
response = await adapter._handle_webhook(_FakeBlueBubblesRequest({
|
||||
"type": "new-message",
|
||||
"data": {
|
||||
"guid": "msg-1",
|
||||
"text": "casual family chatter",
|
||||
"handle": {"address": "+15555550100"},
|
||||
"isFromMe": False,
|
||||
"isGroup": True,
|
||||
"chats": [{"guid": "iMessage;+;group-chat"}],
|
||||
},
|
||||
}))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert response.status == 200
|
||||
assert handled == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_message_with_default_mention_is_dispatched_cleaned(self, monkeypatch):
|
||||
adapter = _make_adapter(
|
||||
monkeypatch,
|
||||
require_mention=True,
|
||||
send_read_receipts=False,
|
||||
)
|
||||
handled = []
|
||||
|
||||
async def fake_handle_message(event):
|
||||
handled.append(event)
|
||||
|
||||
monkeypatch.setattr(adapter, "handle_message", fake_handle_message)
|
||||
response = await adapter._handle_webhook(_FakeBlueBubblesRequest({
|
||||
"type": "new-message",
|
||||
"data": {
|
||||
"guid": "msg-2",
|
||||
"text": "Hermes, summarize this",
|
||||
"handle": {"address": "+15555550100"},
|
||||
"isFromMe": False,
|
||||
"isGroup": True,
|
||||
"chats": [{"guid": "iMessage;+;group-chat"}],
|
||||
},
|
||||
}))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert response.status == 200
|
||||
assert [event.text for event in handled] == ["summarize this"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_message_does_not_require_mention(self, monkeypatch):
|
||||
adapter = _make_adapter(
|
||||
monkeypatch,
|
||||
require_mention=True,
|
||||
send_read_receipts=False,
|
||||
)
|
||||
handled = []
|
||||
|
||||
async def fake_handle_message(event):
|
||||
handled.append(event)
|
||||
|
||||
monkeypatch.setattr(adapter, "handle_message", fake_handle_message)
|
||||
response = await adapter._handle_webhook(_FakeBlueBubblesRequest({
|
||||
"type": "new-message",
|
||||
"data": {
|
||||
"guid": "msg-3",
|
||||
"text": "hello from a dm",
|
||||
"handle": {"address": "user@example.com"},
|
||||
"isFromMe": False,
|
||||
"chatGuid": "iMessage;-;user@example.com",
|
||||
"chatIdentifier": "user@example.com",
|
||||
},
|
||||
}))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert response.status == 200
|
||||
assert [event.text for event in handled] == ["hello from a dm"]
|
||||
|
||||
|
||||
class TestBlueBubblesWebhookParsing:
|
||||
def test_webhook_prefers_chat_guid_over_message_guid(self, monkeypatch):
|
||||
|
||||
@ -38,6 +38,31 @@ BLUEBUBBLES_SERVER_URL=http://192.168.1.10:1234
|
||||
BLUEBUBBLES_PASSWORD=your-server-password
|
||||
```
|
||||
|
||||
#### Optional: Require mentions in group chats
|
||||
|
||||
By default, Hermes responds to every authorized BlueBubbles/iMessage DM or group message. To make group chats opt-in, enable mention gating:
|
||||
|
||||
```yaml
|
||||
platforms:
|
||||
bluebubbles:
|
||||
enabled: true
|
||||
extra:
|
||||
require_mention: true
|
||||
```
|
||||
|
||||
With `require_mention: true`, DMs still work normally, but group-chat messages are ignored unless they match a mention pattern. If you do not configure custom patterns, Hermes uses conservative defaults for `Hermes` and `@Hermes agent` variants.
|
||||
|
||||
For a custom agent name, set regex patterns:
|
||||
|
||||
```yaml
|
||||
platforms:
|
||||
bluebubbles:
|
||||
extra:
|
||||
require_mention: true
|
||||
mention_patterns:
|
||||
- '(?<![\w@])@?amos\b[,:\-]?'
|
||||
```
|
||||
|
||||
### 4. Authorize Users
|
||||
|
||||
Choose one approach:
|
||||
@ -90,6 +115,8 @@ Hermes → BlueBubbles REST API → Messages.app → iMessage
|
||||
| `BLUEBUBBLES_HOME_CHANNEL` | No | — | Phone/email for cron delivery |
|
||||
| `BLUEBUBBLES_ALLOWED_USERS` | No | — | Comma-separated authorized users |
|
||||
| `BLUEBUBBLES_ALLOW_ALL_USERS` | No | `false` | Allow all users |
|
||||
| `BLUEBUBBLES_REQUIRE_MENTION` | No | `false` | Require a mention pattern before responding in group chats |
|
||||
| `BLUEBUBBLES_MENTION_PATTERNS` | No | Hermes wake words | JSON array, newline-separated, or comma-separated regex patterns for group mention matching |
|
||||
|
||||
Auto-marking messages as read is controlled by the `send_read_receipts` key under `platforms.bluebubbles.extra` in `~/.hermes/config.yaml` (default: `true`). There is no corresponding environment variable.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user