Files
rss-ntfy/app/delivery.py
T
dimon 834092a3ec
build-and-push / docker (push) Has been cancelled
8 major features: trafilatura, digest, ntfy actions, templates, FTS5 search, backup/restore, proxy, RSS reader
- Full article extraction via trafilatura (fetch_full_article)
- Digest mode with configurable period (digest_enabled, digest_period_hours)
- ntfy Actions buttons (Open article, Open feed)
- Notification templates with {title}, {body}, {link}, {source}, {image_url}
- FTS5 full-text search in notification history
- Database backup/restore (download/upload .db)
- HTTP/SOCKS proxy for RSS feed fetching (proxy_url setting)
- Built-in RSS reader tab with categories, unread counts, article detail view
- Auto-category 'Общее' for feeds without a category
- Article storage (Article table) for reader
- DigestEntry model for pending digest entries

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 20:47:46 +08:00

221 lines
7.9 KiB
Python

"""Alternative delivery channels and a unified dispatcher.
A single feed entry can fan out to several channels: ntfy (always, if a topic
is set), Telegram, and a generic webhook. Each channel is independent — one
failing does not block the others. dispatch() returns which channels succeeded
and an error string describing any failures (for the history log).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
import httpx
from . import ntfy
from .models import Feed, Settings
log = logging.getLogger("delivery")
@dataclass
class Message:
source: str # feed title
title: str # entry title
body: str # plain-text summary
link: str = ""
image: str = "" # first image URL, for the Attach header
images: list[str] = field(default_factory=list) # all images (full_content mode)
full_html: str = "" # raw HTML body (full_content mode)
videos: list[str] = field(default_factory=list) # video URLs (full_content mode)
full_content: bool = False
@dataclass
class DispatchResult:
channels: list[str] = field(default_factory=list) # succeeded channels
errors: list[str] = field(default_factory=list)
@property
def ok(self) -> bool:
return not self.errors and bool(self.channels)
@property
def detail(self) -> str:
return "; ".join(self.errors)
async def _send_telegram(settings: Settings, msg: Message) -> None:
token = settings.telegram_token.strip()
chat_id = settings.telegram_chat_id.strip()
if not token or not chat_id:
raise ValueError("Telegram не настроен (токен/chat_id)")
text = f"<b>{_esc(msg.title)}</b>"
if msg.source:
text = f"📡 <i>{_esc(msg.source)}</i>\n{text}"
if msg.body:
limit = 3500 if msg.full_content else 600
text += f"\n\n{_esc(msg.body[:limit])}"
if msg.full_content:
for img_url in msg.images[:5]:
text += f'\n<a href="{_esc(img_url)}">🖼️ image</a>'
for vid_url in msg.videos[:3]:
text += f'\n<a href="{_esc(vid_url)}">🎬 video</a>'
if msg.link:
text += f'\n\n<a href="{_esc(msg.link)}">Открыть →</a>'
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(
f"https://api.telegram.org/bot{token}/sendMessage",
json={
"chat_id": chat_id,
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": False,
},
)
resp.raise_for_status()
async def _send_webhook(settings: Settings, feed: Feed, msg: Message) -> None:
url = settings.webhook_url.strip()
if not url:
raise ValueError("Webhook URL не задан")
payload = {
"feed": msg.source,
"feed_url": feed.url,
"title": msg.title,
"body": msg.body,
"link": msg.link,
"image": msg.image,
}
if msg.full_content:
payload["images"] = msg.images
payload["videos"] = msg.videos
payload["full_html"] = msg.full_html
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(url, json=payload)
resp.raise_for_status()
def _esc(text: str) -> str:
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
async def dispatch(feed: Feed, settings: Settings, msg: Message) -> DispatchResult:
"""Send a message across every channel enabled for this feed."""
result = DispatchResult()
server = feed.ntfy_server.strip() or settings.default_ntfy_server
full_title = f"{msg.source}: {msg.title}" if msg.source else msg.title
# Apply notification template to body
template = settings.notification_template or "{title}\n\n{body}\n\n{link}"
try:
formatted_body = template.replace("\\n", "\n").format(
title=msg.title,
body=msg.body,
link=msg.link,
source=msg.source,
image_url=msg.image,
)
except (KeyError, ValueError):
formatted_body = msg.body # fallback on template error
# Build default actions for ntfy
ntfy_actions = []
if msg.link:
ntfy_actions.append({"action": "view", "label": "Открыть статью", "url": msg.link})
if feed.url:
ntfy_actions.append({"action": "view", "label": "Открыть ленту", "url": feed.url})
# Per-feed auth wins; otherwise fall back to the default-server credentials.
has_feed_auth = bool(feed.ntfy_token.strip() or feed.ntfy_username.strip())
token = feed.ntfy_token if has_feed_auth else settings.default_ntfy_token
username = feed.ntfy_username if has_feed_auth else settings.default_ntfy_username
password = feed.ntfy_password if has_feed_auth else settings.default_ntfy_password
# --- ntfy (default channel; requires a topic) ---
if feed.ntfy_topic.strip():
try:
if msg.full_content:
# Build markdown body with all images inlined + template applied.
md_body = formatted_body
if msg.images:
for img_url in msg.images:
md_body += f"\n\n![image]({img_url})"
if msg.videos:
for vid_url in msg.videos:
md_body += f"\n\n📹 {vid_url}"
await ntfy.publish(
server=server,
topic=feed.ntfy_topic,
title=full_title,
message=md_body or "(нет описания)",
click=msg.link,
tags=feed.tags,
priority=feed.priority,
attach=msg.image if feed.attach_image else "",
token=token,
username=username,
password=password,
markdown=True,
actions=ntfy_actions,
)
else:
await ntfy.publish(
server=server,
topic=feed.ntfy_topic,
title=full_title,
message=formatted_body or "(нет описания)",
click=msg.link,
tags=feed.tags,
priority=feed.priority,
attach=msg.image if feed.attach_image else "",
token=token,
username=username,
password=password,
actions=ntfy_actions,
)
result.channels.append("ntfy")
except Exception as exc: # noqa: BLE001
result.errors.append(f"ntfy: {exc}")
# --- Telegram ---
if feed.to_telegram and settings.telegram_enabled:
try:
await _send_telegram(settings, msg)
result.channels.append("telegram")
except Exception as exc: # noqa: BLE001
result.errors.append(f"telegram: {exc}")
# --- Webhook ---
if feed.to_webhook and settings.webhook_enabled:
try:
await _send_webhook(settings, feed, msg)
result.channels.append("webhook")
except Exception as exc: # noqa: BLE001
result.errors.append(f"webhook: {exc}")
return result
async def send_admin_alert(settings: Settings, text: str) -> None:
"""Best-effort health alert to the admin ntfy topic."""
if not settings.alerts_enabled or not settings.alert_topic.strip():
return
try:
await ntfy.publish(
server=settings.default_ntfy_server,
topic=settings.alert_topic,
title="RSS to ntfy — alert",
message=text,
tags="warning",
priority=4,
token=settings.default_ntfy_token,
username=settings.default_ntfy_username,
password=settings.default_ntfy_password,
)
except Exception as exc: # noqa: BLE001
log.warning("admin alert failed: %s", exc)