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 );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Discord_Notifier — Discord webhook channel (v2.5.0 M15).
|
||||
*
|
||||
* Settings: wpdo_discord_enabled, wpdo_discord_webhook,
|
||||
* wpdo_discord_throttle_hours, wpdo_discord_severity.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord channel.
|
||||
*/
|
||||
class TMDO_Discord_Notifier extends TMDO_Abstract_Notifier {
|
||||
|
||||
/**
|
||||
* Channel identifier used as option key prefix.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function channel_id(): string {
|
||||
return 'discord';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Discord-specific context (webhook URL).
|
||||
*
|
||||
* @return array|null Null if webhook URL is missing or invalid.
|
||||
*/
|
||||
public static function build_context(): ?array {
|
||||
$url = class_exists( 'TMDO_Crypto' )
|
||||
? TMDO_Crypto::get_option( 'wpdo_discord_webhook' )
|
||||
: (string) get_option( 'wpdo_discord_webhook', '' );
|
||||
if ( '' === $url || strpos( $url, 'https://discord.com/api/webhooks/' ) !== 0 ) {
|
||||
return null;
|
||||
}
|
||||
return array( 'webhook_url' => $url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Send alert via Discord webhook.
|
||||
*
|
||||
* @param string $subject Alert subject / title.
|
||||
* @param string $body Alert body text.
|
||||
* @param array $context Channel context (webhook_url).
|
||||
* @return bool True on HTTP 2xx response.
|
||||
*/
|
||||
public static function actually_send( string $subject, string $body, array $context ): bool {
|
||||
$url = (string) ( $context['webhook_url'] ?? '' );
|
||||
if ( '' === $url ) {
|
||||
return false;
|
||||
}
|
||||
// Discord max content length = 2000 chars.
|
||||
$content = "**{$subject}**\n```\n" . substr( $body, 0, 1800 ) . "\n```";
|
||||
$payload = wp_json_encode( array( 'content' => $content ) );
|
||||
$resp = wp_remote_post(
|
||||
$url,
|
||||
array(
|
||||
'headers' => array( 'Content-Type' => 'application/json' ),
|
||||
'body' => $payload,
|
||||
'timeout' => 5,
|
||||
'blocking' => true,
|
||||
)
|
||||
);
|
||||
if ( is_wp_error( $resp ) ) {
|
||||
return false;
|
||||
}
|
||||
$code = (int) wp_remote_retrieve_response_code( $resp );
|
||||
// Discord returns 204 on success.
|
||||
return $code >= 200 && $code < 300;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Email_Notifier — Threshold-based email alerts (v2.4.0 M10).
|
||||
*
|
||||
* Subscribes to action `wpdo/health_alert_critical` (fired by Health_Cron M6
|
||||
* when critical_count > 0). Sends a plain-text email to the configured
|
||||
* recipient(s) with a 24h throttle key (per-alert-fingerprint) so admins
|
||||
* don't get spammed.
|
||||
*
|
||||
* Default OFF — admin must explicitly enable via Settings tab.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email notifier — stateless static API.
|
||||
*/
|
||||
class TMDO_Email_Notifier {
|
||||
|
||||
public const OPT_ENABLED = 'wpdo_email_alerts_enabled';
|
||||
public const OPT_RECIPIENT = 'wpdo_alert_email';
|
||||
public const OPT_THROTTLE_HRS = 'wpdo_alert_throttle_hours';
|
||||
|
||||
/** Default throttle window. */
|
||||
public const DEFAULT_THROTTLE_HOURS = 24;
|
||||
|
||||
/**
|
||||
* Register subscriber.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'wpdo/health_alert_critical', array( __CLASS__, 'maybe_send' ), 10, 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide + send.
|
||||
*
|
||||
* @param array $summary Health summary from Health_Cron::run().
|
||||
* @return bool true on send, false on skip / fail.
|
||||
*/
|
||||
public static function maybe_send( array $summary ): bool {
|
||||
if ( ! self::is_enabled() ) {
|
||||
return false;
|
||||
}
|
||||
$recipient = self::recipient();
|
||||
if ( ! is_email( $recipient ) ) {
|
||||
return false;
|
||||
}
|
||||
$fingerprint = self::fingerprint( $summary );
|
||||
if ( self::is_throttled( $fingerprint ) ) {
|
||||
return false;
|
||||
}
|
||||
$subject = self::build_subject( $summary );
|
||||
$body = self::build_body( $summary );
|
||||
$sent = wp_mail( $recipient, $subject, $body );
|
||||
if ( $sent ) {
|
||||
self::mark_sent( $fingerprint );
|
||||
if ( class_exists( 'TMDO_Logger' ) ) {
|
||||
TMDO_Logger::info(
|
||||
'email_alert_sent',
|
||||
array(
|
||||
'fingerprint' => $fingerprint,
|
||||
'critical' => (int) ( $summary['critical_count'] ?? 0 ),
|
||||
'recipient' => $recipient,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
return (bool) $sent;
|
||||
}
|
||||
|
||||
// ─── settings accessors ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Whether email alerts are enabled.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_enabled(): bool {
|
||||
return '1' === (string) get_option( self::OPT_ENABLED, '0' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert recipient email address (falls back to admin_email).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function recipient(): string {
|
||||
$v = (string) get_option( self::OPT_RECIPIENT, '' );
|
||||
if ( '' === $v ) {
|
||||
$v = (string) get_option( 'admin_email', '' );
|
||||
}
|
||||
return $v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle window in hours (clamped 1..168).
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public static function throttle_hours(): int {
|
||||
$v = (int) get_option( self::OPT_THROTTLE_HRS, self::DEFAULT_THROTTLE_HOURS );
|
||||
return max( 1, min( 168, $v ) ); // Clamp 1h..1week.
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stable fingerprint from the alert content (so identical incidents
|
||||
* dedupe within the throttle window).
|
||||
*
|
||||
* @param array $summary Summary array.
|
||||
* @return string md5 hash.
|
||||
*/
|
||||
private 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
|
||||
*/
|
||||
private static function is_throttled( string $fingerprint ): bool {
|
||||
$key = 'wpdo_alert_sent_' . $fingerprint;
|
||||
return false !== get_transient( $key );
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that an alert was sent (sets throttle transient).
|
||||
*
|
||||
* @param string $fingerprint Alert fingerprint (md5).
|
||||
* @return void
|
||||
*/
|
||||
private static function mark_sent( string $fingerprint ): void {
|
||||
$key = 'wpdo_alert_sent_' . $fingerprint;
|
||||
set_transient( $key, time(), self::throttle_hours() * HOUR_IN_SECONDS );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build subject. Site name + critical count + first slug.
|
||||
*
|
||||
* @param array $summary Summary array.
|
||||
* @return string
|
||||
*/
|
||||
private 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(
|
||||
/* translators: 1: site name, 2: critical count, 3: first critical test slug */
|
||||
__( '[%1$s] WPDO 警告:%2$d 項 critical (%3$s)', '2meet-data-optimizer' ),
|
||||
$site,
|
||||
$crit,
|
||||
$first
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build plain-text body.
|
||||
*
|
||||
* @param array $summary Summary array.
|
||||
* @return string
|
||||
*/
|
||||
private static function build_body( array $summary ): string {
|
||||
$site_url = home_url( '/' );
|
||||
$health_url = admin_url( 'site-health.php' );
|
||||
$wpdo_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=doctor' );
|
||||
$lines = array();
|
||||
$lines[] = __( 'WP Data Optimizer 自動健康檢查發現 critical 警告。', '2meet-data-optimizer' );
|
||||
$lines[] = '';
|
||||
$lines[] = sprintf( '站台:%s', $site_url );
|
||||
$lines[] = sprintf(
|
||||
/* translators: %s: timestamp */
|
||||
__( '檢查時間:%s UTC', '2meet-data-optimizer' ),
|
||||
(string) ( $summary['ran_at'] ?? '?' )
|
||||
);
|
||||
$lines[] = sprintf(
|
||||
/* translators: 1: critical count, 2: recommended count */
|
||||
__( '結果:%1$d critical / %2$d recommended', '2meet-data-optimizer' ),
|
||||
(int) ( $summary['critical_count'] ?? 0 ),
|
||||
(int) ( $summary['recommended_count'] ?? 0 )
|
||||
);
|
||||
$lines[] = '';
|
||||
$lines[] = __( '失敗的檢查:', '2meet-data-optimizer' );
|
||||
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[] = __( '建議行動:', '2meet-data-optimizer' );
|
||||
$lines[] = ' · ' . sprintf(
|
||||
/* translators: %s: WPDO Doctor admin URL */
|
||||
__( '立即查看 WPDO Doctor:%s', '2meet-data-optimizer' ),
|
||||
$wpdo_url
|
||||
);
|
||||
$lines[] = ' · ' . sprintf(
|
||||
/* translators: %s: WP Site Health admin URL */
|
||||
__( '或 WP Site Health:%s', '2meet-data-optimizer' ),
|
||||
$health_url
|
||||
);
|
||||
$lines[] = '';
|
||||
$lines[] = sprintf(
|
||||
/* translators: %d: hours */
|
||||
__( '註:相同警告在 %d 小時內不會重發;前往設定可調整 throttle / 收件人 / 關閉。', '2meet-data-optimizer' ),
|
||||
self::throttle_hours()
|
||||
);
|
||||
return implode( "\n", $lines );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Slack_Notifier — Slack incoming webhook channel (v2.5.0 M15).
|
||||
*
|
||||
* Settings: wpdo_slack_enabled, wpdo_slack_webhook, wpdo_slack_throttle_hours,
|
||||
* wpdo_slack_severity. Webhook URL must start with https://hooks.slack.com/.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slack channel.
|
||||
*/
|
||||
class TMDO_Slack_Notifier extends TMDO_Abstract_Notifier {
|
||||
|
||||
/**
|
||||
* Channel identifier used as option key prefix.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function channel_id(): string {
|
||||
return 'slack';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Slack-specific context (webhook URL).
|
||||
*
|
||||
* @return array|null Null if webhook URL is missing or invalid.
|
||||
*/
|
||||
public static function build_context(): ?array {
|
||||
$url = class_exists( 'TMDO_Crypto' )
|
||||
? TMDO_Crypto::get_option( 'wpdo_slack_webhook' )
|
||||
: (string) get_option( 'wpdo_slack_webhook', '' );
|
||||
if ( '' === $url || strpos( $url, 'https://hooks.slack.com/' ) !== 0 ) {
|
||||
return null;
|
||||
}
|
||||
return array( 'webhook_url' => $url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Send alert via Slack incoming webhook.
|
||||
*
|
||||
* @param string $subject Alert subject / title.
|
||||
* @param string $body Alert body text.
|
||||
* @param array $context Channel context (webhook_url).
|
||||
* @return bool True on HTTP 2xx response.
|
||||
*/
|
||||
public static function actually_send( string $subject, string $body, array $context ): bool {
|
||||
$url = (string) ( $context['webhook_url'] ?? '' );
|
||||
if ( '' === $url ) {
|
||||
return false;
|
||||
}
|
||||
// Slack mrkdwn — bold subject + plaintext body in code block for readability.
|
||||
$payload = wp_json_encode(
|
||||
array(
|
||||
'text' => "*{$subject}*\n```\n{$body}\n```",
|
||||
)
|
||||
);
|
||||
$resp = wp_remote_post(
|
||||
$url,
|
||||
array(
|
||||
'headers' => array( 'Content-Type' => 'application/json' ),
|
||||
'body' => $payload,
|
||||
'timeout' => 5,
|
||||
'blocking' => true,
|
||||
)
|
||||
);
|
||||
if ( is_wp_error( $resp ) ) {
|
||||
return false;
|
||||
}
|
||||
$code = (int) wp_remote_retrieve_response_code( $resp );
|
||||
return $code >= 200 && $code < 300;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Telegram_Notifier — Telegram bot channel (v2.5.0 M15).
|
||||
*
|
||||
* Settings: wpdo_telegram_enabled, wpdo_telegram_bot_token,
|
||||
* wpdo_telegram_chat_id, wpdo_telegram_throttle_hours, wpdo_telegram_severity.
|
||||
*
|
||||
* Bot token is sensitive: stored as-is in wp_options for now (admin-only).
|
||||
* Future hardening: integrate TMDO_Crypto encryption (note in v2.5+).
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram channel.
|
||||
*/
|
||||
class TMDO_Telegram_Notifier extends TMDO_Abstract_Notifier {
|
||||
|
||||
/**
|
||||
* Channel identifier used as option key prefix.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function channel_id(): string {
|
||||
return 'telegram';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Telegram-specific context (bot token + chat ID).
|
||||
*
|
||||
* @return array|null Null if token or chat ID is missing or invalid.
|
||||
*/
|
||||
public static function build_context(): ?array {
|
||||
$token = class_exists( 'TMDO_Crypto' )
|
||||
? TMDO_Crypto::get_option( 'wpdo_telegram_bot_token' )
|
||||
: (string) get_option( 'wpdo_telegram_bot_token', '' );
|
||||
$chat = (string) get_option( 'wpdo_telegram_chat_id', '' );
|
||||
if ( '' === $token || '' === $chat ) {
|
||||
return null;
|
||||
}
|
||||
// Bot tokens are formatted as 123456:ABC-DEF...; minimal validation.
|
||||
if ( ! preg_match( '/^\d+:[A-Za-z0-9_\-]{20,}$/', $token ) ) {
|
||||
return null;
|
||||
}
|
||||
return array(
|
||||
'token' => $token,
|
||||
'chat_id' => $chat,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send alert via Telegram Bot API.
|
||||
*
|
||||
* @param string $subject Alert subject / title.
|
||||
* @param string $body Alert body text.
|
||||
* @param array $context Channel context (token, chat_id).
|
||||
* @return bool True on HTTP 2xx response.
|
||||
*/
|
||||
public static function actually_send( string $subject, string $body, array $context ): bool {
|
||||
$token = (string) ( $context['token'] ?? '' );
|
||||
$chat = (string) ( $context['chat_id'] ?? '' );
|
||||
if ( '' === $token || '' === $chat ) {
|
||||
return false;
|
||||
}
|
||||
// Telegram MarkdownV2 has many escaped chars; use plain text mode for safety.
|
||||
$text = "🚨 {$subject}\n\n{$body}";
|
||||
// Telegram message limit = 4096 chars.
|
||||
$text = substr( $text, 0, 4000 );
|
||||
|
||||
$url = sprintf( 'https://api.telegram.org/bot%s/sendMessage', rawurlencode( $token ) );
|
||||
$resp = wp_remote_post(
|
||||
$url,
|
||||
array(
|
||||
'headers' => array( 'Content-Type' => 'application/json' ),
|
||||
'body' => wp_json_encode(
|
||||
array(
|
||||
'chat_id' => $chat,
|
||||
'text' => $text,
|
||||
)
|
||||
),
|
||||
'timeout' => 5,
|
||||
'blocking' => true,
|
||||
)
|
||||
);
|
||||
if ( is_wp_error( $resp ) ) {
|
||||
return false;
|
||||
}
|
||||
$code = (int) wp_remote_retrieve_response_code( $resp );
|
||||
return $code >= 200 && $code < 300;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user