diff --git a/app/fanout/AGENTS_fanout.md b/app/fanout/AGENTS_fanout.md index ac3ece9..d8d7074 100644 --- a/app/fanout/AGENTS_fanout.md +++ b/app/fanout/AGENTS_fanout.md @@ -132,6 +132,7 @@ HTTP webhook delivery. Config blob: Push notifications via Apprise library. Config blob: - `urls` — newline-separated Apprise notification service URLs - `preserve_identity` — suppress Discord webhook name/avatar override +- `include_outgoing` — when true, RemoteTerm-originated manual and bot-sent messages are forwarded to Apprise; missing/false preserves the legacy incoming-only behavior - `include_path` — include routing path in notification body - Channel notifications normalize stored message text by stripping a leading `"{sender_name}: "` prefix when it matches the payload sender so alerts do not duplicate the name. diff --git a/app/fanout/apprise_mod.py b/app/fanout/apprise_mod.py index a00f5ab..208cc95 100644 --- a/app/fanout/apprise_mod.py +++ b/app/fanout/apprise_mod.py @@ -188,14 +188,15 @@ def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool, markdown: b class AppriseModule(FanoutModule): - """Sends push notifications via Apprise for incoming messages.""" + """Sends push notifications via Apprise for matched messages.""" def __init__(self, config_id: str, config: dict, *, name: str = "") -> None: super().__init__(config_id, config, name=name) async def on_message(self, data: dict) -> None: - # Skip outgoing messages — only notify on incoming - if data.get("outgoing"): + # Skip outgoing messages by default. Operators can opt in when they + # want RemoteTerm-originated manual/bot sends mirrored to Apprise. + if data.get("outgoing") and not self.config.get("include_outgoing", False): return urls = self.config.get("urls", "") diff --git a/app/routers/fanout.py b/app/routers/fanout.py index 43d0d6b..9e2daa2 100644 --- a/app/routers/fanout.py +++ b/app/routers/fanout.py @@ -274,9 +274,8 @@ def _validate_apprise_config(config: dict) -> None: status_code=400, detail=f"Invalid format string in {field}" ) from None - markdown_format = config.get("markdown_format") - if markdown_format is not None: - config["markdown_format"] = bool(markdown_format) + config["markdown_format"] = bool(config.get("markdown_format", True)) + config["include_outgoing"] = bool(config.get("include_outgoing", False)) def _validate_webhook_config(config: dict) -> None: diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index 9d96532..70dd277 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -287,6 +287,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [ config: { urls: '', preserve_identity: true, + include_outgoing: false, markdown_format: true, body_format_dm: '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]', body_format_channel: @@ -2590,6 +2591,23 @@ function AppriseConfigEditor({ + +

Message Format

diff --git a/frontend/src/test/fanoutSection.test.tsx b/frontend/src/test/fanoutSection.test.tsx index 3c26658..bf76c8e 100644 --- a/frontend/src/test/fanoutSection.test.tsx +++ b/frontend/src/test/fanoutSection.test.tsx @@ -561,6 +561,107 @@ describe('SettingsFanoutSection', () => { ); }); + it('creates Apprise with outgoing forwarding disabled by default', async () => { + const createdApprise: FanoutConfig = { + id: 'ap-new', + type: 'apprise', + name: 'Apprise #1', + enabled: true, + config: { + urls: '', + preserve_identity: true, + include_outgoing: false, + markdown_format: true, + body_format_dm: '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]', + body_format_channel: + '**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]', + }, + scope: { messages: 'all', raw_packets: 'none' }, + sort_order: 0, + created_at: 2000, + }; + mockedApi.createFanoutConfig.mockResolvedValue(createdApprise); + mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdApprise]); + + renderSection(); + await openCreateIntegrationDialog(); + selectCreateIntegration('Apprise'); + confirmCreateIntegration(); + await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument()); + + expect(screen.getByLabelText(/Forward RemoteTerm-sent messages/)).not.toBeChecked(); + expect( + screen.getByText(/Outgoing messages carry no routing path or signal data/) + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' })); + + await waitFor(() => + expect(mockedApi.createFanoutConfig).toHaveBeenCalledWith({ + type: 'apprise', + name: 'Apprise #1', + config: { + urls: '', + preserve_identity: true, + include_outgoing: false, + markdown_format: true, + body_format_dm: '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]', + body_format_channel: + '**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]', + }, + scope: { messages: 'all', raw_packets: 'none' }, + enabled: true, + }) + ); + }); + + it('can enable outgoing forwarding for an existing Apprise integration', async () => { + const appriseConfig: FanoutConfig = { + id: 'ap-1', + type: 'apprise', + name: 'Apprise Feed', + enabled: true, + config: { + urls: 'discord://abc', + preserve_identity: true, + markdown_format: true, + }, + scope: { messages: 'all', raw_packets: 'none' }, + sort_order: 0, + created_at: 1000, + }; + mockedApi.getFanoutConfigs.mockResolvedValue([appriseConfig]); + mockedApi.updateFanoutConfig.mockResolvedValue({ + ...appriseConfig, + config: { ...appriseConfig.config, include_outgoing: true }, + }); + + renderSection(); + await waitFor(() => expect(screen.getByText('Apprise Feed')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument()); + + const includeOutgoing = screen.getByLabelText(/Forward RemoteTerm-sent messages/); + expect(includeOutgoing).not.toBeChecked(); + fireEvent.click(includeOutgoing); + fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' })); + + await waitFor(() => + expect(mockedApi.updateFanoutConfig).toHaveBeenCalledWith('ap-1', { + name: 'Apprise Feed', + config: { + urls: 'discord://abc', + preserve_identity: true, + markdown_format: true, + include_outgoing: true, + }, + scope: { messages: 'all', raw_packets: 'none' }, + enabled: true, + }) + ); + }); + it('new draft names increment within the integration type', async () => { mockedApi.getFanoutConfigs.mockResolvedValue([ webhookConfig, diff --git a/tests/test_fanout.py b/tests/test_fanout.py index 8f6d902..6597420 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -1017,7 +1017,7 @@ class TestAppriseModule: assert mod.status == "connected" @pytest.mark.asyncio - async def test_skips_outgoing_messages(self): + async def test_skips_outgoing_messages_by_default(self): from unittest.mock import patch as _patch from app.fanout.apprise_mod import AppriseModule @@ -1027,6 +1027,19 @@ class TestAppriseModule: await mod.on_message({"type": "PRIV", "text": "hi", "outgoing": True}) mock_send.assert_not_called() + @pytest.mark.asyncio + async def test_sends_outgoing_messages_when_enabled(self): + from unittest.mock import patch as _patch + + from app.fanout.apprise_mod import AppriseModule + + mod = AppriseModule("test", {"urls": "json://localhost", "include_outgoing": True}) + with _patch("app.fanout.apprise_mod._send_sync", return_value=True) as mock_send: + await mod.on_message( + {"type": "PRIV", "text": "hi", "outgoing": True, "sender_name": "Me"} + ) + mock_send.assert_called_once() + @pytest.mark.asyncio async def test_sends_for_incoming_messages(self): from unittest.mock import patch as _patch @@ -1379,10 +1392,26 @@ class TestAppriseValidation: _validate_apprise_config(config) assert config["markdown_format"] is False - def test_validate_apprise_config_works_without_markdown_format(self): + def test_validate_apprise_config_defaults_markdown_format_true(self): from app.routers.fanout import _validate_apprise_config - _validate_apprise_config({"urls": "discord://123/abc"}) + config: dict = {"urls": "discord://123/abc"} + _validate_apprise_config(config) + assert config["markdown_format"] is True + + def test_validate_apprise_config_defaults_include_outgoing_false(self): + from app.routers.fanout import _validate_apprise_config + + config: dict = {"urls": "discord://123/abc"} + _validate_apprise_config(config) + assert config["include_outgoing"] is False + + def test_validate_apprise_config_normalizes_include_outgoing(self): + from app.routers.fanout import _validate_apprise_config + + config: dict = {"urls": "discord://123/abc", "include_outgoing": 1} + _validate_apprise_config(config) + assert config["include_outgoing"] is True class TestAppriseMarkdownFormat: diff --git a/tests/test_fanout_integration.py b/tests/test_fanout_integration.py index acb34a9..c9d9c29 100644 --- a/tests/test_fanout_integration.py +++ b/tests/test_fanout_integration.py @@ -1247,8 +1247,8 @@ class TestFanoutAppriseIntegration: assert "#general" in body_text @pytest.mark.asyncio - async def test_apprise_skips_outgoing(self, apprise_capture_server, integration_db): - """Apprise should NOT deliver outgoing messages.""" + async def test_apprise_skips_outgoing_by_default(self, apprise_capture_server, integration_db): + """Apprise should NOT deliver outgoing messages unless explicitly enabled.""" cfg = await FanoutConfigRepository.create( config_type="apprise", name="No Outgoing", @@ -1280,6 +1280,45 @@ class TestFanoutAppriseIntegration: assert len(apprise_capture_server.received) == 0 + @pytest.mark.asyncio + async def test_apprise_delivers_outgoing_when_enabled( + self, apprise_capture_server, integration_db + ): + """Apprise can opt in to delivering RemoteTerm-originated messages.""" + cfg = await FanoutConfigRepository.create( + config_type="apprise", + name="Include Outgoing", + config={ + "urls": f"json://127.0.0.1:{apprise_capture_server.port}", + "include_outgoing": True, + }, + scope={"messages": "all", "raw_packets": "none"}, + enabled=True, + ) + + manager = FanoutManager() + try: + await manager.load_from_db() + assert cfg["id"] in manager._modules + + await manager.broadcast_message( + { + "type": "PRIV", + "conversation_key": "pk1", + "text": "my outgoing", + "sender_name": "Me", + "outgoing": True, + } + ) + + results = await apprise_capture_server.wait_for(1) + finally: + await manager.stop_all() + + assert len(results) >= 1 + body_text = str(results[0]) + assert "my outgoing" in body_text + @pytest.mark.asyncio async def test_apprise_disabled_no_delivery(self, apprise_capture_server, integration_db): """Disabled Apprise module should not deliver anything."""