chore: initial snapshot of 2meet-data-optimizer v0.1.0
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
<?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
|
||||
*/
|
||||
|
||||
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 );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user