brief = $brief; $this->settings = $settings; $this->dispatcher = $dispatcher; $this->sites = $sites; $this->email_log = $email_log; $this->slack = $slack; $this->advisor = $advisor; } /** * {@inheritDoc} * * @return array */ protected function actions() { return array( 'save_settings' => 'save', 'save_schedules' => 'save_schedules', 'run_email_queue' => 'run_email_queue', 'test_slack' => 'test_slack', 'test_ai' => 'test_ai', 'test_brief' => 'test_brief', ); } /** * Saves the settings form. * * @return void */ public function save() { $this->guard( 'save_settings' ); $color = $this->text( 'brand_color' ); $logo = absint( $this->text( 'brand_logo_id' ) ); // phpcs:disable WordPress.Security.NonceVerification.Missing -- Checked in guard(). $alerts = isset( $_POST['alert_emails'] ) && is_string( $_POST['alert_emails'] ) ? sanitize_textarea_field( wp_unslash( $_POST['alert_emails'] ) ) : ''; $footer = isset( $_POST['footer_text'] ) && is_string( $_POST['footer_text'] ) ? sanitize_text_field( wp_unslash( $_POST['footer_text'] ) ) : ''; $chosen = isset( $_POST['alert_user_ids'] ) ? array_map( 'absint', array_filter( (array) wp_unslash( $_POST['alert_user_ids'] ), 'is_scalar' ) ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nested values are discarded before each scalar is sanitized with absint(). $sched = isset( $_POST['email_defaults'] ) && is_array( $_POST['email_defaults'] ) ? wp_unslash( $_POST['email_defaults'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitised in Schedule::normalize(). $credit = '1' === $this->text( 'show_credit' ); $flags = array( 'hide_credit' => ! $credit, 'credit_opt_in' => $credit, 'alerts_enabled' => $this->flag( 'alerts_enabled' ), 'warm_cache' => $this->flag( 'warm_cache' ), 'monitor_enabled' => $this->flag( 'monitor_enabled' ), 'brief_enabled' => $this->flag( 'brief_enabled' ), ); $brief_test = $this->flag( 'brief_test' ); $brief_to = isset( $_POST['brief_emails'] ) && is_string( $_POST['brief_emails'] ) ? sanitize_textarea_field( wp_unslash( $_POST['brief_emails'] ) ) : ''; // The AI fields are only on the form when the AI Advisor panel is shown. $ai_form = isset( $_POST['ai_model'] ) && is_string( $_POST['ai_model'] ); if ( $ai_form ) { $flags['ai_enabled'] = $this->flag( 'ai_enabled' ); } $webhook = isset( $_POST['slack_webhook'] ) && is_string( $_POST['slack_webhook'] ) ? trim( sanitize_text_field( wp_unslash( $_POST['slack_webhook'] ) ) ) : ''; $unhook = $this->flag( 'slack_remove' ); $ai_key = isset( $_POST['ai_key'] ) && is_string( $_POST['ai_key'] ) ? trim( sanitize_text_field( wp_unslash( $_POST['ai_key'] ) ) ) : ''; $ai_drop = $this->flag( 'ai_key_remove' ); // phpcs:enable // "Other model…" carries its ID in its own box. $picked = $this->text( 'ai_model' ); $ai_model = preg_replace( '/[^A-Za-z0-9._:\-\/]/', '', '__other' === $picked ? $this->text( 'ai_model_other' ) : $picked ); $advisor = $this->advisor ? call_user_func( $this->advisor ) : null; $ai_prov = $this->text( 'ai_provider' ); if ( ! $advisor ) { // Without the Advisor there is no provider list to check against; keep the stored choice. $ai_prov = (string) $this->settings->get( 'ai_provider' ); } elseif ( ! isset( $advisor->providers()[ $ai_prov ] ) ) { $ai_prov = 'openai'; } $ai_consent = array(); $ai_blocked = false; if ( $ai_form && $flags['ai_enabled'] && ! $this->ai_confirmed( $ai_prov ) ) { if ( $this->flag( 'ai_consent' ) ) { $ai_consent = array( 'provider' => $ai_prov, 'user' => get_current_user_id(), 'time' => time(), ); } else { $flags['ai_enabled'] = false; $ai_blocked = true; } } $sensitivity = Rules::RELAXED === $this->text( 'monitor_sensitivity' ) ? Rules::RELAXED : Rules::STANDARD; $brief_was = (bool) $this->settings->get( 'brief_enabled' ); $reply_to = sanitize_email( $this->text( 'email_reply_to' ) ); $this->settings->save( array_merge( $flags, array( 'brand_name' => Text::substr( $this->text( 'brand_name' ), 0, 80 ), 'brand_logo_id' => $logo && wp_attachment_is_image( $logo ) ? $logo : 0, 'brand_color' => preg_match( '/^#[0-9a-fA-F]{6}$/', $color ) ? strtolower( $color ) : '#0f6b50', 'footer_text' => Text::substr( $footer, 0, 200 ), 'email_from_name' => Text::substr( $this->text( 'email_from_name' ), 0, 80 ), 'email_reply_to' => is_email( $reply_to ) ? $reply_to : '', 'alert_emails' => array_values( array_filter( array_map( 'sanitize_email', preg_split( '/[\s,;]+/', $alerts ) ), 'is_email' ) ), // Only users who manage TurtleReport can be picked for alerts. 'alert_user_ids' => array_values( array_intersect( array_unique( $chosen ), array_column( SettingsPage::managers(), 'id' ) ) ), 'email_defaults' => array_intersect_key( Schedule::normalize( $sched ), array_flip( array( 'frequency', 'weekday', 'monthday', 'hour', 'compare' ) ) ), 'monitor_sensitivity' => $sensitivity, 'brief_weekday' => min( 7, max( 1, absint( $this->text( 'brief_weekday' ) ) ) ), 'brief_hour' => min( 23, absint( $this->text( 'brief_hour' ) ) ), 'brief_emails' => array_slice( array_values( array_unique( array_filter( array_map( 'sanitize_email', preg_split( '/[\s,;]+/', $brief_to ) ), 'is_email' ) ) ), 0, 20 ), ), $ai_form ? array( 'ai_provider' => '' !== $ai_prov ? $ai_prov : 'openai', 'ai_model' => '' !== $ai_model ? Text::substr( $ai_model, 0, 80 ) : \TurtleReport\Advisor\Advisor::default_model_for( $advisor && isset( $advisor->providers()[ $ai_prov ] ) ? $advisor->providers()[ $ai_prov ] : null ), 'ai_daily_limit' => min( 10000, absint( $this->text( 'ai_daily_limit' ) ) ), 'ai_monthly_limit' => min( 100000, absint( $this->text( 'ai_monthly_limit' ) ) ), 'ai_max_output' => min( 4000, max( 300, absint( $this->text( 'ai_max_output' ) ) ) ), ) : array(), $ai_consent ? array( 'ai_consent' => $ai_consent ) : array() ) ); // Switched on now: the first brief goes at the next chosen time, not straight away. if ( ! $brief_was && $flags['brief_enabled'] && $this->brief ) { call_user_func( $this->brief )->start(); } if ( $this->slack ) { if ( $unhook ) { $this->slack->forget(); } elseif ( '' !== $webhook && ! $this->slack->save( $webhook ) ) { $this->finish( SettingsPage::url() . '#tly-monitor', 'error', __( 'Settings saved, but not the Slack webhook: it must look like https://hooks.slack.com/services/T…/B…/…, copied from Slack\'s "Incoming Webhooks" page.', 'turtlereport' ) ); } } if ( $advisor && $ai_form ) { // The provider's kept model list belongs to the old key. if ( $ai_drop || '' !== $ai_key ) { delete_transient( 'turtlereport_ai_models_' . $ai_prov ); } if ( $ai_drop ) { $advisor->keys()->forget(); } elseif ( '' !== $ai_key && ! $advisor->keys()->save( $ai_key ) ) { $this->finish( SettingsPage::url() . '#tly-ai', 'error', __( 'Settings saved, but not the API key: it doesn\'t look like one. Copy it again from your AI provider\'s API keys page.', 'turtlereport' ) ); } } if ( $ai_blocked ) { $this->finish( SettingsPage::url() . '#tly-ai', 'warning', __( 'Settings saved, but the AI Advisor is still off. To switch it on, confirm that client data can be sent to the AI provider.', 'turtlereport' ) ); } // "Save and send test email": the addresses just typed are saved first, so the test goes to them. if ( $brief_test ) { $this->send_test_brief( true ); } $this->finish( SettingsPage::url(), 'success', __( 'Settings saved.', 'turtlereport' ) ); } /** * Whether sending client data to this provider is already confirmed. * * An Advisor that was on before confirmation existed keeps running with its * provider; switching it on or changing provider needs a fresh confirmation. * * @param string $provider Provider ID being saved. * @return bool */ private function ai_confirmed( $provider ) { $consent = $this->settings->get( 'ai_consent' ); if ( is_array( $consent ) && isset( $consent['provider'] ) && $provider === $consent['provider'] ) { return true; } return $this->settings->get( 'ai_enabled' ) && $provider === $this->settings->get( 'ai_provider' ); } /** * Sends the weekly brief now, marked as a test, and says what the mail system answered. * * @param bool $saved Whether the settings were just saved too. * @return void */ private function send_test_brief( $saved ) { $back = SettingsPage::url() . '#tly-brief'; $brief = $this->brief ? call_user_func( $this->brief ) : null; if ( ! $brief ) { $this->finish( $back, 'error', __( 'The weekly summary isn\'t available.', 'turtlereport' ) ); } $to = $brief->recipients(); $result = $brief->send( $to, true ); $first = $saved ? __( 'Settings saved.', 'turtlereport' ) . ' ' : ''; if ( ! $result['sent'] ) { $why = implode( ' ', array_unique( array_filter( $result['errors'] ) ) ); $this->finish( $back, 'error', $first . sprintf( /* translators: 1: email addresses, 2: the mail system's error. */ __( 'The test summary couldn\'t be sent to %1$s. %2$s', 'turtlereport' ), implode( ', ', $to ), '' !== $why ? $why : __( 'WordPress couldn\'t send the email. Check your site\'s mail setup (an SMTP plugin is recommended).', 'turtlereport' ) ) ); } $this->finish( $back, 'success', $first . sprintf( /* translators: %s: email addresses. */ __( 'Test summary handed to your site\'s mail system for %s. If it doesn\'t arrive within a few minutes, check the spam folder. Many hosts only deliver email reliably through an SMTP plugin; the report emails use the same route.', 'turtlereport' ), implode( ', ', array_diff( $to, $result['failed'] ) ) ) . ( $result['failed'] ? ' ' . sprintf( /* translators: %s: email addresses. */ __( 'It couldn\'t be sent to: %s.', 'turtlereport' ), implode( ', ', $result['failed'] ) ) : '' ) ); } /** * Checks the AI key and model with a tiny request. * * @return void */ public function test_ai() { $this->guard( 'test_ai' ); $back = SettingsPage::url() . '#tly-ai'; $advisor = $this->advisor ? call_user_func( $this->advisor ) : null; if ( ! $advisor ) { $this->finish( $back, 'error', __( 'The AI Advisor isn\'t available.', 'turtlereport' ) ); } $ok = $advisor->test(); if ( is_wp_error( $ok ) ) { $this->finish( $back, 'error', $ok->get_error_message() ); } /* translators: %s: model ID. */ $this->finish( $back, 'success', sprintf( __( 'Connected: the API key works with %s.', 'turtlereport' ), $advisor->model() ) ); } /** * Sends the weekly brief now to its recipients, marked as a test. * * @return void */ public function test_brief() { $this->guard( 'test_brief' ); $this->send_test_brief( false ); } /** * Sends a test message to the saved Slack webhook. * * @return void */ public function test_slack() { $this->guard( 'test_slack' ); $back = SettingsPage::url() . '#tly-monitor'; if ( ! $this->slack || ! $this->slack->configured() ) { $this->finish( $back, 'error', __( 'Save a Slack webhook first.', 'turtlereport' ) ); } $user = wp_get_current_user(); $sent = $this->slack->send_test( ! empty( $user->display_name ) ? (string) $user->display_name : '' ); if ( is_wp_error( $sent ) ) { $this->finish( $back, 'error', $sent->get_error_message() ); } $this->finish( $back, 'success', __( 'Slack accepted the test message. Check the channel.', 'turtlereport' ) ); } /** * Saves "Who gets which report": for each site, whether its report is * emailed, how often, to everyone with access and to which addresses. * Everything else in a site's schedule (day, time, note, chosen users) * is kept as it is. * * @return void */ public function save_schedules() { $this->guard( 'save_schedules' ); $back = SettingsPage::url() . '#tly-schedules'; if ( ! $this->sites ) { $this->finish( $back, 'error', __( 'Sites are not available.', 'turtlereport' ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Checked in guard(); each field sanitised below and in Schedule::normalize(). $posted = isset( $_POST['schedules'] ) && is_array( $_POST['schedules'] ) ? wp_unslash( $_POST['schedules'] ) : array(); $changed = 0; $invalid = array(); $nobody = array(); foreach ( $posted as $site_id => $row ) { $site = is_array( $row ) && ! empty( $row['present'] ) ? $this->sites->find( sanitize_key( (string) $site_id ) ) : null; if ( ! $site ) { continue; } $typed = preg_split( '/[\s,;]+/', isset( $row['recipients'] ) && is_string( $row['recipients'] ) ? sanitize_textarea_field( $row['recipients'] ) : '', -1, PREG_SPLIT_NO_EMPTY ); foreach ( $typed as $address ) { if ( ! is_email( $address ) ) { $invalid[] = $address; } } $before = $site->email; $site->email = Schedule::normalize( array_merge( $before, array( 'enabled' => ! empty( $row['enabled'] ), 'frequency' => isset( $row['frequency'] ) ? sanitize_key( (string) $row['frequency'] ) : $before['frequency'], 'weekday' => isset( $row['weekday'] ) ? (int) $row['weekday'] : $before['weekday'], 'monthday' => isset( $row['monthday'] ) ? (int) $row['monthday'] : $before['monthday'], 'hour' => isset( $row['hour'] ) ? (int) $row['hour'] : $before['hour'], 'to_viewers' => ! empty( $row['to_viewers'] ), 'recipients' => $typed, ) ) ); if ( $site->email === $before ) { continue; } $this->sites->save( $site ); ++$changed; if ( $site->email['enabled'] && ! Schedule::is_active( $site->email ) ) { $nobody[] = $site->display_name(); } $timing = array( 'enabled', 'frequency', 'weekday', 'monthday', 'hour' ); $moved = array_intersect_key( $before, array_flip( $timing ) ) !== array_intersect_key( $site->email, array_flip( $timing ) ); $no_next = $this->email_log && ! $this->email_log->get( $site->id )['next']; if ( $this->dispatcher && ( $moved || Schedule::is_active( $before ) !== Schedule::is_active( $site->email ) || $no_next ) ) { call_user_func( $this->dispatcher )->reschedule( $site ); } } if ( $invalid ) { Notices::add( 'warning', /* translators: %s: comma-separated invalid email addresses. */ sprintf( __( 'These aren\'t valid email addresses and were left out: %s', 'turtlereport' ), implode( ', ', array_unique( $invalid ) ) ) ); } if ( $nobody ) { Notices::add( 'warning', /* translators: %s: comma-separated site names. */ sprintf( __( 'Switched on but nobody to send to yet: %s. Tick "Everyone with access" or add an address.', 'turtlereport' ), implode( ', ', $nobody ) ) ); } $this->finish( $back, 'success', $changed /* translators: %d: number of sites changed. */ ? sprintf( _n( 'Saved. %d report\'s email settings changed.', 'Saved. %d reports\' email settings changed.', $changed, 'turtlereport' ), $changed ) : __( 'Saved. Nothing changed.', 'turtlereport' ) ); } /** * Sends every scheduled report that is due right now, without waiting for WP-Cron. * * @return void */ public function run_email_queue() { $this->guard( 'run_email_queue' ); if ( ! $this->dispatcher ) { $this->finish( SettingsPage::url(), 'error', __( 'Emails are not available.', 'turtlereport' ) ); } $sent = call_user_func( $this->dispatcher )->dispatch_due(); $this->finish( SettingsPage::url() . '#tly-schedules', 'success', $sent /* translators: %d: number of scheduled reports started. */ ? sprintf( _n( '%d scheduled report started. See Email deliveries for each recipient\'s result.', '%d scheduled reports started. See Email deliveries for each recipient\'s result.', $sent, 'turtlereport' ), $sent ) : __( 'No scheduled reports were due. Each one goes out at its next send time.', 'turtlereport' ) ); } }