"""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"{_esc(msg.title)}" if msg.source: text = f"📡 {_esc(msg.source)}\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🖼️ image' for vid_url in msg.videos[:3]: text += f'\n🎬 video' if msg.link: text += f'\n\nОткрыть →' 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("&", "&").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 # 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)