From ee6b3bb232886019422c84c1efeb9283cb15e0b2 Mon Sep 17 00:00:00 2001 From: Jonatan <190036+jcastro@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:39:43 +0200 Subject: [PATCH] Clarify Slack and Matrix setup and preserve Apprise recovery severity --- .../tests/test_command_descriptions.py | 5 + .github/workflows/test-apprise-services.yml | 50 +++++++ AppImage/components/apprise-service-help.tsx | 31 ++++ AppImage/components/notification-settings.tsx | 2 + AppImage/messages/en/common.json | 6 + AppImage/scripts/notification_channels.py | 2 +- tests/test_apprise_service_help.cjs | 30 ++++ tests/test_apprise_services.py | 137 ++++++++++++++++++ 8 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test-apprise-services.yml create mode 100644 AppImage/components/apprise-service-help.tsx create mode 100644 tests/test_apprise_service_help.cjs create mode 100644 tests/test_apprise_services.py diff --git a/.github/scripts/tests/test_command_descriptions.py b/.github/scripts/tests/test_command_descriptions.py index ec35c3b2..019ccb0c 100644 --- a/.github/scripts/tests/test_command_descriptions.py +++ b/.github/scripts/tests/test_command_descriptions.py @@ -200,6 +200,11 @@ class CommandDescriptionsTests(unittest.TestCase): if lang != "en": messages = json.loads(path.read_text()) english = catalog("en") + # Provider help ships in English before the translation bot. + for key in ("appriseServiceHelp", "appriseSlackSteps", "appriseSlackDocs", + "appriseMatrixSteps", "appriseMatrixDocs", "appriseServiceFinish"): + messages["settings"]["notifications"]["ui"].setdefault( + key, english["settings"]["notifications"]["ui"][key]) for section, key in (("encryption", "pbsHelp"), ("keyfileActions", "pveKeyDescription")): messages["backup"][section].setdefault(key, english["backup"][section][key]) diff --git a/.github/workflows/test-apprise-services.yml b/.github/workflows/test-apprise-services.yml new file mode 100644 index 00000000..59df1fd0 --- /dev/null +++ b/.github/workflows/test-apprise-services.yml @@ -0,0 +1,50 @@ +name: Apprise Slack and Matrix + +on: + pull_request: + paths: + - '.github/workflows/test-apprise-services.yml' + - 'AppImage/scripts/notification_channels.py' + - 'AppImage/components/apprise-service-help.tsx' + - 'AppImage/components/notification-settings.tsx' + - 'tests/test_apprise_services.py' + - 'tests/test_apprise_service_help.cjs' + push: + branches: [main, develop] + paths: + - 'AppImage/scripts/notification_channels.py' + - 'AppImage/components/apprise-service-help.tsx' + - 'tests/test_apprise_services.py' + - 'tests/test_apprise_service_help.cjs' + workflow_dispatch: + +permissions: + contents: read + +jobs: + apprise-services: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + matrix: + apprise: ['1.7.0', '2.0.1'] + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - uses: actions/setup-node@v6 + with: + node-version: '22.14.0' + - name: Install fixture dependencies + run: | + python -m pip install 'apprise==${{ matrix.apprise }}' + npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund + - name: Test real provider plugins with mocked HTTP + run: python -B -m unittest discover -s tests -p test_apprise_services.py -v + - name: Render provider help and locale fallback + env: + NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules + run: node tests/test_apprise_service_help.cjs diff --git a/AppImage/components/apprise-service-help.tsx b/AppImage/components/apprise-service-help.tsx new file mode 100644 index 00000000..db6eae71 --- /dev/null +++ b/AppImage/components/apprise-service-help.tsx @@ -0,0 +1,31 @@ +"use client" + +import { useT } from "../lib/i18n/provider" + +// These are examples, never defaults: opening help must not replace a saved +// destination or put placeholder credentials into the notification settings. +export function AppriseServiceHelp() { + const t = useT() + return
+ {t("settings.notifications.ui.appriseServiceHelp")} +
+
+

Slack

+

{t("settings.notifications.ui.appriseSlackSteps")}

+ https://hooks.slack.com/services/TOKEN_A/TOKEN_B/TOKEN_C + + {t("settings.notifications.ui.appriseSlackDocs")} + +
+
+

Matrix

+

{t("settings.notifications.ui.appriseMatrixSteps")}

+ matrixs://USER:PASSWORD@matrix.example.com/!ROOM_ID:matrix.example.com + + {t("settings.notifications.ui.appriseMatrixDocs")} + +
+
+

{t("settings.notifications.ui.appriseServiceFinish")}

+
+} diff --git a/AppImage/components/notification-settings.tsx b/AppImage/components/notification-settings.tsx index ac9733f1..e84590ee 100644 --- a/AppImage/components/notification-settings.tsx +++ b/AppImage/components/notification-settings.tsx @@ -1,5 +1,6 @@ "use client" +import { AppriseServiceHelp } from "./apprise-service-help" import { useState, useEffect, useCallback } from "react" import { useTheme } from "next-themes" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" @@ -2061,6 +2062,7 @@ export function NotificationSettings() { Enable toggle + Test button pattern as the other channels. */} +
diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index ca7e536d..157093e4 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -2094,6 +2094,12 @@ "pushoverCredentialsHint": "Create an application in Pushover and copy its API token and your user or group key.", "enableApprise": "Enable Apprise", "urlFormats": "+URL formats", + "appriseServiceHelp": "Slack and Matrix setup", + "appriseSlackSteps": "Create an Incoming Webhook in your Slack app for the target channel, then paste its complete HTTPS URL into Apprise URL. No conversion is needed.", + "appriseSlackDocs": "Slack URL formats and setup", + "appriseMatrixSteps": "Use a notification account that can post to the room. Replace the example user, password, homeserver and full room ID; URL-encode special characters in credentials. The matrixs scheme uses HTTPS. See the documentation for token, SSO and encrypted-room support in your installed Apprise version.", + "appriseMatrixDocs": "Matrix URL formats and setup", + "appriseServiceFinish": "Enable Apprise, enter your URL, choose the notification categories or individual events, save, then use Send Test. These examples do not change your saved URL. This Apprise channel has one destination; use Apprise API for several services at once.", "appriseUrl": "Apprise URL", "showUrl": "Show URL", "hideUrl": "Hide URL", diff --git a/AppImage/scripts/notification_channels.py b/AppImage/scripts/notification_channels.py index c2bff230..ba9aaa4d 100644 --- a/AppImage/scripts/notification_channels.py +++ b/AppImage/scripts/notification_channels.py @@ -1564,7 +1564,7 @@ class AppriseChannel(NotificationChannel): return apprise_mod.NotifyType.FAILURE if sev == 'WARNING': return apprise_mod.NotifyType.WARNING - if sev == 'SUCCESS': + if sev in ('SUCCESS', 'OK'): return apprise_mod.NotifyType.SUCCESS return apprise_mod.NotifyType.INFO diff --git a/tests/test_apprise_service_help.cjs b/tests/test_apprise_service_help.cjs new file mode 100644 index 00000000..d2f0d131 --- /dev/null +++ b/tests/test_apprise_service_help.cjs @@ -0,0 +1,30 @@ +// Render the real help component; only its translation provider is replaced. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const ts = require('typescript'); +const React = require('react'); +const { renderToStaticMarkup } = require('react-dom/server'); +const source = fs.readFileSync('AppImage/components/apprise-service-help.tsx', 'utf8'); +const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX } }).outputText; +const english = JSON.parse(fs.readFileSync('AppImage/messages/en/common.json')); +function resolve(catalog, key) { return key.split('.').reduce((value, part) => value?.[part], catalog); } +for (const locale of ['en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv', 'missing']) { + const catalog = locale === 'missing' ? {} : JSON.parse(fs.readFileSync(`AppImage/messages/${locale}/common.json`)); + const context = { exports: {}, require: name => name === '../lib/i18n/provider' ? { + useT: () => key => resolve(catalog, key) ?? resolve(english, key), + } : require(name) }; + vm.runInNewContext(js, context); + const html = renderToStaticMarkup(React.createElement(context.exports.AppriseServiceHelp)); + assert.ok(html.includes('https://hooks.slack.com/services/TOKEN_A/TOKEN_B/TOKEN_C')); + assert.ok(html.includes('matrixs://USER:PASSWORD@matrix.example.com/!ROOM_ID:matrix.example.com')); + assert.ok(html.includes('https://appriseit.com/services/slack/')); + assert.ok(html.includes('https://appriseit.com/services/matrix/')); + assert.ok(!html.includes('settings.notifications.'), `${locale}: no unresolved labels`); +} +for (const [, key] of source.matchAll(/t\("([^"]+)"\)/g)) assert.ok(resolve(english, key), key); +assert.ok(!source.includes('onChange') && !source.includes('fetch(') && !source.includes('updateChannel')); +const settings = fs.readFileSync('AppImage/components/notification-settings.tsx', 'utf8'); +const section = settings.slice(settings.indexOf('', settings.indexOf('', 'renderChannelCategories("apprise")', 'handleTest("apprise")']) assert.ok(section.includes(part)); +console.log('PASS: real provider-help JSX, shipped locales and English fallback, no secret mutation, native category/test controls retained'); diff --git a/tests/test_apprise_services.py b/tests/test_apprise_services.py new file mode 100644 index 00000000..fffb158e --- /dev/null +++ b/tests/test_apprise_services.py @@ -0,0 +1,137 @@ +"""Real Apprise Slack/Matrix plugins with the HTTP boundary replaced. + +No provider account or network is used. These contracts run against both +the minimum bundled Apprise version and the current tested version. +""" +import gc +import json +from pathlib import Path +import sys +import unittest +from unittest.mock import patch +from urllib.parse import urlparse + +import apprise +import requests + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "AppImage/scripts")) +from notification_channels import create_channel + +SLACK = "https://hooks.slack.com/services/T00000000/B00000000/fixturesecret1234567890" +MATRIX = "matrixs://notify:fixture-password@matrix.example.test/!room:matrix.example.test" + + +class AppriseServiceTests(unittest.TestCase): + def setUp(self): + self.calls = [] + self.status = 200 + self.transport = patch.object(requests.Session, "request", side_effect=self.http) + self.transport.start() + self.addCleanup(self.transport.stop) + self.sleep = patch("notification_channels.time.sleep") + self.sleep.start() + self.addCleanup(self.sleep.stop) + self.addCleanup(gc.collect) + + def http(self, method, url, **kwargs): + self.calls.append((method, url, kwargs)) + response = requests.Response() + response.status_code = self.status + response.url = url + response.reason = "OK" if self.status == 200 else "Rejected" + response._content_consumed = True + host, path = urlparse(url).hostname, urlparse(url).path + if host == "hooks.slack.com": + self.assertTrue(path.startswith("/services/")) + response._content = b"ok" if self.status == 200 else b"invalid_auth" + else: + self.assertEqual(host, "matrix.example.test", "no unexpected destinations") + if self.status != 200: + payload = {"errcode": "M_FORBIDDEN", "error": "Invalid credentials"} + elif path == "/.well-known/matrix/client": + payload = {"m.homeserver": {"base_url": "https://" + host}} + elif path.endswith("/login"): + payload = {"access_token": "fixture-token", "home_server": host, "user_id": "@notify:" + host} + elif path.endswith("/joined_rooms"): + payload = {"joined_rooms": ["!room:" + host]} + elif "/join/" in path: + payload = {"room_id": "!room:" + host} + elif "/send/" in path: + payload = {"event_id": "$fixture"} + elif path.endswith("/logout"): + payload = {} + elif path.endswith("/versions"): + payload = {"versions": ["v1.1"]} + else: + raise AssertionError("Unhandled Matrix request: " + path) + response._content = json.dumps(payload).encode() + return response + + def send(self, url): + channel = create_channel("apprise", {"url": url}) + self.assertIsNotNone(channel) + return channel.send("ProxMenux probe", "Controlled message", "WARNING") + + def test_slack_accepts_the_native_webhook_without_conversion(self): + result = self.send(SLACK) + self.assertTrue(result["success"], result) + self.assertTrue(any(url == SLACK for _, url, _ in self.calls)) + self.assertIn("Controlled message", str(self.calls)) + + def test_slack_apprise_scheme_uses_the_same_transport(self): + result = self.send(SLACK.replace("https://hooks.slack.com/services/", "slack://")) + self.assertTrue(result["success"], result) + self.assertTrue(any(url == SLACK for _, url, _ in self.calls)) + + def test_matrix_authenticates_and_delivers_to_the_full_room_id_over_https(self): + result = self.send(MATRIX) + self.assertTrue(result["success"], result) + sends = [(url, kwargs) for _, url, kwargs in self.calls if "/send/" in url] + self.assertTrue(sends, self.calls) + self.assertTrue(all(url.startswith("https://matrix.example.test/") for url, _ in sends)) + self.assertTrue(all(kwargs.get("verify", True) for _, kwargs in sends)) + self.assertIn("Controlled message", str(sends)) + self.assertTrue(any(url.endswith("/login") for _, url, _ in self.calls)) + + def test_test_button_contract_uses_the_same_provider(self): + ok, error = create_channel("apprise", {"url": SLACK}).test() + self.assertTrue(ok, error) + self.assertTrue(self.calls) + + def test_rejected_slack_webhook_is_not_reported_as_delivered(self): + self.status = 403 + result = self.send(SLACK) + self.assertFalse(result["success"]) + self.assertTrue(result["error"]) + + def test_invalid_matrix_credentials_are_not_reported_as_delivered(self): + self.status = 403 + result = self.send(MATRIX) + self.assertFalse(result["success"]) + self.assertFalse(any("/send/" in url for _, url, _ in self.calls)) + + def test_invalid_urls_and_missing_library_fail_before_any_request(self): + channel = create_channel("apprise", {"url": "not-a-url"}) + self.assertFalse(channel.send("Probe", "Body")["success"]) + channel = create_channel("apprise", {"url": SLACK}) + with patch.object(channel, "_load_apprise", return_value=None): + self.assertFalse(channel.send("Probe", "Body")["success"]) + self.assertEqual(self.calls, []) + + def test_connection_failure_never_claims_delivery(self): + for url in (SLACK, MATRIX): + with patch.object(requests.Session, "request", side_effect=requests.ConnectionError("fixture connection failure")): + result = self.send(url) + self.assertFalse(result["success"]) + self.assertTrue(result["error"]) + + def test_severity_mapping_keeps_native_notification_semantics(self): + channel = create_channel("apprise", {"url": SLACK}) + for severity, expected in (("CRITICAL", apprise.NotifyType.FAILURE), ("WARNING", apprise.NotifyType.WARNING), + ("INFO", apprise.NotifyType.INFO), ("OK", apprise.NotifyType.SUCCESS), + ("SUCCESS", apprise.NotifyType.SUCCESS)): + self.assertEqual(channel._severity_to_notify_type(apprise, severity), expected) + + +if __name__ == "__main__": + unittest.main()