Files
2meet-data-optimizer/includes/notifications/abstract-class-tmdo-notifier.php
T
wpdev 76c01e44df refactor: 全部 128 個生產檔加入 declare(strict_types=1)(PR-H)
對齊 A v3.2.0。型別強制會把隱式轉換變成 TypeError,所以一次全檔加入
並跑完整測試(unit 451 / integration 398 全綠,無迴歸)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 06:13:33 +08:00

252 lines
7.8 KiB
PHP
Raw 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.
<?php // phpcs:ignore Squiz.Classes.ClassFileName.NoMatch -- Abstract class; filename intentionally uses 'abstract-class-' prefix.
/**
* TMDO_Abstract_Notifier — Base for all health-alert notifiers (v2.5.0 M15).
*
* Subclasses (Email / Slack / Telegram / Discord) inherit:
* - wpdo/health_alert_critical action subscription
* - opt-in toggle (`wpdo_{channel}_enabled`)
* - 24h-default throttle keyed by md5 fingerprint of summary
* - shared subject / body builders (overridable)
* - severity subscription (critical_only / critical_and_recommended)
*
* Subclass只 implements `actually_send( $subject, $body, $context ): bool`
* and `channel_id(): string` (used as option-key prefix).
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Abstract base — concrete subclasses below.
*/
abstract class TMDO_Abstract_Notifier {
/** Default throttle window. */
public const DEFAULT_THROTTLE_HOURS = 24;
/**
* Channel ID — used as option key prefix (e.g. 'email', 'slack').
*
* @return string
*/
abstract public static function channel_id(): string;
/**
* Channel-specific delivery. Implementations should return false on
* transient failure so caller can re-throttle attempts.
*
* @param string $subject Subject / title (Slack uses as text-prefix).
* @param string $body Body text (channel-specific markdown).
* @param array $context Optional context (recipient/webhook URL/etc.).
* @return bool
*/
abstract public static function actually_send( string $subject, string $body, array $context ): bool;
/**
* Hook subclass into `wpdo/health_alert_critical`.
*
* @return void
*/
public static function register(): void {
add_action( 'wpdo/health_alert_critical', array( static::class, 'maybe_send' ), 10, 1 );
}
/**
* Decide whether to send + throttle + dispatch.
*
* @param array $summary Summary array from Health_Cron::run().
* @return bool
*/
public static function maybe_send( array $summary ): bool {
if ( ! static::is_enabled() ) {
return false;
}
// Severity subscription check.
$severity_filter = static::severity_filter();
if ( 'critical_only' === $severity_filter && (int) ( $summary['critical_count'] ?? 0 ) === 0 ) {
return false;
}
// Channel-specific recipient/config sanity check.
$context = static::build_context();
if ( null === $context ) {
return false;
}
// Throttle.
$fingerprint = static::fingerprint( $summary );
if ( static::is_throttled( $fingerprint ) ) {
return false;
}
// Build + send.
$subject = static::build_subject( $summary );
$body = static::build_body( $summary );
$sent = static::actually_send( $subject, $body, $context );
if ( $sent ) {
static::mark_sent( $fingerprint );
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
sprintf( '%s_alert_sent', static::channel_id() ),
array(
'fingerprint' => $fingerprint,
'critical' => (int) ( $summary['critical_count'] ?? 0 ),
)
);
}
}
return $sent;
}
// ─── settings accessorsoptional override)────────────────────────
/**
* Whether this notification channel is enabled.
*
* @return bool
*/
public static function is_enabled(): bool {
return '1' === (string) get_option( static::option_key( 'enabled' ), '0' );
}
/**
* Throttle window in hours (clamped 1..168).
*
* @return int
*/
public static function throttle_hours(): int {
$v = (int) get_option( static::option_key( 'throttle_hours' ), self::DEFAULT_THROTTLE_HOURS );
return max( 1, min( 168, $v ) );
}
/**
* Severity subscription filter.
*
* @return string 'critical_only' or 'critical_and_recommended'
*/
public static function severity_filter(): string {
$v = (string) get_option( static::option_key( 'severity' ), 'critical_only' );
return in_array( $v, array( 'critical_only', 'critical_and_recommended' ), true ) ? $v : 'critical_only';
}
/**
* Build channel-specific context (e.g. webhook URL or recipient address).
* Return null to skip send (e.g. invalid email or empty webhook).
*
* @return array|null
*/
public static function build_context(): ?array {
return array();
}
// ─── shared subject / body builders ───────────────────────────────
/**
* Build alert subject line.
*
* @param array $summary Summary array from Health_Cron::run().
* @return string
*/
public static function build_subject( array $summary ): string {
$site = (string) get_option( 'blogname', 'WordPress' );
$crit = (int) ( $summary['critical_count'] ?? 0 );
$first = '';
foreach ( (array) ( $summary['tests'] ?? array() ) as $slug => $t ) {
if ( 'critical' === ( $t['status'] ?? '' ) ) {
$first = $slug;
break;
}
}
return sprintf( '[%s] WPDO 警告:%d 項 critical (%s)', $site, $crit, $first );
}
/**
* Generic plain-text body. Subclasses can override to add channel-specific
* formatting (Slack mrkdwn, Discord markdown, Telegram MarkdownV2, etc.).
*
* @param array $summary Summary.
* @return string
*/
public static function build_body( array $summary ): string {
$lines = array();
$lines[] = sprintf( '站台:%s', home_url( '/' ) );
$lines[] = sprintf( '檢查時間:%s UTC', (string) ( $summary['ran_at'] ?? '?' ) );
$lines[] = sprintf(
'結果:%d critical / %d recommended',
(int) ( $summary['critical_count'] ?? 0 ),
(int) ( $summary['recommended_count'] ?? 0 )
);
$lines[] = '';
$lines[] = '失敗的檢查:';
foreach ( (array) ( $summary['tests'] ?? array() ) as $slug => $t ) {
if ( in_array( ( $t['status'] ?? '' ), array( 'critical', 'recommended' ), true ) ) {
$lines[] = sprintf(
' [%s] %s — %s',
strtoupper( (string) ( $t['status'] ?? '' ) ),
$slug,
(string) ( $t['description'] ?? '' )
);
}
}
$lines[] = '';
$lines[] = '查看詳情:' . admin_url( 'tools.php?page=wp-data-optimizer&tab=doctor' );
return implode( "\n", $lines );
}
// ─── private helpers ─────────────────────────────────────────────
/**
* Per-channel option key (e.g. wpdo_email_enabled, wpdo_slack_webhook).
*
* @param string $field 'enabled' / 'throttle_hours' / 'severity' / etc.
* @return string
*/
protected static function option_key( string $field ): string {
return sprintf( 'wpdo_%s_%s', static::channel_id(), $field );
}
/**
* Stable fingerprint from the alert content for deduplication.
*
* @param array $summary Summary array from Health_Cron::run().
* @return string md5 hash.
*/
protected static function fingerprint( array $summary ): string {
$relevant = array(
'critical_count' => (int) ( $summary['critical_count'] ?? 0 ),
'first_critical' => null,
);
foreach ( (array) ( $summary['tests'] ?? array() ) as $slug => $t ) {
if ( 'critical' === ( $t['status'] ?? '' ) ) {
$relevant['first_critical'] = $slug;
break;
}
}
return md5( wp_json_encode( $relevant ) );
}
/**
* Check whether an alert with this fingerprint was recently sent.
*
* @param string $fingerprint Alert fingerprint (md5).
* @return bool
*/
protected static function is_throttled( string $fingerprint ): bool {
$key = sprintf( 'wpdo_%s_sent_%s', static::channel_id(), $fingerprint );
return false !== get_transient( $key );
}
/**
* Record that an alert was sent (sets throttle transient).
*
* @param string $fingerprint Alert fingerprint (md5).
* @return void
*/
protected static function mark_sent( string $fingerprint ): void {
$key = sprintf( 'wpdo_%s_sent_%s', static::channel_id(), $fingerprint );
set_transient( $key, time(), static::throttle_hours() * HOUR_IN_SECONDS );
}
}