From e8146533001d8da99851b4e31a60d050c3d008d3 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Thu, 30 Apr 2026 19:54:16 -0700 Subject: [PATCH] Add non-markdown option. Closes #232. --- app/fanout/apprise_mod.py | 74 ++++++++-- app/routers/fanout.py | 4 + .../settings/SettingsFanoutSection.tsx | 107 +++++++++++---- tests/test_fanout.py | 128 ++++++++++++++++++ tests/test_fanout_integration.py | 40 ++++++ 5 files changed, 315 insertions(+), 38 deletions(-) diff --git a/app/fanout/apprise_mod.py b/app/fanout/apprise_mod.py index a134905..a00f5ab 100644 --- a/app/fanout/apprise_mod.py +++ b/app/fanout/apprise_mod.py @@ -21,6 +21,12 @@ DEFAULT_BODY_FORMAT_CHANNEL = ( _DEFAULT_BODY_FORMAT_DM_NO_PATH = "**DM:** {sender_name}: {text}" _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH = "**{channel_name}:** {sender_name}: {text}" +# Plain-text variants (no markdown formatting) +DEFAULT_BODY_FORMAT_DM_PLAIN = "DM: {sender_name}: {text} via: [{hops}]" +DEFAULT_BODY_FORMAT_CHANNEL_PLAIN = "{channel_name}: {sender_name}: {text} via: [{hops}]" +_DEFAULT_BODY_FORMAT_DM_NO_PATH_PLAIN = "DM: {sender_name}: {text}" +_DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH_PLAIN = "{channel_name}: {sender_name}: {text}" + # Variables available for user format strings FORMAT_VARIABLES = ( "type", @@ -133,10 +139,17 @@ def _apply_format(fmt: str, variables: dict[str, str]) -> str: def _format_body( data: dict, *, - body_format_dm: str = DEFAULT_BODY_FORMAT_DM, - body_format_channel: str = DEFAULT_BODY_FORMAT_CHANNEL, + body_format_dm: str | None = None, + body_format_channel: str | None = None, + markdown: bool = True, ) -> str: """Build a notification body from message data using format strings.""" + if body_format_dm is None: + body_format_dm = DEFAULT_BODY_FORMAT_DM if markdown else DEFAULT_BODY_FORMAT_DM_PLAIN + if body_format_channel is None: + body_format_channel = ( + DEFAULT_BODY_FORMAT_CHANNEL if markdown else DEFAULT_BODY_FORMAT_CHANNEL_PLAIN + ) variables = _build_template_vars(data) msg_type = data.get("type", "") fmt = body_format_dm if msg_type == "PRIV" else body_format_channel @@ -144,13 +157,21 @@ def _format_body( 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 + if markdown: + default = DEFAULT_BODY_FORMAT_DM if msg_type == "PRIV" else DEFAULT_BODY_FORMAT_CHANNEL + else: + default = ( + DEFAULT_BODY_FORMAT_DM_PLAIN + if msg_type == "PRIV" + else DEFAULT_BODY_FORMAT_CHANNEL_PLAIN + ) return _apply_format(default, variables) -def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool: +def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool, markdown: bool = True) -> bool: """Send notification synchronously via Apprise. Returns True on success.""" import apprise as apprise_lib + from apprise import NotifyFormat urls = _parse_urls(urls_raw) if not urls: @@ -162,7 +183,8 @@ def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool: url = _normalize_discord_url(url) notifier.add(url) - return bool(notifier.notify(title="", body=body)) + body_fmt = NotifyFormat.MARKDOWN if markdown else NotifyFormat.TEXT + return bool(notifier.notify(title="", body=body, body_format=body_fmt)) class AppriseModule(FanoutModule): @@ -181,6 +203,7 @@ class AppriseModule(FanoutModule): return preserve_identity = self.config.get("preserve_identity", True) + markdown = self.config.get("markdown_format", True) # Read format strings; treat empty/whitespace as unset (use default). # Fall back to legacy include_path for pre-migration configs. @@ -189,25 +212,46 @@ class AppriseModule(FanoutModule): 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 markdown: + body_format_dm = ( + DEFAULT_BODY_FORMAT_DM if include_path else _DEFAULT_BODY_FORMAT_DM_NO_PATH + ) + else: + body_format_dm = ( + DEFAULT_BODY_FORMAT_DM_PLAIN + if include_path + else _DEFAULT_BODY_FORMAT_DM_NO_PATH_PLAIN + ) if body_format_channel is None: - body_format_channel = ( - DEFAULT_BODY_FORMAT_CHANNEL - if include_path - else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH - ) + if markdown: + body_format_channel = ( + DEFAULT_BODY_FORMAT_CHANNEL + if include_path + else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH + ) + else: + body_format_channel = ( + DEFAULT_BODY_FORMAT_CHANNEL_PLAIN + if include_path + else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH_PLAIN + ) body = _format_body( - data, body_format_dm=body_format_dm, body_format_channel=body_format_channel + data, + body_format_dm=body_format_dm, + body_format_channel=body_format_channel, + markdown=markdown, ) last_exc: Exception | None = None for attempt in range(_MAX_SEND_ATTEMPTS): try: success = await asyncio.to_thread( - _send_sync, urls, body, preserve_identity=preserve_identity + _send_sync, + urls, + body, + preserve_identity=preserve_identity, + markdown=markdown, ) if success: self._set_last_error(None) diff --git a/app/routers/fanout.py b/app/routers/fanout.py index dc51576..43d0d6b 100644 --- a/app/routers/fanout.py +++ b/app/routers/fanout.py @@ -274,6 +274,10 @@ def _validate_apprise_config(config: dict) -> None: status_code=400, detail=f"Invalid format string in {field}" ) from None + markdown_format = config.get("markdown_format") + if markdown_format is not None: + config["markdown_format"] = bool(markdown_format) + 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 ed9267a..3efe9c1 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -287,6 +287,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [ config: { urls: '', preserve_identity: true, + markdown_format: true, body_format_dm: '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]', body_format_channel: '**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]', @@ -2390,6 +2391,8 @@ 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_DEFAULT_DM_PLAIN = 'DM: {sender_name}: {text} via: [{hops}]'; +const APPRISE_DEFAULT_CHANNEL_PLAIN = '{channel_name}: {sender_name}: {text} via: [{hops}]'; const APPRISE_SAMPLE_VARS: Record = { type: 'CHAN', @@ -2420,19 +2423,32 @@ function appriseApplyFormat(fmt: string, vars: Record): string { return result; } -/** Render a markdown-ish string into inline React elements (bold + code spans). */ +/** Render a markdown-ish string into inline React elements (bold, italic, code). */ function appriseRenderMarkdown(s: string): ReactNode[] { const nodes: ReactNode[] = []; let key = 0; - // Split on **bold** and `code` spans - const parts = s.split(/(\*\*[^*]+\*\*|`[^`]+`)/g); + // Split on **bold**, __bold__, *italic*, _italic_, and `code` spans. + // Longer delimiters first so ** and __ match before * and _. + const parts = s.split(/(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\*[^*]+\*|_[^_]+_)/g); for (const part of parts) { - if (part.startsWith('**') && part.endsWith('**')) { + if ( + (part.startsWith('**') && part.endsWith('**')) || + (part.startsWith('__') && part.endsWith('__')) + ) { nodes.push( {part.slice(2, -2)} ); + } else if ( + (part.startsWith('*') && part.endsWith('*')) || + (part.startsWith('_') && part.endsWith('_')) + ) { + nodes.push( + + {part.slice(1, -1)} + + ); } else if (part.startsWith('`') && part.endsWith('`')) { nodes.push( @@ -2446,19 +2462,29 @@ function appriseRenderMarkdown(s: string): ReactNode[] { return nodes; } -function AppriseFormatPreview({ format, vars }: { format: string; vars: Record }) { +function AppriseFormatPreview({ + format, + vars, + markdown = true, +}: { + format: string; + vars: Record; + markdown?: boolean; +}) { const raw = appriseApplyFormat(format, vars); return (
+ {markdown && ( +
+ + Rendered (Discord, Slack, Telegram) + +

{appriseRenderMarkdown(raw)}

+
+ )}
- Rendered (Discord, Slack) - -

{appriseRenderMarkdown(raw)}

-
-
- - Raw (Telegram, email) + {markdown ? 'Raw (email, SMS)' : 'Preview'}

{raw}

@@ -2483,9 +2509,11 @@ 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; + const markdown = config.markdown_format !== false; + const defaultDm = markdown ? APPRISE_DEFAULT_DM : APPRISE_DEFAULT_DM_PLAIN; + const defaultChan = markdown ? APPRISE_DEFAULT_CHANNEL : APPRISE_DEFAULT_CHANNEL_PLAIN; + const dmFormat = ((config.body_format_dm as string) || '').trim() || defaultDm; + const chanFormat = ((config.body_format_channel as string) || '').trim() || defaultChan; return (
@@ -2549,6 +2577,39 @@ function AppriseConfigEditor({

Message Format

+ +
@@ -2604,12 +2665,12 @@ function AppriseConfigEditor({
- {!appriseIsDefault(config.body_format_dm, APPRISE_DEFAULT_DM) && ( + {!appriseIsDefault(config.body_format_dm, defaultDm) && ( @@ -2618,23 +2679,23 @@ function AppriseConfigEditor({