mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-10 18:53:03 +02:00
Add outbound message opt-in for apprise
This commit is contained in:
@@ -132,6 +132,7 @@ HTTP webhook delivery. Config blob:
|
|||||||
Push notifications via Apprise library. Config blob:
|
Push notifications via Apprise library. Config blob:
|
||||||
- `urls` — newline-separated Apprise notification service URLs
|
- `urls` — newline-separated Apprise notification service URLs
|
||||||
- `preserve_identity` — suppress Discord webhook name/avatar override
|
- `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
|
- `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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -188,14 +188,15 @@ def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool, markdown: b
|
|||||||
|
|
||||||
|
|
||||||
class AppriseModule(FanoutModule):
|
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:
|
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||||
super().__init__(config_id, config, name=name)
|
super().__init__(config_id, config, name=name)
|
||||||
|
|
||||||
async def on_message(self, data: dict) -> None:
|
async def on_message(self, data: dict) -> None:
|
||||||
# Skip outgoing messages — only notify on incoming
|
# Skip outgoing messages by default. Operators can opt in when they
|
||||||
if data.get("outgoing"):
|
# want RemoteTerm-originated manual/bot sends mirrored to Apprise.
|
||||||
|
if data.get("outgoing") and not self.config.get("include_outgoing", False):
|
||||||
return
|
return
|
||||||
|
|
||||||
urls = self.config.get("urls", "")
|
urls = self.config.get("urls", "")
|
||||||
|
|||||||
@@ -274,9 +274,8 @@ def _validate_apprise_config(config: dict) -> None:
|
|||||||
status_code=400, detail=f"Invalid format string in {field}"
|
status_code=400, detail=f"Invalid format string in {field}"
|
||||||
) from None
|
) from None
|
||||||
|
|
||||||
markdown_format = config.get("markdown_format")
|
config["markdown_format"] = bool(config.get("markdown_format", True))
|
||||||
if markdown_format is not None:
|
config["include_outgoing"] = bool(config.get("include_outgoing", False))
|
||||||
config["markdown_format"] = bool(markdown_format)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_webhook_config(config: dict) -> None:
|
def _validate_webhook_config(config: dict) -> None:
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
|||||||
config: {
|
config: {
|
||||||
urls: '',
|
urls: '',
|
||||||
preserve_identity: true,
|
preserve_identity: true,
|
||||||
|
include_outgoing: false,
|
||||||
markdown_format: true,
|
markdown_format: true,
|
||||||
body_format_dm: '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]',
|
body_format_dm: '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]',
|
||||||
body_format_channel:
|
body_format_channel:
|
||||||
@@ -2590,6 +2591,23 @@ function AppriseConfigEditor({
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.include_outgoing === true}
|
||||||
|
onChange={(e) => onChange({ ...config, include_outgoing: e.target.checked })}
|
||||||
|
className="h-4 w-4 rounded border-border"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm">Forward RemoteTerm-sent messages</span>
|
||||||
|
<p className="text-[0.8125rem] text-muted-foreground">
|
||||||
|
Include DMs and channel messages sent by this RemoteTerm instance, including manual
|
||||||
|
sends and bot replies. Outgoing messages carry no routing path or signal data, so
|
||||||
|
path-related format fields render as direct and RSSI/SNR are empty.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<h3 className="text-base font-semibold tracking-tight">Message Format</h3>
|
<h3 className="text-base font-semibold tracking-tight">Message Format</h3>
|
||||||
|
|||||||
@@ -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 () => {
|
it('new draft names increment within the integration type', async () => {
|
||||||
mockedApi.getFanoutConfigs.mockResolvedValue([
|
mockedApi.getFanoutConfigs.mockResolvedValue([
|
||||||
webhookConfig,
|
webhookConfig,
|
||||||
|
|||||||
+32
-3
@@ -1017,7 +1017,7 @@ class TestAppriseModule:
|
|||||||
assert mod.status == "connected"
|
assert mod.status == "connected"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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 unittest.mock import patch as _patch
|
||||||
|
|
||||||
from app.fanout.apprise_mod import AppriseModule
|
from app.fanout.apprise_mod import AppriseModule
|
||||||
@@ -1027,6 +1027,19 @@ class TestAppriseModule:
|
|||||||
await mod.on_message({"type": "PRIV", "text": "hi", "outgoing": True})
|
await mod.on_message({"type": "PRIV", "text": "hi", "outgoing": True})
|
||||||
mock_send.assert_not_called()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_sends_for_incoming_messages(self):
|
async def test_sends_for_incoming_messages(self):
|
||||||
from unittest.mock import patch as _patch
|
from unittest.mock import patch as _patch
|
||||||
@@ -1379,10 +1392,26 @@ class TestAppriseValidation:
|
|||||||
_validate_apprise_config(config)
|
_validate_apprise_config(config)
|
||||||
assert config["markdown_format"] is False
|
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
|
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:
|
class TestAppriseMarkdownFormat:
|
||||||
|
|||||||
@@ -1247,8 +1247,8 @@ class TestFanoutAppriseIntegration:
|
|||||||
assert "#general" in body_text
|
assert "#general" in body_text
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_apprise_skips_outgoing(self, apprise_capture_server, integration_db):
|
async def test_apprise_skips_outgoing_by_default(self, apprise_capture_server, integration_db):
|
||||||
"""Apprise should NOT deliver outgoing messages."""
|
"""Apprise should NOT deliver outgoing messages unless explicitly enabled."""
|
||||||
cfg = await FanoutConfigRepository.create(
|
cfg = await FanoutConfigRepository.create(
|
||||||
config_type="apprise",
|
config_type="apprise",
|
||||||
name="No Outgoing",
|
name="No Outgoing",
|
||||||
@@ -1280,6 +1280,45 @@ class TestFanoutAppriseIntegration:
|
|||||||
|
|
||||||
assert len(apprise_capture_server.received) == 0
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_apprise_disabled_no_delivery(self, apprise_capture_server, integration_db):
|
async def test_apprise_disabled_no_delivery(self, apprise_capture_server, integration_db):
|
||||||
"""Disabled Apprise module should not deliver anything."""
|
"""Disabled Apprise module should not deliver anything."""
|
||||||
|
|||||||
Reference in New Issue
Block a user