From ecb4c99a4343a3b25ed3e28d3e8c86544136cc04 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 21 Apr 2026 19:39:10 -0700 Subject: [PATCH] Make Apprise strings customizable. Closes #212. --- app/fanout/apprise_mod.py | 163 ++++++++++--- app/migrations/_060_apprise_format_strings.py | 57 +++++ app/routers/fanout.py | 15 ++ .../settings/SettingsFanoutSection.tsx | 216 +++++++++++++++++- tests/test_fanout.py | 145 +++++++++++- tests/test_fanout_integration.py | 11 +- tests/test_migrations/conftest.py | 2 +- 7 files changed, 549 insertions(+), 60 deletions(-) create mode 100644 app/migrations/_060_apprise_format_strings.py diff --git a/app/fanout/apprise_mod.py b/app/fanout/apprise_mod.py index 9b71081..2e427f6 100644 --- a/app/fanout/apprise_mod.py +++ b/app/fanout/apprise_mod.py @@ -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( diff --git a/app/migrations/_060_apprise_format_strings.py b/app/migrations/_060_apprise_format_strings.py new file mode 100644 index 0000000..fbfdd87 --- /dev/null +++ b/app/migrations/_060_apprise_format_strings.py @@ -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() diff --git a/app/routers/fanout.py b/app/routers/fanout.py index 66da005..dc51576 100644 --- a/app/routers/fanout.py +++ b/app/routers/fanout.py @@ -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.""" diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index 1e7bee9..ed9267a 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -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 = { + 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 = { + ...APPRISE_SAMPLE_VARS, + type: 'PRIV', + channel_name: '', + conversation_key: 'a1b2c3d4e5f6', +}; + +function appriseApplyFormat(fmt: string, vars: Record): 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( + + {part.slice(2, -2)} + + ); + } else if (part.startsWith('`') && part.endsWith('`')) { + nodes.push( + + {part.slice(1, -1)} + + ); + } else if (part) { + nodes.push({part}); + } + } + return nodes; +} + +function AppriseFormatPreview({ format, vars }: { format: string; vars: Record }) { + const raw = appriseApplyFormat(format, vars); + return ( +
+
+ + Rendered (Discord, Slack) + +

{appriseRenderMarkdown(raw)}

+
+
+ + Raw (Telegram, email) + +

{raw}

+
+
+ ); +} + +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) => void; onScopeChange: (scope: Record) => 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 (

@@ -2445,15 +2545,111 @@ function AppriseConfigEditor({

-