diff --git a/gateway/config.py b/gateway/config.py index 5097372791..1a515b61b3 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -941,6 +941,9 @@ def _apply_env_overrides(config: GatewayConfig) -> None: "account": signal_account, "ignore_stories": os.getenv("SIGNAL_IGNORE_STORIES", "true").lower() in ("true", "1", "yes"), }) + signal_notify_self = os.getenv("SIGNAL_NOTIFY_SELF", "").lower() + if signal_notify_self in ("true", "1", "yes"): + config.platforms[Platform.SIGNAL].extra["notify_self"] = True signal_home = os.getenv("SIGNAL_HOME_CHANNEL") if signal_home and Platform.SIGNAL in config.platforms: config.platforms[Platform.SIGNAL].home_channel = HomeChannel( diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 9a0a6256a4..24d10739a1 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -170,6 +170,7 @@ class SignalAdapter(BasePlatformAdapter): self.http_url = extra.get("http_url", "http://127.0.0.1:8080").rstrip("/") self.account = extra.get("account", "") self.ignore_stories = extra.get("ignore_stories", True) + self.notify_self = extra.get("notify_self", False) # Parse allowlists — group policy is derived from presence of group allowlist group_allowed_str = os.getenv("SIGNAL_GROUP_ALLOWED_USERS", "") @@ -729,7 +730,12 @@ class SignalAdapter(BasePlatformAdapter): if chat_id.startswith("group:"): params["groupId"] = chat_id[6:] else: - params["recipient"] = [await self._resolve_recipient(chat_id)] + recipient = await self._resolve_recipient(chat_id) + params["recipient"] = [recipient] + if self.notify_self and ( + recipient == self._account_normalized or chat_id == self._account_normalized + ): + params["notifySelf"] = True result = await self._rpc("send", params) @@ -841,7 +847,12 @@ class SignalAdapter(BasePlatformAdapter): if chat_id.startswith("group:"): params["groupId"] = chat_id[6:] else: - params["recipient"] = [await self._resolve_recipient(chat_id)] + recipient = await self._resolve_recipient(chat_id) + params["recipient"] = [recipient] + if self.notify_self and ( + recipient == self._account_normalized or chat_id == self._account_normalized + ): + params["notifySelf"] = True result = await self._rpc("send", params) if result is not None: @@ -880,7 +891,12 @@ class SignalAdapter(BasePlatformAdapter): if chat_id.startswith("group:"): params["groupId"] = chat_id[6:] else: - params["recipient"] = [await self._resolve_recipient(chat_id)] + recipient = await self._resolve_recipient(chat_id) + params["recipient"] = [recipient] + if self.notify_self and ( + recipient == self._account_normalized or chat_id == self._account_normalized + ): + params["notifySelf"] = True result = await self._rpc("send", params) if result is not None: diff --git a/tests/gateway/test_signal.py b/tests/gateway/test_signal.py index b51ec713f2..528dabed33 100644 --- a/tests/gateway/test_signal.py +++ b/tests/gateway/test_signal.py @@ -57,6 +57,30 @@ class TestSignalConfigLoading: assert sc.extra["http_url"] == "http://localhost:9090" assert sc.extra["account"] == "+15551234567" + def test_apply_env_overrides_signal_notify_self(self, monkeypatch): + monkeypatch.setenv("SIGNAL_HTTP_URL", "http://localhost:9090") + monkeypatch.setenv("SIGNAL_ACCOUNT", "+15551234567") + monkeypatch.setenv("SIGNAL_NOTIFY_SELF", "true") + + from gateway.config import GatewayConfig, _apply_env_overrides + config = GatewayConfig() + _apply_env_overrides(config) + + sc = config.platforms[Platform.SIGNAL] + assert sc.extra.get("notify_self") is True + + def test_apply_env_overrides_signal_notify_self_disabled(self, monkeypatch): + monkeypatch.setenv("SIGNAL_HTTP_URL", "http://localhost:9090") + monkeypatch.setenv("SIGNAL_ACCOUNT", "+15551234567") + monkeypatch.setenv("SIGNAL_NOTIFY_SELF", "false") + + from gateway.config import GatewayConfig, _apply_env_overrides + config = GatewayConfig() + _apply_env_overrides(config) + + sc = config.platforms[Platform.SIGNAL] + assert "notify_self" not in sc.extra + def test_signal_not_loaded_without_both_vars(self, monkeypatch): monkeypatch.setenv("SIGNAL_HTTP_URL", "http://localhost:9090") # No SIGNAL_ACCOUNT @@ -997,3 +1021,93 @@ class TestSignalTypingBackoff: assert "+155****4567" not in adapter._typing_failures assert "+155****4567" not in adapter._typing_skip_until + + +# --------------------------------------------------------------------------- +# notifySelf toggle for Note-to-Self bubble-side fix +# --------------------------------------------------------------------------- + +class TestSignalNotifySelf: + """Verify that notify_self=True adds notifySelf param for self-messages + in text, image, and attachment sends, while leaving other chats untouched.""" + + @pytest.mark.asyncio + async def test_send_to_self_adds_notifySelf_when_enabled(self, monkeypatch): + adapter = _make_signal_adapter(monkeypatch, notify_self=True) + mock_rpc, captured = _stub_rpc({"timestamp": 1234567890}) + adapter._rpc = mock_rpc + adapter._stop_typing_indicator = AsyncMock() + + result = await adapter.send(chat_id="+15551234567", content="hello self") + assert result.success is True + send_call = [c for c in captured if c["method"] == "send"][0] + assert send_call["params"].get("notifySelf") is True + + @pytest.mark.asyncio + async def test_send_to_self_omits_notifySelf_when_disabled(self, monkeypatch): + adapter = _make_signal_adapter(monkeypatch, notify_self=False) + mock_rpc, captured = _stub_rpc({"timestamp": 1234567890}) + adapter._rpc = mock_rpc + adapter._stop_typing_indicator = AsyncMock() + + result = await adapter.send(chat_id="+15551234567", content="hello self") + assert result.success is True + send_call = [c for c in captured if c["method"] == "send"][0] + assert "notifySelf" not in send_call["params"] + + @pytest.mark.asyncio + async def test_send_to_other_omits_notifySelf_even_when_enabled(self, monkeypatch): + adapter = _make_signal_adapter(monkeypatch, notify_self=True) + mock_rpc, captured = _stub_rpc({"timestamp": 1234567890}) + adapter._rpc = mock_rpc + adapter._stop_typing_indicator = AsyncMock() + + result = await adapter.send(chat_id="+15559999999", content="hello other") + assert result.success is True + send_call = [c for c in captured if c["method"] == "send"][0] + assert "notifySelf" not in send_call["params"] + + @pytest.mark.asyncio + async def test_send_image_file_to_self_adds_notifySelf(self, monkeypatch, tmp_path): + adapter = _make_signal_adapter(monkeypatch, notify_self=True) + mock_rpc, captured = _stub_rpc({"timestamp": 1234567890}) + adapter._rpc = mock_rpc + adapter._stop_typing_indicator = AsyncMock() + + img_path = tmp_path / "chart.png" + img_path.write_bytes(b"\x89PNG" + b"\x00" * 100) + + result = await adapter.send_image_file(chat_id="+15551234567", image_path=str(img_path)) + assert result.success is True + send_call = [c for c in captured if c["method"] == "send"][0] + assert send_call["params"].get("notifySelf") is True + + @pytest.mark.asyncio + async def test_send_voice_to_self_adds_notifySelf(self, monkeypatch, tmp_path): + adapter = _make_signal_adapter(monkeypatch, notify_self=True) + mock_rpc, captured = _stub_rpc({"timestamp": 1234567890}) + adapter._rpc = mock_rpc + adapter._stop_typing_indicator = AsyncMock() + + audio_path = tmp_path / "note.ogg" + audio_path.write_bytes(b"OggS" + b"\x00" * 100) + + result = await adapter.send_voice(chat_id="+15551234567", audio_path=str(audio_path)) + assert result.success is True + send_call = [c for c in captured if c["method"] == "send"][0] + assert send_call["params"].get("notifySelf") is True + + @pytest.mark.asyncio + async def test_group_send_omits_notifySelf(self, monkeypatch, tmp_path): + adapter = _make_signal_adapter(monkeypatch, notify_self=True) + mock_rpc, captured = _stub_rpc({"timestamp": 1234567890}) + adapter._rpc = mock_rpc + adapter._stop_typing_indicator = AsyncMock() + + img_path = tmp_path / "chart.png" + img_path.write_bytes(b"\x89PNG" + b"\x00" * 100) + + result = await adapter.send_image_file(chat_id="group:abc123==", image_path=str(img_path)) + assert result.success is True + send_call = [c for c in captured if c["method"] == "send"][0] + assert "notifySelf" not in send_call["params"]