diff --git a/tests/test_publishers.py b/tests/test_publishers.py
index 0d199d1e..723d1354 100644
--- a/tests/test_publishers.py
+++ b/tests/test_publishers.py
@@ -4,16 +4,18 @@
import pytest
from weather_briefing.api_client import LoggedAsyncClient
-from weather_briefing.models import RenderedMessage
-from weather_briefing.publishers import (
+from weather_briefing.data import resources as data_resources
+from weather_briefing.data.resources import ReferenceDataError
+from weather_briefing.delivery import (
DeliveryError,
DeliveryProvider,
+ PlainTextRenderer,
StdoutPublisher,
TelegramPublisher,
- _split_message,
)
-from weather_briefing.reference_data import ReferenceDataError
-from weather_briefing.render import PlainTextRenderer
+from weather_briefing.delivery.telegram import split_message
+from weather_briefing.delivery.telegram_reference import telegram_error_classification
+from weather_briefing.models import RenderedMessage
class NoopPublisher:
@@ -70,13 +72,11 @@ def test_delivery_provider_applies_platform_limit_without_leaking_it_into_config
async def test_telegram_publisher_validates_error_metadata_on_construction(monkeypatch) -> None:
- def fail_validation() -> None:
- raise ReferenceDataError("invalid Telegram metadata")
-
- monkeypatch.setattr("weather_briefing.publishers.telegram_error_classification", fail_validation)
+ monkeypatch.setattr(data_resources, "load_reference_data", lambda filename: {})
+ telegram_error_classification.cache_clear()
async with httpx.AsyncClient() as client:
- with pytest.raises(ReferenceDataError, match="invalid Telegram metadata"):
+ with pytest.raises(ReferenceDataError, match="supported fields"):
TelegramPublisher(client, "runtime-token", "runtime-chat")
@@ -93,7 +93,17 @@ def test_delivery_error_rejects_non_boolean_channel_availability(value) -> None:
def test_split_message_prefers_line_boundary() -> None:
- assert _split_message("first line\nsecond line", 12) == ("first line", "\nsecond line")
+ assert split_message("first line\nsecond line", 12) == ("first line", "\nsecond line")
+
+
+def test_split_message_preserves_markup_without_visible_text() -> None:
+ assert split_message("", 3) == ("",)
+
+
+@pytest.mark.parametrize(("body", "limit"), (("", 0), ("body", 0), ("body", -1)))
+def test_split_message_rejects_non_positive_limit(body: str, limit: int) -> None:
+ with pytest.raises(ValueError, match="must be positive"):
+ split_message(body, limit)
@pytest.mark.parametrize(
@@ -122,7 +132,7 @@ def test_split_message_balances_html_tags(
limit: int,
expected: tuple[str, ...],
) -> None:
- assert _split_message(body, limit) == expected
+ assert split_message(body, limit) == expected
async def test_telegram_publisher_uses_runtime_values(caplog) -> None:
diff --git a/tests/test_reference_data.py b/tests/test_reference_data.py
index eaf11b84..d34e9846 100644
--- a/tests/test_reference_data.py
+++ b/tests/test_reference_data.py
@@ -6,6 +6,7 @@
from weather_briefing import __version__
from weather_briefing.air_quality import health_guidance
+from weather_briefing.data import resources as data_resources
from weather_briefing.data.resources import (
ReferenceDataError,
load_reference_data,
@@ -14,10 +15,10 @@
reference_value,
)
from weather_briefing.data.service_endpoints import NOMINATIM_USER_AGENT
+from weather_briefing.delivery.telegram_reference import telegram_error_classification
from weather_briefing.localization import localization_table
from weather_briefing.reference_data import (
open_meteo_weather_code_descriptions,
- telegram_error_classification,
)
@@ -214,7 +215,7 @@ def test_reference_string_rejects_invalid_value(monkeypatch, value) -> None:
),
)
def test_telegram_error_classification_rejects_invalid_data(monkeypatch, value, message) -> None:
- monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value)
+ monkeypatch.setattr(data_resources, "load_reference_data", lambda filename: value)
telegram_error_classification.cache_clear()
with pytest.raises(ReferenceDataError, match=message):
diff --git a/tests/test_render.py b/tests/test_render.py
index afc06a47..49604e28 100644
--- a/tests/test_render.py
+++ b/tests/test_render.py
@@ -1,6 +1,7 @@
import pendulum
import pytest
+from weather_briefing.delivery import PlainTextRenderer, TelegramHTMLRenderer
from weather_briefing.models import (
Advice,
AdviceTopic,
@@ -10,7 +11,6 @@
SourceDocument,
Warning,
)
-from weather_briefing.render import PlainTextRenderer, TelegramHTMLRenderer
def test_render_briefing_uses_safe_telegram_html() -> None:
diff --git a/weather_briefing/delivery/__init__.py b/weather_briefing/delivery/__init__.py
new file mode 100644
index 00000000..b80b510a
--- /dev/null
+++ b/weather_briefing/delivery/__init__.py
@@ -0,0 +1,16 @@
+"""Delivery contracts, renderers, and platform adapters."""
+
+from .base import DeliveryError, DeliveryProvider, RenderedTextDiagnostics
+from .renderers import PlainTextRenderer, TelegramHTMLRenderer
+from .stdout import StdoutPublisher
+from .telegram import TelegramPublisher
+
+__all__ = [
+ "DeliveryError",
+ "DeliveryProvider",
+ "PlainTextRenderer",
+ "RenderedTextDiagnostics",
+ "StdoutPublisher",
+ "TelegramHTMLRenderer",
+ "TelegramPublisher",
+]
diff --git a/weather_briefing/delivery/base.py b/weather_briefing/delivery/base.py
new file mode 100644
index 00000000..bdd44ad4
--- /dev/null
+++ b/weather_briefing/delivery/base.py
@@ -0,0 +1,121 @@
+"""Platform-neutral message delivery composition."""
+
+from __future__ import annotations
+
+import logging
+import re
+from dataclasses import dataclass
+from typing import Protocol
+
+from ..models import Article, BriefingResult, RenderedMessage, SourceDocument
+from .renderers import MessageRenderer
+
+_LOGGER = logging.getLogger("weather_briefing.publishers")
+_SAFE_DELIVERY_REASON = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
+
+
+class Publisher(Protocol):
+ """Transport a rendered message to its destination."""
+
+ async def publish(
+ self,
+ message: RenderedMessage,
+ *,
+ single_message: bool = False,
+ silent: bool = False,
+ ) -> None:
+ """Publish one rendered message with delivery hints."""
+ ...
+
+
+class RenderedTextDiagnostics(Protocol):
+ """Expose the runtime switch for sensitive rendered-text logging."""
+
+ def rendered_text_logging_enabled(self) -> bool:
+ """Return whether sensitive rendered-text logging is enabled."""
+ ...
+
+
+@dataclass(frozen=True, slots=True)
+class DeliveryProvider:
+ """Bind a platform renderer to its message transport."""
+
+ renderer: MessageRenderer
+ publisher: Publisher
+ single_message_limit: int | None = None
+ diagnostics: RenderedTextDiagnostics | None = None
+
+ def briefing_limit(self, configured_limit: int) -> int:
+ """Clamp a configured briefing limit to the platform limit."""
+ if self.single_message_limit is None:
+ return configured_limit
+ return min(configured_limit, self.single_message_limit)
+
+ def render_briefing(
+ self,
+ result: BriefingResult,
+ reference_articles: tuple[Article, ...],
+ context: tuple[SourceDocument, ...],
+ ) -> RenderedMessage:
+ """Render a briefing with the configured platform renderer."""
+ return self.renderer.render_briefing(result, reference_articles, context)
+
+ async def publish_rendered(
+ self,
+ message: RenderedMessage,
+ *,
+ single_message: bool = False,
+ silent: bool = False,
+ ) -> None:
+ """Publish an already rendered message with delivery hints."""
+ log_rendered_text(self.diagnostics, "briefing", message.body)
+ await self.publisher.publish(message, single_message=single_message, silent=silent)
+
+ async def publish_verbatim(self, article: Article, *, silent: bool = False) -> None:
+ """Render and publish one cleaned article without summarization."""
+ message = self.renderer.render_verbatim(article)
+ _LOGGER.debug(
+ "Rendered verbatim message: visible_characters=%d payload_characters=%d",
+ message.visible_length,
+ len(message.body),
+ )
+ log_rendered_text(self.diagnostics, "verbatim", message.body)
+ await self.publisher.publish(message, silent=silent)
+
+ async def publish_alert(self, title: str, body: str) -> None:
+ """Render and publish an operational alert."""
+ message = self.renderer.render_alert(title, body)
+ log_rendered_text(self.diagnostics, "alert", message.body)
+ await self.publisher.publish(message)
+
+
+class DeliveryError(RuntimeError):
+ """Raised without exposing private delivery endpoint details."""
+
+ def __init__(self, message: str, *, reason: str, channel_unavailable: bool = False) -> None:
+ """Describe a delivery failure using a safe structured reason."""
+ if not isinstance(reason, str) or _SAFE_DELIVERY_REASON.fullmatch(reason) is None:
+ raise ValueError("Delivery error reason must be a lowercase kebab-case label")
+ if not isinstance(channel_unavailable, bool):
+ raise TypeError("channel_unavailable must be a bool")
+ super().__init__(message)
+ self.reason = reason
+ self.channel_unavailable = channel_unavailable
+
+
+def log_rendered_text(diagnostics: RenderedTextDiagnostics | None, stage: str, body: str) -> None:
+ """Log application-owned rendered text when its temporary switch is enabled."""
+ if rendered_text_logging_enabled(diagnostics):
+ _LOGGER.debug("Sensitive rendered text diagnostic: stage=%s body=%r", stage, body)
+
+
+def rendered_text_logging_enabled(diagnostics: RenderedTextDiagnostics | None) -> bool:
+ """Read the diagnostic switch without affecting delivery on failure."""
+ if diagnostics is None:
+ return False
+ try:
+ enabled = diagnostics.rendered_text_logging_enabled()
+ except Exception:
+ _LOGGER.warning("Rendered text diagnostic state check failed", exc_info=True)
+ return False
+ return enabled and _LOGGER.isEnabledFor(logging.DEBUG)
diff --git a/weather_briefing/delivery/renderers.py b/weather_briefing/delivery/renderers.py
new file mode 100644
index 00000000..b9066b66
--- /dev/null
+++ b/weather_briefing/delivery/renderers.py
@@ -0,0 +1,219 @@
+"""Platform-specific rendering of validated briefing results."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from html import escape, unescape
+from typing import Protocol
+
+from bs4 import BeautifulSoup
+
+from ..languages import LanguageSupport
+from ..localization import localization_table
+from ..models import (
+ Advice,
+ Article,
+ BriefingResult,
+ Conclusion,
+ RenderedMessage,
+ SourceDocument,
+)
+
+_BRIEFING_LABELS = localization_table("briefing")
+_BRIEFING_LANGUAGE_SUPPORT = LanguageSupport(
+ default="en",
+ supported=tuple(_BRIEFING_LABELS),
+)
+
+
+class MessageRenderer(Protocol):
+ """Render platform-neutral briefing data for one delivery platform."""
+
+ def render_briefing(
+ self,
+ result: BriefingResult,
+ reference_articles: tuple[Article, ...],
+ context: tuple[SourceDocument, ...],
+ ) -> RenderedMessage:
+ """Render a validated briefing and its citable references."""
+ ...
+
+ def render_verbatim(self, article: Article) -> RenderedMessage:
+ """Render an article without summarizing its cleaned content."""
+ ...
+
+ def render_alert(self, title: str, body: str) -> RenderedMessage:
+ """Render an operational alert."""
+ ...
+
+
+class TelegramHTMLRenderer:
+ """Render briefings as Telegram-compatible HTML."""
+
+ def render_briefing(
+ self,
+ result: BriefingResult,
+ reference_articles: tuple[Article, ...],
+ context: tuple[SourceDocument, ...],
+ ) -> RenderedMessage:
+ """Render a sourced briefing as Telegram HTML."""
+ labels = _briefing_labels(result.output_language)
+ source_links = {
+ article.id: _html_link(article.url, _article_source_name(article)) for article in reference_articles
+ }
+ source_links.update({document.id: _html_link(document.url, document.name) for document in context})
+ lines = [
+ f"{_html_text(result.headline)} "
+ f"{_html_attribution(result.headline_source_ids, source_links, labels)}",
+ "",
+ ]
+ lines.extend(_html_items(labels["weather"], result.conclusions, source_links, labels))
+ if result.active_warnings:
+ lines.extend([f"{labels['warnings']}", ""])
+ lines.extend(
+ (
+ f"• {_html_text(warning.title)}{labels['status_open']}"
+ f"{_html_text(warning.status)}{labels['status_close']}"
+ f"{labels['detail_separator']}{_html_text(warning.detail)} "
+ f"{_html_attribution(warning.source_ids, source_links, labels)}"
+ )
+ for warning in result.active_warnings
+ )
+ lines.append("")
+ lines.extend(_html_items(labels["disasters"], result.disaster_tracking, source_links, labels))
+ lines.extend(_html_items(labels["advice"], result.advice, source_links, labels))
+ return _html_message("\n".join(lines).strip())
+
+ def render_verbatim(self, article: Article) -> RenderedMessage:
+ """Render cleaned article content as Telegram HTML."""
+ return _html_message(
+ "\n".join(
+ (
+ f"{_html_text(article.title)}",
+ "",
+ _html_text(article.content),
+ )
+ )
+ )
+
+ def render_alert(self, title: str, body: str) -> RenderedMessage:
+ """Render an escaped Telegram HTML alert."""
+ return _html_message(f"{_html_text(title)}\n\n{_html_text(body)}")
+
+
+class PlainTextRenderer:
+ """Render briefings for stdout and other plain-text transports."""
+
+ def render_briefing(
+ self,
+ result: BriefingResult,
+ reference_articles: tuple[Article, ...],
+ context: tuple[SourceDocument, ...],
+ ) -> RenderedMessage:
+ """Render a sourced briefing as plain text."""
+ labels = _briefing_labels(result.output_language)
+ source_references = {
+ article.id: f"{_article_source_name(article)}: {article.url}" for article in reference_articles
+ }
+ source_references.update({document.id: f"{document.name}: {document.url}" for document in context})
+ lines = [
+ f"{result.headline} {_plain_attribution(result.headline_source_ids, source_references, labels)}",
+ "",
+ ]
+ lines.extend(_plain_items(labels["weather"], result.conclusions, source_references, labels))
+ if result.active_warnings:
+ lines.extend([labels["warnings"], ""])
+ for warning in result.active_warnings:
+ sources = _plain_attribution(warning.source_ids, source_references, labels)
+ lines.append(
+ f"- {warning.title}{labels['status_open']}{warning.status}{labels['status_close']}"
+ f"{labels['detail_separator']}{warning.detail} {sources}"
+ )
+ lines.append("")
+ lines.extend(_plain_items(labels["disasters"], result.disaster_tracking, source_references, labels))
+ lines.extend(_plain_items(labels["advice"], result.advice, source_references, labels))
+ return _plain_message("\n".join(lines).strip())
+
+ def render_verbatim(self, article: Article) -> RenderedMessage:
+ """Render cleaned article content as plain text."""
+ return _plain_message(f"{article.title}\n\n{article.content}")
+
+ def render_alert(self, title: str, body: str) -> RenderedMessage:
+ """Render an operational alert as plain text."""
+ return _plain_message(f"{title}\n\n{body}")
+
+
+def _html_text(value: str) -> str:
+ return escape(unescape(value), quote=False)
+
+
+def _article_source_name(article: Article) -> str:
+ return article.source_name.strip() or article.source_id
+
+
+def _html_link(url: str, label: str) -> str:
+ return f'{_html_text(label)}'
+
+
+def _html_items(
+ title: str,
+ items: tuple[Conclusion | Advice, ...],
+ source_links: dict[str, str],
+ labels: Mapping[str, str],
+) -> list[str]:
+ if not items:
+ return []
+ lines = [f"{_html_text(title)}", ""]
+ lines.extend(
+ f"• {_html_text(item.text)} {_html_attribution(item.source_ids, source_links, labels)}" for item in items
+ )
+ lines.append("")
+ return lines
+
+
+def _plain_items(
+ title: str,
+ items: tuple[Conclusion | Advice, ...],
+ source_references: dict[str, str],
+ labels: Mapping[str, str],
+) -> list[str]:
+ if not items:
+ return []
+ lines = [title, ""]
+ lines.extend(f"- {item.text} {_plain_attribution(item.source_ids, source_references, labels)}" for item in items)
+ lines.append("")
+ return lines
+
+
+def _html_attribution(
+ source_ids: tuple[str, ...],
+ source_links: dict[str, str],
+ labels: Mapping[str, str],
+) -> str:
+ sources = labels["html_source_separator"].join(dict.fromkeys(source_links[source_id] for source_id in source_ids))
+ return labels["attribution"].format(sources=sources)
+
+
+def _plain_attribution(
+ source_ids: tuple[str, ...],
+ source_references: dict[str, str],
+ labels: Mapping[str, str],
+) -> str:
+ sources = labels["plain_source_separator"].join(
+ dict.fromkeys(source_references[source_id] for source_id in source_ids)
+ )
+ return labels["attribution"].format(sources=sources)
+
+
+def _briefing_labels(language: str) -> Mapping[str, str]:
+ selected = _BRIEFING_LANGUAGE_SUPPORT.match(language)
+ return _BRIEFING_LABELS[selected]
+
+
+def _html_message(body: str) -> RenderedMessage:
+ visible = BeautifulSoup(body, "html.parser").get_text()
+ return RenderedMessage(body=body, visible_length=len(visible))
+
+
+def _plain_message(body: str) -> RenderedMessage:
+ return RenderedMessage(body=body, visible_length=len(body))
diff --git a/weather_briefing/delivery/stdout.py b/weather_briefing/delivery/stdout.py
new file mode 100644
index 00000000..dc9f6c72
--- /dev/null
+++ b/weather_briefing/delivery/stdout.py
@@ -0,0 +1,19 @@
+"""Standard-output delivery adapter."""
+
+from __future__ import annotations
+
+from ..models import RenderedMessage
+
+
+class StdoutPublisher:
+ """Write rendered messages to standard output."""
+
+ async def publish(
+ self,
+ message: RenderedMessage,
+ *,
+ single_message: bool = False,
+ silent: bool = False,
+ ) -> None:
+ """Print the rendered body and ignore platform delivery hints."""
+ print(message.body)
diff --git a/weather_briefing/delivery/telegram.py b/weather_briefing/delivery/telegram.py
new file mode 100644
index 00000000..583689eb
--- /dev/null
+++ b/weather_briefing/delivery/telegram.py
@@ -0,0 +1,229 @@
+"""Telegram Bot API delivery adapter."""
+
+from __future__ import annotations
+
+import logging
+from html.parser import HTMLParser
+
+import httpx
+
+from ..api_client import api_call_extensions
+from ..models import RenderedMessage
+from . import telegram_reference
+from .base import DeliveryError, RenderedTextDiagnostics, rendered_text_logging_enabled
+
+_LOGGER = logging.getLogger("weather_briefing.publishers")
+
+
+class TelegramPublisher:
+ """Publish rendered HTML messages through the Telegram Bot API."""
+
+ MAX_MESSAGE_LENGTH = 4096
+
+ def __init__(
+ self,
+ client: httpx.AsyncClient,
+ token: str,
+ chat_id: str,
+ diagnostics: RenderedTextDiagnostics | None = None,
+ ) -> None:
+ """Configure Telegram delivery and optional sensitive-text diagnostics."""
+ telegram_reference.telegram_error_classification()
+ self._client = client
+ self._url = f"https://api.telegram.org/bot{token}/sendMessage"
+ self._chat_id = chat_id
+ self._diagnostics = diagnostics
+
+ async def publish(
+ self,
+ message: RenderedMessage,
+ *,
+ single_message: bool = False,
+ silent: bool = False,
+ ) -> None:
+ """Publish one message, splitting it only when allowed."""
+ if single_message and message.visible_length > self.MAX_MESSAGE_LENGTH:
+ raise DeliveryError(
+ "Telegram single message exceeds the platform limit",
+ reason="message-too-long",
+ )
+ chunks = (message.body,) if single_message else split_message(message.body, self.MAX_MESSAGE_LENGTH)
+ _LOGGER.info(
+ "Telegram delivery prepared: visible_characters=%d payload_characters=%d chunks=%d "
+ "single_message=%s silent=%s",
+ message.visible_length,
+ len(message.body),
+ len(chunks),
+ single_message,
+ silent,
+ )
+ log_rendered_text = rendered_text_logging_enabled(self._diagnostics)
+ for index, chunk in enumerate(chunks, start=1):
+ if log_rendered_text:
+ _LOGGER.debug(
+ "Sensitive rendered text diagnostic: stage=telegram-chunk-%d-of-%d body=%r",
+ index,
+ len(chunks),
+ chunk,
+ )
+ try:
+ response = await self._client.post(
+ self._url,
+ json={
+ "chat_id": self._chat_id,
+ "text": chunk,
+ "parse_mode": "HTML",
+ "link_preview_options": {"is_disabled": True},
+ "disable_notification": silent,
+ },
+ extensions=api_call_extensions(
+ "telegram",
+ "send-message",
+ response_error_handled=True,
+ ),
+ )
+ response.raise_for_status()
+ except httpx.HTTPStatusError as exc:
+ reason, channel_unavailable = telegram_error_reason(exc.response)
+ _LOGGER.warning(
+ "Telegram delivery rejected index=%d/%d message_visible_characters=%d payload_characters=%d "
+ "status_code=%d reason=%s",
+ index,
+ len(chunks),
+ message.visible_length,
+ len(chunk),
+ exc.response.status_code,
+ reason,
+ )
+ raise DeliveryError(
+ f"Telegram delivery failed ({reason})",
+ reason=reason,
+ channel_unavailable=channel_unavailable,
+ ) from None
+ except httpx.RequestError as exc:
+ _LOGGER.info(
+ "Telegram delivery request failed index=%d/%d message_visible_characters=%d payload_characters=%d "
+ "reason=%s",
+ index,
+ len(chunks),
+ message.visible_length,
+ len(chunk),
+ type(exc).__name__,
+ )
+ raise DeliveryError(
+ "Telegram delivery failed (request-error)",
+ reason="request-error",
+ ) from None
+ _LOGGER.debug(
+ "Telegram chunk accepted: index=%d/%d payload_characters=%d",
+ index,
+ len(chunks),
+ len(chunk),
+ )
+
+
+def telegram_error_reason(response: httpx.Response) -> tuple[str, bool]:
+ """Classify a Telegram API error without logging its response body."""
+ classification = telegram_reference.telegram_error_classification()
+ try:
+ payload = response.json()
+ except ValueError:
+ payload = None
+
+ if isinstance(payload, dict):
+ parameters = payload.get("parameters")
+ if isinstance(parameters, dict) and type(parameters.get("migrate_to_chat_id")) is int:
+ reason = classification.parameter_reasons["migrate_to_chat_id"]
+ return reason, reason in classification.channel_unavailable_reasons
+
+ description = payload.get("description")
+ if isinstance(description, str):
+ normalized = description.casefold()
+ for marker, reason in classification.description_markers:
+ if marker in normalized:
+ return reason, reason in classification.channel_unavailable_reasons
+
+ reason = classification.status_reasons.get(response.status_code, "api-error")
+ return reason, reason in classification.channel_unavailable_reasons
+
+
+def split_message(body: str, limit: int) -> tuple[str, ...]:
+ """Split Telegram HTML into independently valid chunks."""
+ if limit <= 0:
+ raise ValueError("Message split limit must be positive")
+ if len(body) <= limit:
+ return (body,)
+ chunker = _TelegramHTMLChunker(limit)
+ chunker.feed(body)
+ chunker.close()
+ return chunker.finish() or (body,)
+
+
+class _TelegramHTMLChunker(HTMLParser):
+ """Split Telegram HTML while making every chunk independently valid."""
+
+ def __init__(self, limit: int) -> None:
+ super().__init__(convert_charrefs=False)
+ self._limit = limit
+ self._chunks: list[str] = []
+ self._parts: list[str] = []
+ self._open_tags: list[tuple[str, str]] = []
+ self._visible_length = 0
+
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+ if self._visible_length == self._limit:
+ self._finish_chunk()
+ start_tag = self.get_starttag_text()
+ assert start_tag is not None
+ self._parts.append(start_tag)
+ self._open_tags.append((tag, start_tag))
+
+ def handle_endtag(self, tag: str) -> None:
+ self._parts.append(f"{tag}>")
+ self._open_tags.pop()
+
+ def handle_data(self, data: str) -> None:
+ while True:
+ available = self._limit - self._visible_length
+ if available == 0:
+ self._finish_chunk()
+ available = self._limit
+ if len(data) <= available:
+ self._parts.append(data)
+ self._visible_length += len(data)
+ return
+ split_at = data.rfind("\n", 0, available + 1)
+ if split_at > 0:
+ self._parts.append(data[:split_at])
+ self._visible_length += split_at
+ data = data[split_at:]
+ else:
+ self._parts.append(data[:available])
+ self._visible_length += available
+ data = data[available:]
+ self._finish_chunk()
+
+ def handle_entityref(self, name: str) -> None:
+ self._append_entity(f"&{name};")
+
+ def handle_charref(self, name: str) -> None:
+ self._append_entity(f"{name};")
+
+ def finish(self) -> tuple[str, ...]:
+ """Flush and return all accumulated chunks."""
+ self._finish_chunk()
+ return tuple(self._chunks)
+
+ def _append_entity(self, value: str) -> None:
+ if self._visible_length == self._limit:
+ self._finish_chunk()
+ self._parts.append(value)
+ self._visible_length += 1
+
+ def _finish_chunk(self) -> None:
+ if self._visible_length == 0:
+ return
+ closing_tags = (f"{tag}>" for tag, _ in reversed(self._open_tags))
+ self._chunks.append("".join((*self._parts, *closing_tags)))
+ self._parts = [start_tag for _, start_tag in self._open_tags]
+ self._visible_length = 0
diff --git a/weather_briefing/delivery/telegram_reference.py b/weather_briefing/delivery/telegram_reference.py
new file mode 100644
index 00000000..f0b15b74
--- /dev/null
+++ b/weather_briefing/delivery/telegram_reference.py
@@ -0,0 +1,99 @@
+"""Validated Telegram delivery classification metadata."""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Mapping
+from dataclasses import dataclass
+from functools import cache
+from types import MappingProxyType
+
+from ..data import resources
+from ..data.resources import ReferenceDataError
+
+_CLASSIFICATION_REASON = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
+
+
+@dataclass(frozen=True, slots=True)
+class TelegramErrorClassification:
+ """Validated Telegram API error mappings and delivery metadata."""
+
+ description_markers: tuple[tuple[str, str], ...]
+ parameter_reasons: Mapping[str, str]
+ status_reasons: Mapping[int, str]
+ channel_unavailable_reasons: frozenset[str]
+
+
+@cache
+def telegram_error_classification() -> TelegramErrorClassification:
+ """Return validated Telegram API error classification data."""
+ value = resources.load_reference_data("telegram_error_classification.json")
+ markers = value.get("description_markers")
+ parameters = value.get("parameter_reasons")
+ statuses = value.get("status_reasons")
+ unavailable_reasons = value.get("channel_unavailable_reasons")
+ if set(value) != {
+ "channel_unavailable_reasons",
+ "description_markers",
+ "parameter_reasons",
+ "status_reasons",
+ }:
+ raise ReferenceDataError("Telegram error classification must contain the supported fields")
+ if not isinstance(markers, dict) or not markers:
+ raise ReferenceDataError("Telegram description markers must map normalized strings to reasons")
+ validated_markers: list[tuple[str, str]] = []
+ for marker, reason in markers.items():
+ if (
+ not isinstance(marker, str)
+ or not marker.strip()
+ or marker != marker.casefold()
+ or not isinstance(reason, str)
+ or _CLASSIFICATION_REASON.fullmatch(reason) is None
+ ):
+ raise ReferenceDataError("Telegram description markers must map normalized strings to reasons")
+ validated_markers.append((marker, reason))
+
+ if not isinstance(parameters, dict) or set(parameters) != {"migrate_to_chat_id"}:
+ raise ReferenceDataError("Telegram parameters must map supported API fields to reasons")
+ migration_reason = parameters.get("migrate_to_chat_id")
+ if not isinstance(migration_reason, str) or _CLASSIFICATION_REASON.fullmatch(migration_reason) is None:
+ raise ReferenceDataError("Telegram parameters must map supported API fields to reasons")
+
+ if not isinstance(statuses, dict) or not statuses:
+ raise ReferenceDataError("Telegram statuses must map HTTP error codes to reasons")
+ validated_statuses: dict[int, str] = {}
+ for status, reason in statuses.items():
+ if (
+ not isinstance(status, str)
+ or not status.isascii()
+ or not status.isdigit()
+ or not 400 <= int(status) <= 599
+ or not isinstance(reason, str)
+ or _CLASSIFICATION_REASON.fullmatch(reason) is None
+ ):
+ raise ReferenceDataError("Telegram statuses must map HTTP error codes to reasons")
+ validated_statuses[int(status)] = reason
+
+ known_reasons = {
+ *(reason for _, reason in validated_markers),
+ migration_reason,
+ *validated_statuses.values(),
+ }
+ if not isinstance(unavailable_reasons, list) or not unavailable_reasons:
+ raise ReferenceDataError("Telegram channel availability must reference known reasons")
+ validated_unavailable_reasons: list[str] = []
+ for reason in unavailable_reasons:
+ if not isinstance(reason, str) or _CLASSIFICATION_REASON.fullmatch(reason) is None:
+ raise ReferenceDataError("Telegram channel availability must reference known reasons")
+ validated_unavailable_reasons.append(reason)
+ if len(set(validated_unavailable_reasons)) != len(validated_unavailable_reasons) or not set(
+ validated_unavailable_reasons
+ ).issubset(known_reasons):
+ raise ReferenceDataError("Telegram channel availability must reference known reasons")
+
+ return TelegramErrorClassification(
+ description_markers=tuple(validated_markers),
+ parameter_reasons=MappingProxyType({"migrate_to_chat_id": migration_reason}),
+ status_reasons=MappingProxyType(validated_statuses),
+ channel_unavailable_reasons=frozenset(validated_unavailable_reasons),
+ )
diff --git a/weather_briefing/publishers.py b/weather_briefing/publishers.py
index ce0d5c1a..e4712a39 100644
--- a/weather_briefing/publishers.py
+++ b/weather_briefing/publishers.py
@@ -1,352 +1,19 @@
-"""Delivery composition and message publisher adapters."""
-
-from __future__ import annotations
-
-import logging
-import re
-from dataclasses import dataclass
-from html.parser import HTMLParser
-from typing import Protocol
-
-import httpx
-
-from .api_client import api_call_extensions
-from .models import Article, BriefingResult, RenderedMessage, SourceDocument
-from .reference_data import telegram_error_classification
-from .render import MessageRenderer
-
-_LOGGER = logging.getLogger("weather_briefing.publishers")
-_SAFE_DELIVERY_REASON = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
-
-
-class Publisher(Protocol):
- """Transport a rendered message to its destination."""
-
- async def publish(
- self,
- message: RenderedMessage,
- *,
- single_message: bool = False,
- silent: bool = False,
- ) -> None:
- """Publish one rendered message with delivery hints."""
- ...
-
-
-class RenderedTextDiagnostics(Protocol):
- """Expose the runtime switch for sensitive rendered-text logging."""
-
- def rendered_text_logging_enabled(self) -> bool:
- """Return whether sensitive rendered-text logging is enabled."""
- ...
-
-
-@dataclass(frozen=True, slots=True)
-class DeliveryProvider:
- """Bind a platform renderer to its message transport."""
-
- renderer: MessageRenderer
- publisher: Publisher
- single_message_limit: int | None = None
- diagnostics: RenderedTextDiagnostics | None = None
-
- def briefing_limit(self, configured_limit: int) -> int:
- """Clamp a configured briefing limit to the platform limit."""
- if self.single_message_limit is None:
- return configured_limit
- return min(configured_limit, self.single_message_limit)
-
- def render_briefing(
- self,
- result: BriefingResult,
- reference_articles: tuple[Article, ...],
- context: tuple[SourceDocument, ...],
- ) -> RenderedMessage:
- """Render a briefing with the configured platform renderer."""
- return self.renderer.render_briefing(result, reference_articles, context)
-
- async def publish_rendered(
- self,
- message: RenderedMessage,
- *,
- single_message: bool = False,
- silent: bool = False,
- ) -> None:
- """Publish an already rendered message with delivery hints."""
- _log_rendered_text(self.diagnostics, "briefing", message.body)
- await self.publisher.publish(message, single_message=single_message, silent=silent)
-
- async def publish_verbatim(self, article: Article, *, silent: bool = False) -> None:
- """Render and publish one cleaned article without summarization."""
- message = self.renderer.render_verbatim(article)
- _LOGGER.debug(
- "Rendered verbatim message: visible_characters=%d payload_characters=%d",
- message.visible_length,
- len(message.body),
- )
- _log_rendered_text(self.diagnostics, "verbatim", message.body)
- await self.publisher.publish(message, silent=silent)
-
- async def publish_alert(self, title: str, body: str) -> None:
- """Render and publish an operational alert."""
- message = self.renderer.render_alert(title, body)
- _log_rendered_text(self.diagnostics, "alert", message.body)
- await self.publisher.publish(message)
-
-
-class StdoutPublisher:
- """Write rendered messages to standard output."""
-
- async def publish(
- self,
- message: RenderedMessage,
- *,
- single_message: bool = False,
- silent: bool = False,
- ) -> None:
- """Print the rendered body and ignore platform delivery hints."""
- print(message.body)
-
-
-class DeliveryError(RuntimeError):
- """Raised without exposing private delivery endpoint details."""
-
- def __init__(self, message: str, *, reason: str, channel_unavailable: bool = False) -> None:
- """Describe a delivery failure using a safe structured reason."""
- if not isinstance(reason, str) or _SAFE_DELIVERY_REASON.fullmatch(reason) is None:
- raise ValueError("Delivery error reason must be a lowercase kebab-case label")
- if not isinstance(channel_unavailable, bool):
- raise TypeError("channel_unavailable must be a bool")
- super().__init__(message)
- self.reason = reason
- self.channel_unavailable = channel_unavailable
-
-
-class TelegramPublisher:
- """Publish rendered HTML messages through the Telegram Bot API."""
-
- MAX_MESSAGE_LENGTH = 4096
-
- def __init__(
- self,
- client: httpx.AsyncClient,
- token: str,
- chat_id: str,
- diagnostics: RenderedTextDiagnostics | None = None,
- ) -> None:
- """Configure Telegram delivery and optional sensitive-text diagnostics."""
- telegram_error_classification()
- self._client = client
- self._url = f"https://api.telegram.org/bot{token}/sendMessage"
- self._chat_id = chat_id
- self._diagnostics = diagnostics
-
- async def publish(
- self,
- message: RenderedMessage,
- *,
- single_message: bool = False,
- silent: bool = False,
- ) -> None:
- """Publish one message, splitting it only when allowed."""
- if single_message and message.visible_length > self.MAX_MESSAGE_LENGTH:
- raise DeliveryError(
- "Telegram single message exceeds the platform limit",
- reason="message-too-long",
- )
- chunks = (message.body,) if single_message else _split_message(message.body, self.MAX_MESSAGE_LENGTH)
- _LOGGER.info(
- "Telegram delivery prepared: visible_characters=%d payload_characters=%d chunks=%d "
- "single_message=%s silent=%s",
- message.visible_length,
- len(message.body),
- len(chunks),
- single_message,
- silent,
- )
- log_rendered_text = _rendered_text_logging_enabled(self._diagnostics)
- for index, chunk in enumerate(chunks, start=1):
- if log_rendered_text:
- _LOGGER.debug(
- "Sensitive rendered text diagnostic: stage=telegram-chunk-%d-of-%d body=%r",
- index,
- len(chunks),
- chunk,
- )
- try:
- response = await self._client.post(
- self._url,
- json={
- "chat_id": self._chat_id,
- "text": chunk,
- "parse_mode": "HTML",
- "link_preview_options": {"is_disabled": True},
- "disable_notification": silent,
- },
- extensions=api_call_extensions(
- "telegram",
- "send-message",
- response_error_handled=True,
- ),
- )
- response.raise_for_status()
- except httpx.HTTPStatusError as exc:
- reason, channel_unavailable = _telegram_error_reason(exc.response)
- _LOGGER.warning(
- "Telegram delivery rejected index=%d/%d message_visible_characters=%d payload_characters=%d "
- "status_code=%d reason=%s",
- index,
- len(chunks),
- message.visible_length,
- len(chunk),
- exc.response.status_code,
- reason,
- )
- raise DeliveryError(
- f"Telegram delivery failed ({reason})",
- reason=reason,
- channel_unavailable=channel_unavailable,
- ) from None
- except httpx.RequestError as exc:
- _LOGGER.info(
- "Telegram delivery request failed index=%d/%d message_visible_characters=%d payload_characters=%d "
- "reason=%s",
- index,
- len(chunks),
- message.visible_length,
- len(chunk),
- type(exc).__name__,
- )
- raise DeliveryError(
- "Telegram delivery failed (request-error)",
- reason="request-error",
- ) from None
- _LOGGER.debug(
- "Telegram chunk accepted: index=%d/%d payload_characters=%d",
- index,
- len(chunks),
- len(chunk),
- )
-
-
-def _log_rendered_text(
- diagnostics: RenderedTextDiagnostics | None,
- stage: str,
- body: str,
-) -> None:
- if _rendered_text_logging_enabled(diagnostics):
- _LOGGER.debug("Sensitive rendered text diagnostic: stage=%s body=%r", stage, body)
-
-
-def _rendered_text_logging_enabled(diagnostics: RenderedTextDiagnostics | None) -> bool:
- if diagnostics is None:
- return False
- try:
- enabled = diagnostics.rendered_text_logging_enabled()
- except Exception:
- _LOGGER.warning("Rendered text diagnostic state check failed", exc_info=True)
- return False
- return enabled and _LOGGER.isEnabledFor(logging.DEBUG)
-
-
-def _telegram_error_reason(response: httpx.Response) -> tuple[str, bool]:
- """Classify a Telegram API error without logging its response body."""
- classification = telegram_error_classification()
- try:
- payload = response.json()
- except ValueError:
- payload = None
-
- if isinstance(payload, dict):
- parameters = payload.get("parameters")
- if isinstance(parameters, dict) and type(parameters.get("migrate_to_chat_id")) is int:
- reason = classification.parameter_reasons["migrate_to_chat_id"]
- return reason, reason in classification.channel_unavailable_reasons
-
- description = payload.get("description")
- if isinstance(description, str):
- normalized = description.casefold()
- for marker, reason in classification.description_markers:
- if marker in normalized:
- return reason, reason in classification.channel_unavailable_reasons
-
- reason = classification.status_reasons.get(response.status_code, "api-error")
- return reason, reason in classification.channel_unavailable_reasons
-
-
-def _split_message(body: str, limit: int) -> tuple[str, ...]:
- if len(body) <= limit:
- return (body,)
- chunker = _TelegramHTMLChunker(limit)
- chunker.feed(body)
- chunker.close()
- return chunker.finish()
-
-
-class _TelegramHTMLChunker(HTMLParser):
- """Split Telegram HTML while making every chunk independently valid."""
-
- def __init__(self, limit: int) -> None:
- super().__init__(convert_charrefs=False)
- self._limit = limit
- self._chunks: list[str] = []
- self._parts: list[str] = []
- self._open_tags: list[tuple[str, str]] = []
- self._visible_length = 0
-
- def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
- if self._visible_length == self._limit:
- self._finish_chunk()
- start_tag = self.get_starttag_text()
- assert start_tag is not None
- self._parts.append(start_tag)
- self._open_tags.append((tag, start_tag))
-
- def handle_endtag(self, tag: str) -> None:
- self._parts.append(f"{tag}>")
- self._open_tags.pop()
-
- def handle_data(self, data: str) -> None:
- while True:
- available = self._limit - self._visible_length
- if available == 0:
- self._finish_chunk()
- available = self._limit
- if len(data) <= available:
- self._parts.append(data)
- self._visible_length += len(data)
- return
- split_at = data.rfind("\n", 0, available + 1)
- if split_at > 0:
- self._parts.append(data[:split_at])
- self._visible_length += split_at
- data = data[split_at:]
- else:
- self._parts.append(data[:available])
- self._visible_length += available
- data = data[available:]
- self._finish_chunk()
-
- def handle_entityref(self, name: str) -> None:
- self._append_entity(f"&{name};")
-
- def handle_charref(self, name: str) -> None:
- self._append_entity(f"{name};")
-
- def finish(self) -> tuple[str, ...]:
- self._finish_chunk()
- return tuple(self._chunks)
-
- def _append_entity(self, value: str) -> None:
- if self._visible_length == self._limit:
- self._finish_chunk()
- self._parts.append(value)
- self._visible_length += 1
-
- def _finish_chunk(self) -> None:
- if self._visible_length == 0:
- return
- closing_tags = (f"{tag}>" for tag, _ in reversed(self._open_tags))
- self._chunks.append("".join((*self._parts, *closing_tags)))
- self._parts = [start_tag for _, start_tag in self._open_tags]
- self._visible_length = 0
+"""Compatibility exports for delivery providers."""
+
+from .delivery import (
+ DeliveryError,
+ DeliveryProvider,
+ RenderedTextDiagnostics,
+ StdoutPublisher,
+ TelegramPublisher,
+)
+from .delivery.base import Publisher
+
+__all__ = [
+ "DeliveryError",
+ "DeliveryProvider",
+ "Publisher",
+ "RenderedTextDiagnostics",
+ "StdoutPublisher",
+ "TelegramPublisher",
+]
diff --git a/weather_briefing/reference_data.py b/weather_briefing/reference_data.py
index 8c4ff459..bec76daf 100644
--- a/weather_briefing/reference_data.py
+++ b/weather_briefing/reference_data.py
@@ -1,10 +1,8 @@
-"""Compatibility exports for reference data awaiting feature migration."""
+"""Compatibility exports for reference data awaiting weather migration."""
from __future__ import annotations
-import re
from collections.abc import Mapping
-from dataclasses import dataclass
from functools import cache
from types import MappingProxyType
@@ -17,18 +15,6 @@
)
from .localization import localization_table
-_CLASSIFICATION_REASON = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
-
-
-@dataclass(frozen=True, slots=True)
-class TelegramErrorClassification:
- """Validated Telegram API error mappings and delivery metadata."""
-
- description_markers: tuple[tuple[str, str], ...]
- parameter_reasons: Mapping[str, str]
- status_reasons: Mapping[int, str]
- channel_unavailable_reasons: frozenset[str]
-
@cache
def open_meteo_weather_code_descriptions() -> Mapping[int, str]:
@@ -56,89 +42,12 @@ def open_meteo_weather_code_descriptions() -> Mapping[int, str]:
return MappingProxyType(validated)
-@cache
-def telegram_error_classification() -> TelegramErrorClassification:
- """Return validated Telegram API error classification data."""
- value = load_reference_data("telegram_error_classification.json")
- markers = value.get("description_markers")
- parameters = value.get("parameter_reasons")
- statuses = value.get("status_reasons")
- unavailable_reasons = value.get("channel_unavailable_reasons")
- if set(value) != {
- "channel_unavailable_reasons",
- "description_markers",
- "parameter_reasons",
- "status_reasons",
- }:
- raise ReferenceDataError("Telegram error classification must contain the supported fields")
- if not isinstance(markers, dict) or not markers:
- raise ReferenceDataError("Telegram description markers must map normalized strings to reasons")
- validated_markers: list[tuple[str, str]] = []
- for marker, reason in markers.items():
- if (
- not isinstance(marker, str)
- or not marker.strip()
- or marker != marker.casefold()
- or not isinstance(reason, str)
- or _CLASSIFICATION_REASON.fullmatch(reason) is None
- ):
- raise ReferenceDataError("Telegram description markers must map normalized strings to reasons")
- validated_markers.append((marker, reason))
-
- if not isinstance(parameters, dict) or set(parameters) != {"migrate_to_chat_id"}:
- raise ReferenceDataError("Telegram parameters must map supported API fields to reasons")
- migration_reason = parameters.get("migrate_to_chat_id")
- if not isinstance(migration_reason, str) or _CLASSIFICATION_REASON.fullmatch(migration_reason) is None:
- raise ReferenceDataError("Telegram parameters must map supported API fields to reasons")
-
- if not isinstance(statuses, dict) or not statuses:
- raise ReferenceDataError("Telegram statuses must map HTTP error codes to reasons")
- validated_statuses: dict[int, str] = {}
- for status, reason in statuses.items():
- if (
- not isinstance(status, str)
- or not status.isascii()
- or not status.isdigit()
- or not 400 <= int(status) <= 599
- or not isinstance(reason, str)
- or _CLASSIFICATION_REASON.fullmatch(reason) is None
- ):
- raise ReferenceDataError("Telegram statuses must map HTTP error codes to reasons")
- validated_statuses[int(status)] = reason
-
- known_reasons = {
- *(reason for _, reason in validated_markers),
- migration_reason,
- *validated_statuses.values(),
- }
- if not isinstance(unavailable_reasons, list) or not unavailable_reasons:
- raise ReferenceDataError("Telegram channel availability must reference known reasons")
- validated_unavailable_reasons: list[str] = []
- for reason in unavailable_reasons:
- if not isinstance(reason, str) or _CLASSIFICATION_REASON.fullmatch(reason) is None:
- raise ReferenceDataError("Telegram channel availability must reference known reasons")
- validated_unavailable_reasons.append(reason)
- if len(set(validated_unavailable_reasons)) != len(validated_unavailable_reasons) or not set(
- validated_unavailable_reasons
- ).issubset(known_reasons):
- raise ReferenceDataError("Telegram channel availability must reference known reasons")
-
- return TelegramErrorClassification(
- description_markers=tuple(validated_markers),
- parameter_reasons=MappingProxyType({"migrate_to_chat_id": migration_reason}),
- status_reasons=MappingProxyType(validated_statuses),
- channel_unavailable_reasons=frozenset(validated_unavailable_reasons),
- )
-
-
__all__ = [
"ReferenceDataError",
- "TelegramErrorClassification",
"load_reference_data",
"localization_table",
"open_meteo_weather_code_descriptions",
"reference_string",
"reference_string_tuple",
"reference_value",
- "telegram_error_classification",
]
diff --git a/weather_briefing/render.py b/weather_briefing/render.py
index 2315798e..f1d67bbc 100644
--- a/weather_briefing/render.py
+++ b/weather_briefing/render.py
@@ -1,219 +1,6 @@
-"""Platform-specific rendering of validated briefing results."""
+"""Compatibility exports for delivery renderers."""
-from __future__ import annotations
+from .delivery import PlainTextRenderer, TelegramHTMLRenderer
+from .delivery.renderers import MessageRenderer
-from collections.abc import Mapping
-from html import escape, unescape
-from typing import Protocol
-
-from bs4 import BeautifulSoup
-
-from .languages import LanguageSupport
-from .models import (
- Advice,
- Article,
- BriefingResult,
- Conclusion,
- RenderedMessage,
- SourceDocument,
-)
-from .reference_data import localization_table
-
-_BRIEFING_LABELS = localization_table("briefing")
-_BRIEFING_LANGUAGE_SUPPORT = LanguageSupport(
- default="en",
- supported=tuple(_BRIEFING_LABELS),
-)
-
-
-class MessageRenderer(Protocol):
- """Render platform-neutral briefing data for one delivery platform."""
-
- def render_briefing(
- self,
- result: BriefingResult,
- reference_articles: tuple[Article, ...],
- context: tuple[SourceDocument, ...],
- ) -> RenderedMessage:
- """Render a validated briefing and its citable references."""
- ...
-
- def render_verbatim(self, article: Article) -> RenderedMessage:
- """Render an article without summarizing its cleaned content."""
- ...
-
- def render_alert(self, title: str, body: str) -> RenderedMessage:
- """Render an operational alert."""
- ...
-
-
-class TelegramHTMLRenderer:
- """Render briefings as Telegram-compatible HTML."""
-
- def render_briefing(
- self,
- result: BriefingResult,
- reference_articles: tuple[Article, ...],
- context: tuple[SourceDocument, ...],
- ) -> RenderedMessage:
- """Render a sourced briefing as Telegram HTML."""
- labels = _briefing_labels(result.output_language)
- source_links = {
- article.id: _html_link(article.url, _article_source_name(article)) for article in reference_articles
- }
- source_links.update({document.id: _html_link(document.url, document.name) for document in context})
- lines = [
- f"{_html_text(result.headline)} "
- f"{_html_attribution(result.headline_source_ids, source_links, labels)}",
- "",
- ]
- lines.extend(_html_items(labels["weather"], result.conclusions, source_links, labels))
- if result.active_warnings:
- lines.extend([f"{labels['warnings']}", ""])
- lines.extend(
- (
- f"• {_html_text(warning.title)}{labels['status_open']}"
- f"{_html_text(warning.status)}{labels['status_close']}"
- f"{labels['detail_separator']}{_html_text(warning.detail)} "
- f"{_html_attribution(warning.source_ids, source_links, labels)}"
- )
- for warning in result.active_warnings
- )
- lines.append("")
- lines.extend(_html_items(labels["disasters"], result.disaster_tracking, source_links, labels))
- lines.extend(_html_items(labels["advice"], result.advice, source_links, labels))
- return _html_message("\n".join(lines).strip())
-
- def render_verbatim(self, article: Article) -> RenderedMessage:
- """Render cleaned article content as Telegram HTML."""
- return _html_message(
- "\n".join(
- (
- f"{_html_text(article.title)}",
- "",
- _html_text(article.content),
- )
- )
- )
-
- def render_alert(self, title: str, body: str) -> RenderedMessage:
- """Render an escaped Telegram HTML alert."""
- return _html_message(f"{_html_text(title)}\n\n{_html_text(body)}")
-
-
-class PlainTextRenderer:
- """Render briefings for stdout and other plain-text transports."""
-
- def render_briefing(
- self,
- result: BriefingResult,
- reference_articles: tuple[Article, ...],
- context: tuple[SourceDocument, ...],
- ) -> RenderedMessage:
- """Render a sourced briefing as plain text."""
- labels = _briefing_labels(result.output_language)
- source_references = {
- article.id: f"{_article_source_name(article)}: {article.url}" for article in reference_articles
- }
- source_references.update({document.id: f"{document.name}: {document.url}" for document in context})
- lines = [
- f"{result.headline} {_plain_attribution(result.headline_source_ids, source_references, labels)}",
- "",
- ]
- lines.extend(_plain_items(labels["weather"], result.conclusions, source_references, labels))
- if result.active_warnings:
- lines.extend([labels["warnings"], ""])
- for warning in result.active_warnings:
- sources = _plain_attribution(warning.source_ids, source_references, labels)
- lines.append(
- f"- {warning.title}{labels['status_open']}{warning.status}{labels['status_close']}"
- f"{labels['detail_separator']}{warning.detail} {sources}"
- )
- lines.append("")
- lines.extend(_plain_items(labels["disasters"], result.disaster_tracking, source_references, labels))
- lines.extend(_plain_items(labels["advice"], result.advice, source_references, labels))
- return _plain_message("\n".join(lines).strip())
-
- def render_verbatim(self, article: Article) -> RenderedMessage:
- """Render cleaned article content as plain text."""
- return _plain_message(f"{article.title}\n\n{article.content}")
-
- def render_alert(self, title: str, body: str) -> RenderedMessage:
- """Render an operational alert as plain text."""
- return _plain_message(f"{title}\n\n{body}")
-
-
-def _html_text(value: str) -> str:
- return escape(unescape(value), quote=False)
-
-
-def _article_source_name(article: Article) -> str:
- return article.source_name.strip() or article.source_id
-
-
-def _html_link(url: str, label: str) -> str:
- return f'{_html_text(label)}'
-
-
-def _html_items(
- title: str,
- items: tuple[Conclusion | Advice, ...],
- source_links: dict[str, str],
- labels: Mapping[str, str],
-) -> list[str]:
- if not items:
- return []
- lines = [f"{_html_text(title)}", ""]
- lines.extend(
- f"• {_html_text(item.text)} {_html_attribution(item.source_ids, source_links, labels)}" for item in items
- )
- lines.append("")
- return lines
-
-
-def _plain_items(
- title: str,
- items: tuple[Conclusion | Advice, ...],
- source_references: dict[str, str],
- labels: Mapping[str, str],
-) -> list[str]:
- if not items:
- return []
- lines = [title, ""]
- lines.extend(f"- {item.text} {_plain_attribution(item.source_ids, source_references, labels)}" for item in items)
- lines.append("")
- return lines
-
-
-def _html_attribution(
- source_ids: tuple[str, ...],
- source_links: dict[str, str],
- labels: Mapping[str, str],
-) -> str:
- sources = labels["html_source_separator"].join(dict.fromkeys(source_links[source_id] for source_id in source_ids))
- return labels["attribution"].format(sources=sources)
-
-
-def _plain_attribution(
- source_ids: tuple[str, ...],
- source_references: dict[str, str],
- labels: Mapping[str, str],
-) -> str:
- sources = labels["plain_source_separator"].join(
- dict.fromkeys(source_references[source_id] for source_id in source_ids)
- )
- return labels["attribution"].format(sources=sources)
-
-
-def _briefing_labels(language: str) -> Mapping[str, str]:
- selected = _BRIEFING_LANGUAGE_SUPPORT.match(language)
- return _BRIEFING_LABELS[selected]
-
-
-def _html_message(body: str) -> RenderedMessage:
- visible = BeautifulSoup(body, "html.parser").get_text()
- return RenderedMessage(body=body, visible_length=len(visible))
-
-
-def _plain_message(body: str) -> RenderedMessage:
- return RenderedMessage(body=body, visible_length=len(body))
+__all__ = ["MessageRenderer", "PlainTextRenderer", "TelegramHTMLRenderer"]