..., 'text' => ... ] ) with an // application/json header. Only parse_str() was tried, which on // '{"chat_id":"1","text":"..."}' yields one nonsense key and no 'text', // so EVERY entry in this log stored an empty body, and because // level_from() reads the body, every entry also recorded level 'info'. // A log whose whole purpose is "what did that alert say" answered // nothing, and its severity column said nothing was ever wrong. Verified // on the live site: 29 entries, all body:"" and level:"info", including // the CRITICAL alerts sent that day. // // parse_str() is kept as the fallback: a form-encoded sender (another // plugin's copy of the shared class, or a hand-rolled wp_remote_post) // must still be captured. $decoded_body = json_decode( $posted, true ); if ( is_array( $decoded_body ) ) { $posted = $decoded_body; } else { $parsed = array(); parse_str( $posted, $parsed ); $posted = $parsed; } } if ( is_array( $posted ) ) { $body = (string) ( $posted['text'] ?? '' ); } } $r = CSDT_Http::json( $response ); $code = $r['code']; $error = ''; if ( 0 === $code ) { $error = $r['error']; } elseif ( $code < 200 || $code > 299 ) { // Telegram explains refusals in the body, a revoked token, the bot // removed from the chat, and that explanation is the whole value of // logging a failure at all. $error = CSDT_Http::reason( $r ); } self::add( array( 'at' => time(), 'body' => self::trim_body( $body ), 'level' => self::level_from( $body ), 'code' => $code, 'ok' => ( '' === $error ), 'error' => (string) substr( $error, 0, 200 ), ) ); // The one point every alert passes, whichever plugin sent it, so other channels // (CSDT_Webhook) listen here instead of at each of the ~35 send() call sites. // Fired whether or not Telegram accepted it: a second channel is most useful // exactly when the first one failed. if ( function_exists( 'do_action' ) ) { do_action( 'csdt_alert_captured', $body, self::level_from( $body ) ); } } catch ( \Throwable $e ) { // A log must never be able to break the thing it is logging, least of all the // alert channel. Not routed through CSDT_Monitor either: CS Monitor raises // Telegram alerts, so logging an exception from inside the Telegram send path // could send another alert and recurse. return; } } /** * Cuts a body down for storage. * * The token is in the URL, never the body, so there is nothing secret to strip here, * but the body can be long, and 100 full alert texts in one option row is a page of * wp_options nobody needs. * * @since 1.10.488 * @param string $body Message text. * @return string */ public static function trim_body( string $body ): string { $body = trim( $body ); if ( strlen( $body ) <= self::BODY_MAX ) { return $body; } return substr( $body, 0, self::BODY_MAX ) . '…'; } /** * The severity a message announces about itself. * * Read from the prefix CSDT_Telegram::alert_prefix() puts on every message, so * this needs no cooperation from the shared class. Falls back to 'info' rather than * guessing, because a wrong severity in a log is worse than an absent one. * * @since 1.10.488 * @param string $body Message text. * @return string One of critical|warning|info. */ public static function level_from( string $body ): string { // No text, no verdict. This returned 'info' for an empty body, so when the capture // silently stopped extracting the body, it parsed JSON as form-encoded for months, // every entry in the log claimed the mildest severity there is. 29 rows on the live // site read "info" including that day's CRITICAL alerts. A missing measurement must // never render as good news; it has to say it is missing. if ( '' === trim( $body ) ) { return 'unknown'; } if ( false !== strpos( $body, '🚨' ) || false !== stripos( $body, 'CRITICAL' ) ) { return 'critical'; } if ( false !== strpos( $body, '⚠️' ) || false !== stripos( $body, 'WARNING' ) ) { return 'warning'; } return 'info'; } /** * Records a delivery failure on a channel other than Telegram (the webhook). * * Kept in the same log so "did that alert go out" has one answer. The entry carries its * channel, and recent_failures() leaves it out, because that count is the panel's claim * about Telegram specifically. * * @since 1.10.958 * @param string $channel Channel key, e.g. 'webhook'. * @param string $title What was being sent. * @param string $error Why it failed. * @param int $code HTTP status, 0 for a transport failure. * @param string $level Level of the message. */ public static function record_channel_failure( string $channel, string $title, string $error, int $code, string $level ): void { try { self::add( array( 'at' => time(), 'channel' => $channel, 'body' => self::trim_body( '[' . $channel . '] ' . $title ), 'level' => in_array( $level, array( 'critical', 'warning', 'info' ), true ) ? $level : 'info', 'code' => $code, 'ok' => false, 'error' => (string) substr( $error, 0, 200 ), ) ); } catch ( \Throwable $e ) { return; } } /** * Prepends an entry and trims the log. * * @since 1.10.488 * @param array $entry Log entry. */ private static function add( array $entry ): void { $log = get_option( self::OPTION, array() ); if ( ! is_array( $log ) ) { $log = array(); } array_unshift( $log, $entry ); update_option( self::OPTION, array_slice( $log, 0, self::MAX ), false ); } /** * The log, newest first. * * @since 1.10.488 * @return array> */ public static function entries(): array { $log = get_option( self::OPTION, array() ); return is_array( $log ) ? $log : array(); } /** * How many of the last N alerts failed to reach Telegram. * * Exists so the panel can lead with the number that matters. A log of successes is * reassuring; a count of failures is actionable. * * @since 1.10.488 * @return int */ public static function recent_failures(): int { $n = 0; foreach ( self::entries() as $entry ) { if ( ! empty( $entry['channel'] ) ) { continue; // A webhook failure is shown in the table, not counted against Telegram. } if ( empty( $entry['ok'] ) ) { ++$n; } } return $n; } /** * What the log says about Telegram delivery, for the panel's banner. * * The banner used to give one number for the whole log and blame the bot token. On a log * that reaches back a month, that reported a DNS outage from weeks ago as a present * fault while every recent alert was arriving. Whether delivery works NOW is decided by * the newest attempt, so that is reported apart from the history. * * @param array $entries Log entries, newest first. * @return array{total:int,failures:int,latest_failed:bool,last_failure:int,since_ok:int} */ public static function delivery_summary( array $entries ): array { $out = array( 'total' => 0, 'failures' => 0, 'latest_failed' => false, 'last_failure' => 0, 'since_ok' => 0, ); foreach ( $entries as $entry ) { if ( ! is_array( $entry ) || ! empty( $entry['channel'] ) ) { continue; // A webhook failure says nothing about Telegram. } ++$out['total']; if ( ! empty( $entry['ok'] ) ) { if ( 0 === $out['failures'] ) { ++$out['since_ok']; } continue; } if ( 1 === $out['total'] ) { $out['latest_failed'] = true; } if ( 0 === $out['failures'] ) { $out['last_failure'] = (int) ( $entry['at'] ?? 0 ); } ++$out['failures']; } return $out; } /** Renders the Telegram alert log on the Mail tab. */ public static function render_panel(): void { $entries = self::entries(); $delivery = self::delivery_summary( $entries ); $failures = $delivery['failures']; $muted = class_exists( 'CSDT_Telegram' ) && is_callable( array( 'CSDT_Telegram', 'is_muted' ) ) && CSDT_Telegram::is_muted(); ?>
📣 'Why this exists next to the email log', 'rec' => 'How it works', 'html' => 'Almost every security alert on this site goes to Telegram, not to email, and until now there was no record of it here at all. Telegram itself shows you the alerts that arrived, it can tell you nothing about the ones that failed to send, which are the ones worth knowing about.', ), array( 'name' => 'Where the entries come from', 'rec' => 'How it works', 'html' => 'Captured at the HTTP layer, as the message leaves the site. That means it records alerts sent by any of the CloudScale plugins, not only this one, the Telegram helper is shared between them, and on this install the copy that loads belongs to the backup plugin.

A failure shows Telegram’s own explanation where it gave one: a revoked bot token, the bot removed from the chat, or no outbound HTTPS.', ), array( 'name' => 'What is stored', 'rec' => 'Privacy', 'html' => 'The time, the severity, the outcome, and the first ' . esc_html( (string) self::BODY_MAX ) . ' characters of the message. The last ' . esc_html( (string) self::MAX ) . ' alerts are kept.

The bot token travels in the request URL and is never stored here.', ), array( 'name' => 'An empty log is not proof of silence', 'rec' => 'Honest limits', 'html' => 'Alerts are rate-limited on purpose, the auto-blocker sends at most five in fifteen minutes, and the front-end error monitor batches per page load, so a quiet log during a busy attack is the throttle working, not a gap. The auto-block panel holds the complete record of blocks regardless of how many alerts went out.

This log also only covers alerts sent since this feature shipped. It is not backfilled.', ), ) ); ?>
0 ) : ?>

$entry ) : ?>
✓ ✕