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."""