Make Apprise strings customizable. Closes #212.

This commit is contained in:
Jack Kingsman
2026-04-21 19:39:10 -07:00
parent 2f412e1a93
commit ecb4c99a43
7 changed files with 549 additions and 60 deletions

View File

@@ -11,6 +11,28 @@ from app.path_utils import split_path_hex
logger = logging.getLogger(__name__)
DEFAULT_BODY_FORMAT_DM = "**DM:** {sender_name}: {text} **via:** [{hops_backticked}]"
DEFAULT_BODY_FORMAT_CHANNEL = (
"**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]"
)
_DEFAULT_BODY_FORMAT_DM_NO_PATH = "**DM:** {sender_name}: {text}"
_DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH = "**{channel_name}:** {sender_name}: {text}"
# Variables available for user format strings
FORMAT_VARIABLES = (
"type",
"text",
"sender_name",
"sender_key",
"channel_name",
"conversation_key",
"hops",
"hops_backticked",
"hop_count",
"rssi",
"snr",
)
def _parse_urls(raw: str) -> list[str]:
"""Split multi-line URL string into individual URLs."""
@@ -36,41 +58,91 @@ def _normalize_discord_url(url: str) -> str:
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
def _format_body(data: dict, *, include_path: bool) -> str:
"""Build a human-readable notification body from message data."""
def _compute_hops(data: dict) -> tuple[str, str, int]:
"""Extract hop info from message data. Returns (hops, hops_backticked, hop_count)."""
paths = data.get("paths")
if paths and isinstance(paths, list) and len(paths) > 0:
first_path = paths[0] if isinstance(paths[0], dict) else {}
path_str = first_path.get("path", "")
path_len = first_path.get("path_len")
else:
path_str = None
path_len = None
if path_str is None or path_str.strip() == "":
return ("direct", "`direct`", 0)
path_str = path_str.strip().lower()
hop_count = path_len if isinstance(path_len, int) else len(path_str) // 2
hops = split_path_hex(path_str, hop_count)
if not hops:
return ("direct", "`direct`", 0)
return (
", ".join(hops),
", ".join(f"`{h}`" for h in hops),
len(hops),
)
def _build_template_vars(data: dict) -> dict[str, str]:
"""Build the variable dict for format string substitution."""
hops_raw, hops_bt, hop_count = _compute_hops(data)
paths = data.get("paths")
rssi = ""
snr = ""
if paths and isinstance(paths, list) and len(paths) > 0:
first_path = paths[0] if isinstance(paths[0], dict) else {}
rssi_val = first_path.get("rssi")
snr_val = first_path.get("snr")
if rssi_val is not None:
rssi = str(rssi_val)
if snr_val is not None:
snr = str(snr_val)
return {
"type": data.get("type", ""),
"text": get_fanout_message_text(data),
"sender_name": data.get("sender_name") or "Unknown",
"sender_key": data.get("sender_key") or "",
"channel_name": data.get("channel_name") or data.get("conversation_key", "channel"),
"conversation_key": data.get("conversation_key", ""),
"hops": hops_raw,
"hops_backticked": hops_bt,
"hop_count": str(hop_count),
"rssi": rssi,
"snr": snr,
}
def _apply_format(fmt: str, variables: dict[str, str]) -> str:
"""Apply template variables in a single pass to avoid re-expanding substituted values."""
import re
def _replacer(m: re.Match[str]) -> str:
key = m.group(1)
return variables.get(key, m.group(0))
return re.sub(r"\{(\w+)\}", _replacer, fmt)
def _format_body(
data: dict,
*,
body_format_dm: str = DEFAULT_BODY_FORMAT_DM,
body_format_channel: str = DEFAULT_BODY_FORMAT_CHANNEL,
) -> str:
"""Build a notification body from message data using format strings."""
variables = _build_template_vars(data)
msg_type = data.get("type", "")
text = get_fanout_message_text(data)
sender_name = data.get("sender_name") or "Unknown"
via = ""
if include_path:
paths = data.get("paths")
if paths and isinstance(paths, list) and len(paths) > 0:
first_path = paths[0] if isinstance(paths[0], dict) else {}
path_str = first_path.get("path", "")
path_len = first_path.get("path_len")
else:
path_str = None
path_len = None
if msg_type == "PRIV" and path_str is None:
via = " **via:** [`direct`]"
elif path_str is not None:
path_str = path_str.strip().lower()
if path_str == "":
via = " **via:** [`direct`]"
else:
hop_count = path_len if isinstance(path_len, int) else len(path_str) // 2
hops = split_path_hex(path_str, hop_count)
if hops:
hop_list = ", ".join(f"`{h}`" for h in hops)
via = f" **via:** [{hop_list}]"
if msg_type == "PRIV":
return f"**DM:** {sender_name}: {text}{via}"
channel_name = data.get("channel_name") or data.get("conversation_key", "channel")
return f"**{channel_name}:** {sender_name}: {text}{via}"
fmt = body_format_dm if msg_type == "PRIV" else body_format_channel
try:
return _apply_format(fmt, variables)
except Exception:
logger.warning("Apprise format string error, falling back to default")
default = DEFAULT_BODY_FORMAT_DM if msg_type == "PRIV" else DEFAULT_BODY_FORMAT_CHANNEL
return _apply_format(default, variables)
def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool:
@@ -106,8 +178,27 @@ class AppriseModule(FanoutModule):
return
preserve_identity = self.config.get("preserve_identity", True)
include_path = self.config.get("include_path", True)
body = _format_body(data, include_path=include_path)
# Read format strings; treat empty/whitespace as unset (use default).
# Fall back to legacy include_path for pre-migration configs.
body_format_dm = (self.config.get("body_format_dm") or "").strip() or None
body_format_channel = (self.config.get("body_format_channel") or "").strip() or None
if body_format_dm is None or body_format_channel is None:
include_path = self.config.get("include_path", True)
if body_format_dm is None:
body_format_dm = (
DEFAULT_BODY_FORMAT_DM if include_path else _DEFAULT_BODY_FORMAT_DM_NO_PATH
)
if body_format_channel is None:
body_format_channel = (
DEFAULT_BODY_FORMAT_CHANNEL
if include_path
else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH
)
body = _format_body(
data, body_format_dm=body_format_dm, body_format_channel=body_format_channel
)
try:
success = await asyncio.to_thread(

View File

@@ -0,0 +1,57 @@
import json
import logging
import aiosqlite
logger = logging.getLogger(__name__)
DEFAULT_BODY_FORMAT_DM = "**DM:** {sender_name}: {text} **via:** [{hops_backticked}]"
DEFAULT_BODY_FORMAT_CHANNEL = (
"**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]"
)
_DEFAULT_BODY_FORMAT_DM_NO_PATH = "**DM:** {sender_name}: {text}"
_DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH = "**{channel_name}:** {sender_name}: {text}"
async def migrate(conn: aiosqlite.Connection) -> None:
"""Migrate apprise fanout configs from include_path boolean to format strings."""
table_check = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='fanout_configs'"
)
if not await table_check.fetchone():
await conn.commit()
return
cursor = await conn.execute("SELECT id, config FROM fanout_configs WHERE type = 'apprise'")
rows = await cursor.fetchall()
for row in rows:
config_id = row["id"] if isinstance(row, dict) else row[0]
config_raw = row["config"] if isinstance(row, dict) else row[1]
try:
config = json.loads(config_raw)
except (json.JSONDecodeError, TypeError):
continue
# Skip if already migrated
if "body_format_dm" in config:
continue
include_path = config.get("include_path", True)
config["body_format_dm"] = (
DEFAULT_BODY_FORMAT_DM if include_path else _DEFAULT_BODY_FORMAT_DM_NO_PATH
)
config["body_format_channel"] = (
DEFAULT_BODY_FORMAT_CHANNEL if include_path else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH
)
config.pop("include_path", None)
await conn.execute(
"UPDATE fanout_configs SET config = ? WHERE id = ?",
(json.dumps(config), config_id),
)
logger.info(
"Migrated apprise config %s: include_path=%s -> format strings", config_id, include_path
)
await conn.commit()

View File

@@ -259,6 +259,21 @@ def _validate_apprise_config(config: dict) -> None:
if not urls or not urls.strip():
raise HTTPException(status_code=400, detail="At least one Apprise URL is required")
from app.fanout.apprise_mod import FORMAT_VARIABLES, _apply_format
dummy_vars: dict[str, str] = dict.fromkeys(FORMAT_VARIABLES, "test")
for field in ("body_format_dm", "body_format_channel"):
value = config.get(field)
if value is not None and not isinstance(value, str):
raise HTTPException(status_code=400, detail=f"{field} must be a string")
if isinstance(value, str) and value.strip():
try:
_apply_format(value, dummy_vars)
except Exception:
raise HTTPException(
status_code=400, detail=f"Invalid format string in {field}"
) from None
def _validate_webhook_config(config: dict) -> None:
"""Validate webhook config blob."""

View File

@@ -1,4 +1,13 @@
import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
import {
useState,
useEffect,
useCallback,
useMemo,
useRef,
lazy,
Suspense,
type ReactNode,
} from 'react';
import { ChevronDown, Info } from 'lucide-react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
@@ -278,7 +287,9 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
config: {
urls: '',
preserve_identity: true,
include_path: 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' },
},
@@ -2376,6 +2387,91 @@ function ScopeSelector({
);
}
const APPRISE_DEFAULT_DM = '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]';
const APPRISE_DEFAULT_CHANNEL =
'**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]';
const APPRISE_SAMPLE_VARS: Record<string, string> = {
type: 'CHAN',
text: 'hello world',
sender_name: 'Alice',
sender_key: 'a1b2c3d4e5f6',
channel_name: '#general',
conversation_key: 'abcdef1234567890',
hops: '2a, 3b',
hops_backticked: '`2a`, `3b`',
hop_count: '2',
rssi: '-95',
snr: '6.5',
};
const APPRISE_SAMPLE_VARS_DM: Record<string, string> = {
...APPRISE_SAMPLE_VARS,
type: 'PRIV',
channel_name: '',
conversation_key: 'a1b2c3d4e5f6',
};
function appriseApplyFormat(fmt: string, vars: Record<string, string>): string {
let result = fmt;
for (const [key, value] of Object.entries(vars)) {
result = result.split(`{${key}}`).join(value);
}
return result;
}
/** Render a markdown-ish string into inline React elements (bold + code spans). */
function appriseRenderMarkdown(s: string): ReactNode[] {
const nodes: ReactNode[] = [];
let key = 0;
// Split on **bold** and `code` spans
const parts = s.split(/(\*\*[^*]+\*\*|`[^`]+`)/g);
for (const part of parts) {
if (part.startsWith('**') && part.endsWith('**')) {
nodes.push(
<strong key={key++} className="font-bold">
{part.slice(2, -2)}
</strong>
);
} else if (part.startsWith('`') && part.endsWith('`')) {
nodes.push(
<code key={key++} className="rounded bg-muted px-1 py-0.5 text-[0.6875rem] font-mono">
{part.slice(1, -1)}
</code>
);
} else if (part) {
nodes.push(<span key={key++}>{part}</span>);
}
}
return nodes;
}
function AppriseFormatPreview({ format, vars }: { format: string; vars: Record<string, string> }) {
const raw = appriseApplyFormat(format, vars);
return (
<div className="rounded-md border border-border bg-muted/30 p-2 space-y-1.5">
<div>
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Rendered (Discord, Slack)
</span>
<p className="text-xs break-all">{appriseRenderMarkdown(raw)}</p>
</div>
<div>
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Raw (Telegram, email)
</span>
<p className="text-xs font-mono break-all text-muted-foreground">{raw}</p>
</div>
</div>
);
}
function appriseIsDefault(value: unknown, defaultStr: string): boolean {
if (value == null) return true;
const s = String(value).trim();
return s === '' || s === defaultStr;
}
function AppriseConfigEditor({
config,
scope,
@@ -2387,6 +2483,10 @@ function AppriseConfigEditor({
onChange: (config: Record<string, unknown>) => void;
onScopeChange: (scope: Record<string, unknown>) => void;
}) {
const dmFormat = ((config.body_format_dm as string) || '').trim() || APPRISE_DEFAULT_DM;
const chanFormat =
((config.body_format_channel as string) || '').trim() || APPRISE_DEFAULT_CHANNEL;
return (
<div className="space-y-3">
<p className="text-[0.8125rem] text-muted-foreground">
@@ -2445,15 +2545,111 @@ function AppriseConfigEditor({
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={config.include_path !== false}
onChange={(e) => onChange({ ...config, include_path: e.target.checked })}
className="h-4 w-4 rounded border-border"
<Separator />
<h3 className="text-base font-semibold tracking-tight">Message Format</h3>
<details className="group">
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
Available variables
</summary>
<div className="mt-2 rounded-md border border-border bg-muted/30 p-2 text-xs space-y-0.5">
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5">
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{text}'}</code>
<span className="text-muted-foreground">Message body</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
{'{sender_name}'}
</code>
<span className="text-muted-foreground">Sender display name</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
{'{sender_key}'}
</code>
<span className="text-muted-foreground">Sender public key (hex)</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
{'{channel_name}'}
</code>
<span className="text-muted-foreground">Channel name (channel messages only)</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
{'{conversation_key}'}
</code>
<span className="text-muted-foreground">
Contact pubkey (DM) or channel key (channel)
</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{type}'}</code>
<span className="text-muted-foreground">PRIV or CHAN</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{hops}'}</code>
<span className="text-muted-foreground">
Comma-separated hop IDs, or &quot;direct&quot;
</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
{'{hops_backticked}'}
</code>
<span className="text-muted-foreground">Hops wrapped in backticks for markdown</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
{'{hop_count}'}
</code>
<span className="text-muted-foreground">Number of hops (0 for direct)</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{rssi}'}</code>
<span className="text-muted-foreground">Last-hop RSSI in dBm</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{snr}'}</code>
<span className="text-muted-foreground">Last-hop SNR in dB</span>
</div>
<p className="text-xs text-muted-foreground mt-1.5">
Empty textareas use the default format. RSSI/SNR may be empty if unavailable.
</p>
</div>
</details>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="fanout-apprise-fmt-dm">DM format</Label>
{!appriseIsDefault(config.body_format_dm, APPRISE_DEFAULT_DM) && (
<button
type="button"
aria-label="Reset DM format to default"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => onChange({ ...config, body_format_dm: APPRISE_DEFAULT_DM })}
>
Reset to default
</button>
)}
</div>
<textarea
id="fanout-apprise-fmt-dm"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono min-h-[56px]"
placeholder={APPRISE_DEFAULT_DM}
value={(config.body_format_dm as string) ?? ''}
onChange={(e) => onChange({ ...config, body_format_dm: e.target.value })}
rows={2}
/>
<span className="text-sm">Include routing path in notifications</span>
</label>
<AppriseFormatPreview format={dmFormat} vars={APPRISE_SAMPLE_VARS_DM} />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="fanout-apprise-fmt-chan">Channel format</Label>
{!appriseIsDefault(config.body_format_channel, APPRISE_DEFAULT_CHANNEL) && (
<button
type="button"
aria-label="Reset channel format to default"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => onChange({ ...config, body_format_channel: APPRISE_DEFAULT_CHANNEL })}
>
Reset to default
</button>
)}
</div>
<textarea
id="fanout-apprise-fmt-chan"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono min-h-[56px]"
placeholder={APPRISE_DEFAULT_CHANNEL}
value={(config.body_format_channel as string) ?? ''}
onChange={(e) => onChange({ ...config, body_format_channel: e.target.value })}
rows={2}
/>
<AppriseFormatPreview format={chanFormat} vars={APPRISE_SAMPLE_VARS} />
</div>
<Separator />

View File

@@ -1049,7 +1049,8 @@ class TestAppriseFormatBody:
from app.fanout.apprise_mod import _format_body
body = _format_body(
{"type": "PRIV", "text": "hi", "sender_name": "Alice"}, include_path=False
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
body_format_dm="**DM:** {sender_name}: {text}",
)
assert body == "**DM:** Alice: hi"
@@ -1058,7 +1059,7 @@ class TestAppriseFormatBody:
body = _format_body(
{"type": "CHAN", "text": "hi", "sender_name": "Bob", "channel_name": "#general"},
include_path=False,
body_format_channel="**{channel_name}:** {sender_name}: {text}",
)
assert body == "**#general:** Bob: hi"
@@ -1072,7 +1073,7 @@ class TestAppriseFormatBody:
"sender_name": "Bob",
"channel_name": "#general",
},
include_path=False,
body_format_channel="**{channel_name}:** {sender_name}: {text}",
)
assert body == "**#general:** Bob: hi"
@@ -1086,7 +1087,7 @@ class TestAppriseFormatBody:
"sender_name": "Alice",
"paths": [{"path": "2027"}],
},
include_path=True,
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
)
assert "**via:**" in body
assert "`20`" in body
@@ -1097,7 +1098,7 @@ class TestAppriseFormatBody:
body = _format_body(
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
include_path=True,
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
)
assert "`direct`" in body
@@ -1112,7 +1113,7 @@ class TestAppriseFormatBody:
"sender_name": "Alice",
"paths": [{"path": "aabbccdd", "path_len": 2}],
},
include_path=True,
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
)
assert "**via:**" in body
assert "`aabb`" in body
@@ -1129,7 +1130,7 @@ class TestAppriseFormatBody:
"sender_name": "Alice",
"paths": [{"path": "aabbccddeeff", "path_len": 2}],
},
include_path=True,
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
)
assert "**via:**" in body
assert "`aabbcc`" in body
@@ -1147,7 +1148,7 @@ class TestAppriseFormatBody:
"channel_name": "#general",
"paths": [{"path": "aabbccdd", "path_len": 2}],
},
include_path=True,
body_format_channel="**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]",
)
assert "**#general:**" in body
assert "`aabb`" in body
@@ -1164,12 +1165,118 @@ class TestAppriseFormatBody:
"sender_name": "Alice",
"paths": [{"path": "aabb"}],
},
include_path=True,
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
)
assert "**via:**" in body
assert "`aa`" in body
assert "`bb`" in body
def test_default_format_strings(self):
"""Default format strings produce expected output."""
from app.fanout.apprise_mod import _format_body
body = _format_body(
{
"type": "PRIV",
"text": "hi",
"sender_name": "Alice",
"paths": [{"path": "2a3b"}],
},
)
assert body == "**DM:** Alice: hi **via:** [`2a`, `3b`]"
def test_custom_format_with_rssi(self):
"""Custom format string can include rssi/snr."""
from app.fanout.apprise_mod import _format_body
body = _format_body(
{
"type": "PRIV",
"text": "hi",
"sender_name": "Alice",
"paths": [{"path": "2a", "rssi": -95, "snr": 6.5}],
},
body_format_dm="From {sender_name}: {text} (rssi: {rssi}, snr: {snr})",
)
assert body == "From Alice: hi (rssi: -95, snr: 6.5)"
def test_unknown_placeholder_left_as_is(self):
"""Unknown {placeholders} pass through unchanged."""
from app.fanout.apprise_mod import _format_body
body = _format_body(
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
body_format_dm="{sender_name}: {text} {unknown_var}",
)
assert body == "Alice: hi {unknown_var}"
def test_none_fields_render_empty(self):
"""None optional fields render as empty string, not 'None'."""
from app.fanout.apprise_mod import _format_body
body = _format_body(
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
body_format_dm="{sender_name}: {text} rssi={rssi}",
)
assert body == "Alice: hi rssi="
assert "None" not in body
def test_hops_direct_when_no_paths(self):
"""hops is 'direct' when no path data exists."""
from app.fanout.apprise_mod import _format_body
body = _format_body(
{"type": "CHAN", "text": "hi", "sender_name": "Bob", "channel_name": "#gen"},
body_format_channel="{channel_name} {hops}",
)
assert body == "#gen direct"
def test_hops_direct_when_empty_path(self):
"""hops is 'direct' when path string is empty."""
from app.fanout.apprise_mod import _format_body
body = _format_body(
{
"type": "PRIV",
"text": "hi",
"sender_name": "Alice",
"paths": [{"path": ""}],
},
body_format_dm="{hops}",
)
assert body == "direct"
def test_no_re_expansion_of_substituted_values(self):
"""Placeholders in message text must not be expanded by later passes."""
from app.fanout.apprise_mod import _format_body
body = _format_body(
{"type": "PRIV", "text": "hello {sender_name}", "sender_name": "Alice"},
body_format_dm="{sender_name}: {text}",
)
assert body == "Alice: hello {sender_name}"
@pytest.mark.asyncio
async def test_empty_format_string_uses_default(self):
"""Empty format strings in config should produce default output, not blank."""
from unittest.mock import patch as _patch
from app.fanout.apprise_mod import AppriseModule
mod = AppriseModule(
"test",
{"urls": "json://localhost", "body_format_dm": "", "body_format_channel": " "},
)
with _patch("app.fanout.apprise_mod._send_sync", return_value=True) as mock_send:
await mod.on_message(
{"type": "PRIV", "text": "hi", "outgoing": False, "sender_name": "Alice"}
)
mock_send.assert_called_once()
body = mock_send.call_args[0][1]
assert "Alice" in body
assert "hi" in body
assert body != ""
class TestAppriseNormalizeDiscordUrl:
def test_discord_scheme(self):
@@ -1233,6 +1340,26 @@ class TestAppriseValidation:
_validate_apprise_config({"urls": "discord://123/abc"})
def test_validate_apprise_config_accepts_format_strings(self):
from app.routers.fanout import _validate_apprise_config
_validate_apprise_config(
{
"urls": "discord://123/abc",
"body_format_dm": "DM from {sender_name}: {text}",
"body_format_channel": "{channel_name}: {text}",
}
)
def test_validate_apprise_config_rejects_non_string_format(self):
from fastapi import HTTPException
from app.routers.fanout import _validate_apprise_config
with pytest.raises(HTTPException) as exc_info:
_validate_apprise_config({"urls": "discord://123/abc", "body_format_dm": 123})
assert exc_info.value.status_code == 400
def test_enforce_scope_apprise_strips_raw_packets(self):
from app.routers.fanout import _enforce_scope

View File

@@ -1171,7 +1171,8 @@ class TestFanoutAppriseIntegration:
config={
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
"preserve_identity": True,
"include_path": False,
"body_format_dm": "**DM:** {sender_name}: {text}",
"body_format_channel": "**{channel_name}:** {sender_name}: {text}",
},
scope={"messages": "all", "raw_packets": "none"},
enabled=True,
@@ -1212,7 +1213,8 @@ class TestFanoutAppriseIntegration:
name="Channel Apprise",
config={
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
"include_path": False,
"body_format_dm": "**DM:** {sender_name}: {text}",
"body_format_channel": "**{channel_name}:** {sender_name}: {text}",
},
scope={"messages": "all", "raw_packets": "none"},
enabled=True,
@@ -1541,13 +1543,14 @@ class TestFanoutAppriseIntegration:
@pytest.mark.asyncio
async def test_apprise_includes_routing_path(self, apprise_capture_server, integration_db):
"""Apprise with include_path=True shows routing hops in the body."""
"""Apprise with hops in format string shows routing hops in the body."""
cfg = await FanoutConfigRepository.create(
config_type="apprise",
name="Path Apprise",
config={
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
"include_path": 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,

View File

@@ -2,4 +2,4 @@
# run ``run_migrations`` to completion assert ``get_version == LATEST`` and
# ``applied == LATEST - starting_version`` so only this constant needs to
# change, not every individual assertion.
LATEST_SCHEMA_VERSION = 59
LATEST_SCHEMA_VERSION = 60