mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-01 19:12:57 +02:00
Make Apprise strings customizable. Closes #212.
This commit is contained in:
@@ -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(
|
||||
|
||||
57
app/migrations/_060_apprise_format_strings.py
Normal file
57
app/migrations/_060_apprise_format_strings.py
Normal 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()
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 "direct"
|
||||
</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 />
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user