; https://`, which puts the site's * address in the request header of every alert. The alert body carries the site URL on * purpose and the readme says so, but the header is undeclared, and this class is shared * by five plugins so it cannot borrow any one of their version constants. */ const USER_AGENT = 'CloudScale-Telegram'; /** * Option names: every one carries this plugin's own prefix. * * WordPress.org requires everything a plugin stores to carry its own prefix. These rows * used to be shared by all five plugins under one name (`cloudscale_` until 18 Aug 2026, * then Cyber DevTools' `csdt_`), and WordPress.org flagged both in reviews of the other * plugins. So the canonical source writes `csdt_` and the shared-code sync * stamps each plugin's prefix over it when copying this file in: Backup & * Restore stores `csbr_telegram_bot_token`, Cyber DevTools `csdt_telegram_bot_token`, and * so on. check-shared-copies.php fails the build if a copy stores a name without its * plugin's prefix, so this cannot regress by hand. * * One Telegram configuration for the whole suite is kept by hooks rather than by a shared * row (see register_siblings()): a save in any plugin is offered to the others, and a * plugin with nothing configured asks the others. Only two of the five plugins have a * Telegram settings card, so without this the other three could never alert. */ const OPTION_TOKEN = 'csdt_telegram_bot_token'; // pragma:allow-secret const OPTION_CHAT_ID = 'csdt_telegram_chat_id'; /** Set once this copy has adopted the values stored under the old names. */ const OPTION_MIGRATED = 'csdt_alert_options_migrated'; /** * This plugin's own prefix, as stamped. Hook names are built from it. */ const OWN_PREFIX = 'csdt_'; /** * The prefixes of the five plugins that carry this class, for the sibling hooks. * * These are prefixes of OTHER plugins' hooks this copy listens to, which WordPress allows * any plugin to do; nothing is stored or declared under them. The SEO plugin moved from * cs_seo_ to cloudscale_seo_ai_optimizer_ in 4.21.670; both are listed so a site running an * older SEO copy beside a newer sibling, or the other way round, still connects. */ const SIBLING_PREFIXES = array( 'csdt_', 'csbr_', 'cscc_', 'cspv_', 'cloudscale_seo_ai_optimizer_', 'cs_seo_' ); /** Settings that are one configuration for the whole suite, by option suffix. */ const SHARED_SUFFIXES = array( 'telegram_bot_token', 'telegram_chat_id', 'alert_env_label' ); /** @var bool Whether maybe_migrate() has run in this request. */ private static $migration_checked = false; /** * Adopts the values an earlier version stored under the suite-wide names. Runs once. * * The ONLY place the old names appear. A site that configured Telegram before the rename * keeps alerting the moment this ships, with no re-entry, including a site where this is the * only CloudScale plugin. The old rows are read, never written or deleted: an older copy of * another CloudScale plugin on the same site may still be using them. */ private static function maybe_migrate(): void { if ( self::$migration_checked || ! function_exists( 'get_option' ) ) { return; } self::$migration_checked = true; if ( '1' === (string) get_option( self::OPTION_MIGRATED, '' ) ) { return; } $old_names = array( self::OPTION_TOKEN => array( 'csdt_telegram_bot_token', 'cloudscale_telegram_bot_token' ), // pragma:allow-secret self::OPTION_CHAT_ID => array( 'csdt_telegram_chat_id', 'cloudscale_telegram_chat_id' ), self::OPTION_MUTED => array( 'csdt_alerts_muted', 'cloudscale_alerts_muted' ), self::OPTION_ENV_LABEL => array( 'csdt_alert_env_label' ), ); foreach ( $old_names as $own => $olds ) { if ( self::has_value( get_option( $own, null ) ) ) { continue; } foreach ( $olds as $old ) { if ( $old === $own ) { continue; } $value = get_option( $old, null ); if ( self::has_value( $value ) ) { update_option( $own, $value, false ); break; } } } update_option( self::OPTION_MIGRATED, '1', true ); } /** * An option that is present but empty counts as absent: preferring an empty value over a * populated one would disarm alerting silently, the failure mode this class exists to avoid. * * @param mixed $value Option value, or null when absent. */ private static function has_value( $value ): bool { return null !== $value && false !== $value && '' !== $value && array() !== $value; } /** * Reads one of this plugin's options. * * A suite-wide setting this plugin has never stored is asked of the other CloudScale * plugins and kept, so it survives one of them being deactivated. * * @param string $name Option name (one of the OPTION_ constants). * @param mixed $fallback Returned when there is no value anywhere. * @return mixed */ private static function opt_read( string $name, $fallback = '' ) { self::maybe_migrate(); $value = get_option( $name, null ); if ( self::has_value( $value ) ) { return $value; } $suffix = substr( $name, strlen( self::OWN_PREFIX ) ); if ( in_array( $suffix, self::SHARED_SUFFIXES, true ) && function_exists( 'apply_filters' ) ) { $from = apply_filters( 'csdt_alert_settings_from_siblings', array() ); if ( is_array( $from ) && isset( $from[ $suffix ] ) && self::has_value( $from[ $suffix ] ) ) { update_option( $name, $from[ $suffix ], false ); return $from[ $suffix ]; } } return $fallback; } /** * Writes one of this plugin's options. * * @param string $name Option name (one of the OPTION_ constants). * @param mixed $value Value to store. * @param bool $autoload Autoload flag. */ private static function opt_write( string $name, $value, bool $autoload = false ): void { update_option( $name, $value, $autoload ); } /** * Offers a changed suite-wide setting to the other CloudScale plugins on this site. * * @param array $settings Option suffix => value; '' clears it. */ private static function broadcast( array $settings ): void { if ( function_exists( 'do_action' ) ) { do_action( 'csdt_alert_settings_saved', $settings ); } } /** * Listens to the other CloudScale plugins' alert hooks. Called once, when this file loads. * * Three hooks per sibling, all declared by that sibling under its own prefix: * - `{prefix}alert_settings_saved`: it saved a suite-wide setting, so store it here too. * - `{prefix}alert_settings_from_siblings`: it has none stored, so answer with ours. * - `{prefix}alert_muted_by_sibling`: it asks whether any plugin here is muted. */ public static function register_siblings(): void { static $done = false; if ( $done || ! function_exists( 'add_filter' ) ) { return; } $done = true; foreach ( self::SIBLING_PREFIXES as $prefix ) { if ( self::OWN_PREFIX === $prefix ) { continue; } add_action( $prefix . 'alert_settings_saved', array( __CLASS__, 'receive_settings' ) ); add_filter( $prefix . 'alert_settings_from_siblings', array( __CLASS__, 'provide_settings' ) ); add_filter( $prefix . 'alert_muted_by_sibling', array( __CLASS__, 'provide_muted' ) ); } } /** * Stores the suite-wide settings another CloudScale plugin just saved. Not re-broadcast. * * @param mixed $settings Option suffix => value. */ public static function receive_settings( $settings ): void { if ( ! is_array( $settings ) ) { return; } foreach ( self::SHARED_SUFFIXES as $suffix ) { if ( ! array_key_exists( $suffix, $settings ) ) { continue; } $value = is_scalar( $settings[ $suffix ] ) ? sanitize_text_field( (string) $settings[ $suffix ] ) : ''; if ( '' === $value ) { delete_option( self::OWN_PREFIX . $suffix ); } else { update_option( self::OWN_PREFIX . $suffix, $value, false ); } } } /** * Answers another CloudScale plugin that has no suite-wide settings stored. * * @param mixed $carry What earlier siblings answered; their values win. * @return array */ public static function provide_settings( $carry ): array { $carry = is_array( $carry ) ? $carry : array(); self::maybe_migrate(); foreach ( self::SHARED_SUFFIXES as $suffix ) { if ( isset( $carry[ $suffix ] ) && self::has_value( $carry[ $suffix ] ) ) { continue; } $value = get_option( self::OWN_PREFIX . $suffix, null ); if ( self::has_value( $value ) ) { $carry[ $suffix ] = $value; } } return $carry; } /** * Answers another CloudScale plugin asking whether this site is muted. * * @param mixed $muted What earlier siblings answered. */ public static function provide_muted( $muted ): bool { self::maybe_migrate(); return (bool) $muted || '1' === (string) get_option( self::OPTION_MUTED, '' ); } /** * One switch that silences every alert from every plugin on this install. * * Until this existed there was no way to mute a host. send() gated only on the * token and chat id being present, and each call site checked its own feature * toggle, so `csdt_notify_telegram_enabled` silenced the paths that happened to * consult it and nothing else. CSDT_Test_Accounts::send_security_alert() was one * of the ones that did not. * * That is a problem specifically for a COPY. A restored DR or QA instance keeps * production's site URL, database and bot token, so it pages the owner as though * it were production, and there was nothing to flip to stop it: the only lever * was clearing the shared token, which also disarms the real site. On 2026-08-07 * a QA copy on the DR host emitted hundreds of "elevated test session minted" * alerts while a Playwright suite ran against it, and no setting anywhere could * shut it up. * * So this is deliberately the FIRST thing send() checks, and it is checked here * rather than at the call sites, because the point is that no future call site * can forget it. The disaster-recovery restore process sets it on every copy it builds. */ const OPTION_MUTED = 'csdt_alerts_muted'; /** * Is this install muted? True on restored copies (see OPTION_MUTED). * * ANY CloudScale plugin on the site being muted is enough. The restore process writes * every plugin's key, but a copy that believes it is production is exactly the case where * guessing wrong pages a human at 3am, so this errs toward silence. */ public static function is_muted(): bool { self::maybe_migrate(); if ( '1' === (string) get_option( self::OPTION_MUTED, '' ) ) { return true; } return function_exists( 'apply_filters' ) && (bool) apply_filters( 'csdt_alert_muted_by_sibling', false ); } /** * A short label naming the environment an alert came from, e.g. "DR>". * * The sibling of OPTION_MUTED, for the case muting is too blunt. DR has to * alert: it is where restores are tested, so it is the box most likely to be * the one failing, and a muted DR proves nothing. But it shares production's * chat id, so its deliberate failures land beside real ones and the risk is * that genuine alerts start getting ignored. * * The body already distinguishes them -- alert_prefix() prints the domain and * alert_origin() prints the host -- but both sit inside a message you have to * open. This is for the notification you see on a lock screen without opening * anything. * * Empty on production, which is every install that never sets it, so the * default is the current behaviour exactly. */ const OPTION_ENV_LABEL = 'csdt_alert_env_label'; /** * How the label survives being restored over. * * The label was an option, and an option is in the database, and the database of * a standby is REPLACED BY THE PRIMARY'S on every sync. So the label that exists * to say "this alert is from the copy, not the real site" was erased by the copy * being made. That stopped being survivable when the restore became a nightly * cron job: the protection would have switched itself off on the first night, * silently, and looking exactly like success. * * The durable store is therefore a constant in wp-config.php, which no restore * touches, under each plugin's own prefix: define( 'CSBR_ALERT_ENV_LABEL', 'DR>' ) for * Backup & Restore, CSDT_ for Cyber DevTools, and so on. * CLOUDSCALE_ALERT_ENV_LABEL, the one constant every plugin read before the names were * prefixed, is still read after the plugin's own, so a wp-config.php that already sets * it keeps its label. Neither is ever defined by a plugin. The option is kept as * the non-durable store, and set_env_label() says plainly which of the two it * managed to use. Until 1.10.837 this class also wrote a JSON marker into the * content directory; WordPress.org does not allow a plugin to write outside the * uploads tree, so it no longer does. A marker left by an earlier version is * still READ, so an install that relied on one keeps its label until the * constant is added. */ const CONSTANT_ENV_LABEL = 'CSDT_ALERT_ENV_LABEL'; /** The suite-wide constant read before the per-plugin one existed. Read only. */ const SUITE_CONSTANT_ENV_LABEL = 'CLOUDSCALE_ALERT_ENV_LABEL'; /** * The label set in wp-config.php, or ''. This plugin's constant wins over the suite's. */ private static function env_label_constant(): string { foreach ( array( self::CONSTANT_ENV_LABEL, self::SUITE_CONSTANT_ENV_LABEL ) as $name ) { if ( defined( $name ) && '' !== trim( (string) constant( $name ) ) ) { return (string) constant( $name ); } } return ''; } /** The name of whichever wp-config.php constant is setting the label, or ''. */ private static function env_label_constant_name(): string { foreach ( array( self::CONSTANT_ENV_LABEL, self::SUITE_CONSTANT_ENV_LABEL ) as $name ) { if ( defined( $name ) && '' !== trim( (string) constant( $name ) ) ) { return $name; } } return ''; } /** * Where a marker written by a version before 1.10.837 would be. Read only. * * @return string Absolute path. */ public static function env_label_path(): string { return self::content_dir() . '/cloudscale-alert-env-label.json'; } /** * The content directory, derived from this file's own location. * * plugin_basename() is core's answer to "which plugins directory is this file * under", including the realpath map a symlinked install registers, so removing * that tail from the file's own path yields the plugins directory as core sees * it, and its parent is the content directory. No core constant is read. * * @return string No trailing slash. */ private static function content_dir(): string { $file = str_replace( '\\', '/', __FILE__ ); if ( function_exists( 'plugin_basename' ) ) { $tail = '/' . plugin_basename( __FILE__ ); if ( strlen( $tail ) > 1 && substr( $file, -strlen( $tail ) ) === $tail ) { return dirname( substr( $file, 0, -strlen( $tail ) ) ); } } // includes/ -> plugin -> plugins -> content. return dirname( $file, 3 ); } /** * The label as stored on disk, or '' when there is no marker. * * @return string Raw, unsanitised; env_label() does the capping. */ private static function env_label_marker(): string { $path = self::env_label_path(); if ( ! is_readable( $path ) ) { return ''; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a small marker the plugin owns, read on cron and shutdown paths where WP_Filesystem is unavailable $raw = file_get_contents( $path ); if ( false === $raw || '' === trim( (string) $raw ) ) { return ''; } $data = json_decode( (string) $raw, true ); return is_array( $data ) ? (string) ( $data['label'] ?? '' ) : ''; } /** * Is the label stored somewhere a restore cannot erase? * * Read by the admin screen so a customer is TOLD their label will not survive, * rather than believing in protection they do not have. That is the same stance * the secret store and the failover switch take. * * @return bool True when the constant (or a marker left by an earlier version) * is in force, or no label is set at all. */ public static function env_label_is_durable(): bool { if ( '' !== self::env_label_constant() ) { return true; } if ( '' !== self::env_label_marker() ) { return true; } // No durable store. Durable only if there is nothing to lose. return '' === trim( (string) self::opt_read( self::OPTION_ENV_LABEL, '' ) ); } /** * Set or clear the label. * * The option is the only store this method writes. Whether that is durable is * reported back, not assumed: it is durable only when the wp-config constant is * in force, and the message names the constant to add when it is not. Nothing * is written to disk. A marker file left by an earlier version is not removed * here either; the message says where it is so the operator can remove it. * * @param string $label Label, or '' to clear. Sanitised and capped by env_label(). * @return array{ok: bool, durable: bool, error: string} */ public static function set_env_label( string $label ): array { $label = trim( (string) preg_replace( '/\s+/', ' ', sanitize_text_field( $label ) ) ); $constant = '' !== self::env_label_constant(); $marker = self::env_label_marker(); if ( '' === $label ) { delete_option( self::OPTION_ENV_LABEL ); self::broadcast( array( 'alert_env_label' => '' ) ); $note = ''; if ( $constant ) { $note = 'The label is still set by the ' . self::env_label_constant_name() . ' constant in wp-config.php; remove that line to clear it.'; } elseif ( '' !== $marker ) { $note = 'The label is still set by ' . self::env_label_path() . ', a marker an earlier version wrote; delete that file to clear it.'; } return array( 'ok' => true, 'durable' => true, 'error' => $note ); } update_option( self::OPTION_ENV_LABEL, $label, false ); self::broadcast( array( 'alert_env_label' => $label ) ); if ( $constant ) { return array( 'ok' => true, 'durable' => true, 'error' => '' ); } /* * Saved, but said plainly: the database is what the next restore replaces, so * this label will not survive one. Saving the appearance of protection and * none of it is the failure this whole feature exists to remove, so the * durable alternative is named here rather than left for the operator to * discover after the first silent night. */ return array( 'ok' => true, 'durable' => false, 'error' => 'Stored in the database, which the next restore replaces. To make it survive, add define( \'' . self::CONSTANT_ENV_LABEL . '\', \'' . addslashes( $label ) . '\' ); to wp-config.php.', ); } /** * The environment label, sanitised and length-capped. * * Precedence: the constant, then the marker, then the legacy option. The option * remains readable so an install * that set one before this existed keeps its label until it is re-saved, rather * than silently losing it at the moment this ships -- which on QA and DR would * have been the exact outage this change exists to prevent. * * Capped because the header shares Telegram's 4096-char budget with the alert * itself: a label long enough to matter would truncate the body, and the body * is the part that says what broke. Newlines are stripped for the same reason * the cap exists -- the first line is the notification preview, and a label * containing one would push the domain off it. */ private static function env_label(): string { $label = self::env_label_constant(); if ( '' === trim( $label ) ) { $label = self::env_label_marker(); } if ( '' === trim( $label ) ) { // Legacy only. Existing installs set one before the marker existed and // must not lose it the moment this ships -- on QA and DR that would have // been the exact outage this change prevents. $label = (string) self::opt_read( self::OPTION_ENV_LABEL, '' ); } $label = sanitize_text_field( $label ); $label = trim( preg_replace( '/\s+/', ' ', $label ) ); if ( '' === $label ) { return ''; } return function_exists( 'mb_substr' ) ? mb_substr( $label, 0, 12 ) : substr( $label, 0, 12 ); } /** * The label alerts are sent with, for a settings card or checklist to show. * * Status panels used to read the option row directly, which skips the wp-config.php * constant, the one-time migration from the old names and the copy held by another * CloudScale plugin, so a panel could show no label while alerts carried one. */ public static function current_env_label(): string { return self::env_label(); } /** * The rate ledger: what has actually been sent, and what has been held back. * * AN OPTION, NOT A TRANSIENT, and that is the whole reason this exists centrally. * * Every throttle in these five plugins was a transient with a 6-hour expiry, and every one of * them read as correct. This install runs a persistent Redis object cache, so transients live in * Redis while options live in MariaDB, and every deploy of any of the five runs `wp cache flush` * and reloads PHP-FPM. That deleted every quiet window: the next failure reported an ongoing * incident as new. Several deploys a day, several identical alerts a day, from throttles that * passed their own tests because a test never flushes a cache it is not using. Reported * 2026-08-13 as "I don't want to get these spammed at me". * * WHY IT IS HERE RATHER THAN AT THE CALL SITES, which is the same argument as OPTION_MUTED one * constant above: there are 123 send() call sites across the five plugins. Eight of them had a * throttle; the rest could flood freely, and any new one starts with no throttle at all. A * guarantee that has to be remembered 123 times is not a guarantee. */ const OPTION_RATE = 'csdt_alert_rate'; /** * How long a REPEAT of the same alert stays quiet, by level. * * Keyed on a signature that ignores the numbers inside the message (see rate_signature), because * "cURL error 28: timed out after 90002ms" and "...after 90003ms" are the same news. A critical * gets the shortest window: it is the level that describes something still on fire, where a * second message an hour later is information rather than noise. */ const DUP_WINDOW = [ 'critical' => 3600, // 1h 'error' => 21600, // 6h 'warning' => 21600, // 6h 'info' => 86400, // 24h ]; /** * Words from other severity vocabularies, a scanner's low/medium/high, a caller's * "urgent", and the one of the four levels each means. * * One map for the marker AND the duplicate window. The window used to be looked up * on the caller's raw word, so "urgent" and "high" missed DUP_WINDOW and fell to the * 6h warning window while the marker beside them said critical/error. */ const LEVEL_ALIASES = [ 'success' => 'info', 'notice' => 'info', 'low' => 'warning', 'medium' => 'warning', 'high' => 'error', 'urgent' => 'critical', 'alert' => 'critical', 'fatal' => 'critical', ]; /** * What the last send() did, readable through last_outcome(). * * send() answers a bool, and false covered five different things: muted, not * configured, held by the duplicate window or the cap, and a delivery that really * failed. A caller recording "did the channel work?" read a held repeat as a broken * channel and raised an alert about a healthy one; a caller clearing its queue on * true could not tell "try again later" from "never going to work". The bool stays * for the callers in all five plugins that only want it; this says which false. */ const OUTCOME_SENT = 'sent'; const OUTCOME_MUTED = 'muted'; const OUTCOME_UNCONFIGURED = 'unconfigured'; const OUTCOME_HELD = 'held'; const OUTCOME_FAILED = 'failed'; /** @var string One of the OUTCOME_* values, '' before the first send() in this request. */ private static $last_outcome = ''; /** * The outcome of the most recent send() in this request. See OUTCOME_SENT. * * Callers must check method_exists() first: whichever plugin loads first supplies * this class, and an older copy has no such method. */ public static function last_outcome(): string { return self::$last_outcome; } /** * One of info, warning, error, critical for any word a caller passes. * * @param string $level Caller's severity word. */ private static function normalise_level( string $level ): string { $key = strtolower( trim( $level ) ); if ( '' === $key ) { return 'info'; } return self::LEVEL_ALIASES[ $key ] ?? $key; } /** * The hard ceiling, whatever the callers do. * * Duplicate suppression alone cannot bound the volume: fifty DIFFERENT alerts in a minute is * still a phone nobody can read, and a burst of distinct-but-related messages is exactly what a * failing batch job produces. So the total is capped as well, and the cap is not per-level, the * reader's attention is one pool. * * 6 an hour and 24 a day are chosen to be higher than any real incident needs and far below the * volume that makes alerts unreadable. Held messages are counted and reported on the next one * that goes out (see rate_release), so a cap that bites is visible rather than a silence. */ const MAX_PER_HOUR = 6; const MAX_PER_DAY = 24; /** * Sources whose alerts are counted in a pool of their own. * * One pool for everything meant a burst of CSP violations or slow-page reports * spent the six slots a compromise alert needed, and the compromise alert was * the one held back. The cap itself is unchanged and still has no bypass: a * security source gets the SAME six an hour, counted separately, so noise from * elsewhere cannot starve it and it cannot starve the rest. Matched on the * exact source string a call site passes, so a new security feature opts in by * naming itself here, not by picking a level. */ const SECURITY_SOURCES = [ 'CloudScale Cyber Devtools', 'Login Monitor', 'Security Scan', 'Firewall' ]; /* * There is deliberately NO bypass parameter. Every alert that goes through send() is capped, * including criticals, a level that could opt out would become the level everything uses. The * one message that must always arrive, the settings page's "send test message", posts to * Telegram directly (see register_ajax) and never reaches this gate, so the button still tells * the truth about whether alerts work. */ /** * The signature two alerts share when they are the same news. * * Numbers are dropped, not kept: timings, byte counts, IP addresses, streak counters and dates * are what differ between repeats of one incident, and keeping them would make every repeat * unique, a throttle that never fires. The first 160 characters are enough to tell one alert * apart from another while ignoring the detail that accumulates further down (the host block, * the local time, the request path). * * @param string $text Message body. * @param string $source Sending plugin/feature. * @param string $level Alert level. * @return string 32-char hex signature. */ private static function rate_signature( string $text, string $source, string $level ): string { $key = strtolower( $source . '|' . $level . '|' . substr( $text, 0, 160 ) ); $key = preg_replace( '/\d+/', '#', $key ); $key = preg_replace( '/\s+/', ' ', (string) $key ); return md5( (string) $key ); } /** * May this alert go out now? Records the decision either way. * * Returns the message to send, the body plus, when messages were held back since the last one * delivered, a line saying how many, or null when it must stay quiet. * * @param string $text Message body. * @param string $source Sending plugin/feature. * @param string $level Alert level. * @return string|null */ private static function rate_gate( string $text, string $source, string $level ): ?string { $now = time(); $led = self::opt_read( self::OPTION_RATE, [] ); if ( ! is_array( $led ) ) { $led = []; } // Which pool this alert is counted in. See SECURITY_SOURCES. $pool = in_array( $source, self::SECURITY_SOURCES, true ) ? 'sent_security' : 'sent'; $sent = array_values( array_filter( array_map( 'intval', (array) ( $led[ $pool ] ?? [] ) ), static function ( $t ) use ( $now ) { return $t > 0 && ( $now - $t ) < DAY_IN_SECONDS; } ) ); $sigs = is_array( $led['sigs'] ?? null ) ? $led['sigs'] : []; $held = (int) ( $led['held'] ?? 0 ); $dups = (int) ( $led['dups'] ?? 0 ); $sig = self::rate_signature( $text, $source, $level ); $window = self::DUP_WINDOW[ self::normalise_level( $level ) ] ?? self::DUP_WINDOW['warning']; $last = (int) ( $sigs[ $sig ] ?? 0 ); $blocked = ''; // A clock that has gone backwards must not mute anything until it catches up. if ( $last > 0 && $last <= $now && ( $now - $last ) < $window ) { $blocked = 'dup'; } else { $hour = 0; foreach ( $sent as $t ) { if ( ( $now - $t ) < HOUR_IN_SECONDS ) { ++$hour; } } if ( $hour >= self::MAX_PER_HOUR || count( $sent ) >= self::MAX_PER_DAY ) { $blocked = 'cap'; } } if ( '' !== $blocked ) { $led['held'] = $held + 1; $led['dups'] = $dups + ( 'dup' === $blocked ? 1 : 0 ); $led[ $pool ] = $sent; $led['sigs'] = $sigs; self::opt_write( self::OPTION_RATE, $led, false ); return null; } // Going out: stamp the signature, drop signatures older than a day so the ledger cannot // grow without bound, and reset the held counters now that they are about to be reported. $sigs[ $sig ] = $now; foreach ( $sigs as $k => $t ) { if ( ( $now - (int) $t ) > DAY_IN_SECONDS ) { unset( $sigs[ $k ] ); } } $sent[] = $now; // The OTHER pool is carried, not dropped: rewriting the ledger from scratch // here would forget every security alert sent this hour the moment an // ordinary one went out, and the cap would stop being a cap. $led[ $pool ] = $sent; $led['sigs'] = $sigs; $led['held'] = 0; $led['dups'] = 0; self::opt_write( self::OPTION_RATE, $led, false ); if ( $held > 0 ) { // Said on the alert that DOES go out, rather than as a message of its own: a digest that // is itself an alert would be one more thing arriving on the phone. $text .= "\n\n" . sprintf( '(%d further alert%s held back since the last one, %d of them repeats of something already reported. Alerts are capped at %d an hour.)', $held, 1 === $held ? ' was' : 's were', $dups, self::MAX_PER_HOUR ); } return $text; } /** * The bot token and chat id this plugin would send with, for callers outside this class * (a watchdog script, a status panel). Read through opt_read(), so a value stored under the * old names or by another CloudScale plugin is found; a raw get_option() would not find it. * * @return array{token: string, chat_id: string} */ public static function credentials(): array { return array( 'token' => trim( (string) self::opt_read( self::OPTION_TOKEN, '' ) ), 'chat_id' => trim( (string) self::opt_read( self::OPTION_CHAT_ID, '' ) ), ); } public static function is_configured(): bool { return ! empty( self::opt_read( self::OPTION_TOKEN ) ) && ! empty( self::opt_read( self::OPTION_CHAT_ID ) ); } /** * Build the alert prefix line: "{emoji} Domain : Source\nLEVEL\n\n" * * The marker and the LEVEL word beside it must never disagree, and they did. * The map held only info/warning/error/critical and an unrecognised level fell * back to info, so the one call site that says 'high', the front-end asset * monitor, shipped real failures as "✅ ... HIGH". A green * tick over a broken asset is the most misleading thing an alert can look like * on a phone: it is read before the words are. * * So the severity vocabularies that actually reach here are mapped onto the four * markers, and anything still unrecognised gets a warning. ✅ is reserved for * levels that genuinely mean "nothing is wrong" and cannot be reached by * accident or by a typo. * * @param string $source Human-readable plugin/feature name, e.g. "Backup and Restore". * @param string $level One of: info, warning, error, critical (aliases: success, notice, low, medium, high, urgent, alert, fatal). */ private static function alert_prefix( string $source, string $level ): string { $host = (string) ( wp_parse_url( home_url(), PHP_URL_HOST ) ?: '' ); $domain = implode( '.', array_map( 'ucfirst', explode( '.', strtolower( $host ) ) ) ); $emojis = [ 'info' => "\u{2705}", 'warning' => "\u{26A0}\u{FE0F}", 'error' => "\u{274C}", 'critical' => "\u{1F6A8}", ]; // Words that arrived from another severity vocabulary resolve to one of the // four rather than falling through to the tick. See LEVEL_ALIASES. $emoji = $emojis[ self::normalise_level( $level ) ] ?? $emojis['warning']; // The caller's own word is what prints: "HIGH" carries more than "ERROR" // would, and rewriting it would hide which vocabulary the alert came from. // Only the marker is derived. $label = strtoupper( trim( $level ) ?: 'INFO' ); $header = $source ? $domain . ' : ' . $source : $domain; // Ahead of the domain, not after it: this exists to be read in a lock-screen // preview that truncates, so it has to be in the first few characters or it // is not doing its job. See OPTION_ENV_LABEL. $env = self::env_label(); if ( '' !== $env ) { $header = $env . ' ' . $header; } return $emoji . ' ' . $header . "\n" . $label . "\n\n"; } /** * Identify the machine that produced this alert. * * Stamped here rather than at the ~110 call sites across five plugins, for * the same reason the mute check and the timestamp live here: a new call * site cannot ship without it. * * The domain in alert_prefix() cannot do this job. As OPTION_MUTED's * docblock records, a restored DR or QA copy keeps production's site URL, * database and bot token, so every clone claims to be production. The * machine hostname is the field that actually differs, which is why it * leads. On 2026-08-07 hundreds of alerts from a QA copy on the DR host * were indistinguishable from production's, and the only address in them * was its Docker gateway address. That is why an unroutable * address is now labelled rather than presented as the visitor's IP. */ /** * A name for the machine this is running on, that a human can act on. * * WHY gethostname() IS NOT ENOUGH * * Inside Docker it returns the CONTAINER ID: a random twelve-hex-digit string * such as 4d322d560c76. It does not name the machine, it is not the site, and * it is not even stable, because recreating the container changes it. So two * alerts from the same box a week apart carry different "hosts", and neither * points at anything anyone can look up. * * The host's own address cannot be discovered from inside either: SERVER_ADDR * is the container's address on the bridge network. A container has to be TOLD * where it is running, so this prefers, in order: * * 1. CSBR_HOST_LABEL, a constant or environment variable set by whoever runs * the container, e.g. "web-01" or "web-01 (203.0.113.24)"; * 2. gethostname(), when it does not look like a container id; * 3. the container id, clearly labelled as one, so the reader is not misled * into thinking it names a machine. * * @since 3.2.753 * @return string Host label, never empty. */ private static function host_label(): string { $explicit = ''; if ( defined( 'CSBR_HOST_LABEL' ) && is_string( constant( 'CSBR_HOST_LABEL' ) ) ) { $explicit = trim( (string) constant( 'CSBR_HOST_LABEL' ) ); } if ( '' === $explicit ) { $env = getenv( 'CSBR_HOST_LABEL' ); if ( is_string( $env ) ) { $explicit = trim( $env ); } } if ( '' !== $explicit ) { return sanitize_text_field( $explicit ); } $host = gethostname(); if ( ! is_string( $host ) || '' === $host ) { return 'unknown-host'; } // Docker's default: the twelve-hex-digit container id. Saying so is more // useful than printing it as though it were a machine name. if ( 1 === preg_match( '/^[0-9a-f]{12}$/', $host ) ) { return 'container ' . $host . ', host not labelled'; } return sanitize_text_field( $host ); } /** * The origin block printed under every alert. * * @param bool $request_is_subject Whether the HTTP request executing this code is the alert's * subject. True for almost every alert (a login, a firewall block: the request is the * event). False when the current request only CARRIES the alert, which it never caused, * for example the Edge Gateway POSTing a batch of events it detected elsewhere: there the * courier is a trusted local request to our own events route, and 'From'/'Request' would * name that courier as though it were the attacker. The real actor is then in the body, * so only the host is printed and the request lines are left off. * @return string */ private static function alert_origin( bool $request_is_subject = true ): string { $host = self::host_label(); /* * The container's own address, and labelled as such. * * SERVER_ADDR inside a container is the address on the container network, * a 172.x that means nothing outside this machine and is not the host's * address. Printing it unqualified next to a hostname invites exactly the * wrong reading, so it is only shown when it adds something, and it says * what it is. */ $server_ip = isset( $_SERVER['SERVER_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_ADDR'] ) ) : ''; if ( ! filter_var( $server_ip, FILTER_VALIDATE_IP ) ) { $server_ip = ''; } $lines = array(); $lines[] = 'Host: ' . $host . ( '' !== $server_ip ? ' (container ' . $server_ip . ')' : '' ); // The request lines describe the request in flight. When that request is only the courier // of an event detected elsewhere, they name the courier, not the attacker, so they are // omitted and the body's own 'from X' stands as the source. if ( ! $request_is_subject ) { return "\n" . implode( "\n", $lines ); } $lines[] = 'From: ' . self::trigger_source(); $account = self::acting_account(); if ( '' !== $account ) { $lines[] = 'Account: ' . $account; } $method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; if ( '' !== $method || '' !== $uri ) { // Long query strings would push the body toward Telegram's 4096-char // limit and truncate the alert itself, which matters more than the tail // of a URL. $request = trim( $method . ' ' . $uri ); if ( strlen( $request ) > 120 ) { $request = substr( $request, 0, 117 ) . '...'; } $lines[] = 'Request: ' . $request; } return "\n" . implode( "\n", $lines ); } /** * The WordPress account attached to this request, when there is one. * * "From: 203.0.113.7" alone reads as an unknown attacker even when the request carried a * valid, currently-authenticated admin session, the one piece of context that actually * answers "is this me" was missing from every alert. Added rather than assumed: wp-cron and * WP-CLI genuinely have no session, and a request whose auth cookie has not been processed * yet at the point send() runs gets no line rather than a guessed one. * * @return string The user_login, or '' when no session is attached to this request. */ private static function acting_account(): string { if ( ! function_exists( 'get_current_user_id' ) || ! function_exists( 'wp_get_current_user' ) ) { return ''; } if ( 0 === (int) get_current_user_id() ) { return ''; } $user = wp_get_current_user(); return ( $user && $user->exists() ) ? sanitize_text_field( $user->user_login ) : ''; } /** * What triggered this alert: a request with a client IP, or a background * context that genuinely has no client. Never invent an address for the * latter, a wrong IP is worse than an admitted absence. */ private static function trigger_source(): string { if ( defined( 'WP_CLI' ) && WP_CLI ) { return 'WP-CLI (no client IP)'; } if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) { return 'wp-cron (no client IP)'; } // Prefer the validated helper: it honours HTTP_CF_CONNECTING_IP and // X-Forwarded-For only when the immediate peer is a trusted proxy, so an // attacker cannot dictate what this alert reports. It lives in Cyber // DevTools, hence the guard, this file ships in five plugins. // // BOTH class names are tried because Cyber DevTools renamed this class to CSDT_DevTools // for WordPress.org's prefixing rule, and a shared file cannot know which version of // that plugin is installed beside it. Getting this wrong is quiet rather than loud: the // fallback below is REMOTE_ADDR, which on this install is the container gateway, so the // alert would name the proxy instead of the attacker and still look plausible. $ip = ''; foreach ( array( 'CSDT_DevTools', 'CloudScale_DevTools' ) as $devtools_class ) { if ( is_callable( array( $devtools_class, 'get_client_ip' ) ) ) { $ip = (string) call_user_func( array( $devtools_class, 'get_client_ip' ) ); break; } } if ( '' === $ip && isset( $_SERVER['REMOTE_ADDR'] ) ) { $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ); } if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { return 'no client IP (background context)'; } // A private or loopback address is the container gateway or a reverse // proxy, not the visitor. Label it so it is never read as the origin. $is_public = (bool) filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); return $is_public ? $ip : $ip . ' (private address - proxy/container gateway, not the visitor)'; } /** * The site's own clock, with the zone named: "2026-08-05 07:02:11 SAST". * * wp_date() applies the timezone configured in Settings → General, and the T * format character supplies the abbreviation, so the reader never has to do * arithmetic or guess which zone a bare time is in. * * @param int|null $ts Unix timestamp; current time when null. */ public static function local_time( ?int $ts = null ): string { $ts = $ts ?? time(); $when = wp_date( 'Y-m-d H:i:s', $ts ); // wp_date() returns false if the timezone is unusable; never let a broken // setting cost us the whole alert. if ( ! is_string( $when ) || '' === $when ) { return gmdate( 'Y-m-d H:i:s', $ts ) . ' UTC'; } return $when . ' ' . self::tz_label( $ts ); } /** * The timezone label to print: a real zone abbreviation when there is one. * * Settings → General accepts either a named zone (Africa/Johannesburg, which * makes wp_date('T') produce SAST) or a bare UTC offset. With a bare offset there * IS no zone name, and wp_date('T') answers "GMT+0200", which reads as a GMT * time to anyone scanning an alert, and GMT is not what the clock is showing. * * An offset-shaped label is therefore resolved to a zone abbreviation, via PHP's * own offset→zone table, rather than printed as "+02:00": alerts should name the * clock they are quoting, and a bare offset makes the reader do the work. Several * zones share an offset and PHP returns the canonical one, so the abbreviation is * right for the offset even where the zone id is not the site's own, which is why * the *offset* is what gets resolved and never invented from anything else. Set a * named zone in Settings → General and this returns its own abbreviation directly. * * @param int|null $ts Timestamp the label applies to (DST-dependent zones). */ public static function tz_label( ?int $ts = null ): string { $ts = $ts ?? time(); $abbr = (string) wp_date( 'T', $ts ); if ( preg_match( '/^(?:GMT|UTC)?\s*([+-])(\d{1,2}):?(\d{2})?$/', $abbr, $m ) ) { $seconds = ( (int) $m[2] * 3600 ) + ( (int) ( $m[3] ?? 0 ) * 60 ); if ( '-' === $m[1] ) { $seconds = -$seconds; } // The candidate zone's offset AT THIS INSTANT must equal the offset we are // labelling. Without that check, +02:00 resolved to a zone whose // abbreviation was EEST, a real name for a clock that is +03:00 in // August, printed next to a time that is +02:00. A label contradicting // the timestamp it labels is worse than a bare offset. $moment = new DateTime( '@' . $ts ); $candidates = array(); foreach ( array( 0, 1 ) as $dst ) { $guess = timezone_name_from_abbr( '', $seconds, $dst ); if ( is_string( $guess ) && '' !== $guess ) { $candidates[] = $guess; } } // Nothing from the abbreviation table fits, so ask the zone list directly. $candidates = array_merge( $candidates, timezone_identifiers_list() ); foreach ( $candidates as $zone ) { try { $tz = new DateTimeZone( $zone ); } catch ( \Throwable $e ) { unset( $e ); continue; } if ( $tz->getOffset( $moment ) !== $seconds ) { continue; } $named = $moment->setTimezone( $tz )->format( 'T' ); // A zone whose own label is offset-shaped puts us back where we // started, so keep looking. if ( is_string( $named ) && '' !== $named && ! preg_match( '/^(?:GMT|UTC)?[+-]/', $named ) ) { return $named; } } // Last resort: the offset, never "GMT+0200". return $m[1] . str_pad( $m[2], 2, '0', STR_PAD_LEFT ) . ':' . ( $m[3] ?? '00' ); } return '' !== $abbr ? $abbr : 'UTC'; } /** * Rewrite UTC timestamps inside an alert body into the site's local zone. * * Alerts quote times from places that are UTC by construction, PHP's error log * writes "[04-Aug-2026 20:40:15 UTC]", and several of our own messages used * gmdate(). An alert that says one thing in the footer and another in the body * makes the reader do timezone arithmetic at exactly the moment they can least * afford to, so both are converted to the same clock. * * Only strings explicitly labelled UTC are touched. A time with no zone on it * is left alone rather than guessed at. */ private static function localise_utc_stamps( string $text ): string { // PHP error-log prefix: [04-Aug-2026 20:40:15 UTC], and GMT, which is what // PHP actually emits when date.timezone is UTC. Matching only "UTC" is why // alerts kept showing a GMT stamp after every other clock had been localised: // the copy was correct everywhere we controlled and wrong on the one line we // were quoting verbatim out of the log. $text = (string) preg_replace_callback( '/\[(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}) (?:UTC|GMT)\]/', static function ( array $m ): string { $ts = strtotime( $m[1] . ' UTC' ); return false === $ts ? $m[0] : '[' . self::local_time( $ts ) . ']'; }, $text ); // ISO-ish: 2026-08-05 02:30:01 UTC $text = (string) preg_replace_callback( '/(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})Z?\s+(?:UTC|GMT)\b/', static function ( array $m ): string { $ts = strtotime( str_replace( 'T', ' ', $m[1] ) . ' UTC' ); return false === $ts ? $m[0] : self::local_time( $ts ); }, $text ); return $text; } /** * Send a Telegram message. Returns true on success. * * @param string $text Message body. * @param string $source Plugin/feature name shown in the header (e.g. "Backup and Restore"). * @param string $level Alert level: info, warning, error, critical. * @param bool $request_is_subject Whether the HTTP request in flight is this alert's subject. * Defaults true. Pass false for an alert the current request merely carries (an Edge * Gateway event batch), so the origin block does not name the courier as the actor. */ public static function send( string $text, string $source = '', string $level = 'info', bool $request_is_subject = true ): bool { // Before anything else, and before any call site's own toggle: a muted install // sends nothing at all. See OPTION_MUTED for why this belongs here and not at // the ~20 call sites. // FAILED until proved otherwise, so a throw part-way through cannot leave the // previous send's SENT behind for the caller to read. self::$last_outcome = self::OUTCOME_FAILED; if ( self::is_muted() ) { self::$last_outcome = self::OUTCOME_MUTED; return false; } $token = trim( (string) self::opt_read( self::OPTION_TOKEN, '' ) ); $chat_id = trim( (string) self::opt_read( self::OPTION_CHAT_ID, '' ) ); if ( ! $token || ! $chat_id ) { self::$last_outcome = self::OUTCOME_UNCONFIGURED; return false; } // The rate ceiling, after the credential check so a misconfigured install does not burn its // hourly allowance on messages that were never going to be sent, and before composing the // message so a held alert costs nothing. See OPTION_RATE for why this is here and not at the // 123 call sites. $gated = self::rate_gate( $text, $source, $level ); if ( null === $gated ) { self::$last_outcome = self::OUTCOME_HELD; return false; } $text = $gated; // Local time and originating host on EVERY alert, stamped here rather than // at the call sites so a new alert cannot ship without either. Times quoted // inside the body are converted to the same clock, see // localise_utc_stamps(); see alert_origin() for why the site domain alone // cannot attribute a clone. // Order matters: the origin block goes BEFORE the timestamp because // check-telegram-local-time.php asserts the message ENDS with a zone-named // local time. That invariant is worth keeping, it is what proves no alert // ever ships without a readable clock, so attribution slots in above it // rather than the gate being relaxed to accommodate this. $full = self::alert_prefix( $source, $level ) . self::localise_utc_stamps( $text ) . "\n" . self::alert_origin( $request_is_subject ) . "\n\n" . self::local_time(); $response = wp_remote_post( 'https://api.telegram.org/bot' . rawurlencode( $token ) . '/sendMessage', [ 'user-agent' => self::USER_AGENT, 'timeout' => 10, 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( [ 'chat_id' => $chat_id, 'text' => $full ] ), ] ); if ( is_wp_error( $response ) ) { // Transport failure (timeout, DNS, connection refused). Log internally so the // fire-and-forget callers that don't check the return value still leave a // trace the operator can grep. Without this, a Telegram outage is invisible. self::log_transport_failure( 'transport', CSDT_Error_Text::in_seconds( $response->get_error_message() ) ); self::$last_outcome = self::OUTCOME_FAILED; return false; } $http_code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 !== $http_code ) { // Non-200 (401 bad token, 429 Telegram rate limit, etc.). Same reason as above. self::log_transport_failure( "HTTP {$http_code}", wp_remote_retrieve_body( $response ) ); self::$last_outcome = self::OUTCOME_FAILED; return false; } self::$last_outcome = self::OUTCOME_SENT; return true; } /** * Logs a Telegram delivery failure so a silent outage or revoked bot token is * discoverable. Deduplicated per-request so a burst of failed alerts doesn't flood * the log. * * @param string $kind Failure category (e.g. 'transport', 'HTTP 401'). * @param string $detail Error message or response body. */ private static function log_transport_failure( string $kind, string $detail ): void { static $logged = []; if ( isset( $logged[ $kind ] ) ) { return; } $logged[ $kind ] = true; $msg = 'CSDT_Telegram: delivery failed (' . $kind . ')'; if ( '' !== $detail ) { // Truncate: Telegram error bodies can be long, and the log is one line. $msg .= ': ' . substr( $detail, 0, 200 ); } // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- diagnostic trace for an alert channel that is itself down; no plugin-specific log is guaranteed to exist here error_log( $msg ); } /** * Save the Telegram credentials, and offer them to the other CloudScale plugins. * * Takes values, not a request: the caller reads its own POST fields inside the handler * that verified the nonce, so no request is read anywhere a reviewer cannot see the check. * * @param string $token Bot token, already sanitised. * @param string $chat_id Chat id, already sanitised. */ public static function save_credentials( string $token, string $chat_id ): void { self::opt_write( self::OPTION_TOKEN, $token ); self::opt_write( self::OPTION_CHAT_ID, $chat_id ); self::broadcast( array( 'telegram_bot_token' => $token, 'telegram_chat_id' => $chat_id ) ); } /** * Register this plugin's AJAX handlers (test + fetch-chat-id). * The action names carry the plugin's prefix, stamped by the sync script, so each * plugin's copy owns its own pair. Safe to call twice; handlers are registered once. */ public static function register_ajax(): void { if ( ! has_action( 'wp_ajax_csdt_telegram_fetch_chat_id' ) ) { add_action( 'wp_ajax_csdt_telegram_fetch_chat_id', static function (): void { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Forbidden', 403 ); } check_ajax_referer( 'csdt_telegram_fetch', 'nonce' ); $token = sanitize_text_field( wp_unslash( $_POST['telegram_token'] ?? '' ) ); if ( ! $token ) { wp_send_json_error( 'Bot token is required.' ); return; } $response = wp_remote_get( 'https://api.telegram.org/bot' . rawurlencode( $token ) . '/getUpdates', [ 'user-agent' => self::USER_AGENT, 'timeout' => 10 ] ); if ( is_wp_error( $response ) ) { wp_send_json_error( 'Request failed: ' . CSDT_Error_Text::in_seconds( $response->get_error_message() ) ); return; } $code = (int) wp_remote_retrieve_response_code( $response ); $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( $code !== 200 || empty( $data['ok'] ) ) { $detail = is_array( $data ) ? ( $data['description'] ?? $body ) : $body; wp_send_json_error( "Telegram HTTP {$code}: {$detail}" ); return; } if ( empty( $data['result'] ) ) { wp_send_json_error( 'No messages yet. Send any message to your bot on Telegram first, then try again.' ); return; } $chat_id = null; foreach ( $data['result'] as $update ) { $chat_id = $update['message']['chat']['id'] ?? $update['channel_post']['chat']['id'] ?? $update['my_chat_member']['chat']['id'] ?? null; if ( $chat_id !== null ) { break; } } if ( $chat_id === null ) { wp_send_json_error( 'Could not find a chat ID in the updates.' ); return; } wp_send_json_success( [ 'chat_id' => (string) $chat_id ] ); } ); } if ( has_action( 'wp_ajax_csdt_telegram_test' ) ) { return; } add_action( 'wp_ajax_csdt_telegram_test', static function (): void { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Forbidden', 403 ); } check_ajax_referer( 'csdt_telegram_test', 'nonce' ); // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above $token = sanitize_text_field( wp_unslash( $_POST['telegram_token'] ?? '' ) ); $chat_id = sanitize_text_field( wp_unslash( $_POST['telegram_chat_id'] ?? '' ) ); $source = sanitize_text_field( wp_unslash( $_POST['test_source'] ?? '' ) ) ?: 'Telegram'; // phpcs:enable WordPress.Security.NonceVerification.Missing // Fall back to stored credentials (used by the credentials panel test button). if ( ! $token ) { $token = (string) self::opt_read( self::OPTION_TOKEN, '' ); } if ( ! $chat_id ) { $chat_id = (string) self::opt_read( self::OPTION_CHAT_ID, '' ); } if ( ! $token || ! $chat_id ) { wp_send_json_error( 'Bot token and chat ID are required.' ); return; } $prefix = self::alert_prefix( $source, 'info' ); $response = wp_remote_post( 'https://api.telegram.org/bot' . rawurlencode( $token ) . '/sendMessage', [ 'user-agent' => self::USER_AGENT, 'timeout' => 10, 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( [ 'chat_id' => $chat_id, 'text' => $prefix . 'Test message sent successfully. Alerts are working.' ] ), ] ); if ( is_wp_error( $response ) ) { wp_send_json_error( 'Telegram request failed: ' . CSDT_Error_Text::in_seconds( $response->get_error_message() ) ); return; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code !== 200 ) { $body = wp_remote_retrieve_body( $response ); $decoded = json_decode( $body, true ); $detail = is_array( $decoded ) ? ( $decoded['description'] ?? $body ) : $body; wp_send_json_error( "Telegram HTTP {$code}: {$detail}" ); return; } wp_send_json_success( [ 'msg' => 'Test message sent.' ] ); } ); } /** * Render the Telegram settings card. * * @param string $text_domain Unused; kept for call-site compatibility across plugins. */ public static function render_settings_fields( string $text_domain, string $source = '' ): void { static $js_output = false; $token = esc_attr( (string) self::opt_read( self::OPTION_TOKEN, '' ) ); $chat_id = esc_attr( (string) self::opt_read( self::OPTION_CHAT_ID, '' ) ); $test_nonce = wp_create_nonce( 'csdt_telegram_test' ); $fetch_nonce = wp_create_nonce( 'csdt_telegram_fetch' ); ?>
✈