Add non-markdown option. Closes #232.

This commit is contained in:
Jack Kingsman
2026-04-30 19:54:16 -07:00
parent e76d922752
commit e814653300
5 changed files with 315 additions and 38 deletions

View File

@@ -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)

View File

@@ -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."""

View File

@@ -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<string, string> = {
type: 'CHAN',
@@ -2420,19 +2423,32 @@ function appriseApplyFormat(fmt: string, vars: Record<string, string>): 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(
<strong key={key++} className="font-bold">
{part.slice(2, -2)}
</strong>
);
} else if (
(part.startsWith('*') && part.endsWith('*')) ||
(part.startsWith('_') && part.endsWith('_'))
) {
nodes.push(
<em key={key++} className="italic">
{part.slice(1, -1)}
</em>
);
} 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">
@@ -2446,19 +2462,29 @@ function appriseRenderMarkdown(s: string): ReactNode[] {
return nodes;
}
function AppriseFormatPreview({ format, vars }: { format: string; vars: Record<string, string> }) {
function AppriseFormatPreview({
format,
vars,
markdown = true,
}: {
format: string;
vars: Record<string, string>;
markdown?: boolean;
}) {
const raw = appriseApplyFormat(format, vars);
return (
<div className="rounded-md border border-border bg-muted/30 p-2 space-y-1.5">
{markdown && (
<div>
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Rendered (Discord, Slack, Telegram)
</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">
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)
{markdown ? 'Raw (email, SMS)' : 'Preview'}
</span>
<p className="text-xs font-mono break-all text-muted-foreground">{raw}</p>
</div>
@@ -2483,9 +2509,11 @@ 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;
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 (
<div className="space-y-3">
@@ -2549,6 +2577,39 @@ function AppriseConfigEditor({
<h3 className="text-base font-semibold tracking-tight">Message Format</h3>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={markdown}
onChange={(e) => {
const md = e.target.checked;
const updates: Record<string, unknown> = { ...config, markdown_format: md };
const curDm = ((config.body_format_dm as string) || '').trim();
const curChan = ((config.body_format_channel as string) || '').trim();
if (md) {
if (!curDm || curDm === APPRISE_DEFAULT_DM_PLAIN)
updates.body_format_dm = APPRISE_DEFAULT_DM;
if (!curChan || curChan === APPRISE_DEFAULT_CHANNEL_PLAIN)
updates.body_format_channel = APPRISE_DEFAULT_CHANNEL;
} else {
if (!curDm || curDm === APPRISE_DEFAULT_DM)
updates.body_format_dm = APPRISE_DEFAULT_DM_PLAIN;
if (!curChan || curChan === APPRISE_DEFAULT_CHANNEL)
updates.body_format_channel = APPRISE_DEFAULT_CHANNEL_PLAIN;
}
onChange(updates);
}}
className="h-4 w-4 rounded border-border"
/>
<div>
<span className="text-sm">Markdown formatting</span>
<p className="text-[0.8125rem] text-muted-foreground">
If notifications fail on services like Telegram due to special characters in sender
names, disable this option.
</p>
</div>
</label>
<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" />
@@ -2604,12 +2665,12 @@ function AppriseConfigEditor({
<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) && (
{!appriseIsDefault(config.body_format_dm, defaultDm) && (
<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 })}
onClick={() => onChange({ ...config, body_format_dm: defaultDm })}
>
Reset to default
</button>
@@ -2618,23 +2679,23 @@ function AppriseConfigEditor({
<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}
placeholder={defaultDm}
value={(config.body_format_dm as string) ?? ''}
onChange={(e) => onChange({ ...config, body_format_dm: e.target.value })}
rows={2}
/>
<AppriseFormatPreview format={dmFormat} vars={APPRISE_SAMPLE_VARS_DM} />
<AppriseFormatPreview format={dmFormat} vars={APPRISE_SAMPLE_VARS_DM} markdown={markdown} />
</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) && (
{!appriseIsDefault(config.body_format_channel, defaultChan) && (
<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 })}
onClick={() => onChange({ ...config, body_format_channel: defaultChan })}
>
Reset to default
</button>
@@ -2643,12 +2704,12 @@ function AppriseConfigEditor({
<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}
placeholder={defaultChan}
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} />
<AppriseFormatPreview format={chanFormat} vars={APPRISE_SAMPLE_VARS} markdown={markdown} />
</div>
<Separator />

View File

@@ -1367,6 +1367,134 @@ class TestAppriseValidation:
assert scope["raw_packets"] == "none"
assert scope["messages"] == "all"
def test_validate_apprise_config_accepts_markdown_format_bool(self):
from app.routers.fanout import _validate_apprise_config
_validate_apprise_config({"urls": "discord://123/abc", "markdown_format": False})
def test_validate_apprise_config_normalizes_markdown_format(self):
from app.routers.fanout import _validate_apprise_config
config: dict = {"urls": "discord://123/abc", "markdown_format": 0}
_validate_apprise_config(config)
assert config["markdown_format"] is False
def test_validate_apprise_config_works_without_markdown_format(self):
from app.routers.fanout import _validate_apprise_config
_validate_apprise_config({"urls": "discord://123/abc"})
class TestAppriseMarkdownFormat:
def test_format_body_markdown_true_uses_markdown_fallback(self):
from app.fanout.apprise_mod import _format_body
body = _format_body(
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
markdown=True,
)
assert "**DM:**" in body
def test_format_body_markdown_false_uses_plain_fallback(self):
from app.fanout.apprise_mod import _format_body
body = _format_body(
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
markdown=False,
)
assert "**" not in body
assert "DM:" in body
assert "Alice" in body
def test_format_body_markdown_false_channel(self):
from app.fanout.apprise_mod import _format_body
body = _format_body(
{"type": "CHAN", "text": "hi", "sender_name": "Bob", "channel_name": "#gen"},
markdown=False,
)
assert "**" not in body
assert "#gen:" in body
def test_send_sync_passes_markdown_body_format(self):
from unittest.mock import MagicMock, patch
with patch("app.fanout.apprise_mod.apprise_lib", create=True) as mock_lib:
mock_notifier = MagicMock()
mock_notifier.notify.return_value = True
mock_lib.Apprise.return_value = mock_notifier
with patch.dict("sys.modules", {"apprise": mock_lib}):
from app.fanout.apprise_mod import _send_sync
_send_sync("json://localhost", "test", preserve_identity=False, markdown=True)
call_kwargs = mock_notifier.notify.call_args
assert call_kwargs.kwargs.get("body_format") or call_kwargs[1].get("body_format")
def test_send_sync_passes_text_body_format_when_markdown_false(self):
from unittest.mock import MagicMock, patch
with patch("app.fanout.apprise_mod.apprise_lib", create=True) as mock_lib:
mock_notifier = MagicMock()
mock_notifier.notify.return_value = True
mock_lib.Apprise.return_value = mock_notifier
with patch.dict("sys.modules", {"apprise": mock_lib}):
from app.fanout.apprise_mod import _send_sync
_send_sync("json://localhost", "test", preserve_identity=False, markdown=False)
call_kwargs = mock_notifier.notify.call_args
assert call_kwargs.kwargs.get("body_format") or call_kwargs[1].get("body_format")
@pytest.mark.asyncio
async def test_on_message_reads_markdown_format_config(self):
from unittest.mock import patch as _patch
from app.fanout.apprise_mod import AppriseModule
mod = AppriseModule("test", {"urls": "json://localhost", "markdown_format": False})
with _patch("app.fanout.apprise_mod._send_sync", return_value=True) as mock_send:
await mod.on_message(
{"type": "PRIV", "text": "hello", "outgoing": False, "sender_name": "S_Borkin"}
)
mock_send.assert_called_once()
assert mock_send.call_args.kwargs.get("markdown") is False
@pytest.mark.asyncio
async def test_on_message_defaults_markdown_true(self):
from unittest.mock import patch as _patch
from app.fanout.apprise_mod import AppriseModule
mod = AppriseModule("test", {"urls": "json://localhost"})
with _patch("app.fanout.apprise_mod._send_sync", return_value=True) as mock_send:
await mod.on_message(
{"type": "PRIV", "text": "hello", "outgoing": False, "sender_name": "Alice"}
)
mock_send.assert_called_once()
assert mock_send.call_args.kwargs.get("markdown") is True
@pytest.mark.asyncio
async def test_on_message_markdown_false_uses_plain_default_format(self):
from unittest.mock import patch as _patch
from app.fanout.apprise_mod import AppriseModule
mod = AppriseModule("test", {"urls": "json://localhost", "markdown_format": False})
with _patch("app.fanout.apprise_mod._send_sync", return_value=True) as mock_send:
await mod.on_message(
{
"type": "CHAN",
"text": "hi",
"outgoing": False,
"sender_name": "Bob",
"channel_name": "#general",
}
)
body = mock_send.call_args[0][1]
assert "**" not in body
assert "#general:" in body
# ---------------------------------------------------------------------------
# Comprehensive scope/filter selection logic tests

View File

@@ -1580,6 +1580,46 @@ class TestFanoutAppriseIntegration:
assert "Eve" in body_text
assert "routed msg" in body_text
@pytest.mark.asyncio
async def test_apprise_markdown_false_delivers_plain_text(
self, apprise_capture_server, integration_db
):
"""Apprise with markdown_format=False delivers without markdown formatting."""
cfg = await FanoutConfigRepository.create(
config_type="apprise",
name="Plain Apprise",
config={
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
"markdown_format": False,
},
scope={"messages": "all", "raw_packets": "none"},
enabled=True,
)
manager = FanoutManager()
try:
await manager.load_from_db()
assert cfg["id"] in manager._modules
await manager.broadcast_message(
{
"type": "PRIV",
"conversation_key": "pk1",
"text": "hello",
"sender_name": "S_Borkin",
}
)
results = await apprise_capture_server.wait_for(1)
finally:
await manager.stop_all()
assert len(results) >= 1
body_text = str(results[0])
assert "S_Borkin" in body_text
assert "hello" in body_text
assert "**" not in body_text
# ---------------------------------------------------------------------------
# Bot lifecycle tests