2026-06-02 21:11:57 +08:00
|
|
|
"""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 = ""
|
2026-06-03 20:47:46 +08:00
|
|
|
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
|
2026-06-02 21:11:57 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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:
|
2026-06-03 20:47:46 +08:00
|
|
|
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>'
|
2026-06-02 21:11:57 +08:00
|
|
|
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,
|
|
|
|
|
}
|
2026-06-03 20:47:46 +08:00
|
|
|
if msg.full_content:
|
|
|
|
|
payload["images"] = msg.images
|
|
|
|
|
payload["videos"] = msg.videos
|
|
|
|
|
payload["full_html"] = msg.full_html
|
2026-06-02 21:11:57 +08:00
|
|
|
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("&", "&").replace("<", "<").replace(">", ">")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-06-03 20:47:46 +08:00
|
|
|
# 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})
|
|
|
|
|
|
2026-06-02 21:47:12 +08:00
|
|
|
# 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
|
|
|
|
|
|
2026-06-02 21:11:57 +08:00
|
|
|
# --- ntfy (default channel; requires a topic) ---
|
|
|
|
|
if feed.ntfy_topic.strip():
|
|
|
|
|
try:
|
2026-06-03 20:47:46 +08:00
|
|
|
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"
|
|
|
|
|
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,
|
|
|
|
|
)
|
2026-06-02 21:11:57 +08:00
|
|
|
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,
|
2026-06-02 21:47:12 +08:00
|
|
|
token=settings.default_ntfy_token,
|
|
|
|
|
username=settings.default_ntfy_username,
|
|
|
|
|
password=settings.default_ntfy_password,
|
2026-06-02 21:11:57 +08:00
|
|
|
)
|
|
|
|
|
except Exception as exc: # noqa: BLE001
|
|
|
|
|
log.warning("admin alert failed: %s", exc)
|