diff --git a/docs/design.md b/docs/design.md index 74404f91..f316755c 100644 --- a/docs/design.md +++ b/docs/design.md @@ -168,7 +168,7 @@ CLI 负责关闭自己创建的模型服务对象及其网络资源。测试或 Telegram publisher 把静默标志转换为 `disable_notification=true`。INFO 日志记录消息长度、分块数量、单条消息模式和静默投递选项,但不记录正文、Bot Token 或 Chat ID。 -Telegram 拒绝请求时,publisher 记录 HTTP 状态、分块位置和安全错误类别。错误类别来自已知的 Telegram API 描述,未知响应不会原样进入日志。 +Telegram 拒绝请求时,publisher 以唯一一条 WARNING 记录 HTTP 状态、分块位置和安全错误类别。错误类别来自已知的 Telegram API 描述、`parameters.migrate_to_chat_id` 字段和 HTTP 状态映射,未知响应不会原样进入日志。投递异常携带结构化错误类别;当业务消息因目标会话、Bot 身份或发送权限不可用而失败,且运维告警复用同一投递对象时,服务不再尝试通过该对象发送失败告警,只记录跳过原因。 ## 状态 diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 60072d72..ca05223c 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -41,6 +41,30 @@ async def test_logged_client_records_http_failure_status(caplog) -> None: assert "status_code=503" in caplog.text +async def test_logged_client_leaves_handled_response_error_warning_to_adapter(caplog) -> None: + caplog.set_level(logging.INFO, logger="weather_briefing.api_client") + + async with LoggedAsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(400))) as client: + response = await client.post( + "https://example.test/api", + extensions=api_call_extensions( + "telegram", + "send-message", + response_error_handled=True, + ), + ) + + assert response.status_code == 400 + assert "API call returned handled error provider=telegram operation=send-message" in caplog.text + assert not [record for record in caplog.records if record.levelno >= logging.WARNING] + + +@pytest.mark.parametrize("value", (None, 0, 1, "true")) +def test_api_call_extensions_rejects_non_boolean_response_error_ownership(value) -> None: + with pytest.raises(TypeError, match="response_error_handled must be a bool"): + api_call_extensions("telegram", "send-message", response_error_handled=value) + + async def test_logged_client_records_exception_type_without_message(caplog) -> None: caplog.set_level(logging.INFO, logger="weather_briefing.api_client") diff --git a/tests/test_publishers.py b/tests/test_publishers.py index ccd6d097..0d199d1e 100644 --- a/tests/test_publishers.py +++ b/tests/test_publishers.py @@ -12,6 +12,7 @@ TelegramPublisher, _split_message, ) +from weather_briefing.reference_data import ReferenceDataError from weather_briefing.render import PlainTextRenderer @@ -68,6 +69,29 @@ def test_delivery_provider_applies_platform_limit_without_leaking_it_into_config assert telegram_like.briefing_limit(3500) == 3500 +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) + + async with httpx.AsyncClient() as client: + with pytest.raises(ReferenceDataError, match="invalid Telegram metadata"): + TelegramPublisher(client, "runtime-token", "runtime-chat") + + +@pytest.mark.parametrize("reason", (None, 7, "private detail\nforged-log-line")) +def test_delivery_error_rejects_unsafe_structured_reason(reason) -> None: + with pytest.raises(ValueError, match="lowercase kebab-case"): + DeliveryError("Delivery failed", reason=reason) + + +@pytest.mark.parametrize("value", (None, 0, 1, "true")) +def test_delivery_error_rejects_non_boolean_channel_availability(value) -> None: + with pytest.raises(TypeError, match="channel_unavailable must be a bool"): + DeliveryError("Delivery failed", reason="request-error", channel_unavailable=value) + + def test_split_message_prefers_line_boundary() -> None: assert _split_message("first line\nsecond line", 12) == ("first line", "\nsecond line") @@ -223,9 +247,11 @@ def handler(_: httpx.Request) -> httpx.Response: assert "Telegram delivery prepared: visible_characters=4 payload_characters=4 chunks=1" in caplog.text assert "status_code=400 reason=chat-not-found" in caplog.text assert caught.value.__cause__ is None + assert caught.value.reason == "chat-not-found" + assert caught.value.channel_unavailable is True -async def test_telegram_failure_emits_one_warning_with_classification_at_info(caplog) -> None: +async def test_telegram_failure_emits_one_warning_with_classification(caplog) -> None: transport = httpx.MockTransport(lambda _: httpx.Response(400, json={"description": "chat not found"})) with caplog.at_level("INFO"): @@ -236,36 +262,44 @@ async def test_telegram_failure_emits_one_warning_with_classification_at_info(ca warnings = [record for record in caplog.records if record.levelno == 30] assert len(warnings) == 1 - assert warnings[0].name == "weather_briefing.api_client" - assert "status_code=400 reason=chat-not-found" in caplog.text + assert warnings[0].name == "weather_briefing.publishers" + assert warnings[0].getMessage().endswith("status_code=400 reason=chat-not-found") @pytest.mark.parametrize( - ("status_code", "payload", "expected_reason"), + ("status_code", "payload", "expected_reason", "expected_channel_unavailable"), ( - (400, {"description": "Bad Request: can't parse entities at byte offset 12"}, "invalid-html"), - (400, {"description": "Bad Request: message is too long"}, "message-too-long"), - (400, {"description": "Bad Request: not enough rights to send text messages"}, "insufficient-rights"), - (400, {"parameters": {"migrate_to_chat_id": -100123}}, "chat-migrated"), - (401, {"description": "Unauthorized"}, "bot-token-rejected"), - (404, {"description": "Not Found"}, "bot-endpoint-not-found"), - (429, {"description": "Too Many Requests: retry later"}, "rate-limited"), - (400, {"description": 123}, "api-error"), - (500, {"description": "private provider detail"}, "api-error"), + (400, {"description": "Bad Request: can't parse entities at byte offset 12"}, "invalid-html", False), + (400, {"description": "Bad Request: message is too long"}, "message-too-long", False), + ( + 400, + {"description": "Bad Request: not enough rights to send text messages"}, + "insufficient-rights", + True, + ), + (400, {"parameters": {"migrate_to_chat_id": -100123}}, "chat-migrated", True), + (401, {"description": "Unauthorized"}, "bot-token-rejected", True), + (404, {"description": "Not Found"}, "bot-endpoint-not-found", True), + (429, {"description": "Too Many Requests: retry later"}, "rate-limited", False), + (400, {"description": 123}, "api-error", False), + (500, {"description": "private provider detail"}, "api-error", False), ), ) async def test_telegram_error_classification( status_code: int, payload: dict[str, object], expected_reason: str, + expected_channel_unavailable: bool, ) -> None: async with httpx.AsyncClient( transport=httpx.MockTransport(lambda _: httpx.Response(status_code, json=payload)) ) as client: publisher = TelegramPublisher(client, "runtime-token", "runtime-chat") - with pytest.raises(DeliveryError, match=expected_reason): + with pytest.raises(DeliveryError, match=expected_reason) as caught: await publisher.publish(RenderedMessage("Body", 4)) + assert caught.value.channel_unavailable is expected_channel_unavailable + async def test_telegram_malformed_error_response_uses_status_classification() -> None: async with httpx.AsyncClient( @@ -294,9 +328,12 @@ def handler(request: httpx.Request) -> httpx.Response: async def test_telegram_rejects_oversized_single_message_before_delivery() -> None: async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(200))) as client: publisher = TelegramPublisher(client, "runtime-token", "runtime-chat") - with pytest.raises(DeliveryError, match="exceeds"): + with pytest.raises(DeliveryError, match="exceeds") as caught: await publisher.publish(RenderedMessage("short markup", 4097), single_message=True) + assert caught.value.reason == "message-too-long" + assert caught.value.channel_unavailable is False + async def test_stdout_publisher_outputs_message_body(capsys) -> None: await StdoutPublisher().publish(RenderedMessage("test body", 9)) diff --git a/tests/test_reference_data.py b/tests/test_reference_data.py index ee6c4e3a..ac967e52 100644 --- a/tests/test_reference_data.py +++ b/tests/test_reference_data.py @@ -38,6 +38,15 @@ def _localization_language(tables: dict[str, object], table_name: str, language: return labels +def _telegram_classification_data() -> dict[str, object]: + return { + "description_markers": {"chat not found": "chat-not-found"}, + "parameter_reasons": {"migrate_to_chat_id": "chat-migrated"}, + "status_reasons": {"401": "bot-token-rejected"}, + "channel_unavailable_reasons": ["chat-not-found", "chat-migrated", "bot-token-rejected"], + } + + def test_packaged_reference_data_is_available() -> None: assert reference_value("geography.json", "mainland_china_service_bounds", "latitude") assert reference_string_tuple( @@ -83,7 +92,9 @@ def test_packaged_reference_data_is_available() -> None: assert localization_table("briefing")["zh-Hans"]["weather"] == "天气信息" classification = telegram_error_classification() assert ("chat not found", "chat-not-found") in classification.description_markers + assert classification.parameter_reasons["migrate_to_chat_id"] == "chat-migrated" assert classification.status_reasons[401] == "bot-token-rejected" + assert "chat-not-found" in classification.channel_unavailable_reasons @pytest.mark.parametrize( @@ -149,27 +160,48 @@ def test_reference_string_rejects_invalid_value(monkeypatch, value) -> None: @pytest.mark.parametrize( ("value", "message"), ( - ({"description_markers": {}, "status_reasons": {"400": "api-error"}}, "description markers"), - ({"description_markers": {"chat not found": "chat-not-found"}, "status_reasons": {}}, "statuses"), + ({}, "supported fields"), + (_telegram_classification_data() | {"description_markers": {}}, "description markers"), + (_telegram_classification_data() | {"status_reasons": {}}, "statuses"), ( - {"description_markers": {"CHAT NOT FOUND": "chat-not-found"}, "status_reasons": {"400": "api-error"}}, + _telegram_classification_data() | {"description_markers": {"CHAT NOT FOUND": "chat-not-found"}}, "description markers", ), ( - {"description_markers": {"chat not found": "unsafe\nreason"}, "status_reasons": {"400": "api-error"}}, + _telegram_classification_data() | {"description_markers": {"chat not found": "unsafe\nreason"}}, "description markers", ), ( - {"description_markers": {"chat not found": "chat-not-found"}, "status_reasons": {"invalid": "api-error"}}, + _telegram_classification_data() | {"parameter_reasons": {}}, + "parameters", + ), + ( + _telegram_classification_data() | {"parameter_reasons": {"migrate_to_chat_id": "unsafe\nreason"}}, + "parameters", + ), + ( + _telegram_classification_data() | {"status_reasons": {"invalid": "api-error"}}, "statuses", ), ( - { - "description_markers": {"chat not found": "chat-not-found"}, - "status_reasons": {"400": "api-error"}, - "unknown": {}, - }, - "supported fields", + _telegram_classification_data() | {"channel_unavailable_reasons": []}, + "channel availability", + ), + ( + _telegram_classification_data() | {"channel_unavailable_reasons": ["chat-not-found", "chat-not-found"]}, + "channel availability", + ), + ( + _telegram_classification_data() | {"channel_unavailable_reasons": ["unknown-reason"]}, + "channel availability", + ), + ( + _telegram_classification_data() | {"channel_unavailable_reasons": [7]}, + "channel availability", + ), + ( + _telegram_classification_data() | {"channel_unavailable_reasons": ["unsafe\nreason"]}, + "channel availability", ), ), ) diff --git a/tests/test_service.py b/tests/test_service.py index d2a73eb6..49907508 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -25,7 +25,7 @@ Warning, WeatherContextSnapshot, ) -from weather_briefing.publishers import DeliveryProvider +from weather_briefing.publishers import DeliveryError, DeliveryProvider from weather_briefing.render import PlainTextRenderer from weather_briefing.service import ( BriefingService, @@ -208,6 +208,26 @@ async def publish( await super().publish(message, single_message=single_message, silent=silent) +class UnavailableChannelPublisher(RecordingPublisher): + def __init__(self) -> None: + super().__init__() + self.attempts = 0 + + async def publish( + self, + message: RenderedMessage, + *, + single_message: bool = False, + silent: bool = False, + ) -> None: + self.attempts += 1 + raise DeliveryError( + "Telegram delivery failed (chat-not-found)", + reason="chat-not-found", + channel_unavailable=True, + ) + + class FailingVerbatimPublisher(RecordingPublisher): def __init__(self, failed_attempts: set[int]) -> None: super().__init__() @@ -1506,6 +1526,38 @@ async def test_task_failure_alert_delivery_failure_is_retried( assert "Failed to publish or record briefing failure alert" in caplog.text +async def test_task_failure_alert_skips_unavailable_shared_delivery_channel( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + timezone = pendulum.timezone("Asia/Shanghai") + settings = _TestSettings(timezone=timezone, llm_max_attempts=1) + publisher = UnavailableChannelPublisher() + delivery = DeliveryProvider(PlainTextRenderer(), publisher) + + with SQLiteStateStore(tmp_path / "unavailable-delivery.sqlite3") as state: + service = BriefingService( + settings, + _location(), + state, + EmptyRSSSource(), + EmptyContextSource(), + RecordingLLM(), + delivery, + delivery, + StaticWeatherContextProvider(), + ) + with ( + caplog.at_level("INFO", logger="weather_briefing.service"), + pytest.raises(DeliveryError, match="chat-not-found") as caught, + ): + await service.run("briefing", pendulum.datetime(2026, 7, 13, 9, tz=timezone)) + + assert caught.value.reason == "chat-not-found" + assert publisher.attempts == 1 + assert "Failure alert skipped reason=delivery-channel-unavailable original_reason=chat-not-found" in caplog.text + + async def test_failure_recording_error_does_not_mask_task_error( tmp_path: Path, caplog: pytest.LogCaptureFixture, diff --git a/weather_briefing/api_client.py b/weather_briefing/api_client.py index d1843e30..e2d9ad98 100644 --- a/weather_briefing/api_client.py +++ b/weather_briefing/api_client.py @@ -14,17 +14,28 @@ _LOGGER = logging.getLogger("weather_briefing.api_client") _API_CALL_EXTENSION = "weather_briefing.api_call" +_RESPONSE_ERROR_HANDLED_EXTENSION = "weather_briefing.response_error_handled" _SAFE_LABEL = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") _CURRENT_API_CALL: ContextVar[tuple[str, str] | None] = ContextVar("current_api_call", default=None) -def api_call_extensions(provider: str, operation: str) -> dict[str, object]: +def api_call_extensions( + provider: str, + operation: str, + *, + response_error_handled: bool = False, +) -> dict[str, object]: """Attach non-sensitive API identity to an HTTPX request.""" + if not isinstance(response_error_handled, bool): + raise TypeError("response_error_handled must be a bool") if _SAFE_LABEL.fullmatch(provider) is None: raise ValueError("API provider must be a lowercase kebab-case label") if _SAFE_LABEL.fullmatch(operation) is None: raise ValueError("API operation must be a lowercase kebab-case label") - return {_API_CALL_EXTENSION: (provider, operation)} + extensions: dict[str, object] = {_API_CALL_EXTENSION: (provider, operation)} + if response_error_handled: + extensions[_RESPONSE_ERROR_HANDLED_EXTENSION] = True + return extensions @contextmanager @@ -66,7 +77,7 @@ async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response: ) raise - if response.is_error: + if response.is_error and request.extensions.get(_RESPONSE_ERROR_HANDLED_EXTENSION) is not True: _LOGGER.warning( "API call failed provider=%s operation=%s method=%s duration_ms=%d status_code=%d", provider, @@ -75,6 +86,15 @@ async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response: _elapsed_milliseconds(started_at), response.status_code, ) + elif response.is_error: + _LOGGER.info( + "API call returned handled error provider=%s operation=%s method=%s duration_ms=%d status_code=%d", + provider, + operation, + method, + _elapsed_milliseconds(started_at), + response.status_code, + ) else: _LOGGER.info( "API call succeeded provider=%s operation=%s method=%s duration_ms=%d status_code=%d", diff --git a/weather_briefing/data/telegram_error_classification.json b/weather_briefing/data/telegram_error_classification.json index 7ce4ea76..a2e2991b 100644 --- a/weather_briefing/data/telegram_error_classification.json +++ b/weather_briefing/data/telegram_error_classification.json @@ -10,10 +10,23 @@ "message text is empty": "empty-message", "too many requests": "rate-limited" }, + "parameter_reasons": { + "migrate_to_chat_id": "chat-migrated" + }, "status_reasons": { "401": "bot-token-rejected", "403": "forbidden", "404": "bot-endpoint-not-found", "429": "rate-limited" - } + }, + "channel_unavailable_reasons": [ + "bot-blocked", + "bot-endpoint-not-found", + "bot-token-rejected", + "chat-migrated", + "chat-not-found", + "forbidden", + "insufficient-rights", + "user-deactivated" + ] } diff --git a/weather_briefing/publishers.py b/weather_briefing/publishers.py index a00ad9f8..ce0d5c1a 100644 --- a/weather_briefing/publishers.py +++ b/weather_briefing/publishers.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import re from dataclasses import dataclass from html.parser import HTMLParser from typing import Protocol @@ -15,6 +16,7 @@ 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): @@ -109,6 +111,16 @@ async def publish( 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.""" @@ -123,6 +135,7 @@ def __init__( 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 @@ -137,7 +150,10 @@ async def publish( ) -> 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") + 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 " @@ -167,12 +183,16 @@ async def publish( "link_preview_options": {"is_disabled": True}, "disable_notification": silent, }, - extensions=api_call_extensions("telegram", "send-message"), + extensions=api_call_extensions( + "telegram", + "send-message", + response_error_handled=True, + ), ) response.raise_for_status() except httpx.HTTPStatusError as exc: - reason = _telegram_error_reason(exc.response) - _LOGGER.info( + 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, @@ -182,7 +202,11 @@ async def publish( exc.response.status_code, reason, ) - raise DeliveryError(f"Telegram delivery failed ({reason})") from None + 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 " @@ -193,7 +217,10 @@ async def publish( len(chunk), type(exc).__name__, ) - raise DeliveryError("Telegram delivery failed (request-error)") from None + raise DeliveryError( + "Telegram delivery failed (request-error)", + reason="request-error", + ) from None _LOGGER.debug( "Telegram chunk accepted: index=%d/%d payload_characters=%d", index, @@ -222,8 +249,9 @@ def _rendered_text_logging_enabled(diagnostics: RenderedTextDiagnostics | None) return enabled and _LOGGER.isEnabledFor(logging.DEBUG) -def _telegram_error_reason(response: httpx.Response) -> str: +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: @@ -232,16 +260,18 @@ def _telegram_error_reason(response: httpx.Response) -> str: if isinstance(payload, dict): parameters = payload.get("parameters") if isinstance(parameters, dict) and type(parameters.get("migrate_to_chat_id")) is int: - return "chat-migrated" + 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 telegram_error_classification().description_markers: + for marker, reason in classification.description_markers: if marker in normalized: - return reason + return reason, reason in classification.channel_unavailable_reasons - return telegram_error_classification().status_reasons.get(response.status_code, "api-error") + 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, ...]: diff --git a/weather_briefing/reference_data.py b/weather_briefing/reference_data.py index c270c70e..10fb7037 100644 --- a/weather_briefing/reference_data.py +++ b/weather_briefing/reference_data.py @@ -91,10 +91,12 @@ class ReferenceDataError(RuntimeError): @dataclass(frozen=True, slots=True) class TelegramErrorClassification: - """Validated Telegram API error-description and status mappings.""" + """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 @@ -173,8 +175,15 @@ 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") - if set(value) != {"description_markers", "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") @@ -190,6 +199,12 @@ def telegram_error_classification() -> TelegramErrorClassification: 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] = {} @@ -205,9 +220,28 @@ def telegram_error_classification() -> TelegramErrorClassification: 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/service.py b/weather_briefing/service.py index 9b277986..741bd1e3 100644 --- a/weather_briefing/service.py +++ b/weather_briefing/service.py @@ -25,7 +25,7 @@ Warning, ) from .prompts import SYSTEM_PROMPT -from .publishers import DeliveryProvider +from .publishers import DeliveryError, DeliveryProvider from .sources import ContextDocumentSource, RSSFeedSource from .state import SQLiteStateStore from .time_utils import require_aware_datetime @@ -223,11 +223,21 @@ async def run( else: try: if self._state.task_failure_requires_alert(): - await self._ops_delivery.publish_alert( - "天气简报任务执行失败", - "任务执行失败,请检查运行日志、天气 API 及私密源配置。", - ) - self._state.mark_task_failure_alerted(current_time) + if ( + isinstance(exc, DeliveryError) + and exc.channel_unavailable + and self._ops_delivery is self._delivery + ): + _LOGGER.info( + "Failure alert skipped reason=delivery-channel-unavailable original_reason=%s", + exc.reason, + ) + else: + await self._ops_delivery.publish_alert( + "天气简报任务执行失败", + "任务执行失败,请检查运行日志、天气 API 及私密源配置。", + ) + self._state.mark_task_failure_alerted(current_time) except Exception: _LOGGER.exception("Failed to publish or record briefing failure alert") raise