Files
daily_stock_analysis/src/services/notification_diagnostics.py
Sam Smith a3c039ac13 feat: add feishu file upload support for report delivery (#1932)
* 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
2026-07-06 21:26:19 +08:00

602 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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_TOKENPushPlus 渠道不会启用。",
key="PUSHPLUS_TOKEN",
)
)
if _has(config, "ntfy_token") and not _has(config, "ntfy_url"):
warnings.append(
_issue(
"warning",
"advanced_without_minimal",
"已配置 NTFY_TOKEN但缺少 NTFY_URLntfy 渠道不会启用。",
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_URLAstrBot 渠道不会启用。",
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)