mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
* feat: add feishu file upload support for report delivery - Add FEISHU_SEND_AS_FILE config option to switch from text to file delivery - Implement FeishuSender.send_feishu_file() with App Bot SDK upload + webhook fallback - Integrate file upload path into pipeline dashboard notification loop - Integrate file upload path into NotificationService.send_report() - Add save_and_send_feishu_file() convenience wrapper - Add .env.example entry and CHANGELOG.md record * fix: restrict feishu file mode to report routes and wire into workflow - Limit FEISHU_SEND_AS_FILE to route_type=None or 'report' in _send_to_static_channel, preventing alert/event paths from unexpectedly saving alerts as files (Codex review P2). - Add FEISHU_SEND_AS_FILE env var to .github/workflows/00-daily-analysis.yml so the setting is available in scheduled runs (Codex review P2). - Add 13 tests: 7 FeishuSender unit tests (send_feishu_file webhook/App Bot paths), 6 NotificationService integration tests (route_type filtering) * fix: address round-2 review blockers Correctness: - Remove route_type=None file-mode fallback; only explicit route_type='report' triggers Feishu file delivery (api/v1/endpoints/agent.py calls send() without route_type) Compatibility: - Isolate CreateFileRequest/CreateFileRequestBody imports from CreateMessageRequest; add FEISHU_FILE_SDK_AVAILABLE flag so old lark-oapi without file-upload classes won't break existing App Bot text messaging Test fix: - Fix webhook test assertion: # Test Report -> **Test Report** (format_feishu_markdown converts markdown headings to bold) Documentation: - Add FEISHU_SEND_AS_FILE to docs/notifications.md config table - Add file-send section to docs/bot/feishu-bot-config.md (permissions, deps, webhook fallback, route scope, CI mapping) - Add FEISHU_SEND_AS_FILE to docs/full-guide.md and docs/full-guide_EN.md config tables - Add Feishu file create OpenAPI link to docs/notifications.md * docs: fix feishu_sender drift and document config entry boundary - Fix outdated '本轮未改动 feishu_sender.py' line in docs/notifications.md (we added send_feishu_file to feishu_sender.py) - Document FEISHU_SEND_AS_FILE as .env/Actions-only in docs/bot/feishu-bot-config.md (not exposed in Web/Desktop settings) * feat: add FEISHU_SEND_AS_FILE to system_config_service env mapping - Add FEISHU_SEND_AS_FILE -> feishu_send_as_file (bool) mapping alongside FEISHU_MAX_BYTES in the notification env key mapping - Update docs/bot/feishu-bot-config.md to reflect standard config paths * test: add non-mock SDK import smoke test for file upload classes
602 lines
21 KiB
Python
602 lines
21 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Read-only notification configuration diagnostics."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from typing import List, Literal, Optional, Sequence, Tuple
|
||
|
||
from src.config import Config
|
||
from src.notification import ChannelDetector, NotificationChannel, NotificationService
|
||
from src.notification_contracts import FEISHU_APP_BOT_ENV_GROUP, FEISHU_WEBHOOK_ENV_GROUP
|
||
from src.notification_noise import (
|
||
NOTIFICATION_SEVERITIES,
|
||
P4_NOISE_ENV_KEYS,
|
||
is_supported_notification_severity,
|
||
parse_notification_quiet_hours,
|
||
validate_notification_timezone,
|
||
)
|
||
from src.notification_routing import (
|
||
NOTIFICATION_ROUTE_CONFIGS,
|
||
ROUTABLE_NOTIFICATION_CHANNELS,
|
||
split_notification_route_channels,
|
||
)
|
||
from src.notification_sender.gotify_sender import resolve_gotify_message_endpoint
|
||
from src.notification_sender.ntfy_sender import resolve_ntfy_endpoint
|
||
|
||
KeyTier = Literal["minimal", "advanced"]
|
||
IssueSeverity = Literal["error", "warning", "info"]
|
||
ChannelKind = Literal["configured", "fallback", "context"]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class NotificationKeySpec:
|
||
"""Metadata for a notification-related configuration key."""
|
||
|
||
key: str
|
||
tier: KeyTier
|
||
description: str
|
||
channel: str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class NotificationChannelSpec:
|
||
"""Baseline metadata for one notification channel."""
|
||
|
||
channel: str
|
||
display_name: str
|
||
kind: ChannelKind
|
||
minimal_keys: Tuple[str, ...]
|
||
alternative_minimal_keys: Tuple[Tuple[str, ...], ...] = ()
|
||
advanced_keys: Tuple[str, ...] = ()
|
||
note: str = ""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class NotificationDiagnosticIssue:
|
||
"""One diagnostic message."""
|
||
|
||
severity: IssueSeverity
|
||
code: str
|
||
message: str
|
||
key: Optional[str] = None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class NotificationDiagnosticResult:
|
||
"""Structured notification diagnostic result."""
|
||
|
||
configured_channels: Tuple[str, ...]
|
||
errors: Tuple[NotificationDiagnosticIssue, ...]
|
||
warnings: Tuple[NotificationDiagnosticIssue, ...]
|
||
info: Tuple[NotificationDiagnosticIssue, ...]
|
||
|
||
@property
|
||
def ok(self) -> bool:
|
||
return not self.errors
|
||
|
||
|
||
CHANNEL_SPECS: Tuple[NotificationChannelSpec, ...] = (
|
||
NotificationChannelSpec(
|
||
channel=NotificationChannel.WECHAT.value,
|
||
display_name=ChannelDetector.get_channel_name(NotificationChannel.WECHAT),
|
||
kind="configured",
|
||
minimal_keys=("WECHAT_WEBHOOK_URL",),
|
||
advanced_keys=("WECHAT_MSG_TYPE",),
|
||
),
|
||
NotificationChannelSpec(
|
||
channel=NotificationChannel.FEISHU.value,
|
||
display_name=ChannelDetector.get_channel_name(NotificationChannel.FEISHU),
|
||
kind="configured",
|
||
minimal_keys=FEISHU_WEBHOOK_ENV_GROUP,
|
||
alternative_minimal_keys=(FEISHU_APP_BOT_ENV_GROUP,),
|
||
advanced_keys=("FEISHU_WEBHOOK_SECRET", "FEISHU_WEBHOOK_KEYWORD", "FEISHU_RECEIVE_ID_TYPE", "FEISHU_DOMAIN", "FEISHU_SEND_AS_FILE"),
|
||
),
|
||
NotificationChannelSpec(
|
||
channel=NotificationChannel.DINGTALK.value,
|
||
display_name=ChannelDetector.get_channel_name(NotificationChannel.DINGTALK),
|
||
kind="configured",
|
||
minimal_keys=("DINGTALK_WEBHOOK_URL",),
|
||
alternative_minimal_keys=(),
|
||
advanced_keys=("DINGTALK_SECRET",),
|
||
),
|
||
NotificationChannelSpec(
|
||
channel=NotificationChannel.TELEGRAM.value,
|
||
display_name=ChannelDetector.get_channel_name(NotificationChannel.TELEGRAM),
|
||
kind="configured",
|
||
minimal_keys=("TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID"),
|
||
advanced_keys=("TELEGRAM_MESSAGE_THREAD_ID",),
|
||
),
|
||
NotificationChannelSpec(
|
||
channel=NotificationChannel.EMAIL.value,
|
||
display_name=ChannelDetector.get_channel_name(NotificationChannel.EMAIL),
|
||
kind="configured",
|
||
minimal_keys=("EMAIL_SENDER", "EMAIL_PASSWORD"),
|
||
advanced_keys=("EMAIL_RECEIVERS", "EMAIL_SENDER_NAME"),
|
||
),
|
||
NotificationChannelSpec(
|
||
channel=NotificationChannel.PUSHOVER.value,
|
||
display_name=ChannelDetector.get_channel_name(NotificationChannel.PUSHOVER),
|
||
kind="configured",
|
||
minimal_keys=("PUSHOVER_USER_KEY", "PUSHOVER_API_TOKEN"),
|
||
),
|
||
NotificationChannelSpec(
|
||
channel=NotificationChannel.NTFY.value,
|
||
display_name=ChannelDetector.get_channel_name(NotificationChannel.NTFY),
|
||
kind="configured",
|
||
minimal_keys=("NTFY_URL",),
|
||
advanced_keys=("NTFY_TOKEN", "WEBHOOK_VERIFY_SSL"),
|
||
note="NTFY_URL must include the topic path, e.g. https://ntfy.sh/my-topic.",
|
||
),
|
||
NotificationChannelSpec(
|
||
channel=NotificationChannel.GOTIFY.value,
|
||
display_name=ChannelDetector.get_channel_name(NotificationChannel.GOTIFY),
|
||
kind="configured",
|
||
minimal_keys=("GOTIFY_URL", "GOTIFY_TOKEN"),
|
||
advanced_keys=("WEBHOOK_VERIFY_SSL",),
|
||
note="GOTIFY_URL is the server base URL; the sender appends /message.",
|
||
),
|
||
NotificationChannelSpec(
|
||
channel=NotificationChannel.PUSHPLUS.value,
|
||
display_name=ChannelDetector.get_channel_name(NotificationChannel.PUSHPLUS),
|
||
kind="configured",
|
||
minimal_keys=("PUSHPLUS_TOKEN",),
|
||
advanced_keys=("PUSHPLUS_TOPIC",),
|
||
),
|
||
NotificationChannelSpec(
|
||
channel=NotificationChannel.SERVERCHAN3.value,
|
||
display_name=ChannelDetector.get_channel_name(NotificationChannel.SERVERCHAN3),
|
||
kind="configured",
|
||
minimal_keys=("SERVERCHAN3_SENDKEY",),
|
||
),
|
||
NotificationChannelSpec(
|
||
channel=NotificationChannel.CUSTOM.value,
|
||
display_name=ChannelDetector.get_channel_name(NotificationChannel.CUSTOM),
|
||
kind="configured",
|
||
minimal_keys=("CUSTOM_WEBHOOK_URLS",),
|
||
advanced_keys=("CUSTOM_WEBHOOK_BEARER_TOKEN", "CUSTOM_WEBHOOK_BODY_TEMPLATE", "WEBHOOK_VERIFY_SSL"),
|
||
),
|
||
NotificationChannelSpec(
|
||
channel=NotificationChannel.DISCORD.value,
|
||
display_name=ChannelDetector.get_channel_name(NotificationChannel.DISCORD),
|
||
kind="configured",
|
||
minimal_keys=("DISCORD_WEBHOOK_URL",),
|
||
alternative_minimal_keys=(("DISCORD_BOT_TOKEN", "DISCORD_MAIN_CHANNEL_ID"),),
|
||
advanced_keys=("DISCORD_INTERACTIONS_PUBLIC_KEY",),
|
||
note="Webhook URL or bot token + channel ID can enable Discord.",
|
||
),
|
||
NotificationChannelSpec(
|
||
channel=NotificationChannel.SLACK.value,
|
||
display_name=ChannelDetector.get_channel_name(NotificationChannel.SLACK),
|
||
kind="configured",
|
||
minimal_keys=("SLACK_WEBHOOK_URL",),
|
||
alternative_minimal_keys=(("SLACK_BOT_TOKEN", "SLACK_CHANNEL_ID"),),
|
||
note="Webhook URL or bot token + channel ID can enable Slack.",
|
||
),
|
||
NotificationChannelSpec(
|
||
channel=NotificationChannel.ASTRBOT.value,
|
||
display_name=ChannelDetector.get_channel_name(NotificationChannel.ASTRBOT),
|
||
kind="configured",
|
||
minimal_keys=("ASTRBOT_URL",),
|
||
advanced_keys=("ASTRBOT_TOKEN", "WEBHOOK_VERIFY_SSL"),
|
||
),
|
||
NotificationChannelSpec(
|
||
channel=NotificationChannel.UNKNOWN.value,
|
||
display_name=ChannelDetector.get_channel_name(NotificationChannel.UNKNOWN),
|
||
kind="fallback",
|
||
minimal_keys=(),
|
||
note="Fallback enum value only; it is not configured from static environment keys.",
|
||
),
|
||
NotificationChannelSpec(
|
||
channel="dingtalk_context",
|
||
display_name="钉钉会话",
|
||
kind="context",
|
||
minimal_keys=(),
|
||
note="Runtime-only reply channel extracted from source message context.",
|
||
),
|
||
NotificationChannelSpec(
|
||
channel="feishu_context",
|
||
display_name="飞书会话",
|
||
kind="context",
|
||
minimal_keys=(),
|
||
note="Runtime-only reply channel extracted from source message context.",
|
||
),
|
||
)
|
||
|
||
KEY_SPECS: Tuple[NotificationKeySpec, ...] = tuple(
|
||
NotificationKeySpec(key=key, tier="minimal", description="Required to enable the channel.", channel=spec.channel)
|
||
for spec in CHANNEL_SPECS
|
||
for key in (
|
||
spec.minimal_keys
|
||
+ tuple(key for key_group in spec.alternative_minimal_keys for key in key_group)
|
||
)
|
||
) + tuple(
|
||
NotificationKeySpec(key=key, tier="advanced", description="Optional channel behavior or security setting.", channel=spec.channel)
|
||
for spec in CHANNEL_SPECS
|
||
for key in spec.advanced_keys
|
||
) + tuple(
|
||
NotificationKeySpec(
|
||
key=route["env_key"],
|
||
tier="advanced",
|
||
description=route["description"],
|
||
channel="routing",
|
||
)
|
||
for route in NOTIFICATION_ROUTE_CONFIGS.values()
|
||
) + tuple(
|
||
NotificationKeySpec(
|
||
key=key,
|
||
tier="advanced",
|
||
description="Optional notification noise-control setting.",
|
||
channel="noise",
|
||
)
|
||
for key in P4_NOISE_ENV_KEYS
|
||
)
|
||
|
||
P0_ACTIONS_ENV_KEYS: Tuple[str, ...] = (
|
||
"CUSTOM_WEBHOOK_BODY_TEMPLATE",
|
||
"WEBHOOK_VERIFY_SSL",
|
||
"FEISHU_WEBHOOK_SECRET",
|
||
"FEISHU_WEBHOOK_KEYWORD",
|
||
"PUSHPLUS_TOPIC",
|
||
)
|
||
|
||
P3_ROUTE_ENV_KEYS: Tuple[str, ...] = tuple(
|
||
route["env_key"] for route in NOTIFICATION_ROUTE_CONFIGS.values()
|
||
)
|
||
|
||
P4_NOISE_ACTIONS_ENV_KEYS: Tuple[str, ...] = P4_NOISE_ENV_KEYS
|
||
|
||
P6_CHANNEL_ACTIONS_ENV_KEYS: Tuple[str, ...] = (
|
||
"NTFY_URL",
|
||
"NTFY_TOKEN",
|
||
"GOTIFY_URL",
|
||
"GOTIFY_TOKEN",
|
||
)
|
||
|
||
|
||
def _value(config: Config, attr: str):
|
||
return getattr(config, attr, None)
|
||
|
||
|
||
def _has(config: Config, attr: str) -> bool:
|
||
value = _value(config, attr)
|
||
if isinstance(value, (list, tuple, set, dict)):
|
||
return bool(value)
|
||
return value is not None and str(value).strip() != ""
|
||
|
||
|
||
def _issue(
|
||
severity: IssueSeverity,
|
||
code: str,
|
||
message: str,
|
||
key: Optional[str] = None,
|
||
) -> NotificationDiagnosticIssue:
|
||
return NotificationDiagnosticIssue(severity=severity, code=code, message=message, key=key)
|
||
|
||
|
||
def _require_pair(
|
||
config: Config,
|
||
*,
|
||
left_attr: str,
|
||
right_attr: str,
|
||
left_key: str,
|
||
right_key: str,
|
||
channel_name: str,
|
||
errors: List[NotificationDiagnosticIssue],
|
||
warnings: Optional[List[NotificationDiagnosticIssue]] = None,
|
||
severity: IssueSeverity = "error",
|
||
) -> None:
|
||
left = _has(config, left_attr)
|
||
right = _has(config, right_attr)
|
||
target = errors if severity == "error" else warnings
|
||
if target is None:
|
||
target = errors
|
||
if left and not right:
|
||
target.append(
|
||
_issue(
|
||
severity,
|
||
"partial_channel_config",
|
||
f"{channel_name} 已配置 {left_key},但缺少 {right_key},该渠道不会启用。",
|
||
key=right_key,
|
||
)
|
||
)
|
||
if right and not left:
|
||
target.append(
|
||
_issue(
|
||
severity,
|
||
"partial_channel_config",
|
||
f"{channel_name} 已配置 {right_key},但缺少 {left_key},该渠道不会启用。",
|
||
key=left_key,
|
||
)
|
||
)
|
||
|
||
|
||
def run_notification_diagnostics(config: Config) -> NotificationDiagnosticResult:
|
||
"""Run read-only diagnostics for notification configuration."""
|
||
|
||
configured = tuple(channel.value for channel in NotificationService.detect_configured_channels(config))
|
||
errors: List[NotificationDiagnosticIssue] = []
|
||
warnings: List[NotificationDiagnosticIssue] = []
|
||
info: List[NotificationDiagnosticIssue] = [
|
||
_issue(
|
||
"info",
|
||
"context_channels_runtime_only",
|
||
"钉钉会话和飞书会话属于运行时消息上下文渠道,无法仅靠静态 .env 完整判断。",
|
||
),
|
||
_issue(
|
||
"info",
|
||
"phase_scope",
|
||
"通知诊断会检查渠道基线、只读诊断、Web 测试、P3 路由配置、P4 降噪配置和 P6 ntfy/Gotify 渠道。",
|
||
),
|
||
]
|
||
|
||
if not configured:
|
||
errors.append(
|
||
_issue(
|
||
"error",
|
||
"no_channels_configured",
|
||
"0 个通知渠道已配置;如需发送通知,请至少配置一个渠道的 minimal key。",
|
||
)
|
||
)
|
||
|
||
if _has(config, "ntfy_url"):
|
||
ntfy_server_url, ntfy_topic = resolve_ntfy_endpoint(getattr(config, "ntfy_url", None))
|
||
if not ntfy_server_url or not ntfy_topic:
|
||
errors.append(
|
||
_issue(
|
||
"error",
|
||
"invalid_ntfy_url",
|
||
"NTFY_URL 必须包含 topic path,例如 https://ntfy.sh/my-topic。",
|
||
key="NTFY_URL",
|
||
)
|
||
)
|
||
|
||
if _has(config, "gotify_url"):
|
||
gotify_endpoint = resolve_gotify_message_endpoint(getattr(config, "gotify_url", None))
|
||
if not gotify_endpoint:
|
||
errors.append(
|
||
_issue(
|
||
"error",
|
||
"invalid_gotify_url",
|
||
"GOTIFY_URL 必须是 Gotify server base URL,不包含 /message,例如 https://gotify.example。",
|
||
key="GOTIFY_URL",
|
||
)
|
||
)
|
||
|
||
_require_pair(
|
||
config,
|
||
left_attr="telegram_bot_token",
|
||
right_attr="telegram_chat_id",
|
||
left_key="TELEGRAM_BOT_TOKEN",
|
||
right_key="TELEGRAM_CHAT_ID",
|
||
channel_name="Telegram",
|
||
errors=errors,
|
||
)
|
||
_require_pair(
|
||
config,
|
||
left_attr="email_sender",
|
||
right_attr="email_password",
|
||
left_key="EMAIL_SENDER",
|
||
right_key="EMAIL_PASSWORD",
|
||
channel_name="邮件",
|
||
errors=errors,
|
||
)
|
||
_require_pair(
|
||
config,
|
||
left_attr="pushover_user_key",
|
||
right_attr="pushover_api_token",
|
||
left_key="PUSHOVER_USER_KEY",
|
||
right_key="PUSHOVER_API_TOKEN",
|
||
channel_name="Pushover",
|
||
errors=errors,
|
||
)
|
||
_require_pair(
|
||
config,
|
||
left_attr="gotify_url",
|
||
right_attr="gotify_token",
|
||
left_key="GOTIFY_URL",
|
||
right_key="GOTIFY_TOKEN",
|
||
channel_name="Gotify",
|
||
errors=errors,
|
||
)
|
||
_require_pair(
|
||
config,
|
||
left_attr="discord_bot_token",
|
||
right_attr="discord_main_channel_id",
|
||
left_key="DISCORD_BOT_TOKEN",
|
||
right_key="DISCORD_MAIN_CHANNEL_ID",
|
||
channel_name="Discord Bot",
|
||
errors=errors,
|
||
warnings=warnings,
|
||
severity="warning" if _has(config, "discord_webhook_url") else "error",
|
||
)
|
||
_require_pair(
|
||
config,
|
||
left_attr="slack_bot_token",
|
||
right_attr="slack_channel_id",
|
||
left_key="SLACK_BOT_TOKEN",
|
||
right_key="SLACK_CHANNEL_ID",
|
||
channel_name="Slack Bot",
|
||
errors=errors,
|
||
warnings=warnings,
|
||
severity="warning" if _has(config, "slack_webhook_url") else "error",
|
||
)
|
||
|
||
if (_has(config, "feishu_webhook_secret") or _has(config, "feishu_webhook_keyword")) and not _has(config, "feishu_webhook_url"):
|
||
warnings.append(
|
||
_issue(
|
||
"warning",
|
||
"advanced_without_minimal",
|
||
"已配置飞书 Webhook 高级安全项,但缺少 FEISHU_WEBHOOK_URL,飞书 Webhook 渠道不会启用。",
|
||
key="FEISHU_WEBHOOK_URL",
|
||
)
|
||
)
|
||
if _has(config, "pushplus_topic") and not _has(config, "pushplus_token"):
|
||
warnings.append(
|
||
_issue(
|
||
"warning",
|
||
"advanced_without_minimal",
|
||
"已配置 PUSHPLUS_TOPIC,但缺少 PUSHPLUS_TOKEN,PushPlus 渠道不会启用。",
|
||
key="PUSHPLUS_TOKEN",
|
||
)
|
||
)
|
||
if _has(config, "ntfy_token") and not _has(config, "ntfy_url"):
|
||
warnings.append(
|
||
_issue(
|
||
"warning",
|
||
"advanced_without_minimal",
|
||
"已配置 NTFY_TOKEN,但缺少 NTFY_URL,ntfy 渠道不会启用。",
|
||
key="NTFY_URL",
|
||
)
|
||
)
|
||
if (
|
||
_has(config, "custom_webhook_bearer_token")
|
||
or _has(config, "custom_webhook_body_template")
|
||
) and not _has(config, "custom_webhook_urls"):
|
||
warnings.append(
|
||
_issue(
|
||
"warning",
|
||
"advanced_without_minimal",
|
||
"已配置自定义 Webhook 高级项,但缺少 CUSTOM_WEBHOOK_URLS,自定义 Webhook 渠道不会启用。",
|
||
key="CUSTOM_WEBHOOK_URLS",
|
||
)
|
||
)
|
||
if _has(config, "astrbot_token") and not _has(config, "astrbot_url"):
|
||
warnings.append(
|
||
_issue(
|
||
"warning",
|
||
"advanced_without_minimal",
|
||
"已配置 ASTRBOT_TOKEN,但缺少 ASTRBOT_URL,AstrBot 渠道不会启用。",
|
||
key="ASTRBOT_URL",
|
||
)
|
||
)
|
||
|
||
configured_set = set(configured)
|
||
for route_type, route_config in NOTIFICATION_ROUTE_CONFIGS.items():
|
||
route_channels = getattr(config, route_config["config_attr"], []) or []
|
||
if not route_channels:
|
||
continue
|
||
|
||
valid_channels, invalid_channels = split_notification_route_channels(route_channels)
|
||
if invalid_channels:
|
||
errors.append(
|
||
_issue(
|
||
"error",
|
||
"invalid_route_channel",
|
||
(
|
||
f"{route_config['env_key']} 包含未知通知渠道: {', '.join(invalid_channels)};"
|
||
f"允许值: {', '.join(ROUTABLE_NOTIFICATION_CHANNELS)}。"
|
||
),
|
||
key=route_config["env_key"],
|
||
)
|
||
)
|
||
|
||
disabled_channels = [channel for channel in valid_channels if channel not in configured_set]
|
||
if disabled_channels:
|
||
warnings.append(
|
||
_issue(
|
||
"warning",
|
||
"route_channel_not_configured",
|
||
(
|
||
f"{route_config['env_key']} 路由 {route_type} 指向未启用渠道: "
|
||
f"{', '.join(disabled_channels)};这些渠道不会收到该类型通知。"
|
||
),
|
||
key=route_config["env_key"],
|
||
)
|
||
)
|
||
|
||
if getattr(config, "notification_quiet_hours", ""):
|
||
try:
|
||
parse_notification_quiet_hours(config.notification_quiet_hours)
|
||
except ValueError as exc:
|
||
errors.append(
|
||
_issue(
|
||
"error",
|
||
"invalid_quiet_hours",
|
||
f"NOTIFICATION_QUIET_HOURS 配置无效: {exc}",
|
||
key="NOTIFICATION_QUIET_HOURS",
|
||
)
|
||
)
|
||
|
||
if getattr(config, "notification_timezone", ""):
|
||
try:
|
||
validate_notification_timezone(config.notification_timezone)
|
||
except ValueError as exc:
|
||
errors.append(
|
||
_issue(
|
||
"error",
|
||
"invalid_notification_timezone",
|
||
f"NOTIFICATION_TIMEZONE 配置无效: {exc}",
|
||
key="NOTIFICATION_TIMEZONE",
|
||
)
|
||
)
|
||
|
||
min_severity = getattr(config, "notification_min_severity", "") or ""
|
||
if min_severity and not is_supported_notification_severity(min_severity):
|
||
errors.append(
|
||
_issue(
|
||
"error",
|
||
"invalid_notification_min_severity",
|
||
(
|
||
"NOTIFICATION_MIN_SEVERITY 配置无效;"
|
||
f"允许值: {', '.join(NOTIFICATION_SEVERITIES)}。"
|
||
),
|
||
key="NOTIFICATION_MIN_SEVERITY",
|
||
)
|
||
)
|
||
|
||
if getattr(config, "notification_daily_digest_enabled", False):
|
||
warnings.append(
|
||
_issue(
|
||
"warning",
|
||
"reserved_daily_digest",
|
||
(
|
||
"NOTIFICATION_DAILY_DIGEST_ENABLED 当前为预留配置;"
|
||
"P4 不会发送每日摘要或持久化摘要内容。"
|
||
),
|
||
key="NOTIFICATION_DAILY_DIGEST_ENABLED",
|
||
)
|
||
)
|
||
|
||
return NotificationDiagnosticResult(
|
||
configured_channels=configured,
|
||
errors=tuple(errors),
|
||
warnings=tuple(warnings),
|
||
info=tuple(info),
|
||
)
|
||
|
||
|
||
def _format_issues(title: str, issues: Sequence[NotificationDiagnosticIssue]) -> List[str]:
|
||
if not issues:
|
||
return [f"{title}: 无"]
|
||
lines = [f"{title}:"]
|
||
for item in issues:
|
||
key_suffix = f" [{item.key}]" if item.key else ""
|
||
lines.append(f"- {item.code}{key_suffix}: {item.message}")
|
||
return lines
|
||
|
||
|
||
def format_notification_diagnostics(result: NotificationDiagnosticResult) -> str:
|
||
"""Format diagnostics for CLI output without exposing secret values."""
|
||
|
||
lines = [
|
||
"通知配置诊断",
|
||
f"已配置渠道: {len(result.configured_channels)} 个",
|
||
]
|
||
if result.configured_channels:
|
||
channel_names = [
|
||
ChannelDetector.get_channel_name(NotificationChannel(channel))
|
||
for channel in result.configured_channels
|
||
]
|
||
lines.append("渠道列表: " + ", ".join(channel_names))
|
||
else:
|
||
lines.append("渠道列表: (无)")
|
||
|
||
lines.append("")
|
||
lines.extend(_format_issues("Errors", result.errors))
|
||
lines.append("")
|
||
lines.extend(_format_issues("Warnings", result.warnings))
|
||
lines.append("")
|
||
lines.extend(_format_issues("Info", result.info))
|
||
return "\n".join(lines)
|