76c01e44df
對齊 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
252 lines
7.8 KiB
PHP
252 lines
7.8 KiB
PHP
<?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 accessors(optional 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 );
|
||
}
|
||
}
|