mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-03 12:03:04 +02:00
Add non-markdown option. Closes #232.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user