Clarify Slack and Matrix setup and preserve Apprise recovery severity

This commit is contained in:
Jonatan
2026-10-16 20:39:43 +02:00
parent 3000c1cd66
commit ee6b3bb232
8 changed files with 262 additions and 1 deletions
@@ -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])
@@ -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
@@ -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 <details className="rounded-md border border-border bg-muted/20 p-2 text-xs">
<summary className="cursor-pointer font-medium">{t("settings.notifications.ui.appriseServiceHelp")}</summary>
<div className="grid gap-3 pt-3 sm:grid-cols-2">
<div className="space-y-1.5 min-w-0">
<h4 className="font-medium">Slack</h4>
<p className="text-muted-foreground">{t("settings.notifications.ui.appriseSlackSteps")}</p>
<code className="block break-all rounded bg-background p-2 text-[10px]">https://hooks.slack.com/services/TOKEN_A/TOKEN_B/TOKEN_C</code>
<a href="https://appriseit.com/services/slack/" target="_blank" rel="noopener noreferrer" className="text-cyan-500 hover:underline">
{t("settings.notifications.ui.appriseSlackDocs")}
</a>
</div>
<div className="space-y-1.5 min-w-0">
<h4 className="font-medium">Matrix</h4>
<p className="text-muted-foreground">{t("settings.notifications.ui.appriseMatrixSteps")}</p>
<code className="block break-all rounded bg-background p-2 text-[10px]">matrixs://USER:PASSWORD@matrix.example.com/!ROOM_ID:matrix.example.com</code>
<a href="https://appriseit.com/services/matrix/" target="_blank" rel="noopener noreferrer" className="text-cyan-500 hover:underline">
{t("settings.notifications.ui.appriseMatrixDocs")}
</a>
</div>
</div>
<p className="mt-3 text-muted-foreground">{t("settings.notifications.ui.appriseServiceFinish")}</p>
</details>
}
@@ -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. */}
<TabsContent value="apprise" className="space-y-3 pt-2">
<AppriseServiceHelp />
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Label className="text-xs font-medium">{t("settings.notifications.ui.enableApprise")}</Label>
+6
View File
@@ -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",
+1 -1
View File
@@ -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
+30
View File
@@ -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('<TabsContent value="apprise"'), settings.indexOf('</TabsContent>', settings.indexOf('<TabsContent value="apprise"')));
for (const part of ['<AppriseServiceHelp />', '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');
+137
View File
@@ -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()