> $rows
+ */
+ public function send( string $filename, array $headers, array $rows ): void {
+ if ( ! headers_sent() ) {
+ header( 'Content-Type: text/csv; charset=UTF-8' );
+ header( 'Content-Disposition: attachment; filename="' . rawurlencode( $filename ) . '"' );
+ }
+ echo $this->to_string( $headers, $rows ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ }
+}
diff --git a/src/Analytics/DashboardKpiCalculator.php b/src/Analytics/DashboardKpiCalculator.php
new file mode 100644
index 0000000..331f73b
--- /dev/null
+++ b/src/Analytics/DashboardKpiCalculator.php
@@ -0,0 +1,84 @@
+get_var(
+ $wpdb->prepare(
+ 'SELECT COUNT(*) FROM ' . $wpdb->prefix . 'botcat_subscribers WHERE status = %s',
+ 'active'
+ )
+ );
+ }
+
+ public function deliveries_in_window( AnalyticsWindow $window ): int {
+ global $wpdb;
+ return (int) $wpdb->get_var(
+ $wpdb->prepare(
+ 'SELECT COUNT(*) FROM ' . $wpdb->prefix . 'botcat_push_logs WHERE is_test = 0 AND status = %s AND created_at BETWEEN %s AND %s',
+ 'sent',
+ $window->start_gmt,
+ $window->end_gmt
+ )
+ );
+ }
+
+ public function clicks_in_window( AnalyticsWindow $window ): int {
+ global $wpdb;
+ return (int) $wpdb->get_var(
+ $wpdb->prepare(
+ 'SELECT COUNT(*) FROM ' . $wpdb->prefix . 'botcat_short_link_clicks WHERE clicked_at BETWEEN %s AND %s',
+ $window->start_gmt,
+ $window->end_gmt
+ )
+ );
+ }
+
+ public function ctr_percent( AnalyticsWindow $window ): float {
+ $deliveries = $this->deliveries_in_window( $window );
+ if ( $deliveries === 0 ) {
+ return 0.0;
+ }
+ return round( $this->clicks_in_window( $window ) * 100 / $deliveries, 1 );
+ }
+
+ public function median_click_latency_seconds( AnalyticsWindow $window ): ?int {
+ global $wpdb;
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ 'SELECT TIMESTAMPDIFF(SECOND, j.triggered_at, c.clicked_at) AS dt '
+ . 'FROM ' . $wpdb->prefix . 'botcat_short_link_clicks AS c '
+ . 'INNER JOIN ' . $wpdb->prefix . 'botcat_short_links AS l ON l.id = c.short_link_id '
+ . 'INNER JOIN ' . $wpdb->prefix . 'botcat_push_jobs AS j ON j.post_id = l.post_id '
+ . 'WHERE c.clicked_at BETWEEN %s AND %s AND j.triggered_at IS NOT NULL '
+ . 'ORDER BY dt',
+ $window->start_gmt,
+ $window->end_gmt
+ ),
+ ARRAY_A
+ );
+
+ if ( ! is_array( $rows ) || $rows === array() ) {
+ return null;
+ }
+
+ $values = array_map( static fn( array $r ): int => (int) ( $r['dt'] ?? 0 ), $rows );
+ sort( $values );
+ $mid = (int) ( count( $values ) / 2 );
+ return (int) $values[ $mid ];
+ }
+}
diff --git a/src/Analytics/DashboardPage.php b/src/Analytics/DashboardPage.php
new file mode 100644
index 0000000..ab27849
--- /dev/null
+++ b/src/Analytics/DashboardPage.php
@@ -0,0 +1,203 @@
+ 403 ) );
+ }
+
+ $window = new AnalyticsWindow( $this->current_days() );
+
+ echo '';
+ echo '
' . esc_html__( 'bot-cat Dashboard', 'bot-cat' ) . ' ';
+
+ $this->render_window_selector( $window );
+ $this->render_kpis( $window );
+ $this->render_growth( $window );
+ $this->render_post_table( $window );
+
+ echo '';
+ }
+
+ public function current_days(): int {
+ if ( isset( $_GET['window'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $candidate = (int) wp_unslash( $_GET['window'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( in_array( $candidate, AnalyticsWindow::ALLOWED_DAYS, true ) ) {
+ update_user_meta( get_current_user_id(), self::USER_META_DAYS, $candidate );
+ return $candidate;
+ }
+ }
+
+ $stored = (int) get_user_meta( get_current_user_id(), self::USER_META_DAYS, true );
+ return in_array( $stored, AnalyticsWindow::ALLOWED_DAYS, true ) ? $stored : AnalyticsWindow::DEFAULT_DAYS;
+ }
+
+ public function handle_csv(): void {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_die( esc_html__( 'Insufficient permissions.', 'bot-cat' ), '', array( 'response' => 403 ) );
+ }
+ check_admin_referer( self::CSV_NONCE );
+
+ $window = new AnalyticsWindow( $this->current_days() );
+ $rows = $this->posts->run( $window );
+
+ $csv_rows = array_map(
+ static fn( array $r ): array => array(
+ $r['post_title'],
+ $r['triggered_at'],
+ $r['delivered'],
+ $r['failed'],
+ $r['clicks'],
+ $r['ctr'] . '%',
+ ),
+ $rows
+ );
+
+ $this->csv->send(
+ 'bot-cat-performance-' . gmdate( 'Y-m-d' ) . '.csv',
+ array(
+ __( 'Post', 'bot-cat' ),
+ __( 'Triggered at', 'bot-cat' ),
+ __( 'Delivered', 'bot-cat' ),
+ __( 'Failed', 'bot-cat' ),
+ __( 'Clicks', 'bot-cat' ),
+ __( 'CTR', 'bot-cat' ),
+ ),
+ $csv_rows
+ );
+ exit;
+ }
+
+ private function render_window_selector( AnalyticsWindow $window ): void {
+ echo '';
+ foreach ( AnalyticsWindow::ALLOWED_DAYS as $days ) {
+ $url = add_query_arg(
+ array(
+ 'page' => self::PAGE_SLUG,
+ 'window' => $days,
+ ),
+ admin_url( 'admin.php' )
+ );
+ printf(
+ '%3$s ',
+ esc_url( $url ),
+ $days === $window->days ? ' button-primary' : '',
+ esc_html(
+ sprintf(
+ /* translators: %d: window in days. */
+ __( 'Last %d days', 'bot-cat' ),
+ $days
+ )
+ )
+ );
+ }
+ echo '
';
+ }
+
+ private function render_kpis( AnalyticsWindow $window ): void {
+ $active = $this->kpis->active_subscribers();
+ $deliveries = $this->kpis->deliveries_in_window( $window );
+ $ctr = $this->kpis->ctr_percent( $window );
+ $latency = $this->kpis->median_click_latency_seconds( $window );
+
+ echo '';
+ $this->kpi_card( __( 'Active subscribers', 'bot-cat' ), (string) $active );
+ $this->kpi_card( __( 'Deliveries', 'bot-cat' ), (string) $deliveries );
+ $this->kpi_card( __( 'CTR', 'bot-cat' ), $ctr . '%' );
+ $this->kpi_card( __( 'Median click latency', 'bot-cat' ), $latency !== null ? $latency . 's' : '—' );
+ echo '
';
+ }
+
+ private function kpi_card( string $label, string $value ): void {
+ printf(
+ '',
+ esc_html( $label ),
+ esc_html( $value )
+ );
+ }
+
+ private function render_growth( AnalyticsWindow $window ): void {
+ $series = $this->growth->run( $window );
+ if ( $series === array() ) {
+ return;
+ }
+
+ echo '' . esc_html__( 'Subscriber growth', 'bot-cat' ) . ' ';
+ echo '';
+ printf( '%s %s ', esc_html__( 'Date', 'bot-cat' ), esc_html__( 'Active', 'bot-cat' ) );
+ echo ' ';
+ foreach ( $series as $row ) {
+ echo '';
+ printf( '%s ', esc_html( $row['date'] ) );
+ printf( '%d ', (int) $row['active'] );
+ echo ' ';
+ }
+ echo '
';
+ }
+
+ private function render_post_table( AnalyticsWindow $window ): void {
+ $rows = $this->posts->run( $window );
+
+ echo '' . esc_html__( 'Per-post performance', 'bot-cat' ) . ' ';
+
+ echo '';
+
+ if ( $rows === array() ) {
+ echo '' . esc_html__( 'No pushes in this window yet.', 'bot-cat' ) . '
';
+ return;
+ }
+
+ echo '';
+ printf( '%s ', esc_html__( 'Post', 'bot-cat' ) );
+ printf( '%s ', esc_html__( 'Triggered', 'bot-cat' ) );
+ printf( '%s ', esc_html__( 'Delivered', 'bot-cat' ) );
+ printf( '%s ', esc_html__( 'Failed', 'bot-cat' ) );
+ printf( '%s ', esc_html__( 'Clicks', 'bot-cat' ) );
+ printf( '%s ', esc_html__( 'CTR', 'bot-cat' ) );
+ echo ' ';
+ foreach ( $rows as $r ) {
+ echo '';
+ printf( '%s ', esc_html( $r['post_title'] !== '' ? $r['post_title'] : '#' . $r['post_id'] ) );
+ printf( '%s ', esc_html( $r['triggered_at'] ) );
+ printf( '%d ', (int) $r['delivered'] );
+ printf( '%d ', (int) $r['failed'] );
+ printf( '%d ', (int) $r['clicks'] );
+ printf( '%s%% ', esc_html( (string) $r['ctr'] ) );
+ echo ' ';
+ }
+ echo '
';
+ }
+}
diff --git a/src/Analytics/PostPerformanceQuery.php b/src/Analytics/PostPerformanceQuery.php
new file mode 100644
index 0000000..bdaac29
--- /dev/null
+++ b/src/Analytics/PostPerformanceQuery.php
@@ -0,0 +1,66 @@
+
+ */
+ public function run( AnalyticsWindow $window, int $limit = 100 ): array {
+ global $wpdb;
+
+ $sql = $wpdb->prepare(
+ 'SELECT j.post_id, j.triggered_at, j.sent_count AS delivered, j.failed_count AS failed, '
+ . '(SELECT COUNT(*) FROM ' . $wpdb->prefix . 'botcat_short_link_clicks AS c '
+ . 'INNER JOIN ' . $wpdb->prefix . 'botcat_short_links AS l ON l.id = c.short_link_id '
+ . 'WHERE l.post_id = j.post_id AND c.clicked_at BETWEEN %s AND %s) AS clicks '
+ . 'FROM ' . $wpdb->prefix . 'botcat_push_jobs AS j '
+ . 'WHERE j.is_test = 0 AND j.triggered_at BETWEEN %s AND %s '
+ . 'ORDER BY j.triggered_at DESC LIMIT %d',
+ $window->start_gmt,
+ $window->end_gmt,
+ $window->start_gmt,
+ $window->end_gmt,
+ $limit
+ );
+
+ $rows = $wpdb->get_results( $sql, ARRAY_A );
+ if ( ! is_array( $rows ) ) {
+ return array();
+ }
+
+ $out = array();
+ foreach ( $rows as $row ) {
+ $delivered = (int) ( $row['delivered'] ?? 0 );
+ $clicks = (int) ( $row['clicks'] ?? 0 );
+ $ctr = $delivered > 0 ? round( $clicks * 100 / $delivered, 1 ) : 0.0;
+
+ $out[] = array(
+ 'post_id' => (int) ( $row['post_id'] ?? 0 ),
+ 'post_title' => $row['post_id'] !== null ? (string) get_the_title( (int) $row['post_id'] ) : '',
+ 'triggered_at' => (string) ( $row['triggered_at'] ?? '' ),
+ 'delivered' => $delivered,
+ 'failed' => (int) ( $row['failed'] ?? 0 ),
+ 'clicks' => $clicks,
+ 'ctr' => $ctr,
+ );
+ }
+
+ // Default sort: CTR descending (spec scenario).
+ usort( $out, static fn( array $a, array $b ): int => $b['ctr'] <=> $a['ctr'] );
+
+ return $out;
+ }
+}
diff --git a/src/Analytics/SubscriberGrowthQuery.php b/src/Analytics/SubscriberGrowthQuery.php
new file mode 100644
index 0000000..09c3d52
--- /dev/null
+++ b/src/Analytics/SubscriberGrowthQuery.php
@@ -0,0 +1,54 @@
+
+ */
+ public function run( AnalyticsWindow $window ): array {
+ global $wpdb;
+ $table = $wpdb->prefix . 'botcat_subscribers';
+
+ $series = array();
+ $end_ts = strtotime( $window->end_gmt );
+ if ( $end_ts === false ) {
+ return $series;
+ }
+
+ for ( $day = 0; $day < $window->days; $day++ ) {
+ $cursor = gmdate( 'Y-m-d 23:59:59', $end_ts - ( $window->days - 1 - $day ) * 86400 );
+ $day_label = gmdate( 'Y-m-d', $end_ts - ( $window->days - 1 - $day ) * 86400 );
+
+ $count = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM {$table} "
+ . 'WHERE followed_at IS NOT NULL AND followed_at <= %s '
+ . 'AND (unfollowed_at IS NULL OR unfollowed_at > %s)',
+ $cursor,
+ $cursor
+ )
+ );
+
+ $series[] = array(
+ 'date' => $day_label,
+ 'active' => $count,
+ );
+ }
+
+ return $series;
+ }
+}
diff --git a/src/Channel/ChannelSettings.php b/src/Channel/ChannelSettings.php
new file mode 100644
index 0000000..682cd17
--- /dev/null
+++ b/src/Channel/ChannelSettings.php
@@ -0,0 +1,64 @@
+channel_id;
+ }
+
+ public function channel_secret(): string {
+ return $this->channel_secret;
+ }
+
+ public function access_token(): string {
+ return $this->access_token;
+ }
+
+ public function is_configured(): bool {
+ return $this->channel_id !== ''
+ && $this->channel_secret !== ''
+ && $this->access_token !== '';
+ }
+
+ /**
+ * @param array $data
+ */
+ public static function from_array( array $data ): self {
+ return new self(
+ (string) ( $data['channel_id'] ?? '' ),
+ (string) ( $data['channel_secret'] ?? '' ),
+ (string) ( $data['access_token'] ?? '' )
+ );
+ }
+
+ /**
+ * @return array
+ */
+ public function to_array(): array {
+ return array(
+ 'channel_id' => $this->channel_id,
+ 'channel_secret' => $this->channel_secret,
+ 'access_token' => $this->access_token,
+ );
+ }
+}
diff --git a/src/Channel/ChannelSettingsRepository.php b/src/Channel/ChannelSettingsRepository.php
new file mode 100644
index 0000000..ce8386d
--- /dev/null
+++ b/src/Channel/ChannelSettingsRepository.php
@@ -0,0 +1,44 @@
+encryptor->decrypt( (string) ( $raw['channel_secret'] ?? '' ) ) ?? '' ),
+ (string) ( $this->encryptor->decrypt( (string) ( $raw['access_token'] ?? '' ) ) ?? '' )
+ );
+ }
+
+ public function save( ChannelSettings $settings ): void {
+ update_option(
+ self::OPTION,
+ array(
+ 'channel_id' => $settings->channel_id(),
+ 'channel_secret' => $this->encryptor->encrypt( $settings->channel_secret() ),
+ 'access_token' => $this->encryptor->encrypt( $settings->access_token() ),
+ )
+ );
+ }
+}
diff --git a/src/Channel/CredentialEncryptor.php b/src/Channel/CredentialEncryptor.php
new file mode 100644
index 0000000..bdc78f8
--- /dev/null
+++ b/src/Channel/CredentialEncryptor.php
@@ -0,0 +1,63 @@
+key = hash_hkdf( 'sha256', $auth_key, SODIUM_CRYPTO_SECRETBOX_KEYBYTES, self::SALT );
+ }
+
+ public function encrypt( string $plaintext ): string {
+ if ( $plaintext === '' ) {
+ return '';
+ }
+
+ $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
+ $cipher = sodium_crypto_secretbox( $plaintext, $nonce, $this->key );
+
+ return self::SCHEME . base64_encode( $nonce . $cipher );
+ }
+
+ public function decrypt( string $ciphertext ): ?string {
+ if ( $ciphertext === '' ) {
+ return '';
+ }
+
+ if ( ! str_starts_with( $ciphertext, self::SCHEME ) ) {
+ return null;
+ }
+
+ $blob = base64_decode( substr( $ciphertext, strlen( self::SCHEME ) ), true );
+ if ( $blob === false || strlen( $blob ) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ) {
+ return null;
+ }
+
+ $nonce = substr( $blob, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
+ $cipher = substr( $blob, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
+
+ $result = sodium_crypto_secretbox_open( $cipher, $nonce, $this->key );
+
+ return $result === false ? null : $result;
+ }
+}
diff --git a/src/Channel/ReplyClient.php b/src/Channel/ReplyClient.php
new file mode 100644
index 0000000..0025f98
--- /dev/null
+++ b/src/Channel/ReplyClient.php
@@ -0,0 +1,54 @@
+ array(
+ 'Authorization' => 'Bearer ' . $access_token,
+ 'Content-Type' => 'application/json',
+ ),
+ 'timeout' => 10,
+ 'body' => wp_json_encode(
+ array(
+ 'replyToken' => $reply_token,
+ 'messages' => array(
+ array(
+ 'type' => 'text',
+ 'text' => $text,
+ ),
+ ),
+ )
+ ),
+ )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ return false;
+ }
+
+ return (int) wp_remote_retrieve_response_code( $response ) === 200;
+ }
+}
diff --git a/src/Channel/SettingsPage.php b/src/Channel/SettingsPage.php
new file mode 100644
index 0000000..28d208f
--- /dev/null
+++ b/src/Channel/SettingsPage.php
@@ -0,0 +1,338 @@
+push_scheduler = $scheduler;
+ }
+
+ public function set_rules_tab( \BotCat\Rules\RulesSettingsTab $rules_tab ): void {
+ $this->rules_tab = $rules_tab;
+ }
+
+ public function register_settings(): void {
+ register_setting(
+ self::OPTION_GROUP,
+ ChannelSettingsRepository::OPTION,
+ array(
+ 'type' => 'array',
+ 'sanitize_callback' => array( $this, 'sanitize' ),
+ 'default' => array(),
+ )
+ );
+
+ add_settings_section(
+ self::SECTION_ID,
+ __( 'LINE Channel', 'bot-cat' ),
+ array( $this, 'render_intro' ),
+ 'bot-cat-settings'
+ );
+
+ $fields = array(
+ 'channel_id' => __( 'Channel ID', 'bot-cat' ),
+ 'channel_secret' => __( 'Channel Secret', 'bot-cat' ),
+ 'access_token' => __( 'Channel Access Token', 'bot-cat' ),
+ );
+
+ foreach ( $fields as $key => $label ) {
+ add_settings_field(
+ 'botcat_field_' . $key,
+ $label,
+ array( $this, 'render_field' ),
+ 'bot-cat-settings',
+ self::SECTION_ID,
+ array( 'key' => $key )
+ );
+ }
+ }
+
+ /**
+ * @param array $input
+ * @return array
+ */
+ public function sanitize( array $input ): array {
+ $current = $this->repository->get();
+
+ $channel_id = isset( $input['channel_id'] ) ? sanitize_text_field( (string) $input['channel_id'] ) : '';
+ $channel_secret = isset( $input['channel_secret'] ) && (string) $input['channel_secret'] !== ''
+ ? sanitize_text_field( (string) $input['channel_secret'] )
+ : $current->channel_secret();
+ $access_token = isset( $input['access_token'] ) && (string) $input['access_token'] !== ''
+ ? sanitize_text_field( (string) $input['access_token'] )
+ : $current->access_token();
+
+ if ( $access_token === '' ) {
+ add_settings_error(
+ ChannelSettingsRepository::OPTION,
+ 'access_token_required',
+ __( 'Channel Access Token is required.', 'bot-cat' )
+ );
+ }
+
+ $this->repository->save( new ChannelSettings( $channel_id, $channel_secret, $access_token ) );
+
+ return $this->repository->get()->to_array();
+ }
+
+ public function render_intro(): void {
+ $webhook_url = rest_url( WebhookEndpoint::REST_NAMESPACE . WebhookEndpoint::ROUTE );
+ printf(
+ '%s
%s
',
+ esc_html__( 'Paste this webhook URL into the LINE Developers console:', 'bot-cat' ),
+ esc_url( $webhook_url )
+ );
+ }
+
+ /**
+ * @param array{key:string} $args
+ */
+ public function render_field( array $args ): void {
+ $settings = $this->repository->get();
+ $key = $args['key'];
+
+ $value = match ( $key ) {
+ 'channel_id' => $settings->channel_id(),
+ 'channel_secret' => $settings->channel_secret() === '' ? '' : '••••••••',
+ 'access_token' => $settings->access_token() === '' ? '' : '••••••••',
+ default => '',
+ };
+
+ $type = $key === 'channel_id' ? 'text' : 'password';
+
+ printf(
+ ' ',
+ esc_attr( $type ),
+ esc_attr( $key ),
+ esc_attr( ChannelSettingsRepository::OPTION ),
+ esc_attr( $value )
+ );
+ }
+
+ public function render(): void {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_die( esc_html__( 'Insufficient permissions.', 'bot-cat' ), '', array( 'response' => 403 ) );
+ }
+
+ $tab = $this->current_tab();
+
+ echo '';
+ echo '
' . esc_html__( 'bot-cat Settings', 'bot-cat' ) . ' ';
+
+ $this->render_tab_nav( $tab );
+
+ if ( $tab === self::TAB_RULES && $this->rules_tab !== null ) {
+ $this->rules_tab->render();
+ } else {
+ $this->render_test_result_banner();
+
+ echo '';
+
+ $this->render_test_connection_form();
+ $this->render_send_test_form();
+ }
+
+ echo '';
+ }
+
+ private function current_tab(): string {
+ $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( (string) $_GET['tab'] ) ) : self::TAB_CHANNEL; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+
+ return $tab === self::TAB_RULES ? self::TAB_RULES : self::TAB_CHANNEL;
+ }
+
+ private function render_tab_nav( string $current ): void {
+ $tabs = array(
+ self::TAB_CHANNEL => __( 'Channel', 'bot-cat' ),
+ self::TAB_RULES => __( 'Push rules', 'bot-cat' ),
+ );
+
+ echo '';
+ foreach ( $tabs as $slug => $label ) {
+ $url = add_query_arg(
+ array(
+ 'page' => 'bot-cat-settings',
+ 'tab' => $slug,
+ ),
+ admin_url( 'admin.php' )
+ );
+
+ printf(
+ '%3$s ',
+ esc_url( $url ),
+ $current === $slug ? ' nav-tab-active' : '',
+ esc_html( $label )
+ );
+ }
+ echo ' ';
+ }
+
+ private function render_send_test_form(): void {
+ if ( $this->push_scheduler === null ) {
+ return;
+ }
+
+ $action_url = admin_url( 'admin-post.php' );
+
+ echo '' . esc_html__( 'Send test push', 'bot-cat' ) . ' ';
+ echo '' . esc_html__( 'Send a test message to one or more LINE user IDs (comma or newline separated).', 'bot-cat' ) . '
';
+ echo '';
+ }
+
+ public function handle_send_test(): void {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_die( esc_html__( 'Insufficient permissions.', 'bot-cat' ), '', array( 'response' => 403 ) );
+ }
+
+ check_admin_referer( self::SEND_TEST_NONCE );
+
+ $raw = isset( $_POST['user_ids'] ) ? (string) wp_unslash( $_POST['user_ids'] ) : '';
+ $tokens = preg_split( '/[\s,]+/', $raw );
+ if ( ! is_array( $tokens ) ) {
+ $tokens = array();
+ }
+ $ids = array_values(
+ array_unique(
+ array_filter(
+ array_map( 'trim', $tokens ),
+ static fn( string $id ): bool => $id !== '' && str_starts_with( $id, 'U' )
+ )
+ )
+ );
+
+ if ( $ids === array() ) {
+ wp_safe_redirect(
+ add_query_arg(
+ array(
+ 'page' => 'bot-cat-settings',
+ 'botcat_test_push' => 'invalid',
+ ),
+ admin_url( 'admin.php' )
+ )
+ );
+ exit;
+ }
+
+ if ( $this->push_scheduler !== null ) {
+ $this->push_scheduler->enqueue_test( 0, $ids );
+ }
+
+ wp_safe_redirect(
+ add_query_arg(
+ array(
+ 'page' => 'bot-cat-settings',
+ 'botcat_test_push' => 'queued',
+ ),
+ admin_url( 'admin.php' )
+ )
+ );
+ exit;
+ }
+
+ private function render_test_result_banner(): void {
+ if ( ! isset( $_GET['botcat_test_ok'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ return;
+ }
+
+ // phpcs:disable WordPress.Security.NonceVerification.Recommended
+ $ok = (string) $_GET['botcat_test_ok'] === '1';
+ $name = isset( $_GET['botcat_test_name'] ) ? sanitize_text_field( rawurldecode( wp_unslash( (string) $_GET['botcat_test_name'] ) ) ) : '';
+ $err = isset( $_GET['botcat_test_err'] ) ? sanitize_text_field( rawurldecode( wp_unslash( (string) $_GET['botcat_test_err'] ) ) ) : '';
+ // phpcs:enable WordPress.Security.NonceVerification.Recommended
+
+ if ( $ok ) {
+ printf(
+ '',
+ esc_html(
+ sprintf(
+ /* translators: %s: LINE Official Account display name. */
+ __( 'Connected to %s', 'bot-cat' ),
+ $name !== '' ? $name : __( 'your LINE Official Account', 'bot-cat' )
+ )
+ )
+ );
+ return;
+ }
+
+ printf(
+ '',
+ esc_html__( 'Connection failed:', 'bot-cat' ),
+ esc_html( $err )
+ );
+ }
+
+ private function render_test_connection_form(): void {
+ $action_url = admin_url( 'admin-post.php' );
+
+ echo '' . esc_html__( 'Test connection', 'bot-cat' ) . ' ';
+ echo '';
+ printf( ' ' );
+ wp_nonce_field( self::NONCE_ACTION );
+ submit_button( __( 'Test connection', 'bot-cat' ), 'secondary', 'submit', false );
+ echo ' ';
+ }
+
+ public function handle_test_connection(): void {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_die( esc_html__( 'Insufficient permissions.', 'bot-cat' ), '', array( 'response' => 403 ) );
+ }
+
+ check_admin_referer( self::NONCE_ACTION );
+
+ $tester = new TestConnection();
+ $result = $tester->run( $this->repository->get() );
+
+ $redirect = add_query_arg(
+ array(
+ 'page' => 'bot-cat-settings',
+ 'botcat_test_ok' => $result->ok ? '1' : '0',
+ 'botcat_test_name' => rawurlencode( (string) $result->display_name ),
+ 'botcat_test_err' => rawurlencode( (string) $result->error_message ),
+ ),
+ admin_url( 'admin.php' )
+ );
+
+ wp_safe_redirect( $redirect );
+ exit;
+ }
+}
diff --git a/src/Channel/SignatureVerifier.php b/src/Channel/SignatureVerifier.php
new file mode 100644
index 0000000..aa5961b
--- /dev/null
+++ b/src/Channel/SignatureVerifier.php
@@ -0,0 +1,30 @@
+channel_secret === '' || $signature === null || $signature === '' ) {
+ return false;
+ }
+
+ $expected = base64_encode( hash_hmac( 'sha256', $raw_body, $this->channel_secret, true ) );
+
+ return hash_equals( $expected, $signature );
+ }
+}
diff --git a/src/Channel/TestConnection.php b/src/Channel/TestConnection.php
new file mode 100644
index 0000000..3690a7c
--- /dev/null
+++ b/src/Channel/TestConnection.php
@@ -0,0 +1,70 @@
+is_configured() ) {
+ return new TestConnectionResult(
+ ok: false,
+ error_code: 'not_configured',
+ error_message: __( 'Connect your LINE channel first.', 'bot-cat' )
+ );
+ }
+
+ $response = wp_remote_get(
+ self::ENDPOINT,
+ array(
+ 'headers' => array(
+ 'Authorization' => 'Bearer ' . $settings->access_token(),
+ ),
+ 'timeout' => 10,
+ )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ return new TestConnectionResult(
+ ok: false,
+ error_code: 'network_error',
+ error_message: __( 'Could not reach LINE.', 'bot-cat' )
+ );
+ }
+
+ $code = (int) wp_remote_retrieve_response_code( $response );
+ $body = (string) wp_remote_retrieve_body( $response );
+
+ /** @var array|null $payload */
+ $payload = json_decode( $body, true );
+
+ if ( $code === 200 && is_array( $payload ) ) {
+ return new TestConnectionResult(
+ ok: true,
+ display_name: isset( $payload['displayName'] ) ? (string) $payload['displayName'] : '',
+ http_status: 200
+ );
+ }
+
+ $message = is_array( $payload ) && isset( $payload['message'] )
+ ? (string) $payload['message']
+ : $body;
+
+ return new TestConnectionResult(
+ ok: false,
+ http_status: $code,
+ error_code: 'upstream_error',
+ error_message: $message
+ );
+ }
+}
diff --git a/src/Channel/TestConnectionResult.php b/src/Channel/TestConnectionResult.php
new file mode 100644
index 0000000..190563b
--- /dev/null
+++ b/src/Channel/TestConnectionResult.php
@@ -0,0 +1,24 @@
+ 'POST',
+ 'callback' => array( $this, 'handle' ),
+ 'permission_callback' => static fn(): bool => true,
+ )
+ );
+ }
+
+ public function handle( WP_REST_Request $request ): WP_REST_Response {
+ $status = $this->handle_request(
+ (string) $request->get_body(),
+ $request->get_header( 'x_line_signature' )
+ );
+
+ return new WP_REST_Response( null, $status );
+ }
+
+ /**
+ * Pure-PHP entry point that's easy to unit-test (no WP_REST_Request).
+ */
+ public function handle_request( string $raw_body, ?string $signature ): int {
+ $channel = $this->channel_settings->get();
+
+ if ( ! $channel->is_configured() ) {
+ return 200;
+ }
+
+ $verifier = new SignatureVerifier( $channel->channel_secret() );
+ if ( ! $verifier->verify( $raw_body, $signature ) ) {
+ return 403;
+ }
+
+ $payload = json_decode( $raw_body, true );
+ if ( ! is_array( $payload ) || ! isset( $payload['events'] ) || ! is_array( $payload['events'] ) ) {
+ return 200;
+ }
+
+ foreach ( $payload['events'] as $event ) {
+ $this->dispatch_event( is_array( $event ) ? $event : array() );
+ }
+
+ return 200;
+ }
+
+ /**
+ * @param array $event
+ */
+ private function dispatch_event( array $event ): void {
+ $type = isset( $event['type'] ) ? (string) $event['type'] : '';
+ $line_user_id = isset( $event['source']['userId'] ) ? (string) $event['source']['userId'] : '';
+ $ts_ms = isset( $event['timestamp'] ) ? (int) $event['timestamp'] : 0;
+ $ts = $ts_ms > 0 ? intdiv( $ts_ms, 1000 ) : time();
+
+ if ( $line_user_id === '' ) {
+ return;
+ }
+
+ if ( $type === 'follow' ) {
+ $this->follow->handle( $line_user_id, $ts );
+ return;
+ }
+
+ if ( $type === 'unfollow' ) {
+ $this->unfollow->handle( $line_user_id, $ts );
+ return;
+ }
+
+ if ( $type === 'message' && $this->messages !== null ) {
+ $message = isset( $event['message'] ) && is_array( $event['message'] ) ? $event['message'] : array();
+ $reply = isset( $event['replyToken'] ) ? (string) $event['replyToken'] : '';
+ $is_text = isset( $message['type'] ) && $message['type'] === 'text';
+ $body = $is_text && isset( $message['text'] ) ? (string) $message['text'] : '';
+
+ if ( $reply !== '' && $body !== '' ) {
+ $this->messages->handle( $line_user_id, $reply, $body );
+ }
+ }
+ }
+}
diff --git a/src/Flex/FlexMessageBuilder.php b/src/Flex/FlexMessageBuilder.php
new file mode 100644
index 0000000..15aa304
--- /dev/null
+++ b/src/Flex/FlexMessageBuilder.php
@@ -0,0 +1,152 @@
+
+ */
+ public function build( FlexTemplate $template, object $post ): array {
+ $headline = $this->renderer->render( $template->headline, $post );
+ $body = $this->renderer->render( $template->body, $post );
+
+ $post_id = isset( $post->ID ) ? (int) $post->ID : 0;
+ $hero_url = $this->hero->resolve( $template->hero_source, $template->default_image_url, $post_id );
+
+ $bubble = array(
+ 'type' => 'bubble',
+ 'body' => $this->build_body( $headline, $body ),
+ );
+
+ if ( $hero_url !== null ) {
+ $bubble['hero'] = array(
+ 'type' => 'image',
+ 'url' => $hero_url,
+ 'size' => 'full',
+ 'aspectRatio' => '20:13',
+ 'aspectMode' => 'cover',
+ );
+ }
+
+ $footer = $this->build_footer( $template, $post );
+ if ( $footer !== null ) {
+ $bubble['footer'] = $footer;
+ }
+
+ return array(
+ 'type' => 'flex',
+ 'altText' => $this->derive_alt_text( $headline, $body ),
+ 'contents' => $bubble,
+ );
+ }
+
+ /**
+ * @return array
+ */
+ private function build_body( string $headline, string $body ): array {
+ $contents = array(
+ array(
+ 'type' => 'text',
+ 'text' => $headline,
+ 'weight' => 'bold',
+ 'size' => 'xl',
+ 'wrap' => true,
+ ),
+ );
+
+ if ( $body !== '' ) {
+ $contents[] = array(
+ 'type' => 'text',
+ 'text' => $body,
+ 'size' => 'md',
+ 'wrap' => true,
+ 'color' => '#666666',
+ 'margin' => 'md',
+ );
+ }
+
+ return array(
+ 'type' => 'box',
+ 'layout' => 'vertical',
+ 'spacing' => 'md',
+ 'contents' => $contents,
+ );
+ }
+
+ /**
+ * @return array|null
+ */
+ private function build_footer( FlexTemplate $template, object $post ): ?array {
+ if ( $template->ctas === array() ) {
+ return null;
+ }
+
+ $buttons = array();
+ foreach ( array_slice( $template->ctas, 0, FlexTemplate::MAX_CTAS ) as $cta ) {
+ $label = isset( $cta['label'] ) ? (string) $cta['label'] : '';
+ $uri = $this->renderer->render( (string) ( $cta['url_template'] ?? '' ), $post );
+
+ if ( $label === '' || $uri === '' ) {
+ continue;
+ }
+
+ $buttons[] = array(
+ 'type' => 'button',
+ 'style' => 'primary',
+ 'height' => 'sm',
+ 'action' => array(
+ 'type' => 'uri',
+ 'label' => $label,
+ 'uri' => $uri,
+ ),
+ );
+ }
+
+ if ( $buttons === array() ) {
+ return null;
+ }
+
+ return array(
+ 'type' => 'box',
+ 'layout' => 'vertical',
+ 'spacing' => 'sm',
+ 'contents' => $buttons,
+ );
+ }
+
+ private function derive_alt_text( string $headline, string $body ): string {
+ $raw = trim( $headline . ' ' . $body );
+ if ( $raw === '' ) {
+ $raw = 'A new post is available.';
+ }
+
+ if ( mb_strlen( $raw, 'UTF-8' ) <= self::ALT_TEXT_LIMIT ) {
+ return $raw;
+ }
+
+ return mb_substr( $raw, 0, self::ALT_TEXT_LIMIT - 1, 'UTF-8' ) . '…';
+ }
+}
diff --git a/src/Flex/FlexTemplate.php b/src/Flex/FlexTemplate.php
new file mode 100644
index 0000000..6978f8e
--- /dev/null
+++ b/src/Flex/FlexTemplate.php
@@ -0,0 +1,46 @@
+ $ctas
+ */
+ public function __construct(
+ public readonly string $hero_source,
+ public readonly string $default_image_url,
+ public readonly string $headline,
+ public readonly string $body,
+ public readonly array $ctas
+ ) {
+ }
+
+ public function with_ctas( array $ctas ): self {
+ return new self(
+ $this->hero_source,
+ $this->default_image_url,
+ $this->headline,
+ $this->body,
+ array_slice( $ctas, 0, self::MAX_CTAS )
+ );
+ }
+}
diff --git a/src/Flex/FlexTemplateRepository.php b/src/Flex/FlexTemplateRepository.php
new file mode 100644
index 0000000..0409205
--- /dev/null
+++ b/src/Flex/FlexTemplateRepository.php
@@ -0,0 +1,77 @@
+defaults();
+ }
+
+ $ctas = array();
+ if ( isset( $stored['ctas'] ) && is_array( $stored['ctas'] ) ) {
+ foreach ( $stored['ctas'] as $cta ) {
+ if ( ! is_array( $cta ) ) {
+ continue;
+ }
+ $ctas[] = array(
+ 'label' => isset( $cta['label'] ) ? (string) $cta['label'] : '',
+ 'url_template' => isset( $cta['url_template'] ) ? (string) $cta['url_template'] : '',
+ );
+ }
+ }
+
+ return new FlexTemplate(
+ hero_source: isset( $stored['hero_source'] ) ? (string) $stored['hero_source'] : FlexTemplate::HERO_FEATURED,
+ default_image_url: isset( $stored['default_image_url'] ) ? (string) $stored['default_image_url'] : '',
+ headline: isset( $stored['headline'] ) ? (string) $stored['headline'] : $this->defaults()->headline,
+ body: isset( $stored['body'] ) ? (string) $stored['body'] : $this->defaults()->body,
+ ctas: array_slice( $ctas, 0, FlexTemplate::MAX_CTAS )
+ );
+ }
+
+ public function save( FlexTemplate $template ): void {
+ update_option(
+ self::OPTION,
+ array(
+ 'hero_source' => $template->hero_source,
+ 'default_image_url' => $template->default_image_url,
+ 'headline' => $template->headline,
+ 'body' => $template->body,
+ 'ctas' => array_slice( $template->ctas, 0, FlexTemplate::MAX_CTAS ),
+ )
+ );
+ }
+
+ private function defaults(): FlexTemplate {
+ return new FlexTemplate(
+ hero_source: FlexTemplate::HERO_FEATURED,
+ default_image_url: '',
+ headline: '🆕 {title}',
+ body: '{excerpt}',
+ ctas: array(
+ array(
+ 'label' => __( 'Read more', 'bot-cat' ),
+ 'url_template' => '{permalink}',
+ ),
+ )
+ );
+ }
+}
diff --git a/src/Flex/HeroImageResolver.php b/src/Flex/HeroImageResolver.php
new file mode 100644
index 0000000..0e8c213
--- /dev/null
+++ b/src/Flex/HeroImageResolver.php
@@ -0,0 +1,78 @@
+ 0 ) {
+ $featured = $this->featured( $post_id );
+ if ( $featured !== null ) {
+ return $featured;
+ }
+
+ $attached = $this->first_attached( $post_id );
+ if ( $attached !== null ) {
+ return $attached;
+ }
+ }
+
+ return $default_url !== '' ? $default_url : null;
+ }
+
+ private function featured( int $post_id ): ?string {
+ if ( ! function_exists( 'has_post_thumbnail' ) || ! has_post_thumbnail( $post_id ) ) {
+ return null;
+ }
+
+ $thumbnail_id = (int) get_post_thumbnail_id( $post_id );
+ if ( $thumbnail_id <= 0 ) {
+ return null;
+ }
+
+ $src = wp_get_attachment_image_src( $thumbnail_id, 'large' );
+ if ( ! is_array( $src ) || empty( $src[0] ) ) {
+ return null;
+ }
+
+ return (string) $src[0];
+ }
+
+ private function first_attached( int $post_id ): ?string {
+ if ( ! function_exists( 'get_attached_media' ) ) {
+ return null;
+ }
+
+ $media = get_attached_media( 'image', $post_id );
+ if ( ! is_array( $media ) || $media === array() ) {
+ return null;
+ }
+
+ $first = reset( $media );
+ if ( ! is_object( $first ) || ! isset( $first->ID ) ) {
+ return null;
+ }
+
+ $src = wp_get_attachment_image_src( (int) $first->ID, 'large' );
+ if ( ! is_array( $src ) || empty( $src[0] ) ) {
+ return null;
+ }
+
+ return (string) $src[0];
+ }
+}
diff --git a/src/Foundation/Activator.php b/src/Foundation/Activator.php
new file mode 100644
index 0000000..1d88d53
--- /dev/null
+++ b/src/Foundation/Activator.php
@@ -0,0 +1,78 @@
+run( PHP_VERSION, (string) $wp_version );
+ }
+
+ public function run( string $php_version, string $wp_version ): void {
+ if ( version_compare( $php_version, self::MIN_PHP, '<' ) ) {
+ $this->abort(
+ sprintf(
+ /* translators: %1$s: required PHP version, %2$s: actual PHP version. */
+ __( 'bot-cat requires PHP %1$s or newer. You are running PHP %2$s.', 'bot-cat' ),
+ self::MIN_PHP,
+ $php_version
+ )
+ );
+ return;
+ }
+
+ if ( version_compare( $wp_version, self::MIN_WP, '<' ) ) {
+ $this->abort(
+ sprintf(
+ /* translators: %1$s: required WordPress version, %2$s: actual WordPress version. */
+ __( 'bot-cat requires WordPress %1$s or newer. You are running WordPress %2$s.', 'bot-cat' ),
+ self::MIN_WP,
+ $wp_version
+ )
+ );
+ return;
+ }
+
+ $this->schema->install();
+
+ if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
+ wp_schedule_event( time(), 'daily', self::CRON_HOOK );
+ }
+ }
+
+ private function abort( string $message ): void {
+ if ( $this->abort_handler !== null ) {
+ ( $this->abort_handler )( $message );
+ }
+
+ deactivate_plugins( plugin_basename( BOT_CAT_FILE ) );
+ wp_die( esc_html( $message ) );
+ }
+}
diff --git a/src/Foundation/AdminMenu.php b/src/Foundation/AdminMenu.php
new file mode 100644
index 0000000..03d8a17
--- /dev/null
+++ b/src/Foundation/AdminMenu.php
@@ -0,0 +1,88 @@
+ $renderers Map of submenu slug => render callback. Slugs not present fall back to a placeholder.
+ */
+ public function __construct(
+ private readonly Edition $edition,
+ private readonly array $renderers = array()
+ ) {
+ }
+
+ public function register(): void {
+ add_menu_page(
+ __( 'bot-cat', 'bot-cat' ),
+ __( 'bot-cat', 'bot-cat' ),
+ self::CAPABILITY,
+ self::MENU_SLUG,
+ $this->renderer_for( self::MENU_SLUG ),
+ 'dashicons-format-chat',
+ 30
+ );
+
+ foreach ( $this->pages() as $page ) {
+ [ $slug, $title ] = $page;
+ add_submenu_page(
+ self::MENU_SLUG,
+ $title,
+ $title,
+ self::CAPABILITY,
+ $slug,
+ $this->renderer_for( $slug )
+ );
+ }
+ }
+
+ /**
+ * @return list
+ */
+ private function pages(): array {
+ $pages = array(
+ array( 'bot-cat', __( 'Dashboard', 'bot-cat' ) ),
+ array( 'bot-cat-subscribers', __( 'Subscribers', 'bot-cat' ) ),
+ array( 'bot-cat-templates', __( 'Templates', 'bot-cat' ) ),
+ array( 'bot-cat-logs', __( 'Push Logs', 'bot-cat' ) ),
+ array( 'bot-cat-settings', __( 'Settings', 'bot-cat' ) ),
+ );
+
+ if ( $this->edition->is_pro() ) {
+ $pages[] = array( 'bot-cat-tags', __( 'Tags', 'bot-cat' ) );
+ $pages[] = array( 'bot-cat-license', __( 'License', 'bot-cat' ) );
+ }
+
+ return $pages;
+ }
+
+ private function renderer_for( string $slug ): callable {
+ if ( isset( $this->renderers[ $slug ] ) ) {
+ return $this->renderers[ $slug ];
+ }
+
+ return function () use ( $slug ): void {
+ printf(
+ '',
+ esc_html( $slug ),
+ esc_html__( 'This page is not implemented yet.', 'bot-cat' )
+ );
+ };
+ }
+}
diff --git a/src/Foundation/Deactivator.php b/src/Foundation/Deactivator.php
new file mode 100644
index 0000000..cc8f0a0
--- /dev/null
+++ b/src/Foundation/Deactivator.php
@@ -0,0 +1,21 @@
+plugin_file ) ) . '/languages'
+ );
+ }
+}
diff --git a/src/Foundation/Plugin.php b/src/Foundation/Plugin.php
new file mode 100644
index 0000000..e292dd9
--- /dev/null
+++ b/src/Foundation/Plugin.php
@@ -0,0 +1,348 @@
+register_hooks();
+ }
+
+ return self::$instance;
+ }
+
+ public static function instance(): ?self {
+ return self::$instance;
+ }
+
+ public function register_hooks(): void {
+ $i18n = new I18n( $this->plugin_file );
+ add_action( 'init', array( $i18n, 'load' ) );
+
+ $schema_version = new SchemaVersion( new Schema(), BOT_CAT_VERSION );
+ add_action( 'admin_init', array( $schema_version, 'maybe_upgrade' ) );
+
+ $cron = new RetentionCron();
+ add_action( Activator::CRON_HOOK, array( $cron, 'run' ) );
+
+ $channel_repo = $this->channel_repository();
+ $jobs = new PushJobRepository();
+ $logs = new PushLogRepository();
+ $subscribers = new SubscriberRepository();
+ $tags = new TagRepository();
+ $subscriber_tags = new SubscriberTagRepository();
+
+ // Templates.
+ $template_repo = new TemplateRepository();
+ $template_renderer = new TemplateRenderer( new TokenResolver() );
+
+ // Pro Flex template.
+ $flex_template_repo = new FlexTemplateRepository();
+ $flex_builder = new FlexMessageBuilder( $template_renderer, new HeroImageResolver() );
+
+ // License (must come before Edition so the filter is registered).
+ $license_repo = new LicenseRepository();
+ $license_evaluator = new LicenseStatusEvaluator();
+ $license_gate = new LicenseGate( $license_repo, $license_evaluator );
+ $polar_client = new PolarClient();
+ add_filter( 'botcat_is_pro', array( $license_gate, 'filter_is_pro' ) );
+
+ // Edition + rules.
+ $edition = new Edition();
+ $eligible = new EligiblePostTypes();
+ $excluded = new ExcludedCategories();
+ $opt_out = new PerPostOptOut();
+ $throttle = new PushThrottle();
+ $rules_tab = new RulesSettingsTab( $eligible, $excluded );
+ add_action( 'admin_init', array( $rules_tab, 'register_settings' ) );
+
+ // Push scheduler with throttle.
+ $scheduler = new PushJobScheduler( $jobs, $throttle );
+
+ // Template editor page (with Pro Flex tab).
+ $template_page = new TemplatePage( $template_repo, $template_renderer, $eligible, $edition, $flex_template_repo );
+ add_action( 'admin_init', array( $template_page, 'register_settings' ) );
+
+ // Channel settings page (with rules tab + send test).
+ $settings_page = new ChannelSettingsPage( $channel_repo );
+ $settings_page->set_push_scheduler( $scheduler );
+ $settings_page->set_rules_tab( $rules_tab );
+ add_action( 'admin_init', array( $settings_page, 'register_settings' ) );
+ add_action( 'admin_post_botcat_test_connection', array( $settings_page, 'handle_test_connection' ) );
+ add_action( 'admin_post_' . ChannelSettingsPage::SEND_TEST_ACTION, array( $settings_page, 'handle_send_test' ) );
+
+ // Per-post opt-out metabox.
+ add_action(
+ 'add_meta_boxes',
+ function () use ( $opt_out, $eligible ): void {
+ foreach ( $eligible->get() as $post_type ) {
+ $opt_out->register_meta_box( $post_type );
+ }
+ }
+ );
+ add_action( 'save_post', array( $opt_out, 'on_save_post' ) );
+
+ // Subscribers + push log pages.
+ $subscribers_page = new SubscribersPage( $subscribers );
+ $push_logs_page = new PushLogsPage( $jobs, $logs );
+ $push_detail_page = new PushJobDetailPage( $jobs, $logs );
+ add_action( 'admin_post_' . PushJobDetailPage::RESEND_ACTION, array( $push_detail_page, 'handle_resend' ) );
+
+ // Pro: Tags admin page.
+ $tags_page = new TagsPage( $tags );
+ add_action( 'admin_post_' . TagsPage::ACTION_SAVE, array( $tags_page, 'handle_save' ) );
+ add_action( 'admin_post_' . TagsPage::ACTION_DELETE, array( $tags_page, 'handle_delete' ) );
+
+ // Pro: short link infrastructure.
+ $short_links = new ShortLinkRepository( new ShortCodeGenerator() );
+ $click_log = new ClickLogRepository();
+ $token_signer = new SubscriberTokenSigner( defined( 'AUTH_KEY' ) && AUTH_KEY !== '' ? AUTH_KEY : 'bot-cat-fallback-key' );
+ $short_base = function_exists( 'home_url' ) ? (string) home_url( '/l/' ) : '/l/';
+ $link_rewriter = new LinkRewriter( $short_links, function_exists( 'home_url' ) ? (string) home_url() : '', $short_base );
+
+ if ( $edition->is_pro() ) {
+ $redirect_handler = new ClickRedirectHandler( $short_links, $click_log, $token_signer, defined( 'AUTH_KEY' ) && AUTH_KEY !== '' ? AUTH_KEY : 'bot-cat-fallback-salt' );
+ $rewrite_registrar = new RewriteRuleRegistrar( $redirect_handler );
+ add_action( 'init', array( $rewrite_registrar, 'register_rewrite' ) );
+ add_filter( 'query_vars', array( $rewrite_registrar, 'register_query_var' ) );
+ add_action( 'template_redirect', array( $rewrite_registrar, 'dispatch' ) );
+ }
+
+ $short_links_page = new ShortLinksPage( $short_links, $click_log, $short_base );
+
+ // Pro: analytics dashboard.
+ $dashboard_page = new DashboardPage(
+ new DashboardKpiCalculator(),
+ new PostPerformanceQuery(),
+ new SubscriberGrowthQuery(),
+ new CsvExporter()
+ );
+ add_action( 'admin_post_' . DashboardPage::CSV_ACTION, array( $dashboard_page, 'handle_csv' ) );
+
+ // Top-level menu.
+ $default_landing = $edition->is_pro()
+ ? array( $dashboard_page, 'render' )
+ : array( $subscribers_page, 'render' );
+
+ $menu_renderers = array(
+ 'bot-cat' => $default_landing,
+ 'bot-cat-subscribers' => array( $subscribers_page, 'render' ),
+ 'bot-cat-templates' => array( $template_page, 'render' ),
+ 'bot-cat-logs' => array( $push_logs_page, 'render' ),
+ 'bot-cat-settings' => array( $settings_page, 'render' ),
+ );
+ if ( $edition->is_pro() ) {
+ $menu_renderers['bot-cat-tags'] = array( $tags_page, 'render' );
+ $menu_renderers['bot-cat-short-links'] = array( $short_links_page, 'render' );
+
+ $license_page = new LicensePage( $license_repo, $license_evaluator );
+ $activation_handler = new LicenseActivationHandler( $license_repo, $polar_client );
+ $polar_webhook = new PolarWebhookEndpoint( $license_repo );
+ $revalidation_cron = new LicenseRevalidationCron( $license_repo, $polar_client );
+
+ $menu_renderers['bot-cat-license'] = array( $license_page, 'render' );
+
+ add_action( 'admin_post_' . LicensePage::ACTION_ACTIVATE, array( $activation_handler, 'handle_activate' ) );
+ add_action( 'admin_post_' . LicensePage::ACTION_DEACTIVATE, array( $activation_handler, 'handle_deactivate' ) );
+ add_action( 'rest_api_init', array( $polar_webhook, 'register' ) );
+ add_action( LicenseRevalidationCron::HOOK, array( $revalidation_cron, 'run' ) );
+
+ // Schedule the daily revalidation if not already.
+ if ( function_exists( 'wp_next_scheduled' ) && ! wp_next_scheduled( LicenseRevalidationCron::HOOK ) ) {
+ wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', LicenseRevalidationCron::HOOK );
+ }
+
+ // Pro auto-update channel.
+ $update_channel = new UpdateChannel( $license_repo, plugin_basename( $this->plugin_file ), BOT_CAT_VERSION );
+ $update_channel->register();
+ }
+ $menu = new AdminMenu( $edition, $menu_renderers );
+ add_action( 'admin_menu', array( $menu, 'register' ) );
+
+ // Webhook + subscriber + tag event handlers.
+ $profile_fetcher = new LineProfileFetcher();
+ $reply_client = new ReplyClient();
+ $welcome = new WelcomeMessageBuilder( $tags );
+ $follow_handler = new FollowHandler(
+ $subscribers,
+ $channel_repo,
+ $profile_fetcher,
+ $edition->is_pro() ? $welcome : null,
+ $edition->is_pro() ? $reply_client : null
+ );
+ $unfollow_handler = new UnfollowHandler( $subscribers );
+
+ $message_handler = null;
+ if ( $edition->is_pro() ) {
+ $command_handler = new TagCommandHandler( $tags, $subscriber_tags, $subscribers );
+ $message_handler = new MessageEventHandler(
+ new TagCommandParser(),
+ $command_handler,
+ $channel_repo,
+ $reply_client
+ );
+ }
+
+ $webhook = new WebhookEndpoint( $channel_repo, $follow_handler, $unfollow_handler, $message_handler );
+ add_action( 'rest_api_init', array( $webhook, 'register' ) );
+
+ add_action(
+ FollowHandler::RETRY_HOOK,
+ function ( string $line_user_id ) use ( $follow_handler ): void {
+ $follow_handler->handle( $line_user_id, time() );
+ }
+ );
+
+ // Publish observer.
+ $observer = new PostPublishObserver( $eligible, $scheduler, $excluded, $opt_out );
+ add_action( 'transition_post_status', array( $observer, 'on_transition' ), 10, 3 );
+
+ // Pro: tag-aware audience.
+ $audience = $edition->is_pro()
+ ? new AudienceResolver( $subscribers, $tags, $subscriber_tags )
+ : null;
+
+ // Push runners.
+ $job_runner = new PushJobRunner( $jobs, $logs, $subscribers, $audience );
+ add_action(
+ PushJobScheduler::ACTION_RUN_JOB,
+ static function ( $job_id, $custom_user_ids = array() ) use ( $job_runner ): void {
+ $ids = is_array( $custom_user_ids ) ? array_map( 'strval', $custom_user_ids ) : array();
+ $job_runner->run( (int) $job_id, $ids );
+ },
+ 10,
+ 2
+ );
+
+ $message_builder = new MessageBuilder(
+ $template_repo,
+ $template_renderer,
+ $edition->is_pro() ? $flex_template_repo : null,
+ $edition->is_pro() ? $flex_builder : null,
+ $edition,
+ $edition->is_pro() ? $link_rewriter : null
+ );
+
+ // Surface Flex render failures on the Push Job detail page.
+ add_action(
+ 'botcat_flex_render_failed',
+ static function ( PushJob $job, Throwable $exception ) use ( $jobs ): void {
+ $jobs->set_last_error(
+ $job->id,
+ sprintf(
+ /* translators: %s: underlying exception message. */
+ __( 'Flex template failed — sent as text: %s', 'bot-cat' ),
+ $exception->getMessage()
+ )
+ );
+ },
+ 10,
+ 2
+ );
+
+ $batch_runner = new BatchRunner(
+ $jobs,
+ $logs,
+ $channel_repo,
+ new MulticastClient(),
+ new RetryPolicy(),
+ $message_builder
+ );
+ add_action(
+ PushJobScheduler::ACTION_RUN_BATCH,
+ static function ( $job_id, $log_ids = array(), $attempt = 1 ) use ( $batch_runner ): void {
+ $ids = is_array( $log_ids ) ? array_map( 'intval', $log_ids ) : array();
+ $batch_runner->run( (int) $job_id, $ids, (int) $attempt );
+ },
+ 10,
+ 3
+ );
+ }
+
+ private function channel_repository(): ChannelSettingsRepository {
+ $auth_key = defined( 'AUTH_KEY' ) && AUTH_KEY !== '' ? AUTH_KEY : 'bot-cat-fallback-key';
+ return new ChannelSettingsRepository( new CredentialEncryptor( $auth_key ) );
+ }
+}
diff --git a/src/Foundation/RetentionCron.php b/src/Foundation/RetentionCron.php
new file mode 100644
index 0000000..bdee560
--- /dev/null
+++ b/src/Foundation/RetentionCron.php
@@ -0,0 +1,36 @@
+prefix . 'botcat_push_logs';
+ $sql = $wpdb->prepare(
+ "DELETE FROM {$table} WHERE created_at < DATE_SUB( UTC_TIMESTAMP(), INTERVAL %d DAY )",
+ $days
+ );
+ $wpdb->query( $sql );
+ }
+}
diff --git a/src/Foundation/Schema.php b/src/Foundation/Schema.php
new file mode 100644
index 0000000..fd37127
--- /dev/null
+++ b/src/Foundation/Schema.php
@@ -0,0 +1,180 @@
+prefix}botcat_` per the spec. `install()`
+ * delegates to WordPress core's `dbDelta()` so repeat invocations are
+ * idempotent.
+ */
+class Schema {
+
+ public const TABLES = array( 'subscribers', 'push_jobs', 'push_logs', 'tags', 'subscriber_tags', 'short_links', 'short_link_clicks' );
+
+ /**
+ * @return list Fully qualified table names (prefix included).
+ */
+ public function tables(): array {
+ global $wpdb;
+ $prefix = $wpdb->prefix;
+
+ return array_map(
+ static fn( string $name ): string => $prefix . 'botcat_' . $name,
+ self::TABLES
+ );
+ }
+
+ public function install(): void {
+ if ( ! function_exists( 'dbDelta' ) ) {
+ $upgrade = ABSPATH . 'wp-admin/includes/upgrade.php';
+ if ( file_exists( $upgrade ) ) {
+ require_once $upgrade;
+ }
+ }
+
+ foreach ( self::TABLES as $table ) {
+ dbDelta( $this->sql_for( $table ) );
+ }
+ }
+
+ public function sql_for( string $table ): string {
+ global $wpdb;
+ $prefix = $wpdb->prefix . 'botcat_';
+ $charset_collate = $this->charset_collate();
+
+ return match ( $table ) {
+ 'subscribers' => << << << << << << << '',
+ };
+ }
+
+ private function charset_collate(): string {
+ global $wpdb;
+
+ if ( method_exists( $wpdb, 'get_charset_collate' ) ) {
+ return $wpdb->get_charset_collate();
+ }
+
+ $charset = $wpdb->charset ?? 'utf8mb4';
+ $collate = $wpdb->collate ?? 'utf8mb4_unicode_ci';
+
+ return "DEFAULT CHARACTER SET {$charset} COLLATE {$collate}";
+ }
+}
diff --git a/src/Foundation/SchemaVersion.php b/src/Foundation/SchemaVersion.php
new file mode 100644
index 0000000..70ffed0
--- /dev/null
+++ b/src/Foundation/SchemaVersion.php
@@ -0,0 +1,34 @@
+bundled_version, '>=' ) ) {
+ return;
+ }
+
+ $this->schema->install();
+ update_option( self::OPTION, $this->bundled_version );
+ }
+}
diff --git a/src/Foundation/Uninstaller.php b/src/Foundation/Uninstaller.php
new file mode 100644
index 0000000..7be1cf0
--- /dev/null
+++ b/src/Foundation/Uninstaller.php
@@ -0,0 +1,39 @@
+tables() as $table ) {
+ $wpdb->query( "DROP TABLE IF EXISTS {$table}" );
+ }
+
+ foreach ( self::OPTIONS as $option ) {
+ delete_option( $option );
+ }
+
+ wp_clear_scheduled_hook( Activator::CRON_HOOK );
+ }
+}
diff --git a/src/License/LicenseActivationHandler.php b/src/License/LicenseActivationHandler.php
new file mode 100644
index 0000000..826e739
--- /dev/null
+++ b/src/License/LicenseActivationHandler.php
@@ -0,0 +1,100 @@
+ 403 ) );
+ }
+ check_admin_referer( LicensePage::NONCE_ACTIVATE );
+
+ $key = isset( $_POST['license_key'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['license_key'] ) ) : '';
+
+ $result = $this->polar->validate( $key );
+
+ if ( ! $result->ok ) {
+ $this->repository->save_status(
+ $result->error_code === PolarValidationResult::ERROR_INVALID ? LicenseStatus::INVALID : LicenseStatus::INACTIVE
+ );
+ $this->redirect( 'failed', (string) $result->error_message );
+ }
+
+ if ( ! $result->entitled ) {
+ $this->repository->save_status( LicenseStatus::INVALID );
+ $this->redirect( 'failed', __( 'Polar reports this key is not entitled.', 'bot-cat' ) );
+ }
+
+ $this->repository->save_key( $key );
+ $this->repository->save_cache(
+ new LicenseCache(
+ entitled: true,
+ verified_at: time(),
+ last_failure_at: null,
+ consecutive_failures: 0,
+ plan_name: $result->plan_name,
+ renews_at: $result->renews_at,
+ )
+ );
+ $this->repository->save_status( LicenseStatus::ACTIVE );
+
+ $this->redirect(
+ 'activated',
+ $result->plan_name !== null
+ ? sprintf(
+ /* translators: 1: plan name, 2: renewal date. */
+ __( 'Active — %1$s, renews %2$s', 'bot-cat' ),
+ $result->plan_name,
+ $result->renews_at ?? '—'
+ )
+ : __( 'License activated.', 'bot-cat' )
+ );
+ }
+
+ public function handle_deactivate(): void {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_die( esc_html__( 'Insufficient permissions.', 'bot-cat' ), '', array( 'response' => 403 ) );
+ }
+ check_admin_referer( LicensePage::NONCE_DEACTIVATE );
+
+ $key = $this->repository->get_key();
+ $this->polar->deactivate( $key );
+
+ $this->repository->clear();
+
+ $this->redirect( 'deactivated', __( 'License deactivated.', 'bot-cat' ) );
+ }
+
+ private function redirect( string $status, string $message ): void {
+ wp_safe_redirect(
+ add_query_arg(
+ array(
+ 'page' => LicensePage::PAGE_SLUG,
+ 'botcat_license' => $status,
+ 'botcat_message' => rawurlencode( $message ),
+ ),
+ admin_url( 'admin.php' )
+ )
+ );
+ exit;
+ }
+}
diff --git a/src/License/LicenseCache.php b/src/License/LicenseCache.php
new file mode 100644
index 0000000..376e994
--- /dev/null
+++ b/src/License/LicenseCache.php
@@ -0,0 +1,58 @@
+ $payload
+ */
+ public static function from_array( array $payload ): self {
+ return new self(
+ entitled: ! empty( $payload['entitled'] ),
+ verified_at: (int) ( $payload['verified_at'] ?? 0 ),
+ last_failure_at: isset( $payload['last_failure_at'] ) ? (int) $payload['last_failure_at'] : null,
+ consecutive_failures: (int) ( $payload['consecutive_failures'] ?? 0 ),
+ plan_name: isset( $payload['plan_name'] ) ? (string) $payload['plan_name'] : null,
+ renews_at: isset( $payload['renews_at'] ) ? (string) $payload['renews_at'] : null,
+ );
+ }
+
+ /**
+ * @return array
+ */
+ public function to_array(): array {
+ return array(
+ 'entitled' => $this->entitled,
+ 'verified_at' => $this->verified_at,
+ 'last_failure_at' => $this->last_failure_at,
+ 'consecutive_failures' => $this->consecutive_failures,
+ 'plan_name' => $this->plan_name,
+ 'renews_at' => $this->renews_at,
+ );
+ }
+}
diff --git a/src/License/LicenseGate.php b/src/License/LicenseGate.php
new file mode 100644
index 0000000..b509a0d
--- /dev/null
+++ b/src/License/LicenseGate.php
@@ -0,0 +1,47 @@
+repository->get_cache();
+ $now = $this->now_provider !== null ? (int) ( $this->now_provider )() : time();
+
+ return $this->evaluator->evaluate( $cache, $now ) === LicenseStatus::ACTIVE;
+ }
+
+ public function filter_is_pro( bool $current ): bool {
+ return $current || $this->is_pro_active();
+ }
+}
diff --git a/src/License/LicensePage.php b/src/License/LicensePage.php
new file mode 100644
index 0000000..56828e9
--- /dev/null
+++ b/src/License/LicensePage.php
@@ -0,0 +1,130 @@
+ 403 ) );
+ }
+
+ $status = $this->repository->get_status();
+ $cache = $this->repository->get_cache();
+ $now = time();
+ $days = $this->evaluator->days_since_verified( $cache, $now );
+
+ echo '';
+ echo '
' . esc_html__( 'License', 'bot-cat' ) . ' ';
+
+ $this->render_banner();
+ $this->render_status_card( $status, $cache, $days );
+
+ if ( $status === LicenseStatus::ACTIVE ) {
+ $this->render_deactivate_form();
+ } else {
+ $this->render_activate_form( $this->repository->get_key() );
+ }
+
+ echo '';
+ }
+
+ private function render_status_card( string $status, LicenseCache $cache, int $days ): void {
+ echo '';
+ }
+
+ private function render_activate_form( string $current_key ): void {
+ $url = admin_url( 'admin-post.php' );
+
+ echo '' . esc_html__( 'Activate license', 'bot-cat' ) . ' ';
+ echo '';
+ printf( ' ', esc_attr( self::ACTION_ACTIVATE ) );
+ wp_nonce_field( self::NONCE_ACTIVATE );
+ printf(
+ '
',
+ esc_attr( $current_key ),
+ esc_attr__( 'Paste your Polar license key', 'bot-cat' )
+ );
+ submit_button( __( 'Activate', 'bot-cat' ) );
+ echo ' ';
+ }
+
+ private function render_deactivate_form(): void {
+ $url = admin_url( 'admin-post.php' );
+
+ echo '';
+ printf( ' ', esc_attr( self::ACTION_DEACTIVATE ) );
+ wp_nonce_field( self::NONCE_DEACTIVATE );
+ submit_button( __( 'Deactivate', 'bot-cat' ), 'secondary', 'submit', false );
+ echo ' ';
+ }
+
+ private function render_banner(): void {
+ if ( ! isset( $_GET['botcat_license'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ return;
+ }
+
+ $status = sanitize_key( wp_unslash( (string) $_GET['botcat_license'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $message = isset( $_GET['botcat_message'] ) ? sanitize_text_field( rawurldecode( wp_unslash( (string) $_GET['botcat_message'] ) ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+
+ $class = $status === 'activated' ? 'notice-success' : 'notice-error';
+ printf(
+ '',
+ esc_attr( $class ),
+ esc_html( $message !== '' ? $message : __( 'License update completed.', 'bot-cat' ) )
+ );
+ }
+
+ private function row( string $label, string $value_html ): void {
+ printf(
+ '%1$s %2$s ',
+ esc_html( $label ),
+ $value_html
+ );
+ }
+}
diff --git a/src/License/LicenseRepository.php b/src/License/LicenseRepository.php
new file mode 100644
index 0000000..962e9b2
--- /dev/null
+++ b/src/License/LicenseRepository.php
@@ -0,0 +1,63 @@
+to_array() );
+ }
+
+ public function clear(): void {
+ delete_option( self::OPTION_KEY );
+ delete_option( self::OPTION_STATUS );
+ delete_option( self::OPTION_CACHE );
+ }
+}
diff --git a/src/License/LicenseRevalidationCron.php b/src/License/LicenseRevalidationCron.php
new file mode 100644
index 0000000..b889666
--- /dev/null
+++ b/src/License/LicenseRevalidationCron.php
@@ -0,0 +1,91 @@
+repository->get_key();
+ if ( $key === '' ) {
+ return;
+ }
+
+ $result = $this->polar->validate( $key );
+ $cache = $this->repository->get_cache();
+ $now = time();
+
+ if ( ! $result->ok ) {
+ $this->repository->save_cache(
+ new LicenseCache(
+ entitled: $cache->entitled,
+ verified_at: $cache->verified_at,
+ last_failure_at: $now,
+ consecutive_failures: $cache->consecutive_failures + 1,
+ plan_name: $cache->plan_name,
+ renews_at: $cache->renews_at,
+ )
+ );
+ return;
+ }
+
+ if ( ! $result->entitled ) {
+ $this->repository->save_cache(
+ new LicenseCache(
+ entitled: false,
+ verified_at: $cache->verified_at,
+ last_failure_at: $now,
+ consecutive_failures: $cache->consecutive_failures + 1,
+ plan_name: $result->plan_name ?? $cache->plan_name,
+ renews_at: $result->renews_at ?? $cache->renews_at,
+ )
+ );
+ $this->maybe_expire( $cache->verified_at, $now );
+ return;
+ }
+
+ $this->repository->save_cache(
+ new LicenseCache(
+ entitled: true,
+ verified_at: $now,
+ last_failure_at: null,
+ consecutive_failures: 0,
+ plan_name: $result->plan_name ?? $cache->plan_name,
+ renews_at: $result->renews_at ?? $cache->renews_at,
+ )
+ );
+ $this->repository->save_status( LicenseStatus::ACTIVE );
+ }
+
+ private function maybe_expire( int $verified_at, int $now ): void {
+ if ( $verified_at === 0 ) {
+ return;
+ }
+ if ( $now - $verified_at > LicenseStatusEvaluator::GRACE_SECONDS ) {
+ $this->repository->save_status( LicenseStatus::EXPIRED );
+ }
+ }
+}
diff --git a/src/License/LicenseStatus.php b/src/License/LicenseStatus.php
new file mode 100644
index 0000000..6ef58b4
--- /dev/null
+++ b/src/License/LicenseStatus.php
@@ -0,0 +1,28 @@
+verified_at === 0 ) {
+ return LicenseStatus::INACTIVE;
+ }
+
+ if ( $now - $cache->verified_at <= self::GRACE_SECONDS ) {
+ return LicenseStatus::ACTIVE;
+ }
+
+ return LicenseStatus::EXPIRED;
+ }
+
+ public function days_since_verified( LicenseCache $cache, int $now ): int {
+ if ( $cache->verified_at === 0 ) {
+ return 0;
+ }
+ return (int) floor( ( $now - $cache->verified_at ) / 86400 );
+ }
+}
diff --git a/src/License/PolarClient.php b/src/License/PolarClient.php
new file mode 100644
index 0000000..6305aed
--- /dev/null
+++ b/src/License/PolarClient.php
@@ -0,0 +1,123 @@
+ array(
+ 'Content-Type' => 'application/json',
+ 'Accept' => 'application/json',
+ ),
+ 'timeout' => 15,
+ 'body' => wp_json_encode( array( 'key' => $key ) ),
+ )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ return new PolarValidationResult(
+ ok: false,
+ error_code: PolarValidationResult::ERROR_NETWORK,
+ error_message: __( 'Could not reach Polar.', 'bot-cat' )
+ );
+ }
+
+ $code = (int) wp_remote_retrieve_response_code( $response );
+ $body = (string) wp_remote_retrieve_body( $response );
+ /** @var array|null $payload */
+ $payload = json_decode( $body, true );
+
+ if ( $code === 401 || $code === 403 || $code === 404 ) {
+ return new PolarValidationResult(
+ ok: false,
+ error_code: PolarValidationResult::ERROR_INVALID,
+ error_message: is_array( $payload ) && isset( $payload['detail'] ) ? (string) $payload['detail'] : $body
+ );
+ }
+
+ if ( $code < 200 || $code >= 300 || ! is_array( $payload ) ) {
+ return new PolarValidationResult(
+ ok: false,
+ error_code: PolarValidationResult::ERROR_UPSTREAM,
+ error_message: $body
+ );
+ }
+
+ $entitled = ! empty( $payload['valid'] ) || ! empty( $payload['entitled'] );
+ $plan_name = $this->extract_plan_name( $payload );
+ $renews_at = isset( $payload['expires_at'] ) ? (string) $payload['expires_at'] : null;
+
+ return new PolarValidationResult(
+ ok: true,
+ entitled: $entitled,
+ plan_name: $plan_name,
+ renews_at: $renews_at
+ );
+ }
+
+ public function deactivate( string $key ): bool {
+ if ( $key === '' ) {
+ return false;
+ }
+
+ $response = wp_remote_post(
+ self::ENDPOINT_DEACTIVATE,
+ array(
+ 'headers' => array(
+ 'Content-Type' => 'application/json',
+ ),
+ 'timeout' => 15,
+ 'body' => wp_json_encode( array( 'key' => $key ) ),
+ )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ return false;
+ }
+
+ $code = (int) wp_remote_retrieve_response_code( $response );
+ return $code >= 200 && $code < 300;
+ }
+
+ /**
+ * @param array $payload
+ */
+ private function extract_plan_name( array $payload ): ?string {
+ foreach ( array( 'plan_name', 'product_name', 'tier' ) as $key ) {
+ if ( isset( $payload[ $key ] ) && (string) $payload[ $key ] !== '' ) {
+ return (string) $payload[ $key ];
+ }
+ }
+
+ if ( isset( $payload['benefit'] ) && is_array( $payload['benefit'] ) && isset( $payload['benefit']['description'] ) ) {
+ return (string) $payload['benefit']['description'];
+ }
+
+ return null;
+ }
+}
diff --git a/src/License/PolarValidationResult.php b/src/License/PolarValidationResult.php
new file mode 100644
index 0000000..03c33bf
--- /dev/null
+++ b/src/License/PolarValidationResult.php
@@ -0,0 +1,32 @@
+ 'POST',
+ 'callback' => array( $this, 'handle' ),
+ 'permission_callback' => static fn(): bool => true,
+ )
+ );
+ }
+
+ public function handle( WP_REST_Request $request ): WP_REST_Response {
+ $secret = (string) get_option( self::SECRET_OPTION, '' );
+ if ( $secret === '' ) {
+ return new WP_REST_Response( null, 503 );
+ }
+
+ $body = (string) $request->get_body();
+ $signature = (string) $request->get_header( 'webhook_signature' );
+
+ $expected = base64_encode( hash_hmac( 'sha256', $body, $secret, true ) );
+ if ( $signature === '' || ! hash_equals( $expected, $signature ) ) {
+ return new WP_REST_Response( null, 403 );
+ }
+
+ $payload = json_decode( $body, true );
+ if ( ! is_array( $payload ) ) {
+ return new WP_REST_Response( null, 200 );
+ }
+
+ $type = isset( $payload['type'] ) ? (string) $payload['type'] : '';
+ if ( in_array( $type, array( 'subscription.cancelled', 'subscription.refunded', 'license_key.revoked' ), true ) ) {
+ $this->mark_expired();
+ }
+
+ return new WP_REST_Response( null, 200 );
+ }
+
+ private function mark_expired(): void {
+ $cache = $this->repository->get_cache();
+ $this->repository->save_cache(
+ new LicenseCache(
+ entitled: false,
+ verified_at: $cache->verified_at,
+ last_failure_at: time(),
+ consecutive_failures: max( 1, $cache->consecutive_failures ),
+ plan_name: $cache->plan_name,
+ renews_at: $cache->renews_at,
+ )
+ );
+ $this->repository->save_status( LicenseStatus::EXPIRED );
+ }
+}
diff --git a/src/License/UpdateChannel.php b/src/License/UpdateChannel.php
new file mode 100644
index 0000000..69f0a64
--- /dev/null
+++ b/src/License/UpdateChannel.php
@@ -0,0 +1,131 @@
+?license=&site=
+ * 200 → { "version": "x.y.z", "package": "",
+ * "tested": "x.y", "requires_php": "8.1" }
+ * 204 / 404 → no update available
+ *
+ * Free builds never instantiate this class — the standard WordPress.org
+ * channel handles their updates.
+ */
+class UpdateChannel {
+
+ public const MANIFEST_FILTER = 'botcat_pro_update_manifest_url';
+
+ public function __construct(
+ private readonly LicenseRepository $repository,
+ private readonly string $plugin_basename,
+ private readonly string $current_version
+ ) {
+ }
+
+ public function register(): void {
+ add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'inject_update' ) );
+ add_filter( 'plugins_api', array( $this, 'plugin_info' ), 10, 3 );
+ }
+
+ public function inject_update( $transient ) {
+ if ( ! is_object( $transient ) ) {
+ return $transient;
+ }
+
+ $manifest = $this->fetch_manifest();
+ if ( $manifest === null ) {
+ return $transient;
+ }
+
+ if ( ! isset( $manifest['version'] ) || version_compare( (string) $manifest['version'], $this->current_version, '<=' ) ) {
+ return $transient;
+ }
+
+ $info = (object) array(
+ 'slug' => dirname( $this->plugin_basename ),
+ 'plugin' => $this->plugin_basename,
+ 'new_version' => (string) $manifest['version'],
+ 'package' => isset( $manifest['package'] ) ? (string) $manifest['package'] : '',
+ 'tested' => isset( $manifest['tested'] ) ? (string) $manifest['tested'] : '',
+ 'requires_php' => isset( $manifest['requires_php'] ) ? (string) $manifest['requires_php'] : '8.1',
+ );
+
+ if ( ! isset( $transient->response ) || ! is_array( $transient->response ) ) {
+ $transient->response = array();
+ }
+
+ $transient->response[ $this->plugin_basename ] = $info;
+ return $transient;
+ }
+
+ public function plugin_info( $result, $action, $args ) {
+ if ( $action !== 'plugin_information' ) {
+ return $result;
+ }
+
+ if ( ! isset( $args->slug ) || $args->slug !== dirname( $this->plugin_basename ) ) {
+ return $result;
+ }
+
+ $manifest = $this->fetch_manifest();
+ if ( $manifest === null || ! isset( $manifest['version'] ) ) {
+ return $result;
+ }
+
+ return (object) array(
+ 'name' => 'bot-cat Pro',
+ 'slug' => dirname( $this->plugin_basename ),
+ 'version' => (string) $manifest['version'],
+ 'requires_php' => isset( $manifest['requires_php'] ) ? (string) $manifest['requires_php'] : '8.1',
+ 'tested' => isset( $manifest['tested'] ) ? (string) $manifest['tested'] : '',
+ 'download_link' => isset( $manifest['package'] ) ? (string) $manifest['package'] : '',
+ );
+ }
+
+ /**
+ * @return array|null
+ */
+ private function fetch_manifest(): ?array {
+ $key = $this->repository->get_key();
+ if ( $key === '' ) {
+ return null;
+ }
+
+ $url = (string) apply_filters( self::MANIFEST_FILTER, 'https://bot-cat.com/updates/manifest.json' );
+
+ $response = wp_remote_get(
+ add_query_arg(
+ array(
+ 'license' => $key,
+ 'site' => home_url(),
+ ),
+ $url
+ ),
+ array( 'timeout' => 10 )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ return null;
+ }
+
+ $code = (int) wp_remote_retrieve_response_code( $response );
+ if ( $code < 200 || $code >= 300 ) {
+ return null;
+ }
+
+ $payload = json_decode( (string) wp_remote_retrieve_body( $response ), true );
+ return is_array( $payload ) ? $payload : null;
+ }
+}
diff --git a/src/Push/BatchRunner.php b/src/Push/BatchRunner.php
new file mode 100644
index 0000000..e6c9bcb
--- /dev/null
+++ b/src/Push/BatchRunner.php
@@ -0,0 +1,113 @@
+ $log_ids
+ */
+ public function run( int $job_id, array $log_ids, int $attempt = 1 ): void {
+ $job = $this->jobs->find_by_id( $job_id );
+ if ( $job === null || $job->is_terminal() ) {
+ return;
+ }
+
+ if ( $log_ids === array() ) {
+ return;
+ }
+
+ $settings = $this->channel->get();
+ if ( ! $settings->is_configured() ) {
+ $this->jobs->mark_status(
+ $job_id,
+ PushJob::STATUS_FAILED,
+ __( 'channel is not configured', 'bot-cat' )
+ );
+ return;
+ }
+
+ $logs = $this->logs->find_by_ids_for_job( $job_id, $log_ids );
+ if ( $logs === array() ) {
+ return;
+ }
+
+ $line_user_ids = array_values( array_map( static fn( PushLog $log ): string => $log->line_user_id, $logs ) );
+ $messages = $this->messages->build_for_job( $job );
+
+ $result = $this->client->send( $settings->access_token(), $line_user_ids, $messages );
+ $decision = $this->policy->decide( $attempt, $result );
+
+ switch ( $decision->action ) {
+ case RetryDecision::ACTION_DONE:
+ $this->logs->mark_status( $log_ids, PushLog::STATUS_SENT );
+ $this->jobs->increment_counts( $job_id, count( $log_ids ), 0 );
+ $this->maybe_finalize( $job_id );
+ return;
+
+ case RetryDecision::ACTION_RETRY:
+ if ( function_exists( 'as_schedule_single_action' ) ) {
+ as_schedule_single_action(
+ time() + $decision->delay_seconds,
+ PushJobScheduler::ACTION_RUN_BATCH,
+ array( $job_id, $log_ids, $attempt + 1 ),
+ PushJobScheduler::GROUP
+ );
+ }
+ return;
+
+ case RetryDecision::ACTION_FAIL:
+ $this->logs->mark_status( $log_ids, PushLog::STATUS_FAILED, $decision->error ?? $result->error_message );
+ $this->jobs->increment_counts( $job_id, 0, count( $log_ids ) );
+ $this->maybe_finalize( $job_id );
+ return;
+
+ case RetryDecision::ACTION_ABORT_AUTH:
+ $this->jobs->mark_status( $job_id, PushJob::STATUS_ABORTED_AUTH, $decision->error ?? '' );
+ return;
+ }
+ }
+
+ private function maybe_finalize( int $job_id ): void {
+ $pending = $this->logs->count_for_job( $job_id, PushLog::STATUS_PENDING );
+ if ( $pending > 0 ) {
+ return;
+ }
+
+ $job = $this->jobs->find_by_id( $job_id );
+ if ( $job === null ) {
+ return;
+ }
+
+ if ( $job->failed_count === 0 ) {
+ $this->jobs->mark_status( $job_id, PushJob::STATUS_SENT );
+ } elseif ( $job->sent_count === 0 ) {
+ $this->jobs->mark_status( $job_id, PushJob::STATUS_FAILED );
+ } else {
+ $this->jobs->mark_status( $job_id, PushJob::STATUS_PARTIAL );
+ }
+ }
+}
diff --git a/src/Push/EligiblePostTypes.php b/src/Push/EligiblePostTypes.php
new file mode 100644
index 0000000..a8cfffc
--- /dev/null
+++ b/src/Push/EligiblePostTypes.php
@@ -0,0 +1,58 @@
+
+ */
+ public function get(): array {
+ $stored = get_option( self::OPTION, self::DEFAULT );
+ if ( ! is_array( $stored ) || $stored === array() ) {
+ $stored = self::DEFAULT;
+ }
+
+ /** @var list $filtered */
+ $filtered = apply_filters( self::FILTER, array_values( array_map( 'strval', $stored ) ) );
+
+ return is_array( $filtered ) ? array_values( $filtered ) : self::DEFAULT;
+ }
+
+ public function is_eligible( string $post_type ): bool {
+ return in_array( $post_type, $this->get(), true );
+ }
+
+ /**
+ * @param list $types
+ */
+ public function save( array $types ): void {
+ update_option(
+ self::OPTION,
+ array_values(
+ array_unique(
+ array_filter(
+ array_map( 'strval', $types ),
+ static fn( string $t ): bool => $t !== ''
+ )
+ )
+ )
+ );
+ }
+}
diff --git a/src/Push/MessageBuilder.php b/src/Push/MessageBuilder.php
new file mode 100644
index 0000000..0ae7893
--- /dev/null
+++ b/src/Push/MessageBuilder.php
@@ -0,0 +1,164 @@
+>
+ */
+ public function build_for_job( PushJob $job ): array {
+ if ( $job->is_test ) {
+ return $this->filtered(
+ $job,
+ array(
+ array(
+ 'type' => 'text',
+ 'text' => __( 'bot-cat connection test — if you see this, your channel is wired up correctly.', 'bot-cat' ),
+ ),
+ )
+ );
+ }
+
+ if ( $this->should_attempt_flex() ) {
+ $flex = $this->build_flex( $job );
+ if ( $flex !== null ) {
+ return $this->filtered( $job, $this->rewrite_messages( array( $flex ), $job ) );
+ }
+ }
+
+ return $this->filtered( $job, $this->rewrite_messages( array( $this->build_text( $job ) ), $job ) );
+ }
+
+ /**
+ * @param list> $messages
+ * @return list>
+ */
+ private function rewrite_messages( array $messages, PushJob $job ): array {
+ if ( $this->link_rewriter === null || $job->post_id <= 0 ) {
+ return $messages;
+ }
+
+ $permalink = (string) get_permalink( $job->post_id );
+ if ( $permalink === '' ) {
+ return $messages;
+ }
+
+ return array_map(
+ function ( array $msg ) use ( $job, $permalink ): array {
+ $json = wp_json_encode( $msg, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
+ if ( ! is_string( $json ) ) {
+ return $msg;
+ }
+ $rewritten = $this->link_rewriter->rewrite( $json, $job->post_id, $permalink );
+ $decoded = json_decode( $rewritten, true );
+ return is_array( $decoded ) ? $decoded : $msg;
+ },
+ $messages
+ );
+ }
+
+ private function should_attempt_flex(): bool {
+ if ( $this->flex_builder === null || $this->flex_templates === null ) {
+ return false;
+ }
+ if ( $this->edition === null ) {
+ return false;
+ }
+ return $this->edition->is_pro();
+ }
+
+ /**
+ * @return array|null
+ */
+ private function build_flex( PushJob $job ): ?array {
+ try {
+ $template = $this->flex_templates->get();
+ $post = $job->post_id > 0 ? get_post( $job->post_id ) : null;
+ if ( ! is_object( $post ) ) {
+ return null;
+ }
+ return $this->flex_builder->build( $template, $post );
+ } catch ( Throwable $e ) {
+ do_action( 'botcat_flex_render_failed', $job, $e );
+ return null;
+ }
+ }
+
+ /**
+ * @return array
+ */
+ private function build_text( PushJob $job ): array {
+ $text = $this->render_text_for_post( $job->post_id );
+
+ if ( $text === '' ) {
+ $text = (string) __( 'A new post has been published.', 'bot-cat' );
+ }
+
+ return array(
+ 'type' => 'text',
+ 'text' => $text,
+ );
+ }
+
+ private function render_text_for_post( int $post_id ): string {
+ if ( $this->templates === null || $this->renderer === null ) {
+ $title = (string) get_the_title( $post_id );
+ $url = (string) get_permalink( $post_id );
+ return trim( $title . "\n" . $url );
+ }
+
+ $post = get_post( $post_id );
+ if ( ! is_object( $post ) ) {
+ return '';
+ }
+
+ return $this->renderer->render( $this->templates->get(), $post );
+ }
+
+ /**
+ * @param list> $messages
+ * @return list>
+ */
+ private function filtered( PushJob $job, array $messages ): array {
+ /** @var list> $filtered */
+ $filtered = apply_filters( 'botcat_push_messages', $messages, $job );
+
+ return is_array( $filtered ) ? $filtered : $messages;
+ }
+}
diff --git a/src/Push/MulticastClient.php b/src/Push/MulticastClient.php
new file mode 100644
index 0000000..45b4fba
--- /dev/null
+++ b/src/Push/MulticastClient.php
@@ -0,0 +1,121 @@
+ $line_user_ids
+ * @param list> $messages LINE Messaging API message objects.
+ */
+ public function send( string $access_token, array $line_user_ids, array $messages ): MulticastResult {
+ if ( $line_user_ids === array() || $messages === array() || $access_token === '' ) {
+ return new MulticastResult(
+ ok: false,
+ error_code: self::ERROR_INVALID_INPUT,
+ error_message: __( 'multicast requires access token, recipients, and at least one message', 'bot-cat' )
+ );
+ }
+
+ $response = wp_remote_post(
+ self::ENDPOINT,
+ array(
+ 'headers' => array(
+ 'Authorization' => 'Bearer ' . $access_token,
+ 'Content-Type' => 'application/json',
+ ),
+ 'timeout' => 30,
+ 'body' => wp_json_encode(
+ array(
+ 'to' => array_values( $line_user_ids ),
+ 'messages' => array_values( $messages ),
+ )
+ ),
+ )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ return new MulticastResult(
+ ok: false,
+ error_code: self::ERROR_NETWORK,
+ error_message: __( 'Could not reach LINE.', 'bot-cat' )
+ );
+ }
+
+ $code = (int) wp_remote_retrieve_response_code( $response );
+ $body = (string) wp_remote_retrieve_body( $response );
+
+ if ( $code >= 200 && $code < 300 ) {
+ return new MulticastResult( ok: true, http_status: $code );
+ }
+
+ return $this->classify_error( $code, $body, $response );
+ }
+
+ /**
+ * @param mixed $response The raw wp_remote_* return — opaque object passed through to wp_remote_retrieve_header.
+ */
+ private function classify_error( int $code, string $body, $response ): MulticastResult {
+ $payload = json_decode( $body, true );
+ $message = is_array( $payload ) && isset( $payload['message'] )
+ ? (string) $payload['message']
+ : $body;
+
+ if ( $code === 401 || $code === 403 ) {
+ return new MulticastResult(
+ ok: false,
+ http_status: $code,
+ error_code: self::ERROR_AUTH,
+ error_message: $message
+ );
+ }
+
+ if ( $code === 429 ) {
+ $retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' );
+
+ return new MulticastResult(
+ ok: false,
+ http_status: $code,
+ retry_after: $retry_after > 0 ? $retry_after : null,
+ error_code: self::ERROR_RATE_LIMITED,
+ error_message: $message
+ );
+ }
+
+ if ( $code >= 500 ) {
+ return new MulticastResult(
+ ok: false,
+ http_status: $code,
+ error_code: self::ERROR_SERVER,
+ error_message: $message
+ );
+ }
+
+ return new MulticastResult(
+ ok: false,
+ http_status: $code,
+ error_code: self::ERROR_CLIENT,
+ error_message: $message
+ );
+ }
+}
diff --git a/src/Push/MulticastResult.php b/src/Push/MulticastResult.php
new file mode 100644
index 0000000..ddadad6
--- /dev/null
+++ b/src/Push/MulticastResult.php
@@ -0,0 +1,26 @@
+ID, $post->post_type ) ) {
+ return;
+ }
+
+ $post_id = (int) $post->ID;
+ $post_type = (string) $post->post_type;
+
+ if ( ! $this->eligible->is_eligible( $post_type ) ) {
+ return;
+ }
+
+ if ( $this->excluded !== null && $this->excluded->is_excluded( $post_id ) ) {
+ return;
+ }
+
+ if ( $this->opt_out !== null && ! $this->opt_out->is_opted_in( $post_id ) ) {
+ return;
+ }
+
+ $this->scheduler->enqueue( $post_id, $post_type );
+ }
+}
diff --git a/src/Push/PushJob.php b/src/Push/PushJob.php
new file mode 100644
index 0000000..24f7c00
--- /dev/null
+++ b/src/Push/PushJob.php
@@ -0,0 +1,80 @@
+status, self::TERMINAL_STATUSES, true );
+ }
+
+ /**
+ * @param array $row
+ */
+ public static function from_row( array $row ): self {
+ return new self(
+ id: (int) ( $row['id'] ?? 0 ),
+ post_id: (int) ( $row['post_id'] ?? 0 ),
+ post_type: (string) ( $row['post_type'] ?? 'post' ),
+ status: (string) ( $row['status'] ?? self::STATUS_PENDING ),
+ recipient_count: (int) ( $row['recipient_count'] ?? 0 ),
+ sent_count: (int) ( $row['sent_count'] ?? 0 ),
+ failed_count: (int) ( $row['failed_count'] ?? 0 ),
+ is_test: ! empty( $row['is_test'] ),
+ last_error: isset( $row['last_error'] ) && $row['last_error'] !== null ? (string) $row['last_error'] : null,
+ created_at: (string) ( $row['created_at'] ?? '' ),
+ triggered_at: isset( $row['triggered_at'] ) ? (string) $row['triggered_at'] : null,
+ started_at: isset( $row['started_at'] ) ? (string) $row['started_at'] : null,
+ finished_at: isset( $row['finished_at'] ) ? (string) $row['finished_at'] : null,
+ );
+ }
+}
diff --git a/src/Push/PushJobDetailPage.php b/src/Push/PushJobDetailPage.php
new file mode 100644
index 0000000..a44d81a
--- /dev/null
+++ b/src/Push/PushJobDetailPage.php
@@ -0,0 +1,174 @@
+ 403 ) );
+ }
+
+ $job = $this->jobs->find_by_id( $job_id );
+ if ( $job === null ) {
+ echo '
' . esc_html__( 'Push job not found', 'bot-cat' ) . ' ';
+ return;
+ }
+
+ $rows = $this->logs->list_for_job( $job_id, 1, 500 );
+
+ echo '';
+ printf(
+ '
%s ',
+ esc_html(
+ sprintf(
+ /* translators: %d: push job id. */
+ __( 'Push job #%d', 'bot-cat' ),
+ $job->id
+ )
+ )
+ );
+
+ $this->render_summary( $job );
+
+ if ( $job->failed_count > 0 ) {
+ $this->render_resend_form( $job );
+ }
+
+ $this->render_rows( $rows );
+
+ echo '';
+ }
+
+ public function handle_resend(): void {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_die( esc_html__( 'Insufficient permissions.', 'bot-cat' ), '', array( 'response' => 403 ) );
+ }
+
+ check_admin_referer( self::RESEND_NONCE );
+
+ $job_id = isset( $_REQUEST['job'] ) ? (int) $_REQUEST['job'] : 0;
+ if ( $job_id <= 0 ) {
+ wp_safe_redirect( admin_url( 'admin.php?page=' . PushJobsListTable::PAGE_SLUG ) );
+ exit;
+ }
+
+ $failed = $this->logs->failed_for_job( $job_id );
+ $ids = array_map( static fn( PushLog $log ): int => $log->id, $failed );
+
+ if ( $ids !== array() ) {
+ $this->logs->reset_for_resend( $ids );
+
+ if ( function_exists( 'as_schedule_single_action' ) ) {
+ as_schedule_single_action(
+ time(),
+ PushJobScheduler::ACTION_RUN_BATCH,
+ array( $job_id, $ids, 1 ),
+ PushJobScheduler::GROUP
+ );
+ }
+ }
+
+ wp_safe_redirect(
+ add_query_arg(
+ array(
+ 'page' => PushJobsListTable::PAGE_SLUG,
+ 'job' => $job_id,
+ 'botcat_resend' => 'queued',
+ ),
+ admin_url( 'admin.php' )
+ )
+ );
+ exit;
+ }
+
+ private function render_summary( PushJob $job ): void {
+ echo '';
+ }
+
+ private function render_resend_form( PushJob $job ): void {
+ $action_url = admin_url( 'admin-post.php' );
+
+ echo '';
+ printf( ' ', esc_attr( self::RESEND_ACTION ) );
+ printf( ' ', (int) $job->id );
+ wp_nonce_field( self::RESEND_NONCE );
+ submit_button(
+ sprintf(
+ /* translators: %d: number of failed recipients. */
+ __( 'Resend to %d failed recipients', 'bot-cat' ),
+ $job->failed_count
+ ),
+ 'primary',
+ 'submit',
+ false
+ );
+ echo ' ';
+ }
+
+ /**
+ * @param list $rows
+ */
+ private function render_rows( array $rows ): void {
+ echo '' . esc_html__( 'Per-recipient log', 'bot-cat' ) . ' ';
+
+ if ( $rows === array() ) {
+ echo '' . esc_html__( 'No log rows yet.', 'bot-cat' ) . '
';
+ return;
+ }
+
+ echo '';
+ printf( '%s ', esc_html__( 'LINE User ID', 'bot-cat' ) );
+ printf( '%s ', esc_html__( 'Status', 'bot-cat' ) );
+ printf( '%s ', esc_html__( 'Attempts', 'bot-cat' ) );
+ printf( '%s ', esc_html__( 'Error', 'bot-cat' ) );
+ echo ' ';
+
+ foreach ( $rows as $row ) {
+ echo '';
+ printf( '%s ', esc_html( $row->line_user_id ) );
+ printf( '%s ', esc_html( $row->status ) );
+ printf( '%d ', (int) $row->attempts );
+ printf( '%s ', esc_html( $row->error ?? '' ) );
+ echo ' ';
+ }
+
+ echo '
';
+ }
+
+ private function row( string $label, string $value ): void {
+ printf(
+ '%1$s %2$s ',
+ esc_html( $label ),
+ $value
+ );
+ }
+}
diff --git a/src/Push/PushJobRepository.php b/src/Push/PushJobRepository.php
new file mode 100644
index 0000000..f81404b
--- /dev/null
+++ b/src/Push/PushJobRepository.php
@@ -0,0 +1,167 @@
+insert(
+ $wpdb->prefix . self::TABLE,
+ array(
+ 'post_id' => $post_id,
+ 'post_type' => $post_type,
+ 'status' => PushJob::STATUS_PENDING,
+ 'is_test' => $is_test ? 1 : 0,
+ 'created_at' => $now,
+ 'triggered_at' => $now,
+ ),
+ array( '%d', '%s', '%s', '%d', '%s', '%s' )
+ );
+
+ return (int) $wpdb->insert_id;
+ }
+
+ public function find_by_id( int $id ): ?PushJob {
+ global $wpdb;
+
+ $row = $wpdb->get_row(
+ $wpdb->prepare(
+ 'SELECT * FROM ' . $wpdb->prefix . self::TABLE . ' WHERE id = %d LIMIT 1',
+ $id
+ ),
+ ARRAY_A
+ );
+
+ return is_array( $row ) ? PushJob::from_row( $row ) : null;
+ }
+
+ public function find_completed_for_post( int $post_id ): ?PushJob {
+ global $wpdb;
+
+ $row = $wpdb->get_row(
+ $wpdb->prepare(
+ 'SELECT * FROM ' . $wpdb->prefix . self::TABLE
+ . ' WHERE post_id = %d AND status IN (%s, %s) ORDER BY id DESC LIMIT 1',
+ $post_id,
+ PushJob::STATUS_SENT,
+ PushJob::STATUS_PARTIAL
+ ),
+ ARRAY_A
+ );
+
+ return is_array( $row ) ? PushJob::from_row( $row ) : null;
+ }
+
+ public function mark_running( int $id, int $recipient_count ): void {
+ global $wpdb;
+
+ $wpdb->update(
+ $wpdb->prefix . self::TABLE,
+ array(
+ 'status' => PushJob::STATUS_RUNNING,
+ 'recipient_count' => $recipient_count,
+ 'started_at' => gmdate( 'Y-m-d H:i:s' ),
+ ),
+ array( 'id' => $id ),
+ array( '%s', '%d', '%s' ),
+ array( '%d' )
+ );
+ }
+
+ public function mark_status( int $id, string $status, ?string $last_error = null ): void {
+ global $wpdb;
+
+ $data = array(
+ 'status' => $status,
+ 'finished_at' => in_array( $status, PushJob::TERMINAL_STATUSES, true ) ? gmdate( 'Y-m-d H:i:s' ) : null,
+ );
+ $formats = array( '%s', '%s' );
+
+ if ( $last_error !== null ) {
+ $data['last_error'] = $last_error;
+ $formats[] = '%s';
+ }
+
+ $wpdb->update(
+ $wpdb->prefix . self::TABLE,
+ $data,
+ array( 'id' => $id ),
+ $formats,
+ array( '%d' )
+ );
+ }
+
+ public function set_last_error( int $id, string $error ): void {
+ global $wpdb;
+
+ $wpdb->update(
+ $wpdb->prefix . self::TABLE,
+ array( 'last_error' => $error ),
+ array( 'id' => $id ),
+ array( '%s' ),
+ array( '%d' )
+ );
+ }
+
+ public function increment_counts( int $id, int $sent_delta, int $failed_delta ): void {
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE;
+
+ $wpdb->query(
+ $wpdb->prepare(
+ "UPDATE {$table} SET sent_count = sent_count + %d, failed_count = failed_count + %d WHERE id = %d",
+ $sent_delta,
+ $failed_delta,
+ $id
+ )
+ );
+ }
+
+ /**
+ * @return list
+ */
+ public function list_paged( int $page = 1, int $per_page = 50 ): array {
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE;
+ $page = max( 1, $page );
+ $per = min( 500, max( 1, $per_page ) );
+
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT * FROM {$table} WHERE is_test = 0 ORDER BY triggered_at DESC LIMIT %d OFFSET %d",
+ $per,
+ ( $page - 1 ) * $per
+ ),
+ ARRAY_A
+ );
+
+ if ( ! is_array( $rows ) ) {
+ return array();
+ }
+
+ return array_map(
+ static fn( array $row ): PushJob => PushJob::from_row( $row ),
+ $rows
+ );
+ }
+
+ public function count_non_test(): int {
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE;
+ return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE is_test = 0" );
+ }
+}
diff --git a/src/Push/PushJobRunner.php b/src/Push/PushJobRunner.php
new file mode 100644
index 0000000..35a3d14
--- /dev/null
+++ b/src/Push/PushJobRunner.php
@@ -0,0 +1,128 @@
+ $custom_user_ids Non-empty list for manual test pushes.
+ */
+ public function run( int $job_id, array $custom_user_ids = array() ): void {
+ $job = $this->jobs->find_by_id( $job_id );
+ if ( $job === null || $job->status !== PushJob::STATUS_PENDING ) {
+ return;
+ }
+
+ if ( ! $job->is_test && $this->is_duplicate( $job ) ) {
+ $this->jobs->mark_status( $job_id, PushJob::STATUS_SKIPPED_DUPLICATE );
+ return;
+ }
+
+ $batches = $this->collect_batches( $job, $custom_user_ids );
+
+ $recipient_count = array_sum( array_map( static fn( array $b ): int => count( $b['line_user_ids'] ), $batches ) );
+ $this->jobs->mark_running( $job_id, $recipient_count );
+
+ if ( $batches === array() ) {
+ $this->jobs->mark_status( $job_id, PushJob::STATUS_SENT );
+ return;
+ }
+
+ $limiter = new RateLimiter( time() );
+
+ foreach ( $batches as $index => $batch ) {
+ $ids = $this->logs->create_for_job(
+ $job_id,
+ $batch['line_user_ids'],
+ $batch['subscriber_ids'],
+ $job->is_test
+ );
+
+ if ( function_exists( 'as_schedule_single_action' ) ) {
+ as_schedule_single_action(
+ $limiter->slot_for( (int) $index ),
+ PushJobScheduler::ACTION_RUN_BATCH,
+ array( $job_id, $ids, 1 ),
+ PushJobScheduler::GROUP
+ );
+ }
+ }
+ }
+
+ private function is_duplicate( PushJob $job ): bool {
+ $prior = $this->jobs->find_completed_for_post( $job->post_id );
+ return $prior !== null && $prior->id !== $job->id;
+ }
+
+ /**
+ * @param list $custom_user_ids
+ * @return list, subscriber_ids: list}>
+ */
+ private function collect_batches( PushJob $job, array $custom_user_ids ): array {
+ $batches = array();
+ $current = array(
+ 'line_user_ids' => array(),
+ 'subscriber_ids' => array(),
+ );
+
+ if ( $job->is_test && $custom_user_ids !== array() ) {
+ $iterator = ( function () use ( $custom_user_ids ) {
+ foreach ( $custom_user_ids as $id ) {
+ yield (string) $id;
+ }
+ } )();
+ } elseif ( $this->audience !== null ) {
+ $iterator = $this->audience->iterator_for_job( $job );
+ } else {
+ $iterator = $this->subscribers->active_ids();
+ }
+
+ foreach ( $iterator as $line_user_id ) {
+ $current['line_user_ids'][] = $line_user_id;
+ $current['subscriber_ids'][] = null;
+
+ if ( count( $current['line_user_ids'] ) === self::BATCH_SIZE ) {
+ $batches[] = $current;
+ $current = array(
+ 'line_user_ids' => array(),
+ 'subscriber_ids' => array(),
+ );
+ }
+ }
+
+ if ( $current['line_user_ids'] !== array() ) {
+ $batches[] = $current;
+ }
+
+ return $batches;
+ }
+}
diff --git a/src/Push/PushJobScheduler.php b/src/Push/PushJobScheduler.php
new file mode 100644
index 0000000..fc9e997
--- /dev/null
+++ b/src/Push/PushJobScheduler.php
@@ -0,0 +1,66 @@
+jobs->create_pending( $post_id, $post_type, $is_test );
+
+ if ( function_exists( 'as_schedule_single_action' ) ) {
+ $when = $is_test || $this->throttle === null ? time() : $this->throttle->next_slot();
+ as_schedule_single_action( $when, self::ACTION_RUN_JOB, array( $job_id ), self::GROUP );
+ }
+
+ return $job_id;
+ }
+
+ /**
+ * Enqueue a manual test push to a specific list of LINE user ids.
+ *
+ * @param list $line_user_ids
+ */
+ public function enqueue_test( int $post_id, array $line_user_ids ): int {
+ $job_id = $this->jobs->create_pending( $post_id, 'test', true );
+
+ if ( function_exists( 'as_schedule_single_action' ) ) {
+ as_schedule_single_action(
+ time(),
+ self::ACTION_RUN_JOB,
+ array( $job_id, $line_user_ids ),
+ self::GROUP
+ );
+ }
+
+ return $job_id;
+ }
+}
diff --git a/src/Push/PushJobsListTable.php b/src/Push/PushJobsListTable.php
new file mode 100644
index 0000000..e0bf2ac
--- /dev/null
+++ b/src/Push/PushJobsListTable.php
@@ -0,0 +1,107 @@
+ 'push_job',
+ 'plural' => 'push_jobs',
+ 'ajax' => false,
+ )
+ );
+ }
+
+ public function get_columns(): array {
+ return array(
+ 'post' => __( 'Post', 'bot-cat' ),
+ 'triggered_at' => __( 'Triggered', 'bot-cat' ),
+ 'progress' => __( 'Sent / Total', 'bot-cat' ),
+ 'failed' => __( 'Failed', 'bot-cat' ),
+ 'status' => __( 'Status', 'bot-cat' ),
+ );
+ }
+
+ public function prepare_items(): void {
+ $per_page = 50;
+ $page = max( 1, (int) $this->get_pagenum() );
+
+ $this->items = $this->jobs->list_paged( $page, $per_page );
+ $total = $this->jobs->count_non_test();
+
+ $this->_column_headers = array( $this->get_columns(), array(), array() );
+
+ $this->set_pagination_args(
+ array(
+ 'total_items' => $total,
+ 'per_page' => $per_page,
+ 'total_pages' => max( 1, (int) ceil( $total / $per_page ) ),
+ )
+ );
+ }
+
+ public function column_post( PushJob $item ): string {
+ $title = $item->post_id > 0 ? (string) get_the_title( $item->post_id ) : __( '(test push)', 'bot-cat' );
+ if ( $title === '' ) {
+ $title = sprintf( '#%d', $item->post_id );
+ }
+
+ $detail_url = add_query_arg(
+ array(
+ 'page' => self::PAGE_SLUG,
+ 'job' => $item->id,
+ ),
+ admin_url( 'admin.php' )
+ );
+
+ return sprintf(
+ '%2$s ',
+ esc_url( $detail_url ),
+ esc_html( $title )
+ );
+ }
+
+ public function column_triggered_at( PushJob $item ): string {
+ return esc_html( (string) $item->triggered_at );
+ }
+
+ public function column_progress( PushJob $item ): string {
+ return esc_html( sprintf( '%d / %d', $item->sent_count, $item->recipient_count ) );
+ }
+
+ public function column_failed( PushJob $item ): string {
+ return esc_html( (string) $item->failed_count );
+ }
+
+ public function column_status( PushJob $item ): string {
+ return sprintf(
+ '%2$s ',
+ esc_attr( $item->status ),
+ esc_html( str_replace( '_', ' ', $item->status ) )
+ );
+ }
+
+ public function column_default( $item, $column_name ) {
+ return '';
+ }
+
+ public function no_items(): void {
+ esc_html_e( 'No push jobs yet — publish a post to send your first.', 'bot-cat' );
+ }
+}
diff --git a/src/Push/PushLog.php b/src/Push/PushLog.php
new file mode 100644
index 0000000..253243c
--- /dev/null
+++ b/src/Push/PushLog.php
@@ -0,0 +1,53 @@
+ $row
+ */
+ public static function from_row( array $row ): self {
+ return new self(
+ id: (int) ( $row['id'] ?? 0 ),
+ job_id: (int) ( $row['job_id'] ?? 0 ),
+ subscriber_id: isset( $row['subscriber_id'] ) && $row['subscriber_id'] !== null ? (int) $row['subscriber_id'] : null,
+ line_user_id: (string) ( $row['line_user_id'] ?? '' ),
+ status: (string) ( $row['status'] ?? self::STATUS_PENDING ),
+ error: isset( $row['error'] ) && $row['error'] !== null ? (string) $row['error'] : null,
+ attempts: (int) ( $row['attempts'] ?? 0 ),
+ is_test: ! empty( $row['is_test'] ),
+ created_at: (string) ( $row['created_at'] ?? '' ),
+ );
+ }
+}
diff --git a/src/Push/PushLogRepository.php b/src/Push/PushLogRepository.php
new file mode 100644
index 0000000..c95dcf6
--- /dev/null
+++ b/src/Push/PushLogRepository.php
@@ -0,0 +1,211 @@
+ $line_user_ids
+ * @param list $subscriber_ids Indexed parallel to $line_user_ids; may contain null entries.
+ * @return list Inserted log IDs in the same order.
+ */
+ public function create_for_job(
+ int $job_id,
+ array $line_user_ids,
+ array $subscriber_ids = array(),
+ bool $is_test = false
+ ): array {
+ global $wpdb;
+
+ $now = gmdate( 'Y-m-d H:i:s' );
+ $ids = array();
+ $test = $is_test ? 1 : 0;
+
+ foreach ( $line_user_ids as $index => $line_user_id ) {
+ $subscriber_id = $subscriber_ids[ $index ] ?? null;
+
+ $wpdb->insert(
+ $wpdb->prefix . self::TABLE,
+ array(
+ 'job_id' => $job_id,
+ 'subscriber_id' => $subscriber_id,
+ 'line_user_id' => $line_user_id,
+ 'status' => PushLog::STATUS_PENDING,
+ 'attempts' => 0,
+ 'is_test' => $test,
+ 'created_at' => $now,
+ ),
+ array( '%d', $subscriber_id === null ? '%s' : '%d', '%s', '%s', '%d', '%d', '%s' )
+ );
+
+ $ids[] = (int) $wpdb->insert_id;
+ }
+
+ return $ids;
+ }
+
+ /**
+ * @param list $ids
+ */
+ public function mark_status( array $ids, string $status, ?string $error = null ): void {
+ if ( $ids === array() ) {
+ return;
+ }
+
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE;
+ $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
+ $sent_at = $status === PushLog::STATUS_SENT ? gmdate( 'Y-m-d H:i:s' ) : null;
+
+ if ( $error !== null ) {
+ $sql = sprintf(
+ 'UPDATE %s SET status = %%s, error = %%s, sent_at = %%s, attempts = attempts + 1 WHERE id IN (%s)',
+ $table,
+ $placeholders
+ );
+ $wpdb->query( $wpdb->prepare( $sql, $status, $error, $sent_at, ...$ids ) );
+ return;
+ }
+
+ $sql = sprintf(
+ 'UPDATE %s SET status = %%s, sent_at = %%s, attempts = attempts + 1 WHERE id IN (%s)',
+ $table,
+ $placeholders
+ );
+ $wpdb->query( $wpdb->prepare( $sql, $status, $sent_at, ...$ids ) );
+ }
+
+ public function count_for_job( int $job_id, string $status ): int {
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE;
+
+ return (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM {$table} WHERE job_id = %d AND status = %s",
+ $job_id,
+ $status
+ )
+ );
+ }
+
+ /**
+ * @return list
+ */
+ public function failed_for_job( int $job_id ): array {
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE;
+
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT * FROM {$table} WHERE job_id = %d AND status = %s",
+ $job_id,
+ PushLog::STATUS_FAILED
+ ),
+ ARRAY_A
+ );
+
+ if ( ! is_array( $rows ) ) {
+ return array();
+ }
+
+ return array_map(
+ static fn( array $row ): PushLog => PushLog::from_row( $row ),
+ $rows
+ );
+ }
+
+ /**
+ * @param list $ids
+ */
+ public function reset_for_resend( array $ids ): void {
+ if ( $ids === array() ) {
+ return;
+ }
+
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE;
+ $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
+
+ $sql = sprintf(
+ 'UPDATE %s SET status = %%s, error = NULL, sent_at = NULL WHERE id IN (%s)',
+ $table,
+ $placeholders
+ );
+
+ $wpdb->query( $wpdb->prepare( $sql, PushLog::STATUS_PENDING, ...$ids ) );
+ }
+
+ /**
+ * @param list $ids
+ * @return list
+ */
+ public function find_by_ids_for_job( int $job_id, array $ids ): array {
+ if ( $ids === array() ) {
+ return array();
+ }
+
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE;
+ $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
+
+ $sql = sprintf(
+ 'SELECT * FROM %s WHERE job_id = %%d AND id IN (%s)',
+ $table,
+ $placeholders
+ );
+
+ $rows = $wpdb->get_results( $wpdb->prepare( $sql, $job_id, ...$ids ), ARRAY_A );
+ if ( ! is_array( $rows ) ) {
+ return array();
+ }
+
+ return array_map(
+ static fn( array $row ): PushLog => PushLog::from_row( $row ),
+ $rows
+ );
+ }
+
+ /**
+ * @return list
+ */
+ public function list_for_job( int $job_id, int $page = 1, int $per_page = 50 ): array {
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE;
+ $page = max( 1, $page );
+ $per = min( 500, max( 1, $per_page ) );
+
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT * FROM {$table} WHERE job_id = %d ORDER BY id DESC LIMIT %d OFFSET %d",
+ $job_id,
+ $per,
+ ( $page - 1 ) * $per
+ ),
+ ARRAY_A
+ );
+
+ if ( ! is_array( $rows ) ) {
+ return array();
+ }
+
+ return array_map(
+ static fn( array $row ): PushLog => PushLog::from_row( $row ),
+ $rows
+ );
+ }
+}
diff --git a/src/Push/PushLogsPage.php b/src/Push/PushLogsPage.php
new file mode 100644
index 0000000..58a4660
--- /dev/null
+++ b/src/Push/PushLogsPage.php
@@ -0,0 +1,59 @@
+ 403 ) );
+ }
+
+ $job_id = isset( $_GET['job'] ) ? (int) $_GET['job'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+
+ if ( $job_id > 0 ) {
+ ( new PushJobDetailPage( $this->jobs, $this->logs ) )->render( $job_id );
+ return;
+ }
+
+ $this->ensure_list_table_loaded();
+
+ $table = new PushJobsListTable( $this->jobs );
+ $table->prepare_items();
+
+ echo '';
+ echo '
' . esc_html__( 'Push Logs', 'bot-cat' ) . ' ';
+ echo '';
+ echo '';
+ printf(
+ ' ',
+ esc_attr( self::PAGE_SLUG )
+ );
+ $table->display();
+ echo ' ';
+ echo '';
+ }
+
+ private function ensure_list_table_loaded(): void {
+ if ( ! class_exists( 'WP_List_Table' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
+ }
+ }
+}
diff --git a/src/Push/RateLimiter.php b/src/Push/RateLimiter.php
new file mode 100644
index 0000000..348126b
--- /dev/null
+++ b/src/Push/RateLimiter.php
@@ -0,0 +1,29 @@
+base_timestamp + (int) ceil( $batch_index * self::SECONDS_PER_SLOT );
+ }
+}
diff --git a/src/Push/RetryDecision.php b/src/Push/RetryDecision.php
new file mode 100644
index 0000000..e086c25
--- /dev/null
+++ b/src/Push/RetryDecision.php
@@ -0,0 +1,47 @@
+ok ) {
+ return RetryDecision::done();
+ }
+
+ if ( $result->error_code === MulticastClient::ERROR_AUTH ) {
+ return RetryDecision::abort_auth( $result->error_message );
+ }
+
+ if ( $result->error_code === MulticastClient::ERROR_CLIENT ) {
+ return RetryDecision::fail( $result->error_message );
+ }
+
+ if ( $result->error_code === MulticastClient::ERROR_RATE_LIMITED ) {
+ if ( $result->retry_after !== null && $result->retry_after > 0 ) {
+ return RetryDecision::retry( $result->retry_after );
+ }
+ }
+
+ if ( $attempt > self::MAX_ATTEMPTS ) {
+ return RetryDecision::fail( $result->error_message );
+ }
+
+ $delay = self::BACKOFFS[ $attempt - 1 ] ?? null;
+ if ( $delay === null ) {
+ return RetryDecision::fail( $result->error_message );
+ }
+
+ return RetryDecision::retry( $delay );
+ }
+}
diff --git a/src/Rules/ExcludedCategories.php b/src/Rules/ExcludedCategories.php
new file mode 100644
index 0000000..7ee02bb
--- /dev/null
+++ b/src/Rules/ExcludedCategories.php
@@ -0,0 +1,59 @@
+
+ */
+ public function get(): array {
+ $stored = get_option( self::OPTION, array() );
+ if ( ! is_array( $stored ) ) {
+ return array();
+ }
+
+ return array_values( array_unique( array_map( 'intval', $stored ) ) );
+ }
+
+ /**
+ * @param array $term_ids
+ */
+ public function save( array $term_ids ): void {
+ update_option(
+ self::OPTION,
+ array_values(
+ array_unique(
+ array_map( 'intval', $term_ids )
+ )
+ )
+ );
+ }
+
+ public function is_excluded( int $post_id ): bool {
+ $excluded = $this->get();
+ if ( $excluded === array() ) {
+ return false;
+ }
+
+ $post_categories = wp_get_post_categories( $post_id, array( 'fields' => 'ids' ) );
+ if ( ! is_array( $post_categories ) ) {
+ return false;
+ }
+
+ return array_intersect( $excluded, array_map( 'intval', $post_categories ) ) !== array();
+ }
+}
diff --git a/src/Rules/PerPostOptOut.php b/src/Rules/PerPostOptOut.php
new file mode 100644
index 0000000..ddff0f6
--- /dev/null
+++ b/src/Rules/PerPostOptOut.php
@@ -0,0 +1,87 @@
+default_for_new_posts();
+ }
+
+ return $meta === '1';
+ }
+
+ public function save_for_post( int $post_id, bool $checked ): void {
+ update_post_meta( $post_id, self::META_KEY, $checked ? '1' : '0' );
+ }
+
+ public function register_meta_box( string $post_type ): void {
+ add_meta_box(
+ 'botcat_per_post_optout',
+ __( 'LINE Push', 'bot-cat' ),
+ array( $this, 'render_meta_box' ),
+ $post_type,
+ 'side',
+ 'default'
+ );
+ }
+
+ public function render_meta_box( object $post ): void {
+ $post_id = isset( $post->ID ) ? (int) $post->ID : 0;
+ $checked = $post_id > 0 ? $this->is_opted_in( $post_id ) : $this->default_for_new_posts();
+
+ wp_nonce_field( self::META_BOX_NONCE, self::META_BOX_NONCE );
+ ?>
+
+ />
+
+
+ save_for_post( $post_id, $checked );
+ }
+}
diff --git a/src/Rules/PushThrottle.php b/src/Rules/PushThrottle.php
new file mode 100644
index 0000000..05930e8
--- /dev/null
+++ b/src/Rules/PushThrottle.php
@@ -0,0 +1,44 @@
+interval_seconds();
+
+ if ( $interval <= 0 ) {
+ return $now;
+ }
+
+ $stored = (int) get_option( self::OPTION_NEXT_ALLOWED_AT, 0 );
+ $slot = max( $now, $stored );
+
+ update_option( self::OPTION_NEXT_ALLOWED_AT, $slot + $interval );
+
+ return $slot;
+ }
+}
diff --git a/src/Rules/RulesSettingsTab.php b/src/Rules/RulesSettingsTab.php
new file mode 100644
index 0000000..4ccc812
--- /dev/null
+++ b/src/Rules/RulesSettingsTab.php
@@ -0,0 +1,222 @@
+ 'array',
+ 'sanitize_callback' => array( $this, 'sanitize_post_types' ),
+ 'default' => EligiblePostTypes::DEFAULT,
+ )
+ );
+
+ register_setting(
+ self::OPTION_GROUP,
+ ExcludedCategories::OPTION,
+ array(
+ 'type' => 'array',
+ 'sanitize_callback' => array( $this, 'sanitize_term_ids' ),
+ 'default' => array(),
+ )
+ );
+
+ register_setting(
+ self::OPTION_GROUP,
+ PushThrottle::OPTION_INTERVAL_MIN,
+ array(
+ 'type' => 'integer',
+ 'sanitize_callback' => static fn( $v ) => max( 0, (int) $v ),
+ 'default' => 0,
+ )
+ );
+
+ register_setting(
+ self::OPTION_GROUP,
+ PerPostOptOut::DEFAULT_OPTION,
+ array(
+ 'type' => 'boolean',
+ 'sanitize_callback' => static fn( $v ) => (bool) $v,
+ 'default' => true,
+ )
+ );
+ }
+
+ /**
+ * @param mixed $input
+ * @return list
+ */
+ public function sanitize_post_types( $input ): array {
+ if ( ! is_array( $input ) ) {
+ return EligiblePostTypes::DEFAULT;
+ }
+
+ $candidates = array_map( 'sanitize_key', $input );
+ $candidates = array_filter(
+ $candidates,
+ static fn( string $key ): bool => $key !== '' && ! in_array( $key, array( 'page', 'attachment' ), true )
+ );
+
+ return $candidates === array() ? EligiblePostTypes::DEFAULT : array_values( array_unique( $candidates ) );
+ }
+
+ /**
+ * @param mixed $input
+ * @return list
+ */
+ public function sanitize_term_ids( $input ): array {
+ if ( ! is_array( $input ) ) {
+ return array();
+ }
+
+ return array_values(
+ array_unique(
+ array_filter(
+ array_map( 'intval', $input ),
+ static fn( int $id ): bool => $id > 0
+ )
+ )
+ );
+ }
+
+ public function render(): void {
+ echo '';
+ settings_fields( self::OPTION_GROUP );
+ echo '';
+ submit_button();
+ echo ' ';
+ }
+
+ private function render_post_types_row(): void {
+ $selected = $this->eligible->get();
+ $available = $this->available_post_types();
+
+ echo '' . esc_html__( 'Eligible post types', 'bot-cat' ) . ' ';
+ foreach ( $available as $slug => $label ) {
+ $disabled = in_array( $slug, array( 'page', 'attachment' ), true );
+ $checked = in_array( $slug, $selected, true );
+
+ printf(
+ ' %5$s %2$s ',
+ esc_attr( EligiblePostTypes::OPTION ),
+ esc_attr( $slug ),
+ $disabled ? ' disabled' : '',
+ $checked && ! $disabled ? ' checked' : '',
+ esc_html( $label )
+ );
+ }
+ echo '' . esc_html__( 'page and attachment cannot be selected.', 'bot-cat' ) . '
';
+ echo ' ';
+ }
+
+ private function render_excluded_categories_row(): void {
+ $selected = $this->excluded->get();
+ $categories = get_categories(
+ array(
+ 'hide_empty' => false,
+ 'orderby' => 'name',
+ )
+ );
+
+ echo '' . esc_html__( 'Excluded categories', 'bot-cat' ) . ' ';
+
+ if ( ! is_array( $categories ) || $categories === array() ) {
+ echo '' . esc_html__( 'No categories defined yet.', 'bot-cat' ) . '
';
+ } else {
+ foreach ( $categories as $cat ) {
+ $id = isset( $cat->term_id ) ? (int) $cat->term_id : 0;
+ $name = isset( $cat->name ) ? (string) $cat->name : '';
+ $checked = in_array( $id, $selected, true );
+
+ printf(
+ ' %4$s ',
+ esc_attr( ExcludedCategories::OPTION ),
+ (int) $id,
+ $checked ? ' checked' : '',
+ esc_html( $name )
+ );
+ }
+ }
+
+ echo '' . esc_html__( 'Posts assigned to any selected category will NOT push.', 'bot-cat' ) . '
';
+ echo ' ';
+ }
+
+ private function render_throttle_row(): void {
+ $current = (int) get_option( PushThrottle::OPTION_INTERVAL_MIN, 0 );
+
+ echo '' . esc_html__( 'Min minutes between pushes', 'bot-cat' ) . ' ';
+ printf(
+ ' ',
+ esc_attr( PushThrottle::OPTION_INTERVAL_MIN ),
+ (int) $current
+ );
+ echo '' . esc_html__( '0 = no throttle. Bursts publish all queue at once but the channel waits this many minutes between consecutive multicast jobs.', 'bot-cat' ) . '
';
+ echo ' ';
+ }
+
+ private function render_default_opt_in_row(): void {
+ $default_on = (bool) get_option( PerPostOptOut::DEFAULT_OPTION, true );
+
+ echo '' . esc_html__( 'Default for new posts', 'bot-cat' ) . ' ';
+ printf(
+ ' %3$s ',
+ esc_attr( PerPostOptOut::DEFAULT_OPTION ),
+ $default_on ? ' checked' : '',
+ esc_html__( 'New posts default to "Send LINE notification" being ON.', 'bot-cat' )
+ );
+ echo ' ';
+ }
+
+ /**
+ * @return array
+ */
+ private function available_post_types(): array {
+ $types = get_post_types( array( 'public' => true ), 'objects' );
+ if ( ! is_array( $types ) ) {
+ return array( 'post' => 'Post' );
+ }
+
+ $map = array();
+ foreach ( $types as $slug => $object ) {
+ $map[ (string) $slug ] = isset( $object->label ) ? (string) $object->label : (string) $slug;
+ }
+
+ return $map;
+ }
+}
diff --git a/src/Shortener/ClickLogRepository.php b/src/Shortener/ClickLogRepository.php
new file mode 100644
index 0000000..cbb6d8e
--- /dev/null
+++ b/src/Shortener/ClickLogRepository.php
@@ -0,0 +1,70 @@
+insert(
+ $wpdb->prefix . self::TABLE,
+ array(
+ 'short_link_id' => $short_link_id,
+ 'subscriber_id' => $subscriber_id,
+ 'ip_hash' => $ip_hash,
+ 'user_agent' => $this->truncate( $user_agent ),
+ 'referer' => $this->truncate( $referer ),
+ 'clicked_at' => gmdate( 'Y-m-d H:i:s' ),
+ ),
+ array( '%d', $subscriber_id === null ? '%s' : '%d', '%s', '%s', '%s', '%s' )
+ );
+ }
+
+ public function count_total( int $short_link_id ): int {
+ global $wpdb;
+ return (int) $wpdb->get_var(
+ $wpdb->prepare(
+ 'SELECT COUNT(*) FROM ' . $wpdb->prefix . self::TABLE . ' WHERE short_link_id = %d',
+ $short_link_id
+ )
+ );
+ }
+
+ public function count_unique( int $short_link_id ): int {
+ global $wpdb;
+ return (int) $wpdb->get_var(
+ $wpdb->prepare(
+ 'SELECT COUNT(DISTINCT ip_hash) FROM ' . $wpdb->prefix . self::TABLE . ' WHERE short_link_id = %d',
+ $short_link_id
+ )
+ );
+ }
+
+ private function truncate( ?string $value ): ?string {
+ if ( $value === null ) {
+ return null;
+ }
+ return mb_substr( $value, 0, self::STRING_LIMIT, 'UTF-8' );
+ }
+}
diff --git a/src/Shortener/ClickRedirectHandler.php b/src/Shortener/ClickRedirectHandler.php
new file mode 100644
index 0000000..95cc3fb
--- /dev/null
+++ b/src/Shortener/ClickRedirectHandler.php
@@ -0,0 +1,63 @@
+ 301|404, 'location' => ?string]` — the caller
+ * is responsible for the actual HTTP response.
+ */
+class ClickRedirectHandler {
+
+ public function __construct(
+ private readonly ShortLinkRepository $links,
+ private readonly ClickLogRepository $clicks,
+ private readonly SubscriberTokenSigner $signer,
+ private readonly string $ip_salt
+ ) {
+ }
+
+ /**
+ * @return array{status:int, location:?string}
+ */
+ public function handle( string $code, ?string $token, string $ip, ?string $user_agent, ?string $referer ): array {
+ $link = $this->links->find_by_code( $code );
+
+ if ( $link === null ) {
+ return array(
+ 'status' => 404,
+ 'location' => null,
+ );
+ }
+
+ $subscriber_id = null;
+ if ( $token !== null && $token !== '' ) {
+ $subscriber_id = $this->signer->verify( $token );
+ }
+
+ $ip_hash = hash( 'sha256', $ip . $this->ip_salt );
+
+ $this->clicks->log(
+ $link->id,
+ $subscriber_id,
+ $ip_hash,
+ $user_agent,
+ $referer
+ );
+
+ return array(
+ 'status' => 301,
+ 'location' => $link->original_url,
+ );
+ }
+}
diff --git a/src/Shortener/LinkRewriter.php b/src/Shortener/LinkRewriter.php
new file mode 100644
index 0000000..22ed490
--- /dev/null
+++ b/src/Shortener/LinkRewriter.php
@@ -0,0 +1,40 @@
+links->create_for_post( $post_id, $permalink );
+ $short = rtrim( $this->short_base, '/' ) . '/' . $link->code;
+
+ return str_replace( $permalink, $short, $body );
+ }
+}
diff --git a/src/Shortener/RewriteRuleRegistrar.php b/src/Shortener/RewriteRuleRegistrar.php
new file mode 100644
index 0000000..f2201fe
--- /dev/null
+++ b/src/Shortener/RewriteRuleRegistrar.php
@@ -0,0 +1,63 @@
+handler->handle( $code, $token, $ip, $ua, $referer );
+
+ if ( $result['status'] === 404 ) {
+ status_header( 404 );
+ nocache_headers();
+ printf( '%s
', esc_html__( 'This short link is invalid.', 'bot-cat' ) );
+ exit;
+ }
+
+ if ( $result['location'] !== null ) {
+ wp_redirect( $result['location'], 301 ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
+ exit;
+ }
+ }
+}
diff --git a/src/Shortener/ShortCodeGenerator.php b/src/Shortener/ShortCodeGenerator.php
new file mode 100644
index 0000000..bf9cb3f
--- /dev/null
+++ b/src/Shortener/ShortCodeGenerator.php
@@ -0,0 +1,29 @@
+ $row
+ */
+ public static function from_row( array $row ): self {
+ return new self(
+ id: (int) ( $row['id'] ?? 0 ),
+ code: (string) ( $row['code'] ?? '' ),
+ post_id: (int) ( $row['post_id'] ?? 0 ),
+ original_url: (string) ( $row['original_url'] ?? '' ),
+ created_at: (string) ( $row['created_at'] ?? '' ),
+ );
+ }
+}
diff --git a/src/Shortener/ShortLinkRepository.php b/src/Shortener/ShortLinkRepository.php
new file mode 100644
index 0000000..d247356
--- /dev/null
+++ b/src/Shortener/ShortLinkRepository.php
@@ -0,0 +1,113 @@
+find_for_post( $post_id );
+ if ( $existing !== null && $existing->original_url === $url ) {
+ return $existing;
+ }
+
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE;
+ $now = gmdate( 'Y-m-d H:i:s' );
+
+ for ( $attempt = 0; $attempt < self::COLLISION_MAX; $attempt++ ) {
+ $code = $this->generator->generate();
+
+ if ( $this->find_by_code( $code ) !== null ) {
+ continue;
+ }
+
+ $wpdb->insert(
+ $table,
+ array(
+ 'code' => $code,
+ 'post_id' => $post_id,
+ 'original_url' => $url,
+ 'created_at' => $now,
+ ),
+ array( '%s', '%d', '%s', '%s' )
+ );
+
+ return new ShortLink(
+ id: (int) $wpdb->insert_id,
+ code: $code,
+ post_id: $post_id,
+ original_url: $url,
+ created_at: $now,
+ );
+ }
+
+ throw new \RuntimeException( 'Could not generate a unique short code after retries' );
+ }
+
+ public function find_by_code( string $code ): ?ShortLink {
+ global $wpdb;
+ $row = $wpdb->get_row(
+ $wpdb->prepare( 'SELECT * FROM ' . $wpdb->prefix . self::TABLE . ' WHERE code = %s LIMIT 1', $code ),
+ ARRAY_A
+ );
+ return is_array( $row ) ? ShortLink::from_row( $row ) : null;
+ }
+
+ public function find_for_post( int $post_id ): ?ShortLink {
+ global $wpdb;
+ $row = $wpdb->get_row(
+ $wpdb->prepare(
+ 'SELECT * FROM ' . $wpdb->prefix . self::TABLE . ' WHERE post_id = %d ORDER BY id DESC LIMIT 1',
+ $post_id
+ ),
+ ARRAY_A
+ );
+ return is_array( $row ) ? ShortLink::from_row( $row ) : null;
+ }
+
+ /**
+ * @return list
+ */
+ public function list_paged( int $page = 1, int $per_page = 50 ): array {
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE;
+ $page = max( 1, $page );
+ $per = min( 500, max( 1, $per_page ) );
+
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT * FROM {$table} ORDER BY id DESC LIMIT %d OFFSET %d",
+ $per,
+ ( $page - 1 ) * $per
+ ),
+ ARRAY_A
+ );
+
+ if ( ! is_array( $rows ) ) {
+ return array();
+ }
+
+ return array_map(
+ static fn( array $row ): ShortLink => ShortLink::from_row( $row ),
+ $rows
+ );
+ }
+}
diff --git a/src/Shortener/ShortLinksPage.php b/src/Shortener/ShortLinksPage.php
new file mode 100644
index 0000000..eb89374
--- /dev/null
+++ b/src/Shortener/ShortLinksPage.php
@@ -0,0 +1,79 @@
+ 403 ) );
+ }
+
+ $rows = $this->links->list_paged( 1, 100 );
+
+ echo '';
+ echo '
' . esc_html__( 'Short Links', 'bot-cat' ) . ' ';
+
+ if ( $rows === array() ) {
+ echo '
' . esc_html__( 'No short links yet — publish a post to mint one.', 'bot-cat' ) . '
';
+ echo '
';
+ return;
+ }
+
+ echo '';
+ printf( '%s ', esc_html__( 'Code', 'bot-cat' ) );
+ printf( '%s ', esc_html__( 'Post', 'bot-cat' ) );
+ printf( '%s ', esc_html__( 'Short URL', 'bot-cat' ) );
+ printf( '%s ', esc_html__( 'Created', 'bot-cat' ) );
+ printf( '%s ', esc_html__( 'Clicks', 'bot-cat' ) );
+ echo ' ';
+
+ foreach ( $rows as $link ) {
+ $total = $this->clicks->count_total( $link->id );
+ $unique = $this->clicks->count_unique( $link->id );
+ $short = rtrim( $this->short_base, '/' ) . '/' . $link->code;
+ $title = $link->post_id > 0 ? (string) get_the_title( $link->post_id ) : __( '(test push)', 'bot-cat' );
+
+ echo '';
+ printf( '%s ', esc_html( $link->code ) );
+ printf( '%s ', esc_html( $title ) );
+ printf( '%1$s ', esc_url( $short ) );
+ printf( '%s ', esc_html( $link->created_at ) );
+ printf(
+ '%s ',
+ esc_html(
+ sprintf(
+ /* translators: 1: total clicks, 2: unique clicks. */
+ __( '%1$d clicks · %2$d unique', 'bot-cat' ),
+ $total,
+ $unique
+ )
+ )
+ );
+ echo ' ';
+ }
+
+ echo '
';
+ echo '';
+ }
+}
diff --git a/src/Shortener/SubscriberTokenSigner.php b/src/Shortener/SubscriberTokenSigner.php
new file mode 100644
index 0000000..3a873b8
--- /dev/null
+++ b/src/Shortener/SubscriberTokenSigner.php
@@ -0,0 +1,72 @@
+key = hash_hkdf( 'sha256', $secret, 32, self::SALT );
+ }
+
+ public function sign( int $subscriber_id ): string {
+ $payload = (string) $subscriber_id;
+ $signature = substr( hash_hmac( 'sha256', $payload, $this->key ), 0, self::SIG_LENGTH );
+ return $this->base64url_encode( $payload . '.' . $signature );
+ }
+
+ public function verify( string $token ): ?int {
+ if ( $token === '' ) {
+ return null;
+ }
+
+ $decoded = $this->base64url_decode( $token );
+ if ( $decoded === null ) {
+ return null;
+ }
+
+ $parts = explode( '.', $decoded, 2 );
+ if ( count( $parts ) !== 2 ) {
+ return null;
+ }
+
+ [ $payload, $signature ] = $parts;
+ if ( $payload === '' || $signature === '' || ! ctype_digit( $payload ) ) {
+ return null;
+ }
+
+ $expected = substr( hash_hmac( 'sha256', $payload, $this->key ), 0, self::SIG_LENGTH );
+ if ( ! hash_equals( $expected, $signature ) ) {
+ return null;
+ }
+
+ return (int) $payload;
+ }
+
+ private function base64url_encode( string $bytes ): string {
+ return rtrim( strtr( base64_encode( $bytes ), '+/', '-_' ), '=' );
+ }
+
+ private function base64url_decode( string $encoded ): ?string {
+ $padded = str_pad( strtr( $encoded, '-_', '+/' ), strlen( $encoded ) + ( 4 - strlen( $encoded ) % 4 ) % 4, '=' );
+ $decoded = base64_decode( $padded, true );
+ return $decoded === false ? null : $decoded;
+ }
+}
diff --git a/src/Subscribers/FollowHandler.php b/src/Subscribers/FollowHandler.php
new file mode 100644
index 0000000..2291305
--- /dev/null
+++ b/src/Subscribers/FollowHandler.php
@@ -0,0 +1,73 @@
+subscribers->find_by_line_id( $line_user_id );
+ $is_new = $existing === null;
+ $followed_at = gmdate( 'Y-m-d H:i:s', $event_timestamp );
+ $channel = $this->channel_settings->get();
+
+ if ( ! $channel->is_configured() ) {
+ $this->subscribers->upsert_active( $line_user_id, null, null, $followed_at );
+ return;
+ }
+
+ $profile = $this->profiles->fetch( $line_user_id, $channel->access_token() );
+
+ if ( $profile->ok ) {
+ $this->subscribers->upsert_active(
+ $line_user_id,
+ $profile->display_name,
+ $profile->picture_url,
+ $followed_at
+ );
+ } else {
+ $this->subscribers->upsert_active( $line_user_id, null, null, $followed_at );
+
+ if ( function_exists( 'as_enqueue_async_action' ) ) {
+ as_enqueue_async_action(
+ self::RETRY_HOOK,
+ array( $line_user_id ),
+ 'bot-cat'
+ );
+ }
+ }
+
+ if ( $is_new && $reply_token !== null && $reply_token !== '' && $this->welcome !== null && $this->reply_client !== null ) {
+ $message = $this->welcome->build();
+ if ( $message !== null ) {
+ $this->reply_client->reply( $channel->access_token(), $reply_token, $message );
+ }
+ }
+ }
+}
diff --git a/src/Subscribers/LineProfileFetcher.php b/src/Subscribers/LineProfileFetcher.php
new file mode 100644
index 0000000..7e0ed4f
--- /dev/null
+++ b/src/Subscribers/LineProfileFetcher.php
@@ -0,0 +1,56 @@
+ array(
+ 'Authorization' => 'Bearer ' . $access_token,
+ ),
+ 'timeout' => 10,
+ )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ return new LineProfileResult( ok: false, error_code: 'network_error' );
+ }
+
+ $code = (int) wp_remote_retrieve_response_code( $response );
+ if ( $code !== 200 ) {
+ return new LineProfileResult( ok: false, error_code: 'http_' . $code );
+ }
+
+ $payload = json_decode( (string) wp_remote_retrieve_body( $response ), true );
+ if ( ! is_array( $payload ) ) {
+ return new LineProfileResult( ok: false, error_code: 'invalid_response' );
+ }
+
+ return new LineProfileResult(
+ ok: true,
+ display_name: isset( $payload['displayName'] ) ? (string) $payload['displayName'] : null,
+ picture_url: isset( $payload['pictureUrl'] ) ? (string) $payload['pictureUrl'] : null
+ );
+ }
+}
diff --git a/src/Subscribers/LineProfileResult.php b/src/Subscribers/LineProfileResult.php
new file mode 100644
index 0000000..ba59e46
--- /dev/null
+++ b/src/Subscribers/LineProfileResult.php
@@ -0,0 +1,22 @@
+ $row
+ */
+ public static function from_row( array $row ): self {
+ return new self(
+ id: (int) ( $row['id'] ?? 0 ),
+ job_id: (int) ( $row['job_id'] ?? 0 ),
+ subscriber_id: isset( $row['subscriber_id'] ) ? (int) $row['subscriber_id'] : null,
+ line_user_id: (string) ( $row['line_user_id'] ?? '' ),
+ status: (string) ( $row['status'] ?? '' ),
+ error: isset( $row['error'] ) ? (string) $row['error'] : null,
+ created_at: (string) ( $row['created_at'] ?? '' ),
+ post_id: isset( $row['post_id'] ) ? (int) $row['post_id'] : null,
+ );
+ }
+}
diff --git a/src/Subscribers/Subscriber.php b/src/Subscribers/Subscriber.php
new file mode 100644
index 0000000..5cbd59f
--- /dev/null
+++ b/src/Subscribers/Subscriber.php
@@ -0,0 +1,51 @@
+status === self::STATUS_ACTIVE;
+ }
+
+ public function is_unfollowed(): bool {
+ return $this->status === self::STATUS_UNFOLLOWED;
+ }
+
+ /**
+ * @param array $row
+ */
+ public static function from_row( array $row ): self {
+ return new self(
+ id: (int) ( $row['id'] ?? 0 ),
+ line_user_id: (string) ( $row['line_user_id'] ?? '' ),
+ display_name: isset( $row['display_name'] ) ? (string) $row['display_name'] : null,
+ picture_url: isset( $row['picture_url'] ) ? (string) $row['picture_url'] : null,
+ status: (string) ( $row['status'] ?? self::STATUS_ACTIVE ),
+ followed_at: isset( $row['followed_at'] ) ? (string) $row['followed_at'] : null,
+ unfollowed_at: isset( $row['unfollowed_at'] ) ? (string) $row['unfollowed_at'] : null
+ );
+ }
+}
diff --git a/src/Subscribers/SubscriberDetailPage.php b/src/Subscribers/SubscriberDetailPage.php
new file mode 100644
index 0000000..10f462c
--- /dev/null
+++ b/src/Subscribers/SubscriberDetailPage.php
@@ -0,0 +1,118 @@
+ 403 ) );
+ }
+
+ $subscriber = $this->find_by_id( $subscriber_id );
+
+ if ( $subscriber === null ) {
+ echo '
' . esc_html__( 'Subscriber not found', 'bot-cat' ) . ' ';
+ return;
+ }
+
+ $logs = $this->repository->recent_logs_for_subscriber( $subscriber->id, 20 );
+
+ echo '';
+ printf( '
%s ', esc_html( $subscriber->display_name ?? __( '(unknown)', 'bot-cat' ) ) );
+ $this->render_identity( $subscriber );
+ $this->render_logs( $logs );
+ echo '';
+ }
+
+ private function find_by_id( int $id ): ?Subscriber {
+ // The repository keys on line_user_id; for the detail page the
+ // list table already passes the numeric id, so walk the active
+ // stream once to locate it. Detail views are infrequent so this
+ // is acceptable until a find_by_id() helper is added.
+ foreach ( $this->repository->active_ids() as $line_user_id ) {
+ $candidate = $this->repository->find_by_line_id( $line_user_id );
+ if ( $candidate !== null && $candidate->id === $id ) {
+ return $candidate;
+ }
+ }
+ return null;
+ }
+
+ private function render_identity( Subscriber $subscriber ): void {
+ echo '';
+ }
+
+ /**
+ * @param list $logs
+ */
+ private function render_logs( array $logs ): void {
+ echo '' . esc_html__( 'Recent push deliveries', 'bot-cat' ) . ' ';
+
+ if ( $logs === array() ) {
+ echo '' . esc_html__( 'No pushes sent to this subscriber yet.', 'bot-cat' ) . '
';
+ return;
+ }
+
+ echo '';
+ printf( '%s ', esc_html__( 'Sent at', 'bot-cat' ) );
+ printf( '%s ', esc_html__( 'Post', 'bot-cat' ) );
+ printf( '%s ', esc_html__( 'Status', 'bot-cat' ) );
+ printf( '%s ', esc_html__( 'Error', 'bot-cat' ) );
+ echo ' ';
+
+ foreach ( $logs as $log ) {
+ echo '';
+ printf( '%s ', esc_html( $log->created_at ) );
+ printf(
+ '%s ',
+ $log->post_id !== null
+ ? sprintf(
+ '#%2$d ',
+ esc_url( (string) get_edit_post_link( (int) $log->post_id ) ),
+ (int) $log->post_id
+ )
+ : '—'
+ );
+ printf( '%s ', esc_html( $log->status ) );
+ printf( '%s ', esc_html( $log->error ?? '' ) );
+ echo ' ';
+ }
+
+ echo '
';
+ }
+
+ private function row( string $label, string $value_html ): void {
+ printf(
+ '%1$s %2$s ',
+ esc_html( $label ),
+ $value_html // Already escaped at the call site.
+ );
+ }
+}
diff --git a/src/Subscribers/SubscriberListTable.php b/src/Subscribers/SubscriberListTable.php
new file mode 100644
index 0000000..e6b1f6d
--- /dev/null
+++ b/src/Subscribers/SubscriberListTable.php
@@ -0,0 +1,187 @@
+ 'subscriber',
+ 'plural' => 'subscribers',
+ 'ajax' => false,
+ )
+ );
+ }
+
+ public function get_columns(): array {
+ return array(
+ 'cb' => ' ',
+ 'display_name' => __( 'Display Name', 'bot-cat' ),
+ 'line_user_id' => __( 'LINE User ID', 'bot-cat' ),
+ 'status' => __( 'Status', 'bot-cat' ),
+ 'followed_at' => __( 'Followed', 'bot-cat' ),
+ );
+ }
+
+ protected function get_sortable_columns(): array {
+ return array(
+ 'status' => array( 'status', false ),
+ 'followed_at' => array( 'followed_at', true ),
+ );
+ }
+
+ public function get_bulk_actions(): array {
+ return array(
+ 'delete' => __( 'Delete', 'bot-cat' ),
+ );
+ }
+
+ public function prepare_items(): void {
+ $this->process_bulk_action();
+
+ $query = SubscribersQuery::from_request( $this->sanitized_request_args() );
+ $page = $this->repository->search( $query );
+
+ $this->items = $page->rows;
+ $this->_column_headers = array( $this->get_columns(), array(), $this->get_sortable_columns() );
+
+ $this->set_pagination_args(
+ array(
+ 'total_items' => $page->total,
+ 'per_page' => $query->per_page,
+ 'total_pages' => max( 1, (int) ceil( $page->total / max( 1, $query->per_page ) ) ),
+ )
+ );
+ }
+
+ public function process_bulk_action(): void {
+ if ( 'delete' !== $this->current_action() ) {
+ return;
+ }
+
+ check_admin_referer( self::BULK_NONCE_ACTION );
+
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_die( esc_html__( 'Insufficient permissions.', 'bot-cat' ), '', array( 'response' => 403 ) );
+ }
+
+ $ids = isset( $_REQUEST['subscriber'] )
+ ? array_map( 'intval', (array) wp_unslash( $_REQUEST['subscriber'] ) )
+ : array();
+
+ if ( $ids === array() ) {
+ return;
+ }
+
+ $this->repository->delete_many( $ids );
+ }
+
+ public function column_cb( $item ): string {
+ return sprintf(
+ ' ',
+ (int) $item->id
+ );
+ }
+
+ public function column_display_name( Subscriber $item ): string {
+ $name = $item->display_name ?? __( '(unknown)', 'bot-cat' );
+
+ $detail_url = add_query_arg(
+ array(
+ 'page' => self::PAGE_SLUG,
+ 'subscriber' => (int) $item->id,
+ ),
+ admin_url( 'admin.php' )
+ );
+
+ return sprintf(
+ '%2$s ',
+ esc_url( $detail_url ),
+ esc_html( $name )
+ );
+ }
+
+ public function column_line_user_id( Subscriber $item ): string {
+ $short = substr( $item->line_user_id, 0, 8 );
+ return '' . esc_html( $short ) . '…';
+ }
+
+ public function column_status( Subscriber $item ): string {
+ $label = $item->status === Subscriber::STATUS_ACTIVE
+ ? __( 'Active', 'bot-cat' )
+ : __( 'Unfollowed', 'bot-cat' );
+
+ return sprintf(
+ '%2$s ',
+ esc_attr( $item->status ),
+ esc_html( $label )
+ );
+ }
+
+ public function column_followed_at( Subscriber $item ): string {
+ return esc_html( (string) $item->followed_at );
+ }
+
+ public function column_default( $item, $column_name ) {
+ return '';
+ }
+
+ public function no_items(): void {
+ esc_html_e( 'No subscribers yet — share your LINE OA link so people can add it as a friend.', 'bot-cat' );
+ }
+
+ protected function extra_tablenav( $which ): void {
+ if ( 'top' !== $which ) {
+ return;
+ }
+
+ $current = isset( $_REQUEST['status'] ) ? sanitize_text_field( wp_unslash( (string) $_REQUEST['status'] ) ) : '';
+ ?>
+
+
+
+ >
+ >
+ >
+
+
+
+
+ */
+ private function sanitized_request_args(): array {
+ $args = array();
+
+ foreach ( array( 's', 'status', 'orderby', 'order', 'paged' ) as $key ) {
+ if ( isset( $_REQUEST[ $key ] ) ) {
+ $args[ $key ] = sanitize_text_field( wp_unslash( (string) $_REQUEST[ $key ] ) );
+ }
+ }
+
+ return $args;
+ }
+}
diff --git a/src/Subscribers/SubscriberPage.php b/src/Subscribers/SubscriberPage.php
new file mode 100644
index 0000000..a07427f
--- /dev/null
+++ b/src/Subscribers/SubscriberPage.php
@@ -0,0 +1,23 @@
+ $rows
+ */
+ public function __construct(
+ public readonly array $rows,
+ public readonly int $total
+ ) {
+ }
+}
diff --git a/src/Subscribers/SubscriberRepository.php b/src/Subscribers/SubscriberRepository.php
new file mode 100644
index 0000000..bea7499
--- /dev/null
+++ b/src/Subscribers/SubscriberRepository.php
@@ -0,0 +1,253 @@
+prefix . self::TABLE;
+
+ $sql = $wpdb->prepare(
+ "SELECT id, line_user_id, display_name, picture_url, status, followed_at, unfollowed_at FROM {$table} WHERE line_user_id = %s LIMIT 1",
+ $line_user_id
+ );
+
+ $row = $wpdb->get_row( $sql, ARRAY_A );
+ if ( ! is_array( $row ) || $row === array() ) {
+ return null;
+ }
+
+ return Subscriber::from_row( $row );
+ }
+
+ public function count_active(): int {
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE;
+
+ $sql = $wpdb->prepare(
+ "SELECT COUNT(*) FROM {$table} WHERE status = %s",
+ Subscriber::STATUS_ACTIVE
+ );
+
+ return (int) $wpdb->get_var( $sql );
+ }
+
+ /**
+ * Stream every active subscriber's LINE user id.
+ *
+ * @return Generator
+ */
+ public function active_ids(): Generator {
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE;
+ $offset = 0;
+
+ do {
+ $sql = $wpdb->prepare(
+ "SELECT line_user_id FROM {$table} WHERE status = %s ORDER BY id LIMIT %d OFFSET %d",
+ Subscriber::STATUS_ACTIVE,
+ self::CHUNK_SIZE,
+ $offset
+ );
+
+ $rows = $wpdb->get_results( $sql, ARRAY_A );
+ if ( ! is_array( $rows ) ) {
+ $rows = array();
+ }
+
+ foreach ( $rows as $row ) {
+ yield (string) ( $row['line_user_id'] ?? '' );
+ }
+
+ $offset += self::CHUNK_SIZE;
+ } while ( count( $rows ) === self::CHUNK_SIZE );
+ }
+
+ /**
+ * Tag-aware variant. For W1 (free) it falls back to all active ids;
+ * tag-segmentation (Pro / W4) overrides this via the
+ * `botcat_active_ids_for_tags` filter.
+ *
+ * @param list $tag_ids
+ * @return Generator
+ */
+ public function active_ids_for_tags( array $tag_ids ): Generator {
+ /** @var iterable|null $override */
+ $override = apply_filters( 'botcat_active_ids_for_tags', null, $tag_ids );
+
+ if ( $override !== null ) {
+ yield from $override;
+ return;
+ }
+
+ yield from $this->active_ids();
+ }
+
+ public function upsert_active(
+ string $line_user_id,
+ ?string $display_name,
+ ?string $picture_url,
+ string $followed_at
+ ): void {
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE;
+ $now = gmdate( 'Y-m-d H:i:s' );
+
+ $sql = $wpdb->prepare(
+ "INSERT INTO {$table} (line_user_id, display_name, picture_url, status, followed_at, unfollowed_at, created_at, updated_at) "
+ . 'VALUES (%s, %s, %s, %s, %s, NULL, %s, %s) '
+ . 'ON DUPLICATE KEY UPDATE '
+ . 'display_name = COALESCE(VALUES(display_name), display_name), '
+ . 'picture_url = COALESCE(VALUES(picture_url), picture_url), '
+ . 'status = VALUES(status), '
+ . 'followed_at = VALUES(followed_at), '
+ . 'unfollowed_at = NULL, '
+ . 'updated_at = VALUES(updated_at)',
+ $line_user_id,
+ $display_name,
+ $picture_url,
+ Subscriber::STATUS_ACTIVE,
+ $followed_at,
+ $now,
+ $now
+ );
+
+ $wpdb->query( $sql );
+ }
+
+ public function mark_unfollowed( string $line_user_id, string $unfollowed_at ): void {
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE;
+ $now = gmdate( 'Y-m-d H:i:s' );
+
+ $sql = $wpdb->prepare(
+ "UPDATE {$table} SET status = %s, unfollowed_at = %s, updated_at = %s WHERE line_user_id = %s",
+ Subscriber::STATUS_UNFOLLOWED,
+ $unfollowed_at,
+ $now,
+ $line_user_id
+ );
+
+ $wpdb->query( $sql );
+ }
+
+ /**
+ * @return list
+ */
+ public function recent_logs_for_subscriber( int $subscriber_id, int $limit = 20 ): array {
+ global $wpdb;
+ $logs = $wpdb->prefix . 'botcat_push_logs';
+ $jobs = $wpdb->prefix . 'botcat_push_jobs';
+
+ $sql = $wpdb->prepare(
+ 'SELECT l.id, l.job_id, l.subscriber_id, l.line_user_id, l.status, l.error, l.created_at, j.post_id '
+ . "FROM {$logs} AS l "
+ . "INNER JOIN {$jobs} AS j ON j.id = l.job_id "
+ . 'WHERE l.subscriber_id = %d '
+ . 'ORDER BY l.created_at DESC '
+ . 'LIMIT %d',
+ $subscriber_id,
+ $limit
+ );
+
+ $rows = $wpdb->get_results( $sql, ARRAY_A );
+ if ( ! is_array( $rows ) ) {
+ return array();
+ }
+
+ return array_map(
+ static fn( array $row ): PushLogEntry => PushLogEntry::from_row( $row ),
+ $rows
+ );
+ }
+
+ public function search( SubscribersQuery $query ): SubscriberPage {
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE;
+
+ $where_sql = 'WHERE 1=1';
+ $args = array();
+
+ if ( $query->search !== '' ) {
+ $where_sql .= ' AND (display_name LIKE %s OR line_user_id LIKE %s)';
+ $like = '%' . $query->search . '%';
+ $args[] = $like;
+ $args[] = $like;
+ }
+
+ if ( $query->status !== null ) {
+ $where_sql .= ' AND status = %s';
+ $args[] = $query->status;
+ }
+
+ $count_sql = "SELECT COUNT(*) FROM {$table} {$where_sql}";
+ $total = (int) $wpdb->get_var( $args === array() ? $count_sql : $wpdb->prepare( $count_sql, ...$args ) );
+
+ $select_sql = sprintf(
+ 'SELECT id, line_user_id, display_name, picture_url, status, followed_at, unfollowed_at FROM %s %s ORDER BY %s %s LIMIT %%d OFFSET %%d',
+ $table,
+ $where_sql,
+ $query->orderby,
+ $query->order
+ );
+ $select_args = array_merge( $args, array( $query->per_page, $query->offset() ) );
+
+ $rows = $wpdb->get_results( $wpdb->prepare( $select_sql, ...$select_args ), ARRAY_A );
+ if ( ! is_array( $rows ) ) {
+ $rows = array();
+ }
+
+ $subscribers = array_map(
+ static fn( array $row ): Subscriber => Subscriber::from_row( $row ),
+ $rows
+ );
+
+ return new SubscriberPage( $subscribers, $total );
+ }
+
+ /**
+ * @param list $subscriber_ids
+ */
+ public function delete_many( array $subscriber_ids ): int {
+ if ( $subscriber_ids === array() ) {
+ return 0;
+ }
+
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE;
+ $logs = $wpdb->prefix . 'botcat_push_logs';
+
+ $placeholders = implode( ',', array_fill( 0, count( $subscriber_ids ), '%d' ) );
+
+ $wpdb->query(
+ $wpdb->prepare(
+ "UPDATE {$logs} SET subscriber_id = NULL WHERE subscriber_id IN ({$placeholders})",
+ ...$subscriber_ids
+ )
+ );
+
+ return (int) $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM {$table} WHERE id IN ({$placeholders})",
+ ...$subscriber_ids
+ )
+ );
+ }
+}
diff --git a/src/Subscribers/SubscribersPage.php b/src/Subscribers/SubscribersPage.php
new file mode 100644
index 0000000..958df65
--- /dev/null
+++ b/src/Subscribers/SubscribersPage.php
@@ -0,0 +1,60 @@
+ 403 ) );
+ }
+
+ $subscriber_id = isset( $_GET['subscriber'] ) ? (int) $_GET['subscriber'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+
+ if ( $subscriber_id > 0 ) {
+ ( new SubscriberDetailPage( $this->repository ) )->render( $subscriber_id );
+ return;
+ }
+
+ $this->ensure_list_table_loaded();
+
+ $table = new SubscriberListTable( $this->repository );
+ $table->prepare_items();
+
+ echo '';
+ echo '
' . esc_html__( 'Subscribers', 'bot-cat' ) . ' ';
+ echo '';
+ echo '';
+ printf(
+ ' ',
+ esc_attr( self::PAGE_SLUG )
+ );
+ wp_nonce_field( SubscriberListTable::BULK_NONCE_ACTION );
+ $table->search_box( __( 'Search subscribers', 'bot-cat' ), 'subscriber-search' );
+ $table->display();
+ echo ' ';
+ echo '';
+ }
+
+ private function ensure_list_table_loaded(): void {
+ if ( ! class_exists( 'WP_List_Table' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
+ }
+ }
+}
diff --git a/src/Subscribers/SubscribersQuery.php b/src/Subscribers/SubscribersQuery.php
new file mode 100644
index 0000000..2907813
--- /dev/null
+++ b/src/Subscribers/SubscribersQuery.php
@@ -0,0 +1,61 @@
+search = trim( $search );
+ $this->status = $status === null || $status === '' ? null : $status;
+ $this->orderby = in_array( $orderby, self::ALLOWED_ORDERBY, true ) ? $orderby : 'followed_at';
+ $this->order = strtoupper( $order ) === 'ASC' ? 'ASC' : 'DESC';
+ $this->page = max( 1, $page );
+ $this->per_page = min( self::MAX_PER_PAGE, max( 1, $per_page ) );
+ }
+
+ public function offset(): int {
+ return ( $this->page - 1 ) * $this->per_page;
+ }
+
+ /**
+ * @param array $request Usually $_GET.
+ */
+ public static function from_request( array $request ): self {
+ return new self(
+ search: isset( $request['s'] ) ? (string) $request['s'] : '',
+ status: isset( $request['status'] ) && (string) $request['status'] !== '' ? (string) $request['status'] : null,
+ orderby: isset( $request['orderby'] ) ? (string) $request['orderby'] : 'followed_at',
+ order: isset( $request['order'] ) ? (string) $request['order'] : 'DESC',
+ page: isset( $request['paged'] ) ? (int) $request['paged'] : 1,
+ per_page: isset( $request['per_page'] ) ? (int) $request['per_page'] : 50,
+ );
+ }
+}
diff --git a/src/Subscribers/UnfollowHandler.php b/src/Subscribers/UnfollowHandler.php
new file mode 100644
index 0000000..85d9542
--- /dev/null
+++ b/src/Subscribers/UnfollowHandler.php
@@ -0,0 +1,27 @@
+subscribers->mark_unfollowed(
+ $line_user_id,
+ gmdate( 'Y-m-d H:i:s', $event_timestamp )
+ );
+ }
+}
diff --git a/src/Tags/AudienceResolver.php b/src/Tags/AudienceResolver.php
new file mode 100644
index 0000000..51e2fe7
--- /dev/null
+++ b/src/Tags/AudienceResolver.php
@@ -0,0 +1,64 @@
+
+ */
+ public function iterator_for_job( PushJob $job ): Generator {
+ if ( $job->is_test ) {
+ return;
+ }
+
+ if ( $job->post_id <= 0 ) {
+ yield from $this->subscribers->active_ids();
+ return;
+ }
+
+ $category_ids = wp_get_post_categories( $job->post_id, array( 'fields' => 'ids' ) );
+ if ( ! is_array( $category_ids ) || $category_ids === array() ) {
+ yield from $this->subscribers->active_ids();
+ return;
+ }
+
+ $matching = $this->tags->find_for_categories( array_map( 'intval', $category_ids ) );
+ if ( $matching === array() ) {
+ yield from $this->subscribers->active_ids();
+ return;
+ }
+
+ $tag_ids = array_map( static fn( Tag $tag ): int => $tag->id, $matching );
+
+ yield from $this->joins->active_line_user_ids_for_tags( $tag_ids );
+ }
+}
diff --git a/src/Tags/MessageEventHandler.php b/src/Tags/MessageEventHandler.php
new file mode 100644
index 0000000..85dcb0f
--- /dev/null
+++ b/src/Tags/MessageEventHandler.php
@@ -0,0 +1,44 @@
+commands->handle( $line_user_id, $this->parser->parse( $text ) );
+
+ if ( $reply === null ) {
+ return;
+ }
+
+ $settings = $this->channel->get();
+ if ( ! $settings->is_configured() ) {
+ return;
+ }
+
+ $this->client->reply( $settings->access_token(), $reply_token, $reply );
+ }
+}
diff --git a/src/Tags/SubscriberTagRepository.php b/src/Tags/SubscriberTagRepository.php
new file mode 100644
index 0000000..7614e8b
--- /dev/null
+++ b/src/Tags/SubscriberTagRepository.php
@@ -0,0 +1,114 @@
+prefix . self::TABLE;
+ $now = gmdate( 'Y-m-d H:i:s' );
+
+ $wpdb->query(
+ $wpdb->prepare(
+ "INSERT INTO {$table} (subscriber_id, tag_id, created_at) VALUES (%d, %d, %s) ON DUPLICATE KEY UPDATE subscriber_id = subscriber_id",
+ $subscriber_id,
+ $tag_id,
+ $now
+ )
+ );
+ }
+
+ public function unsubscribe( int $subscriber_id, int $tag_id ): void {
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE;
+
+ $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM {$table} WHERE subscriber_id = %d AND tag_id = %d",
+ $subscriber_id,
+ $tag_id
+ )
+ );
+ }
+
+ /**
+ * @return list
+ */
+ public function tag_ids_for_subscriber( int $subscriber_id ): array {
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE;
+
+ $rows = $wpdb->get_results(
+ $wpdb->prepare( "SELECT tag_id FROM {$table} WHERE subscriber_id = %d", $subscriber_id ),
+ ARRAY_A
+ );
+
+ if ( ! is_array( $rows ) ) {
+ return array();
+ }
+
+ return array_values(
+ array_map(
+ static fn( array $row ): int => (int) ( $row['tag_id'] ?? 0 ),
+ $rows
+ )
+ );
+ }
+
+ /**
+ * Stream every active subscriber line_user_id who subscribed to any
+ * of $tag_ids.
+ *
+ * @param list $tag_ids
+ * @return Generator
+ */
+ public function active_line_user_ids_for_tags( array $tag_ids ): Generator {
+ if ( $tag_ids === array() ) {
+ return;
+ }
+
+ global $wpdb;
+ $join = $wpdb->prefix . self::TABLE;
+ $subscribers = $wpdb->prefix . 'botcat_subscribers';
+ $placeholders = implode( ',', array_fill( 0, count( $tag_ids ), '%d' ) );
+ $offset = 0;
+
+ do {
+ $sql = sprintf(
+ 'SELECT DISTINCT s.line_user_id FROM %1$s AS s INNER JOIN %2$s AS st ON st.subscriber_id = s.id WHERE s.status = %%s AND st.tag_id IN (%3$s) ORDER BY s.id LIMIT %%d OFFSET %%d',
+ $subscribers,
+ $join,
+ $placeholders
+ );
+
+ $args = array_merge( array( 'active' ), $tag_ids, array( self::CHUNK_SIZE, $offset ) );
+ $rows = $wpdb->get_results( $wpdb->prepare( $sql, ...$args ), ARRAY_A );
+ if ( ! is_array( $rows ) ) {
+ $rows = array();
+ }
+
+ foreach ( $rows as $row ) {
+ yield (string) ( $row['line_user_id'] ?? '' );
+ }
+
+ $offset += self::CHUNK_SIZE;
+ } while ( count( $rows ) === self::CHUNK_SIZE );
+ }
+}
diff --git a/src/Tags/Tag.php b/src/Tags/Tag.php
new file mode 100644
index 0000000..6e6e53f
--- /dev/null
+++ b/src/Tags/Tag.php
@@ -0,0 +1,46 @@
+ $mapped_categories
+ */
+ public function __construct(
+ public readonly int $id,
+ public readonly string $slug,
+ public readonly string $display_name,
+ public readonly string $keyword,
+ public readonly array $mapped_categories,
+ public readonly string $created_at,
+ public readonly string $updated_at
+ ) {
+ }
+
+ /**
+ * @param array $row
+ */
+ public static function from_row( array $row ): self {
+ $mapped = isset( $row['mapped_categories'] ) ? (string) $row['mapped_categories'] : '';
+ $ids = $mapped === '' ? array() : (array) json_decode( $mapped, true );
+
+ return new self(
+ id: (int) ( $row['id'] ?? 0 ),
+ slug: (string) ( $row['slug'] ?? '' ),
+ display_name: (string) ( $row['display_name'] ?? '' ),
+ keyword: (string) ( $row['keyword'] ?? '' ),
+ mapped_categories: array_values( array_map( 'intval', $ids ) ),
+ created_at: (string) ( $row['created_at'] ?? '' ),
+ updated_at: (string) ( $row['updated_at'] ?? '' ),
+ );
+ }
+}
diff --git a/src/Tags/TagCommand.php b/src/Tags/TagCommand.php
new file mode 100644
index 0000000..ec4d62b
--- /dev/null
+++ b/src/Tags/TagCommand.php
@@ -0,0 +1,47 @@
+type ) {
+ case TagCommand::TYPE_NOT_A_COMMAND:
+ return null;
+
+ case TagCommand::TYPE_UNKNOWN:
+ return $this->help_reply();
+
+ case TagCommand::TYPE_LIST:
+ return $this->list_reply( $line_user_id );
+
+ case TagCommand::TYPE_SUBSCRIBE:
+ return $this->subscribe_reply( $line_user_id, (string) $command->keyword );
+
+ case TagCommand::TYPE_UNSUBSCRIBE:
+ return $this->unsubscribe_reply( $line_user_id, (string) $command->keyword );
+ }
+
+ return null;
+ }
+
+ private function subscribe_reply( string $line_user_id, string $keyword ): string {
+ $tag = $this->tags->find_by_keyword( $keyword );
+ if ( $tag === null ) {
+ return $this->unknown_keyword_reply();
+ }
+
+ $subscriber = $this->subscribers->find_by_line_id( $line_user_id );
+ if ( $subscriber === null ) {
+ return (string) __( 'Subscriber not found — please re-add the OA as a friend.', 'bot-cat' );
+ }
+
+ $this->joins->subscribe( $subscriber->id, $tag->id );
+
+ return sprintf( '✅ 已訂閱「%s」推播', $tag->display_name );
+ }
+
+ private function unsubscribe_reply( string $line_user_id, string $keyword ): string {
+ $tag = $this->tags->find_by_keyword( $keyword );
+ if ( $tag === null ) {
+ return $this->unknown_keyword_reply();
+ }
+
+ $subscriber = $this->subscribers->find_by_line_id( $line_user_id );
+ if ( $subscriber === null ) {
+ return (string) __( 'Subscriber not found — please re-add the OA as a friend.', 'bot-cat' );
+ }
+
+ $this->joins->unsubscribe( $subscriber->id, $tag->id );
+
+ return sprintf( '已取消訂閱「%s」', $tag->display_name );
+ }
+
+ private function list_reply( string $line_user_id ): string {
+ $subscriber = $this->subscribers->find_by_line_id( $line_user_id );
+ if ( $subscriber === null ) {
+ return (string) __( 'Subscriber not found — please re-add the OA as a friend.', 'bot-cat' );
+ }
+
+ $tag_ids = $this->joins->tag_ids_for_subscriber( $subscriber->id );
+ if ( $tag_ids === array() ) {
+ return (string) __( '目前沒有訂閱任何標籤。輸入「/tag 列表」查看可用關鍵字。', 'bot-cat' );
+ }
+
+ $all = $this->tags->list_all();
+ $names = array();
+ foreach ( $all as $tag ) {
+ if ( in_array( $tag->id, $tag_ids, true ) ) {
+ $names[] = $tag->display_name;
+ }
+ }
+
+ return sprintf(
+ '目前訂閱:%s',
+ implode( '、', $names )
+ );
+ }
+
+ private function unknown_keyword_reply(): string {
+ $tags = $this->tags->list_all();
+ if ( $tags === array() ) {
+ return (string) __( 'No tags defined yet.', 'bot-cat' );
+ }
+
+ $keywords = array_map( static fn( Tag $t ): string => $t->display_name, $tags );
+
+ return sprintf(
+ '找不到關鍵字。可用:%s',
+ implode( '、', $keywords )
+ );
+ }
+
+ private function help_reply(): string {
+ return (string) __( '指令格式:/tag 訂閱 <關鍵字>、/tag 取消 <關鍵字>、/tag 列表', 'bot-cat' );
+ }
+}
diff --git a/src/Tags/TagCommandParser.php b/src/Tags/TagCommandParser.php
new file mode 100644
index 0000000..acc2b09
--- /dev/null
+++ b/src/Tags/TagCommandParser.php
@@ -0,0 +1,41 @@
+ | /tag subscribe
+ * /tag 取消 | /tag unsubscribe
+ * /tag 列表 | /tag list
+ */
+class TagCommandParser {
+
+ public function parse( string $text ): TagCommand {
+ $trimmed = trim( $text );
+
+ if ( ! str_starts_with( strtolower( $trimmed ), '/tag' ) ) {
+ return TagCommand::not_a_command();
+ }
+
+ if ( preg_match( '#^/tag\s+(?:訂閱|subscribe)\s+(.+)$#iu', $trimmed, $m ) ) {
+ return TagCommand::subscribe( trim( $m[1] ) );
+ }
+
+ if ( preg_match( '#^/tag\s+(?:取消|unsubscribe)\s+(.+)$#iu', $trimmed, $m ) ) {
+ return TagCommand::unsubscribe( trim( $m[1] ) );
+ }
+
+ if ( preg_match( '#^/tag\s+(?:列表|list)\s*$#iu', $trimmed ) ) {
+ return TagCommand::list();
+ }
+
+ return TagCommand::unknown();
+ }
+}
diff --git a/src/Tags/TagRepository.php b/src/Tags/TagRepository.php
new file mode 100644
index 0000000..e9664bf
--- /dev/null
+++ b/src/Tags/TagRepository.php
@@ -0,0 +1,162 @@
+ $mapped_categories
+ */
+ public function create( string $slug, string $display_name, string $keyword, array $mapped_categories ): int {
+ global $wpdb;
+ $now = gmdate( 'Y-m-d H:i:s' );
+
+ $wpdb->insert(
+ $wpdb->prefix . self::TABLE,
+ array(
+ 'slug' => $slug,
+ 'display_name' => $display_name,
+ 'keyword' => $keyword,
+ 'mapped_categories' => $this->encode_categories( $mapped_categories ),
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ),
+ array( '%s', '%s', '%s', '%s', '%s', '%s' )
+ );
+
+ return (int) $wpdb->insert_id;
+ }
+
+ public function find_by_id( int $id ): ?Tag {
+ global $wpdb;
+ $row = $wpdb->get_row(
+ $wpdb->prepare( 'SELECT * FROM ' . $wpdb->prefix . self::TABLE . ' WHERE id = %d LIMIT 1', $id ),
+ ARRAY_A
+ );
+ return is_array( $row ) ? Tag::from_row( $row ) : null;
+ }
+
+ public function find_by_slug( string $slug ): ?Tag {
+ global $wpdb;
+ $row = $wpdb->get_row(
+ $wpdb->prepare( 'SELECT * FROM ' . $wpdb->prefix . self::TABLE . ' WHERE slug = %s LIMIT 1', $slug ),
+ ARRAY_A
+ );
+ return is_array( $row ) ? Tag::from_row( $row ) : null;
+ }
+
+ public function find_by_keyword( string $keyword ): ?Tag {
+ global $wpdb;
+ $row = $wpdb->get_row(
+ $wpdb->prepare( 'SELECT * FROM ' . $wpdb->prefix . self::TABLE . ' WHERE keyword = %s LIMIT 1', $keyword ),
+ ARRAY_A
+ );
+ return is_array( $row ) ? Tag::from_row( $row ) : null;
+ }
+
+ /**
+ * @return list
+ */
+ public function list_all(): array {
+ global $wpdb;
+ $rows = $wpdb->get_results(
+ 'SELECT * FROM ' . $wpdb->prefix . self::TABLE . ' ORDER BY display_name',
+ ARRAY_A
+ );
+ if ( ! is_array( $rows ) ) {
+ return array();
+ }
+ return array_map( static fn( array $row ): Tag => Tag::from_row( $row ), $rows );
+ }
+
+ /**
+ * @param list $category_ids
+ * @return list
+ */
+ public function find_for_categories( array $category_ids ): array {
+ if ( $category_ids === array() ) {
+ return array();
+ }
+
+ $matching = array();
+ foreach ( $this->list_all() as $tag ) {
+ if ( array_intersect( $tag->mapped_categories, $category_ids ) !== array() ) {
+ $matching[] = $tag;
+ }
+ }
+ return $matching;
+ }
+
+ /**
+ * @param array{slug?:string, display_name?:string, keyword?:string, mapped_categories?:list} $changes
+ */
+ public function update( int $id, array $changes ): void {
+ global $wpdb;
+ $data = array( 'updated_at' => gmdate( 'Y-m-d H:i:s' ) );
+ $formats = array( '%s' );
+
+ if ( isset( $changes['slug'] ) ) {
+ $data['slug'] = (string) $changes['slug'];
+ $formats[] = '%s';
+ }
+ if ( isset( $changes['display_name'] ) ) {
+ $data['display_name'] = (string) $changes['display_name'];
+ $formats[] = '%s';
+ }
+ if ( isset( $changes['keyword'] ) ) {
+ $data['keyword'] = (string) $changes['keyword'];
+ $formats[] = '%s';
+ }
+ if ( isset( $changes['mapped_categories'] ) ) {
+ $data['mapped_categories'] = $this->encode_categories( $changes['mapped_categories'] );
+ $formats[] = '%s';
+ }
+
+ $wpdb->update(
+ $wpdb->prefix . self::TABLE,
+ $data,
+ array( 'id' => $id ),
+ $formats,
+ array( '%d' )
+ );
+ }
+
+ public function delete( int $id ): void {
+ global $wpdb;
+
+ $wpdb->query(
+ $wpdb->prepare(
+ 'DELETE FROM ' . $wpdb->prefix . self::JOIN . ' WHERE tag_id = %d',
+ $id
+ )
+ );
+
+ $wpdb->delete(
+ $wpdb->prefix . self::TABLE,
+ array( 'id' => $id ),
+ array( '%d' )
+ );
+ }
+
+ /**
+ * @param array $categories
+ */
+ private function encode_categories( array $categories ): string {
+ $ints = array_values( array_unique( array_map( 'intval', $categories ) ) );
+ return (string) wp_json_encode( $ints );
+ }
+}
diff --git a/src/Tags/TagsPage.php b/src/Tags/TagsPage.php
new file mode 100644
index 0000000..1c25704
--- /dev/null
+++ b/src/Tags/TagsPage.php
@@ -0,0 +1,224 @@
+ 403 ) );
+ }
+
+ $editing_id = isset( $_GET['edit'] ) ? (int) $_GET['edit'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $editing = $editing_id > 0 ? $this->tags->find_by_id( $editing_id ) : null;
+
+ echo '';
+ echo '
' . esc_html__( 'Tags', 'bot-cat' ) . ' ';
+
+ $this->render_existing_tags();
+ $this->render_form( $editing );
+
+ echo '';
+ }
+
+ private function render_existing_tags(): void {
+ $tags = $this->tags->list_all();
+
+ echo '' . esc_html__( 'Existing tags', 'bot-cat' ) . ' ';
+
+ if ( $tags === array() ) {
+ echo '' . esc_html__( 'No tags yet.', 'bot-cat' ) . '
';
+ return;
+ }
+
+ echo '';
+ printf( '%s ', esc_html__( 'Display name', 'bot-cat' ) );
+ printf( '%s ', esc_html__( 'Slug', 'bot-cat' ) );
+ printf( '%s ', esc_html__( 'Keyword', 'bot-cat' ) );
+ printf( '%s ', esc_html__( 'Mapped categories', 'bot-cat' ) );
+ printf( '%s ', esc_html__( 'Actions', 'bot-cat' ) );
+ echo ' ';
+
+ foreach ( $tags as $tag ) {
+ $edit_url = add_query_arg(
+ array(
+ 'page' => self::PAGE_SLUG,
+ 'edit' => $tag->id,
+ ),
+ admin_url( 'admin.php' )
+ );
+
+ echo '';
+ printf( '%s ', esc_html( $tag->display_name ) );
+ printf( '%s ', esc_html( $tag->slug ) );
+ printf( '%s ', esc_html( $tag->keyword ) );
+ printf( '%s ', esc_html( implode( ', ', array_map( 'strval', $tag->mapped_categories ) ) ) );
+ printf(
+ '%2$s · %3$s ',
+ esc_url( $edit_url ),
+ esc_html__( 'Edit', 'bot-cat' ),
+ $this->delete_form_html( $tag )
+ );
+ echo ' ';
+ }
+
+ echo '
';
+ }
+
+ private function render_form( ?Tag $editing ): void {
+ $action_url = admin_url( 'admin-post.php' );
+
+ echo '' . esc_html( $editing !== null ? __( 'Edit tag', 'bot-cat' ) : __( 'Create tag', 'bot-cat' ) ) . ' ';
+ echo '';
+ printf( ' ', esc_attr( self::ACTION_SAVE ) );
+ if ( $editing !== null ) {
+ printf( ' ', (int) $editing->id );
+ }
+ wp_nonce_field( self::NONCE_SAVE );
+
+ echo '';
+
+ submit_button( $editing !== null ? __( 'Update tag', 'bot-cat' ) : __( 'Add tag', 'bot-cat' ) );
+ echo ' ';
+ }
+
+ private function text_row( string $name, string $label, string $value ): void {
+ printf(
+ '%2$s ',
+ esc_attr( $name ),
+ esc_html( $label ),
+ esc_attr( $value )
+ );
+ }
+
+ /**
+ * @param list $selected
+ */
+ private function categories_row( array $selected ): void {
+ $categories = get_categories( array( 'hide_empty' => false ) );
+
+ echo '' . esc_html__( 'Mapped categories', 'bot-cat' ) . ' ';
+
+ if ( ! is_array( $categories ) || $categories === array() ) {
+ echo '' . esc_html__( 'No categories defined yet.', 'bot-cat' ) . '
';
+ } else {
+ foreach ( $categories as $cat ) {
+ $id = isset( $cat->term_id ) ? (int) $cat->term_id : 0;
+ $name = isset( $cat->name ) ? (string) $cat->name : '';
+ $checked = in_array( $id, $selected, true );
+
+ printf(
+ ' %3$s ',
+ (int) $id,
+ $checked ? ' checked' : '',
+ esc_html( $name )
+ );
+ }
+ }
+
+ echo '' . esc_html__( 'Posts in any of these categories will push to subscribers of this tag.', 'bot-cat' ) . '
';
+ echo ' ';
+ }
+
+ private function delete_form_html( Tag $tag ): string {
+ ob_start();
+ echo '';
+ printf( ' ', esc_attr( self::ACTION_DELETE ) );
+ printf( ' ', (int) $tag->id );
+ wp_nonce_field( self::NONCE_DELETE );
+ echo '' . esc_html__( 'Delete', 'bot-cat' ) . ' ';
+ echo ' ';
+ return (string) ob_get_clean();
+ }
+
+ public function handle_save(): void {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_die( esc_html__( 'Insufficient permissions.', 'bot-cat' ), '', array( 'response' => 403 ) );
+ }
+ check_admin_referer( self::NONCE_SAVE );
+
+ $slug = isset( $_POST['slug'] ) ? sanitize_title( wp_unslash( (string) $_POST['slug'] ) ) : '';
+ $display_name = isset( $_POST['display_name'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['display_name'] ) ) : '';
+ $keyword = isset( $_POST['keyword'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['keyword'] ) ) : '';
+ $mapped_categories = isset( $_POST['mapped_categories'] ) && is_array( $_POST['mapped_categories'] )
+ ? array_map( 'intval', wp_unslash( $_POST['mapped_categories'] ) )
+ : array();
+ $tag_id = isset( $_POST['tag_id'] ) ? (int) $_POST['tag_id'] : 0;
+
+ if ( $slug === '' || $display_name === '' || $keyword === '' ) {
+ $this->redirect( 'invalid' );
+ }
+
+ if ( $tag_id > 0 ) {
+ $this->tags->update(
+ $tag_id,
+ array(
+ 'slug' => $slug,
+ 'display_name' => $display_name,
+ 'keyword' => $keyword,
+ 'mapped_categories' => $mapped_categories,
+ )
+ );
+ } else {
+ if ( $this->tags->find_by_slug( $slug ) !== null ) {
+ $this->redirect( 'duplicate' );
+ }
+ $this->tags->create( $slug, $display_name, $keyword, $mapped_categories );
+ }
+
+ $this->redirect( 'saved' );
+ }
+
+ public function handle_delete(): void {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_die( esc_html__( 'Insufficient permissions.', 'bot-cat' ), '', array( 'response' => 403 ) );
+ }
+ check_admin_referer( self::NONCE_DELETE );
+
+ $tag_id = isset( $_POST['tag_id'] ) ? (int) $_POST['tag_id'] : 0;
+ if ( $tag_id > 0 ) {
+ $this->tags->delete( $tag_id );
+ }
+
+ $this->redirect( 'deleted' );
+ }
+
+ private function redirect( string $status ): void {
+ wp_safe_redirect(
+ add_query_arg(
+ array(
+ 'page' => self::PAGE_SLUG,
+ 'botcat_tag' => $status,
+ ),
+ admin_url( 'admin.php' )
+ )
+ );
+ exit;
+ }
+}
diff --git a/src/Tags/WelcomeMessageBuilder.php b/src/Tags/WelcomeMessageBuilder.php
new file mode 100644
index 0000000..7743419
--- /dev/null
+++ b/src/Tags/WelcomeMessageBuilder.php
@@ -0,0 +1,39 @@
+tags->list_all();
+ if ( $tags === array() ) {
+ return null;
+ }
+
+ $keywords = array_map( static fn( Tag $tag ): string => $tag->display_name, $tags );
+
+ return sprintf(
+ "%s\n\n%s\n\n%s\n /tag 訂閱 <%s>\n /tag 取消 <%s>\n /tag 列表",
+ (string) __( '歡迎!您可以訂閱感興趣的主題推播。', 'bot-cat' ),
+ (string) __( '可用主題:', 'bot-cat' ) . implode( '、', $keywords ),
+ (string) __( '指令格式:', 'bot-cat' ),
+ (string) __( '關鍵字', 'bot-cat' ),
+ (string) __( '關鍵字', 'bot-cat' )
+ );
+ }
+}
diff --git a/src/Template/ExcerptFallback.php b/src/Template/ExcerptFallback.php
new file mode 100644
index 0000000..0100458
--- /dev/null
+++ b/src/Template/ExcerptFallback.php
@@ -0,0 +1,39 @@
+ 'string',
+ 'sanitize_callback' => array( $this, 'sanitize' ),
+ 'default' => TemplateRepository::DEFAULT_TEMPLATE,
+ )
+ );
+
+ if ( $this->flex_templates !== null ) {
+ register_setting(
+ self::FLEX_OPTION_GROUP,
+ FlexTemplateRepository::OPTION,
+ array(
+ 'type' => 'array',
+ 'sanitize_callback' => array( $this, 'sanitize_flex' ),
+ 'default' => array(),
+ )
+ );
+ }
+ }
+
+ public function sanitize( $input ): string {
+ $value = is_string( $input ) ? $input : '';
+
+ $preview = $this->render_preview( $value );
+ $error = $this->templates->length_check( $preview );
+
+ if ( $error !== null ) {
+ add_settings_error( TemplateRepository::OPTION, 'too_long', $error );
+ return $this->templates->get();
+ }
+
+ return $value;
+ }
+
+ public function render(): void {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_die( esc_html__( 'Insufficient permissions.', 'bot-cat' ), '', array( 'response' => 403 ) );
+ }
+
+ echo '';
+ echo '
' . esc_html__( 'Message Template', 'bot-cat' ) . ' ';
+
+ if ( $this->is_pro_with_flex() ) {
+ $tab = $this->current_tab();
+ $this->render_tab_nav( $tab );
+
+ if ( $tab === self::TAB_FLEX ) {
+ $this->render_flex_form();
+ echo '';
+ return;
+ }
+ }
+
+ $this->render_text_form();
+ echo '';
+ }
+
+ private function render_text_form(): void {
+ $active = $this->templates->get();
+ $preview = $this->render_preview( $active );
+ $length = mb_strlen( $preview, 'UTF-8' );
+ $status = $this->templates->status_for_length( $length );
+
+ echo '';
+ settings_fields( self::OPTION_GROUP );
+
+ echo '';
+
+ echo '' . esc_html__( 'Preview', 'bot-cat' ) . ' ';
+ $this->render_preview_box( $preview, $length, $status );
+
+ submit_button( __( 'Save Template', 'bot-cat' ), 'primary', 'submit', true, $status === TemplateRepository::STATUS_RED ? array( 'disabled' => 'disabled' ) : array() );
+
+ echo ' ';
+ }
+
+ private function render_flex_form(): void {
+ $template = $this->flex_templates->get();
+
+ echo '';
+ settings_fields( self::FLEX_OPTION_GROUP );
+
+ echo '';
+
+ submit_button( __( 'Save Flex Template', 'bot-cat' ) );
+ echo ' ';
+ }
+
+ private function flex_select_row( FlexTemplate $template ): void {
+ echo '' . esc_html__( 'Hero image source', 'bot-cat' ) . ' ';
+ printf( '', esc_attr( FlexTemplateRepository::OPTION ) );
+ foreach ( array(
+ FlexTemplate::HERO_FEATURED => __( 'Featured image', 'bot-cat' ),
+ FlexTemplate::HERO_ATTACHED => __( 'First attached image', 'bot-cat' ),
+ FlexTemplate::HERO_DEFAULT => __( 'Default URL only', 'bot-cat' ),
+ FlexTemplate::HERO_NONE => __( 'No hero image', 'bot-cat' ),
+ ) as $value => $label ) {
+ printf(
+ '%3$s ',
+ esc_attr( $value ),
+ selected( $template->hero_source, $value, false ),
+ esc_html( $label )
+ );
+ }
+ echo ' ';
+ }
+
+ private function flex_text_row( string $field, string $label, string $value ): void {
+ printf(
+ '%2$s ',
+ esc_attr( $field ),
+ esc_html( $label ),
+ esc_attr( FlexTemplateRepository::OPTION ),
+ esc_attr( $value )
+ );
+ }
+
+ /**
+ * @param list $ctas
+ */
+ private function flex_ctas_row( array $ctas ): void {
+ echo '' . esc_html__( 'Call-to-action buttons', 'bot-cat' ) . ' ';
+ for ( $i = 0; $i < FlexTemplate::MAX_CTAS; $i++ ) {
+ $cta = $ctas[ $i ] ?? array(
+ 'label' => '',
+ 'url_template' => '',
+ );
+ printf(
+ '
',
+ esc_attr( FlexTemplateRepository::OPTION ),
+ (int) $i,
+ esc_attr( (string) ( $cta['label'] ?? '' ) ),
+ esc_attr__( 'Label', 'bot-cat' ),
+ esc_attr( (string) ( $cta['url_template'] ?? '' ) ),
+ esc_attr__( 'URL template ({permalink} etc.)', 'bot-cat' )
+ );
+ }
+ echo '' . esc_html__( 'Up to 3 buttons (LINE limit).', 'bot-cat' ) . '
';
+ echo ' ';
+ }
+
+ public function sanitize_flex( $input ): array {
+ if ( ! is_array( $input ) ) {
+ return array();
+ }
+
+ $ctas = array();
+ if ( isset( $input['ctas'] ) && is_array( $input['ctas'] ) ) {
+ foreach ( $input['ctas'] as $cta ) {
+ if ( ! is_array( $cta ) ) {
+ continue;
+ }
+ $label = isset( $cta['label'] ) ? sanitize_text_field( (string) $cta['label'] ) : '';
+ $url = isset( $cta['url_template'] ) ? sanitize_text_field( (string) $cta['url_template'] ) : '';
+ if ( $label === '' && $url === '' ) {
+ continue;
+ }
+ $ctas[] = array(
+ 'label' => $label,
+ 'url_template' => $url,
+ );
+ }
+ }
+
+ return array(
+ 'hero_source' => isset( $input['hero_source'] ) ? sanitize_key( (string) $input['hero_source'] ) : FlexTemplate::HERO_FEATURED,
+ 'default_image_url' => isset( $input['default_image_url'] ) ? esc_url_raw( (string) $input['default_image_url'] ) : '',
+ 'headline' => isset( $input['headline'] ) ? sanitize_text_field( (string) $input['headline'] ) : '',
+ 'body' => isset( $input['body'] ) ? sanitize_text_field( (string) $input['body'] ) : '',
+ 'ctas' => array_slice( $ctas, 0, FlexTemplate::MAX_CTAS ),
+ );
+ }
+
+ private function is_pro_with_flex(): bool {
+ return $this->edition !== null && $this->edition->is_pro() && $this->flex_templates !== null;
+ }
+
+ private function current_tab(): string {
+ $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( (string) $_GET['tab'] ) ) : self::TAB_TEXT; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ return $tab === self::TAB_FLEX ? self::TAB_FLEX : self::TAB_TEXT;
+ }
+
+ private function render_tab_nav( string $current ): void {
+ $tabs = array(
+ self::TAB_TEXT => __( 'Text', 'bot-cat' ),
+ self::TAB_FLEX => __( 'Flex Message', 'bot-cat' ),
+ );
+
+ echo '';
+ foreach ( $tabs as $slug => $label ) {
+ $url = add_query_arg(
+ array(
+ 'page' => self::PAGE_SLUG,
+ 'tab' => $slug,
+ ),
+ admin_url( 'admin.php' )
+ );
+ printf(
+ '%3$s ',
+ esc_url( $url ),
+ $current === $slug ? ' nav-tab-active' : '',
+ esc_html( $label )
+ );
+ }
+ echo ' ';
+ }
+
+ private function render_textarea_row( string $template ): void {
+ echo '';
+ echo '' . esc_html__( 'Template', 'bot-cat' ) . ' ';
+ echo '';
+ printf(
+ '%2$s ',
+ esc_attr( TemplateRepository::OPTION ),
+ esc_textarea( $template )
+ );
+ echo '' . esc_html__( 'Use the tokens below; unknown tokens are kept literal.', 'bot-cat' ) . '
';
+ echo ' ';
+ echo ' ';
+ }
+
+ private function render_token_help_row(): void {
+ $descriptions = array(
+ 'title' => __( 'Post title.', 'bot-cat' ),
+ 'excerpt' => __( 'Post excerpt, falling back to the first 100 chars of the content.', 'bot-cat' ),
+ 'permalink' => __( 'Full URL to the post.', 'bot-cat' ),
+ 'author' => __( 'Author display name.', 'bot-cat' ),
+ 'category' => __( 'Primary category name.', 'bot-cat' ),
+ 'date' => __( 'Publish date, site timezone, format Y-m-d H:i.', 'bot-cat' ),
+ 'site_name' => __( 'Site name (from General Settings).', 'bot-cat' ),
+ );
+
+ echo '';
+ echo '' . esc_html__( 'Available tokens', 'bot-cat' ) . ' ';
+ echo '';
+
+ foreach ( $descriptions as $token => $description ) {
+ printf(
+ '{%1$s}%2$s ',
+ esc_html( $token ),
+ esc_html( $description )
+ );
+ }
+
+ echo ' ';
+ echo ' ';
+ }
+
+ private function render_preview_box( string $preview, int $length, string $status ): void {
+ $style_color = match ( $status ) {
+ TemplateRepository::STATUS_RED => '#b32d2e',
+ TemplateRepository::STATUS_YELLOW => '#dba617',
+ default => '#1f7c34',
+ };
+
+ echo '';
+ echo esc_html( $preview !== '' ? $preview : __( '(Publish a post to see a real preview)', 'bot-cat' ) );
+ echo ' ';
+
+ printf(
+ '%2$d / %3$d %4$s
',
+ esc_attr( $style_color ),
+ (int) $length,
+ (int) TemplateRepository::MAX_RENDERED_LENGTH,
+ esc_html__( 'characters', 'bot-cat' )
+ );
+
+ if ( $status === TemplateRepository::STATUS_RED ) {
+ echo '' .
+ esc_html__( 'LINE will reject messages longer than 5000 characters. Shorten the template or trim the post excerpt.', 'bot-cat' ) .
+ '
';
+ }
+ }
+
+ private function render_preview( string $template ): string {
+ $post = $this->latest_eligible_post();
+ if ( $post === null ) {
+ return $this->renderer->render( $template, $this->synthetic_post() );
+ }
+
+ return $this->renderer->render( $template, $post );
+ }
+
+ private function latest_eligible_post(): ?object {
+ $types = $this->eligible->get();
+ if ( $types === array() ) {
+ return null;
+ }
+
+ $query = get_posts(
+ array(
+ 'post_type' => $types,
+ 'post_status' => 'publish',
+ 'posts_per_page' => 1,
+ 'orderby' => 'date',
+ 'order' => 'DESC',
+ 'no_found_rows' => true,
+ )
+ );
+
+ if ( ! is_array( $query ) || $query === array() ) {
+ return null;
+ }
+
+ $post = $query[0];
+
+ return is_object( $post ) ? $post : null;
+ }
+
+ private function synthetic_post(): object {
+ $post = new \stdClass();
+ $post->ID = 0;
+ $post->post_title = __( 'Sample post title', 'bot-cat' );
+ $post->post_excerpt = __( 'This is a sample excerpt used to render a preview before any post is published.', 'bot-cat' );
+ $post->post_content = '';
+ $post->post_author = 0;
+ $post->post_date_gmt = gmdate( 'Y-m-d H:i:s' );
+ return $post;
+ }
+}
diff --git a/src/Template/TemplateRenderer.php b/src/Template/TemplateRenderer.php
new file mode 100644
index 0000000..1c07319
--- /dev/null
+++ b/src/Template/TemplateRenderer.php
@@ -0,0 +1,36 @@
+resolver->resolve_for_post( $post );
+ $replacements = array();
+
+ foreach ( $values as $name => $value ) {
+ $replacements[ '{' . $name . '}' ] = (string) $value;
+ }
+
+ return strtr( $template, $replacements );
+ }
+}
diff --git a/src/Template/TemplateRepository.php b/src/Template/TemplateRepository.php
new file mode 100644
index 0000000..e049d8f
--- /dev/null
+++ b/src/Template/TemplateRepository.php
@@ -0,0 +1,63 @@
+= self::MAX_RENDERED_LENGTH ) {
+ return self::STATUS_RED;
+ }
+ if ( $length >= self::YELLOW_THRESHOLD ) {
+ return self::STATUS_YELLOW;
+ }
+ return self::STATUS_GREEN;
+ }
+}
diff --git a/src/Template/TokenResolver.php b/src/Template/TokenResolver.php
new file mode 100644
index 0000000..5770075
--- /dev/null
+++ b/src/Template/TokenResolver.php
@@ -0,0 +1,56 @@
+ value` map. Knows nothing
+ * about the template string itself — substitution is in
+ * {@see TemplateRenderer}.
+ */
+class TokenResolver {
+
+ public const TOKENS = array( 'title', 'excerpt', 'permalink', 'author', 'category', 'date', 'site_name' );
+
+ /**
+ * @return array
+ */
+ public function resolve_for_post( object $post ): array {
+ $post_id = isset( $post->ID ) ? (int) $post->ID : 0;
+
+ return array(
+ 'title' => (string) get_the_title( $post_id ),
+ 'excerpt' => ExcerptFallback::derive(
+ isset( $post->post_excerpt ) ? (string) $post->post_excerpt : '',
+ isset( $post->post_content ) ? (string) $post->post_content : ''
+ ),
+ 'permalink' => (string) get_permalink( $post_id ),
+ 'author' => (string) get_the_author_meta( 'display_name', isset( $post->post_author ) ? (int) $post->post_author : 0 ),
+ 'category' => $this->resolve_category( $post_id ),
+ 'date' => $this->resolve_date( $post ),
+ 'site_name' => (string) get_bloginfo( 'name' ),
+ );
+ }
+
+ private function resolve_category( int $post_id ): string {
+ /** @var list $names */
+ $names = (array) wp_get_post_categories( $post_id, array( 'fields' => 'names' ) );
+
+ return isset( $names[0] ) ? (string) $names[0] : '';
+ }
+
+ private function resolve_date( object $post ): string {
+ $gmt = isset( $post->post_date_gmt ) ? (string) $post->post_date_gmt : '';
+ if ( $gmt === '' ) {
+ return '';
+ }
+
+ return (string) get_date_from_gmt( $gmt, 'Y-m-d H:i' );
+ }
+}
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 0000000..66e5016
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,47 @@
+ static fn(string $text, ?string $domain = null): string => $text,
+ '_e' => static fn(string $text, ?string $domain = null): string => $text,
+ '_x' => static fn(string $text, ?string $ctx = null, ?string $domain = null): string => $text,
+ '_n' => static function (string $single, string $plural, int $number, ?string $domain = null): string {
+ return $number === 1 ? $single : $plural;
+ },
+ 'esc_html' => static fn($s) => $s,
+ 'esc_attr' => static fn($s) => $s,
+ 'esc_html__' => static fn(string $text, ?string $domain = null): string => $text,
+ 'esc_attr__' => static fn(string $text, ?string $domain = null): string => $text,
+ 'esc_html_e' => static fn(string $text, ?string $domain = null): string => $text,
+ 'wp_kses_post' => static fn($s) => $s,
+ 'sanitize_text_field' => static fn($s) => $s,
+ 'sanitize_key' => static fn(string $key): string => strtolower(preg_replace('/[^a-z0-9_]/i', '', $key)),
+ 'wp_strip_all_tags' => static fn(string $s, bool $remove_breaks = false): string => trim(strip_tags($s)),
+ ]);
+ }
+
+ protected function tearDown(): void
+ {
+ Monkey\tearDown();
+ parent::tearDown();
+ }
+}
diff --git a/tests/Unit/Analytics/AnalyticsWindowTest.php b/tests/Unit/Analytics/AnalyticsWindowTest.php
new file mode 100644
index 0000000..1d821fd
--- /dev/null
+++ b/tests/Unit/Analytics/AnalyticsWindowTest.php
@@ -0,0 +1,34 @@
+assertSame(30, (new AnalyticsWindow(0))->days);
+ $this->assertSame(30, (new AnalyticsWindow(123))->days);
+ }
+
+ public function testAcceptsSeven(): void
+ {
+ $this->assertSame(7, (new AnalyticsWindow(7))->days);
+ }
+
+ public function testStartEndAreSpacedByDays(): void
+ {
+ $now = 1_700_000_000;
+ $window = new AnalyticsWindow(7, $now);
+
+ $this->assertSame(gmdate('Y-m-d H:i:s', $now), $window->end_gmt);
+ $this->assertSame(gmdate('Y-m-d H:i:s', $now - 7 * 86400), $window->start_gmt);
+ }
+}
diff --git a/tests/Unit/Analytics/CsvExporterTest.php b/tests/Unit/Analytics/CsvExporterTest.php
new file mode 100644
index 0000000..5578097
--- /dev/null
+++ b/tests/Unit/Analytics/CsvExporterTest.php
@@ -0,0 +1,46 @@
+to_string(['title'], [['Hello']]);
+
+ $this->assertStringStartsWith("\xEF\xBB\xBF", $out);
+ }
+
+ public function testCjkTitlesRoundTrip(): void
+ {
+ $out = (new CsvExporter())->to_string(['title'], [['測試文章']]);
+
+ $this->assertStringContainsString('測試文章', $out);
+ $this->assertStringNotContainsString('??', $out);
+ }
+
+ public function testEscapesQuotesAndCommas(): void
+ {
+ $out = (new CsvExporter())->to_string(['title'], [['Hello, "World"']]);
+
+ $this->assertStringContainsString('"Hello, ""World"""', $out);
+ }
+
+ public function testHeaderAndRowsAreOnSeparateLines(): void
+ {
+ $out = (new CsvExporter())->to_string(['a', 'b'], [['1', '2'], ['3', '4']]);
+
+ $lines = array_filter(explode("\n", str_replace("\xEF\xBB\xBF", '', $out)));
+
+ $this->assertCount(3, $lines);
+ $this->assertStringStartsWith('a,b', trim((string) array_shift($lines)));
+ }
+}
diff --git a/tests/Unit/Channel/ChannelSettingsRepositoryTest.php b/tests/Unit/Channel/ChannelSettingsRepositoryTest.php
new file mode 100644
index 0000000..15029e9
--- /dev/null
+++ b/tests/Unit/Channel/ChannelSettingsRepositoryTest.php
@@ -0,0 +1,102 @@
+once()
+ ->andReturnUsing(function (string $option, $value) use (&$stored): bool {
+ $stored = ['option' => $option, 'value' => $value];
+ return true;
+ });
+
+ $repo->save(new ChannelSettings('123', 'plain-secret', 'plain-token'));
+
+ $this->assertSame('botcat_channel_settings', $stored['option']);
+ $this->assertSame('123', $stored['value']['channel_id']);
+ $this->assertStringStartsWith(CredentialEncryptor::SCHEME, $stored['value']['channel_secret']);
+ $this->assertStringStartsWith(CredentialEncryptor::SCHEME, $stored['value']['access_token']);
+ $this->assertStringNotContainsString('plain-secret', $stored['value']['channel_secret']);
+ $this->assertStringNotContainsString('plain-token', $stored['value']['access_token']);
+ }
+
+ public function testGetDecryptsCiphertextBackToPlaintext(): void
+ {
+ $encryptor = new CredentialEncryptor('site-A-auth-key-32-chars-padding-AAA');
+ $repo = new ChannelSettingsRepository($encryptor);
+
+ $stored = [
+ 'channel_id' => '123',
+ 'channel_secret' => $encryptor->encrypt('plain-secret'),
+ 'access_token' => $encryptor->encrypt('plain-token'),
+ ];
+
+ Functions\expect('get_option')->once()
+ ->with('botcat_channel_settings', [])
+ ->andReturn($stored);
+
+ $settings = $repo->get();
+
+ $this->assertSame('123', $settings->channel_id());
+ $this->assertSame('plain-secret', $settings->channel_secret());
+ $this->assertSame('plain-token', $settings->access_token());
+ $this->assertTrue($settings->is_configured());
+ }
+
+ public function testGetReturnsEmptySettingsWhenOptionIsMissing(): void
+ {
+ $encryptor = new CredentialEncryptor('site-A-auth-key-32-chars-padding-AAA');
+ $repo = new ChannelSettingsRepository($encryptor);
+
+ Functions\expect('get_option')->once()
+ ->with('botcat_channel_settings', [])
+ ->andReturn([]);
+
+ $settings = $repo->get();
+
+ $this->assertFalse($settings->is_configured());
+ }
+
+ public function testGetTreatsUndecipherableCiphertextAsEmpty(): void
+ {
+ $alice = new CredentialEncryptor('alice-key-32-chars-padding-AAA-xyz');
+ $bob = new CredentialEncryptor('bob-key-32-chars-padding-BBB-uvw-zz');
+
+ $stored = [
+ 'channel_id' => '123',
+ 'channel_secret' => $alice->encrypt('plain-secret'),
+ 'access_token' => $alice->encrypt('plain-token'),
+ ];
+
+ Functions\expect('get_option')->once()->andReturn($stored);
+
+ $bob_repo = new ChannelSettingsRepository($bob);
+ $settings = $bob_repo->get();
+
+ $this->assertSame('123', $settings->channel_id());
+ $this->assertSame('', $settings->channel_secret(), 'bob cannot decrypt alice secret');
+ $this->assertSame('', $settings->access_token());
+ $this->assertFalse($settings->is_configured(), 'cross-site export should not stay configured');
+ }
+}
diff --git a/tests/Unit/Channel/ChannelSettingsTest.php b/tests/Unit/Channel/ChannelSettingsTest.php
new file mode 100644
index 0000000..fabfdcb
--- /dev/null
+++ b/tests/Unit/Channel/ChannelSettingsTest.php
@@ -0,0 +1,62 @@
+assertFalse($settings->is_configured());
+ $this->assertSame('', $settings->channel_id());
+ $this->assertSame('', $settings->channel_secret());
+ $this->assertSame('', $settings->access_token());
+ }
+
+ public function testIsConfiguredReturnsTrueWhenAllRequiredFieldsPresent(): void
+ {
+ $settings = new ChannelSettings('1234567890', 'shhh', 'token-xyz');
+
+ $this->assertTrue($settings->is_configured());
+ }
+
+ public function testIsConfiguredReturnsFalseWhenAccessTokenIsMissing(): void
+ {
+ $settings = new ChannelSettings('1234567890', 'shhh', '');
+
+ $this->assertFalse($settings->is_configured());
+ }
+
+ public function testFromArrayConstructsFromOptionPayload(): void
+ {
+ $settings = ChannelSettings::from_array([
+ 'channel_id' => '99',
+ 'channel_secret' => 'sec',
+ 'access_token' => 'tok',
+ ]);
+
+ $this->assertSame('99', $settings->channel_id());
+ $this->assertSame('sec', $settings->channel_secret());
+ $this->assertSame('tok', $settings->access_token());
+ }
+
+ public function testFromArrayDefaultsMissingKeysToEmptyString(): void
+ {
+ $settings = ChannelSettings::from_array([]);
+
+ $this->assertFalse($settings->is_configured());
+ $this->assertSame('', $settings->channel_id());
+ }
+}
diff --git a/tests/Unit/Channel/CredentialEncryptorTest.php b/tests/Unit/Channel/CredentialEncryptorTest.php
new file mode 100644
index 0000000..b8db55a
--- /dev/null
+++ b/tests/Unit/Channel/CredentialEncryptorTest.php
@@ -0,0 +1,59 @@
+encrypt('s3cret-token');
+
+ $this->assertNotSame('s3cret-token', $cipher);
+ $this->assertStringStartsWith('botcat1:', $cipher, 'ciphertext should carry scheme identifier');
+ }
+
+ public function testEncryptDecryptRoundTripsToOriginal(): void
+ {
+ $enc = new CredentialEncryptor('site-A-auth-key-32-chars-padding-AAA');
+ $cipher = $enc->encrypt('s3cret-token');
+
+ $this->assertSame('s3cret-token', $enc->decrypt($cipher));
+ }
+
+ public function testDecryptionWithDifferentKeyReturnsNull(): void
+ {
+ $alice = new CredentialEncryptor('alice-key-32-chars-padding-AAA-xyz');
+ $cipher = $alice->encrypt('s3cret-token');
+
+ $bob = new CredentialEncryptor('bob-key-32-chars-padding-BBB-uvw-zz');
+ $this->assertNull($bob->decrypt($cipher), 'wrong key must not decrypt successfully');
+ }
+
+ public function testEmptyStringPassesThroughBothWays(): void
+ {
+ $enc = new CredentialEncryptor('site-A-auth-key-32-chars-padding-AAA');
+
+ $this->assertSame('', $enc->encrypt(''));
+ $this->assertSame('', $enc->decrypt(''));
+ }
+
+ public function testGarbledCiphertextReturnsNull(): void
+ {
+ $enc = new CredentialEncryptor('site-A-auth-key-32-chars-padding-AAA');
+
+ $this->assertNull($enc->decrypt('botcat1:not-valid-base64-+++'));
+ $this->assertNull($enc->decrypt('no-prefix-at-all'));
+ }
+}
diff --git a/tests/Unit/Channel/SignatureVerifierTest.php b/tests/Unit/Channel/SignatureVerifierTest.php
new file mode 100644
index 0000000..6cf7073
--- /dev/null
+++ b/tests/Unit/Channel/SignatureVerifierTest.php
@@ -0,0 +1,58 @@
+assertTrue($verifier->verify($body, $sig));
+ }
+
+ public function testTamperedBodyIsRejected(): void
+ {
+ $body = '{"events":[{"type":"follow"}]}';
+ $sig = base64_encode(hash_hmac('sha256', $body, self::SECRET, true));
+
+ $verifier = new SignatureVerifier(self::SECRET);
+ $tampered = '{"events":[{"type":"unfollow"}]}';
+
+ $this->assertFalse($verifier->verify($tampered, $sig));
+ }
+
+ public function testMissingSignatureIsRejected(): void
+ {
+ $verifier = new SignatureVerifier(self::SECRET);
+
+ $this->assertFalse($verifier->verify('{}', ''));
+ $this->assertFalse($verifier->verify('{}', null));
+ }
+
+ public function testWrongSecretIsRejected(): void
+ {
+ $body = '{"events":[{"type":"follow"}]}';
+ $sig = base64_encode(hash_hmac('sha256', $body, 'other-secret', true));
+
+ $verifier = new SignatureVerifier(self::SECRET);
+
+ $this->assertFalse($verifier->verify($body, $sig));
+ }
+}
diff --git a/tests/Unit/Channel/TestConnectionTest.php b/tests/Unit/Channel/TestConnectionTest.php
new file mode 100644
index 0000000..54779c5
--- /dev/null
+++ b/tests/Unit/Channel/TestConnectionTest.php
@@ -0,0 +1,86 @@
+once()
+ ->with(
+ 'https://api.line.me/v2/bot/info',
+ \Mockery::on(function (array $args): bool {
+ return isset($args['headers']['Authorization'])
+ && $args['headers']['Authorization'] === 'Bearer tok'
+ && ($args['timeout'] ?? 0) >= 5;
+ })
+ )
+ ->andReturn(['mock-response']);
+ Functions\expect('is_wp_error')->andReturn(false);
+ Functions\expect('wp_remote_retrieve_response_code')->andReturn(200);
+ Functions\expect('wp_remote_retrieve_body')
+ ->andReturn('{"displayName":"My Cat OA","basicId":"@cat","userId":"U123"}');
+
+ $tester = new TestConnection();
+ $result = $tester->run(new ChannelSettings('1', 'sec', 'tok'));
+
+ $this->assertTrue($result->ok);
+ $this->assertSame('My Cat OA', $result->display_name);
+ }
+
+ public function testInvalidTokenSurfacesUpstreamError(): void
+ {
+ Functions\expect('wp_remote_get')->once()->andReturn(['mock-response']);
+ Functions\expect('is_wp_error')->andReturn(false);
+ Functions\expect('wp_remote_retrieve_response_code')->andReturn(401);
+ Functions\expect('wp_remote_retrieve_body')
+ ->andReturn('{"message":"Authentication failed"}');
+
+ $tester = new TestConnection();
+ $result = $tester->run(new ChannelSettings('1', 'sec', 'bad-tok'));
+
+ $this->assertFalse($result->ok);
+ $this->assertSame(401, $result->http_status);
+ $this->assertStringContainsString('Authentication failed', $result->error_message);
+ }
+
+ public function testNetworkFailureDegradesGracefully(): void
+ {
+ $wp_error = new \stdClass();
+
+ Functions\expect('wp_remote_get')->once()->andReturn($wp_error);
+ Functions\expect('is_wp_error')->once()->with($wp_error)->andReturn(true);
+ Functions\expect('wp_remote_retrieve_response_code')->never();
+
+ $tester = new TestConnection();
+ $result = $tester->run(new ChannelSettings('1', 'sec', 'tok'));
+
+ $this->assertFalse($result->ok);
+ $this->assertNull($result->http_status);
+ $this->assertSame('network_error', $result->error_code);
+ }
+
+ public function testUnconfiguredSettingsShortCircuitWithoutHttpCall(): void
+ {
+ Functions\expect('wp_remote_get')->never();
+
+ $tester = new TestConnection();
+ $result = $tester->run(new ChannelSettings('', '', ''));
+
+ $this->assertFalse($result->ok);
+ $this->assertSame('not_configured', $result->error_code);
+ }
+}
diff --git a/tests/Unit/Channel/WebhookEndpointTest.php b/tests/Unit/Channel/WebhookEndpointTest.php
new file mode 100644
index 0000000..18c8cdc
--- /dev/null
+++ b/tests/Unit/Channel/WebhookEndpointTest.php
@@ -0,0 +1,147 @@
+once()
+ ->andReturnUsing(function (...$args) use (&$captured) {
+ $captured = $args;
+ return true;
+ });
+
+ $endpoint = $this->build_endpoint();
+ $endpoint->register();
+
+ $this->assertSame('botcat/v1', $captured[0]);
+ $this->assertSame('/webhook', $captured[1]);
+ $this->assertSame('POST', $captured[2]['methods']);
+ $this->assertIsCallable($captured[2]['callback']);
+ $this->assertIsCallable($captured[2]['permission_callback']);
+ $this->assertTrue(($captured[2]['permission_callback'])(), 'webhook MUST be publicly callable');
+ }
+
+ public function testMissingSignatureIsRejectedWithoutInvokingHandlers(): void
+ {
+ $follow = $this->createMock(FollowHandler::class);
+ $unfollow = $this->createMock(UnfollowHandler::class);
+ $follow->expects($this->never())->method('handle');
+ $unfollow->expects($this->never())->method('handle');
+
+ $endpoint = $this->build_endpoint($follow, $unfollow, channel_secret: 'sec');
+ $status = $endpoint->handle_request('{"events":[]}', null);
+
+ $this->assertSame(403, $status);
+ }
+
+ public function testTamperedBodyIsRejected(): void
+ {
+ $follow = $this->createMock(FollowHandler::class);
+ $unfollow = $this->createMock(UnfollowHandler::class);
+ $follow->expects($this->never())->method('handle');
+ $unfollow->expects($this->never())->method('handle');
+
+ $body = '{"events":[{"type":"follow"}]}';
+ $sig = base64_encode(hash_hmac('sha256', $body, 'sec', true));
+
+ $endpoint = $this->build_endpoint($follow, $unfollow, channel_secret: 'sec');
+ $status = $endpoint->handle_request('{"events":[{"type":"unfollow"}]}', $sig);
+
+ $this->assertSame(403, $status);
+ }
+
+ public function testValidFollowEventDispatchesToFollowHandler(): void
+ {
+ $follow = $this->createMock(FollowHandler::class);
+ $unfollow = $this->createMock(UnfollowHandler::class);
+ $follow->expects($this->once())->method('handle')
+ ->with($this->equalTo('U123'), $this->equalTo(1_715_000_000));
+ $unfollow->expects($this->never())->method('handle');
+
+ $body = json_encode([
+ 'events' => [[
+ 'type' => 'follow',
+ 'timestamp' => 1_715_000_000_000,
+ 'source' => ['userId' => 'U123'],
+ ]],
+ ]);
+ $sig = base64_encode(hash_hmac('sha256', $body, 'sec', true));
+
+ $endpoint = $this->build_endpoint($follow, $unfollow, channel_secret: 'sec');
+ $status = $endpoint->handle_request($body, $sig);
+
+ $this->assertSame(200, $status);
+ }
+
+ public function testValidUnfollowEventDispatchesToUnfollowHandler(): void
+ {
+ $follow = $this->createMock(FollowHandler::class);
+ $unfollow = $this->createMock(UnfollowHandler::class);
+ $unfollow->expects($this->once())->method('handle')
+ ->with($this->equalTo('U456'), $this->isType('int'));
+ $follow->expects($this->never())->method('handle');
+
+ $body = json_encode([
+ 'events' => [[
+ 'type' => 'unfollow',
+ 'timestamp' => 1_715_000_000_000,
+ 'source' => ['userId' => 'U456'],
+ ]],
+ ]);
+ $sig = base64_encode(hash_hmac('sha256', $body, 'sec', true));
+
+ $endpoint = $this->build_endpoint($follow, $unfollow, channel_secret: 'sec');
+ $status = $endpoint->handle_request($body, $sig);
+
+ $this->assertSame(200, $status);
+ }
+
+ public function testUnconfiguredChannelStillReturns200ButHandlesNothing(): void
+ {
+ $follow = $this->createMock(FollowHandler::class);
+ $unfollow = $this->createMock(UnfollowHandler::class);
+ $follow->expects($this->never())->method('handle');
+ $unfollow->expects($this->never())->method('handle');
+
+ $endpoint = $this->build_endpoint($follow, $unfollow, channel_secret: '');
+ $status = $endpoint->handle_request('{"events":[]}', 'any-sig');
+
+ $this->assertSame(200, $status);
+ }
+
+ private function build_endpoint(
+ ?FollowHandler $follow = null,
+ ?UnfollowHandler $unfollow = null,
+ string $channel_secret = 'sec'
+ ): WebhookEndpoint {
+ $channel = $this->createMock(ChannelSettingsRepository::class);
+ $channel->method('get')
+ ->willReturn(new ChannelSettings('1', $channel_secret, 'tok'));
+
+ return new WebhookEndpoint(
+ $channel,
+ $follow ?? $this->createMock(FollowHandler::class),
+ $unfollow ?? $this->createMock(UnfollowHandler::class)
+ );
+ }
+}
diff --git a/tests/Unit/Flex/FlexMessageBuilderTest.php b/tests/Unit/Flex/FlexMessageBuilderTest.php
new file mode 100644
index 0000000..cbeaed7
--- /dev/null
+++ b/tests/Unit/Flex/FlexMessageBuilderTest.php
@@ -0,0 +1,119 @@
+createMock(TokenResolver::class);
+ $resolver->method('resolve_for_post')->willReturn([
+ 'title' => 'Headline X',
+ 'excerpt' => 'Excerpt Y',
+ 'permalink' => 'https://x.test/p/1',
+ ]);
+
+ $hero = $this->createMock(HeroImageResolver::class);
+ $hero->method('resolve')->willReturn('https://x.test/img.jpg');
+
+ $template = new FlexTemplate(
+ FlexTemplate::HERO_FEATURED,
+ '',
+ '🆕 {title}',
+ '{excerpt}',
+ [['label' => 'Read', 'url_template' => '{permalink}']]
+ );
+
+ $result = (new FlexMessageBuilder(new TemplateRenderer($resolver), $hero))
+ ->build($template, $this->post(1));
+
+ $this->assertSame('flex', $result['type']);
+ $this->assertArrayHasKey('altText', $result);
+ $this->assertSame('bubble', $result['contents']['type']);
+ $this->assertSame('https://x.test/img.jpg', $result['contents']['hero']['url']);
+
+ $body_texts = array_column($result['contents']['body']['contents'], 'text');
+ $this->assertContains('🆕 Headline X', $body_texts);
+ $this->assertContains('Excerpt Y', $body_texts);
+
+ $cta = $result['contents']['footer']['contents'][0];
+ $this->assertSame('Read', $cta['action']['label']);
+ $this->assertSame('https://x.test/p/1', $cta['action']['uri']);
+ }
+
+ public function testOmitsHeroBlockWhenResolverReturnsNull(): void
+ {
+ $resolver = $this->createMock(TokenResolver::class);
+ $resolver->method('resolve_for_post')->willReturn(['title' => 't', 'excerpt' => 'e']);
+
+ $hero = $this->createMock(HeroImageResolver::class);
+ $hero->method('resolve')->willReturn(null);
+
+ $template = new FlexTemplate(FlexTemplate::HERO_NONE, '', '{title}', '{excerpt}', []);
+
+ $result = (new FlexMessageBuilder(new TemplateRenderer($resolver), $hero))
+ ->build($template, $this->post(1));
+
+ $this->assertArrayNotHasKey('hero', $result['contents']);
+ }
+
+ public function testAltTextIsCappedAt400Chars(): void
+ {
+ $resolver = $this->createMock(TokenResolver::class);
+ $resolver->method('resolve_for_post')->willReturn([
+ 'title' => str_repeat('a', 500),
+ 'excerpt' => str_repeat('b', 500),
+ ]);
+
+ $hero = $this->createMock(HeroImageResolver::class);
+ $hero->method('resolve')->willReturn(null);
+
+ $template = new FlexTemplate(FlexTemplate::HERO_NONE, '', '{title}', '{excerpt}', []);
+
+ $result = (new FlexMessageBuilder(new TemplateRenderer($resolver), $hero))
+ ->build($template, $this->post(1));
+
+ $this->assertLessThanOrEqual(400, mb_strlen($result['altText'], 'UTF-8'));
+ }
+
+ public function testCtaCountCappedAtThree(): void
+ {
+ $resolver = $this->createMock(TokenResolver::class);
+ $resolver->method('resolve_for_post')->willReturn(['permalink' => 'u']);
+
+ $hero = $this->createMock(HeroImageResolver::class);
+ $hero->method('resolve')->willReturn(null);
+
+ $template = new FlexTemplate(FlexTemplate::HERO_NONE, '', 'h', 'b', [
+ ['label' => 'A', 'url_template' => '{permalink}'],
+ ['label' => 'B', 'url_template' => '{permalink}'],
+ ['label' => 'C', 'url_template' => '{permalink}'],
+ ['label' => 'D', 'url_template' => '{permalink}'],
+ ]);
+
+ $result = (new FlexMessageBuilder(new TemplateRenderer($resolver), $hero))
+ ->build($template, $this->post(1));
+
+ $this->assertCount(3, $result['contents']['footer']['contents']);
+ }
+
+ private function post(int $id): object
+ {
+ $p = new \stdClass();
+ $p->ID = $id;
+ return $p;
+ }
+}
diff --git a/tests/Unit/Flex/FlexTemplateRepositoryTest.php b/tests/Unit/Flex/FlexTemplateRepositoryTest.php
new file mode 100644
index 0000000..c2ea02f
--- /dev/null
+++ b/tests/Unit/Flex/FlexTemplateRepositoryTest.php
@@ -0,0 +1,69 @@
+alias(static fn() => false);
+
+ $template = (new FlexTemplateRepository())->get();
+
+ $this->assertSame(FlexTemplate::HERO_FEATURED, $template->hero_source);
+ $this->assertNotEmpty($template->headline);
+ }
+
+ public function testStoredArrayHydratesToTemplate(): void
+ {
+ Functions\when('get_option')->alias(static fn() => [
+ 'hero_source' => 'attached',
+ 'default_image_url' => 'https://x.test/img.jpg',
+ 'headline' => '📝 {title}',
+ 'body' => '{excerpt}',
+ 'ctas' => [
+ ['label' => 'Read', 'url_template' => '{permalink}'],
+ ],
+ ]);
+
+ $template = (new FlexTemplateRepository())->get();
+
+ $this->assertSame('attached', $template->hero_source);
+ $this->assertSame('https://x.test/img.jpg', $template->default_image_url);
+ $this->assertSame('📝 {title}', $template->headline);
+ $this->assertCount(1, $template->ctas);
+ }
+
+ public function testSaveCapsCtasAtThree(): void
+ {
+ $captured = null;
+ Functions\when('update_option')->alias(static function ($name, $value) use (&$captured) {
+ $captured = $value;
+ return true;
+ });
+
+ $repo = new FlexTemplateRepository();
+ $repo->save(new FlexTemplate(
+ FlexTemplate::HERO_FEATURED, '', 'h', 'b',
+ [
+ ['label' => 'A', 'url_template' => '{permalink}'],
+ ['label' => 'B', 'url_template' => '{permalink}'],
+ ['label' => 'C', 'url_template' => '{permalink}'],
+ ['label' => 'D', 'url_template' => '{permalink}'],
+ ]
+ ));
+
+ $this->assertNotNull($captured);
+ $this->assertCount(3, $captured['ctas']);
+ }
+}
diff --git a/tests/Unit/Flex/HeroImageResolverTest.php b/tests/Unit/Flex/HeroImageResolverTest.php
new file mode 100644
index 0000000..b8849c3
--- /dev/null
+++ b/tests/Unit/Flex/HeroImageResolverTest.php
@@ -0,0 +1,73 @@
+alias(static fn() => true);
+ Functions\when('get_post_thumbnail_id')->alias(static fn() => 99);
+ Functions\when('wp_get_attachment_image_src')->alias(static fn() => ['https://x.test/img.jpg', 1024, 768]);
+
+ $url = (new HeroImageResolver())->resolve(FlexTemplate::HERO_FEATURED, '', 7);
+
+ $this->assertSame('https://x.test/img.jpg', $url);
+ }
+
+ public function testFallsBackToAttachedWhenFeaturedAbsent(): void
+ {
+ Functions\when('has_post_thumbnail')->alias(static fn() => false);
+ Functions\when('get_attached_media')->alias(static function ($mime, $post_id) {
+ $attachment = new \stdClass();
+ $attachment->ID = 200;
+ return [$attachment];
+ });
+ Functions\when('wp_get_attachment_image_src')->alias(static fn() => ['https://x.test/attached.jpg']);
+
+ $url = (new HeroImageResolver())->resolve(FlexTemplate::HERO_FEATURED, '', 7);
+
+ $this->assertSame('https://x.test/attached.jpg', $url);
+ }
+
+ public function testFallsBackToDefaultUrl(): void
+ {
+ Functions\when('has_post_thumbnail')->alias(static fn() => false);
+ Functions\when('get_attached_media')->alias(static fn() => []);
+
+ $url = (new HeroImageResolver())->resolve(FlexTemplate::HERO_FEATURED, 'https://default.test/img.jpg', 7);
+
+ $this->assertSame('https://default.test/img.jpg', $url);
+ }
+
+ public function testReturnsNullWhenNothingAvailable(): void
+ {
+ Functions\when('has_post_thumbnail')->alias(static fn() => false);
+ Functions\when('get_attached_media')->alias(static fn() => []);
+
+ $url = (new HeroImageResolver())->resolve(FlexTemplate::HERO_FEATURED, '', 7);
+
+ $this->assertNull($url);
+ }
+
+ public function testStrategyNoneReturnsNullEvenWhenImagesPresent(): void
+ {
+ Functions\when('has_post_thumbnail')->alias(static fn() => true);
+ Functions\when('get_post_thumbnail_id')->alias(static fn() => 99);
+ Functions\when('wp_get_attachment_image_src')->alias(static fn() => ['https://x.test/img.jpg']);
+
+ $url = (new HeroImageResolver())->resolve(FlexTemplate::HERO_NONE, '', 7);
+
+ $this->assertNull($url);
+ }
+}
diff --git a/tests/Unit/Foundation/ActivatorTest.php b/tests/Unit/Foundation/ActivatorTest.php
new file mode 100644
index 0000000..a1b90e4
--- /dev/null
+++ b/tests/Unit/Foundation/ActivatorTest.php
@@ -0,0 +1,82 @@
+createMock(Schema::class);
+ $schema->expects($this->never())->method('install');
+
+ Functions\expect('deactivate_plugins')->once();
+ Functions\expect('plugin_basename')->andReturn('bot-cat/bot-cat.php');
+ Functions\expect('wp_die')->once()
+ ->with($this->stringContains('PHP'));
+ Functions\stubs(['esc_html' => static fn($s) => $s]);
+
+ $activator = new Activator($schema, fn() => null);
+ $activator->run('8.0.99', '7.0');
+ }
+
+ public function testActivationAbortsWhenWordPressIsTooOld(): void
+ {
+ $schema = $this->createMock(Schema::class);
+ $schema->expects($this->never())->method('install');
+
+ Functions\expect('deactivate_plugins')->once();
+ Functions\expect('plugin_basename')->andReturn('bot-cat/bot-cat.php');
+ Functions\expect('wp_die')->once()
+ ->with($this->stringContains('WordPress'));
+ Functions\stubs(['esc_html' => static fn($s) => $s]);
+
+ $activator = new Activator($schema, fn() => null);
+ $activator->run('8.1.0', '6.9');
+ }
+
+ public function testActivationInstallsSchemaAndSchedulesCronWhenSupported(): void
+ {
+ $schema = $this->createMock(Schema::class);
+ $schema->expects($this->once())->method('install');
+
+ Functions\expect('wp_next_scheduled')->once()
+ ->with('botcat_cleanup_old_logs')
+ ->andReturn(false);
+ Functions\expect('wp_schedule_event')->once()
+ ->with($this->isType('int'), 'daily', 'botcat_cleanup_old_logs');
+
+ $activator = new Activator($schema, fn() => null);
+ $activator->run('8.2.0', '7.1');
+ }
+
+ public function testCronIsNotRescheduledWhenAlreadyPresent(): void
+ {
+ $schema = $this->createMock(Schema::class);
+ $schema->method('install');
+
+ Functions\expect('wp_next_scheduled')->once()
+ ->with('botcat_cleanup_old_logs')
+ ->andReturn(1_750_000_000);
+ Functions\expect('wp_schedule_event')->never();
+
+ $activator = new Activator($schema, fn() => null);
+ $activator->run('8.2.0', '7.1');
+
+ $this->assertTrue(true, 'expectations on wp_schedule_event are the real assertion');
+ }
+}
diff --git a/tests/Unit/Foundation/AdminMenuTest.php b/tests/Unit/Foundation/AdminMenuTest.php
new file mode 100644
index 0000000..e5fc4d5
--- /dev/null
+++ b/tests/Unit/Foundation/AdminMenuTest.php
@@ -0,0 +1,68 @@
+createMock(Edition::class);
+ $edition->method('is_pro')->willReturn(false);
+
+ $top_calls = [];
+ $sub_calls = [];
+
+ Functions\expect('add_menu_page')->once()
+ ->andReturnUsing(function (...$args) use (&$top_calls) {
+ $top_calls[] = $args;
+ return 'toplevel_page_bot-cat';
+ });
+ Functions\expect('add_submenu_page')->times(5)
+ ->andReturnUsing(function (...$args) use (&$sub_calls) {
+ $sub_calls[] = $args;
+ return 'bot-cat_page_' . $args[3];
+ });
+
+ $menu = new AdminMenu($edition);
+ $menu->register();
+
+ // add_menu_page( $page_title, $menu_title, $capability, $menu_slug, $callback, $icon, $position )
+ $this->assertSame('bot-cat', $top_calls[0][3], 'top-level menu slug should be bot-cat');
+ $this->assertSame('manage_options', $top_calls[0][2], 'capability should be manage_options');
+
+ // add_submenu_page( $parent, $page_title, $menu_title, $capability, $menu_slug, $callback )
+ $sub_slugs = array_map(fn($a) => $a[4], $sub_calls);
+ $this->assertSame(
+ ['bot-cat', 'bot-cat-subscribers', 'bot-cat-templates', 'bot-cat-logs', 'bot-cat-settings'],
+ $sub_slugs,
+ 'submenus must appear in the specified order'
+ );
+ }
+
+ public function testRegistersLicenseSubmenuOnlyWhenProEditionActive(): void
+ {
+ $edition = $this->createMock(Edition::class);
+ $edition->method('is_pro')->willReturn(true);
+
+ Functions\expect('add_menu_page')->once();
+ Functions\expect('add_submenu_page')->times(7);
+
+ $menu = new AdminMenu($edition);
+ $menu->register();
+
+ $this->assertTrue(true);
+ }
+}
diff --git a/tests/Unit/Foundation/DeactivatorTest.php b/tests/Unit/Foundation/DeactivatorTest.php
new file mode 100644
index 0000000..4b7ad83
--- /dev/null
+++ b/tests/Unit/Foundation/DeactivatorTest.php
@@ -0,0 +1,43 @@
+with('botcat_cleanup_old_logs')
+ ->andReturn(1_700_000_000);
+ Functions\expect('wp_unschedule_event')->once()
+ ->with(1_700_000_000, 'botcat_cleanup_old_logs');
+
+ Deactivator::deactivate();
+
+ $this->assertTrue(true);
+ }
+
+ public function testDeactivationSkipsUnscheduledCron(): void
+ {
+ Functions\expect('wp_next_scheduled')
+ ->with('botcat_cleanup_old_logs')
+ ->andReturn(false);
+ Functions\expect('wp_unschedule_event')->never();
+
+ Deactivator::deactivate();
+
+ $this->assertTrue(true);
+ }
+}
diff --git a/tests/Unit/Foundation/EditionTest.php b/tests/Unit/Foundation/EditionTest.php
new file mode 100644
index 0000000..97c7c4a
--- /dev/null
+++ b/tests/Unit/Foundation/EditionTest.php
@@ -0,0 +1,40 @@
+once()->with(false)->andReturn(false);
+
+ $edition = new Edition();
+
+ $this->assertFalse($edition->is_pro());
+ }
+
+ public function testProBuildFlipsViaFilter(): void
+ {
+ Filters\expectApplied('botcat_is_pro')->once()->with(false)->andReturn(true);
+
+ $edition = new Edition();
+
+ $this->assertTrue($edition->is_pro());
+ }
+}
diff --git a/tests/Unit/Foundation/I18nTest.php b/tests/Unit/Foundation/I18nTest.php
new file mode 100644
index 0000000..0566867
--- /dev/null
+++ b/tests/Unit/Foundation/I18nTest.php
@@ -0,0 +1,33 @@
+once()
+ ->with('bot-cat', false, 'bot-cat/languages');
+
+ Functions\stubs([
+ 'plugin_basename' => static fn($path) => 'bot-cat/' . basename($path),
+ ]);
+
+ $i18n = new I18n('/wp-content/plugins/bot-cat/bot-cat.php');
+ $i18n->load();
+
+ $this->assertTrue(true);
+ }
+}
diff --git a/tests/Unit/Foundation/RetentionCronTest.php b/tests/Unit/Foundation/RetentionCronTest.php
new file mode 100644
index 0000000..fd9d070
--- /dev/null
+++ b/tests/Unit/Foundation/RetentionCronTest.php
@@ -0,0 +1,81 @@
+queries[] = ['prepare', $query, $args];
+ return vsprintf(str_replace(['%d', '%s'], ['%d', "'%s'"], $query), $args);
+ }
+ public function query(string $sql): int
+ {
+ $this->queries[] = ['query', $sql];
+ return 0;
+ }
+ };
+ $this->wpdb = $wpdb;
+ }
+
+ public function testDefaultRetentionIsThirtyDays(): void
+ {
+ Filters\expectApplied('botcat_log_retention_days')
+ ->once()
+ ->with(30)
+ ->andReturn(30);
+
+ $cron = new RetentionCron();
+ $cron->run();
+
+ $found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'prepare' && str_contains($q[1], 'wp_botcat_push_logs')) {
+ $found = true;
+ $this->assertSame(30, $q[2][0] ?? null, 'retention argument should be 30');
+ }
+ }
+ $this->assertTrue($found, 'expected push_logs DELETE prepared statement');
+ }
+
+ public function testRetentionWindowIsFilterable(): void
+ {
+ Filters\expectApplied('botcat_log_retention_days')
+ ->once()
+ ->with(30)
+ ->andReturn(7);
+
+ $cron = new RetentionCron();
+ $cron->run();
+
+ $arg = null;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'prepare' && str_contains($q[1], 'wp_botcat_push_logs')) {
+ $arg = $q[2][0] ?? null;
+ }
+ }
+ $this->assertSame(7, $arg, 'filtered retention should reach prepare()');
+ }
+}
diff --git a/tests/Unit/Foundation/SchemaTest.php b/tests/Unit/Foundation/SchemaTest.php
new file mode 100644
index 0000000..f77b6f5
--- /dev/null
+++ b/tests/Unit/Foundation/SchemaTest.php
@@ -0,0 +1,134 @@
+prefix = 'wp_';
+ $wpdb->charset = 'utf8mb4';
+ $wpdb->collate = 'utf8mb4_unicode_ci';
+ $this->wpdb = $wpdb;
+
+ Functions\stubs([
+ 'sanitize_key' => static fn($s) => $s,
+ ]);
+ }
+
+ public function testTableNamesReturnedAreAllBotcatPrefixed(): void
+ {
+ $schema = new Schema();
+
+ foreach ($schema->tables() as $name) {
+ $this->assertStringStartsWith('wp_botcat_', $name, "table $name not botcat-prefixed");
+ }
+ }
+
+ public function testTablesIncludeSubscribersPushJobsPushLogs(): void
+ {
+ $schema = new Schema();
+ $tables = $schema->tables();
+
+ $this->assertContains('wp_botcat_subscribers', $tables);
+ $this->assertContains('wp_botcat_push_jobs', $tables);
+ $this->assertContains('wp_botcat_push_logs', $tables);
+ }
+
+ public function testTagsAndSubscriberTagsTablesAddedInV1Point2(): void
+ {
+ $tables = (new Schema())->tables();
+
+ $this->assertContains('wp_botcat_tags', $tables);
+ $this->assertContains('wp_botcat_subscriber_tags', $tables);
+ }
+
+ public function testTagsTableHasUniqueSlug(): void
+ {
+ $sql = (new Schema())->sql_for('tags');
+
+ $this->assertMatchesRegularExpression('/UNIQUE KEY\s+slug\s*\(\s*slug\s*\)/i', $sql);
+ }
+
+ public function testSubscriberTagsHasCompositePrimaryKey(): void
+ {
+ $sql = (new Schema())->sql_for('subscriber_tags');
+
+ $this->assertMatchesRegularExpression('/PRIMARY KEY\s+\(\s*subscriber_id\s*,\s*tag_id\s*\)/i', $sql);
+ }
+
+ public function testPushLogsHasIndexesOnForeignKeysAndJobStatus(): void
+ {
+ $schema = new Schema();
+ $sql = $schema->sql_for('push_logs');
+
+ $this->assertMatchesRegularExpression('/KEY\s+\w+\s*\(\s*job_id\s*,\s*status\s*\)/i', $sql, 'must have composite (job_id, status) index per W2 spec');
+ $this->assertMatchesRegularExpression('/KEY\s+\w*subscriber_id\w*\s*\(\s*subscriber_id\s*\)/i', $sql);
+ }
+
+ public function testPushJobsHasPostStatusCompositeIndex(): void
+ {
+ $sql = (new Schema())->sql_for('push_jobs');
+
+ $this->assertMatchesRegularExpression('/KEY\s+\w+\s*\(\s*post_id\s*,\s*status\s*\)/i', $sql, '(post_id, status) composite index required for idempotency lookup');
+ }
+
+ public function testPushJobsHasTriggeredAtAndTestFlag(): void
+ {
+ $sql = (new Schema())->sql_for('push_jobs');
+
+ $this->assertStringContainsString('triggered_at', $sql);
+ $this->assertStringContainsString('is_test', $sql);
+ $this->assertStringContainsString('post_type', $sql);
+ }
+
+ public function testPushLogsHasIsTestFlag(): void
+ {
+ $sql = (new Schema())->sql_for('push_logs');
+
+ $this->assertStringContainsString('is_test', $sql);
+ $this->assertStringContainsString('attempts', $sql);
+ }
+
+ public function testInstallCallsDbDeltaWithEverySchemaStatement(): void
+ {
+ $captured = [];
+ Functions\expect('dbDelta')
+ ->times(count(Schema::TABLES))
+ ->andReturnUsing(function ($sql) use (&$captured) {
+ $captured[] = $sql;
+ return [];
+ });
+
+ $schema = new Schema();
+ $schema->install();
+
+ $this->assertCount(count(Schema::TABLES), $captured);
+ $joined = implode("\n", $captured);
+ $this->assertStringContainsString('wp_botcat_subscribers', $joined);
+ $this->assertStringContainsString('wp_botcat_push_jobs', $joined);
+ $this->assertStringContainsString('wp_botcat_push_logs', $joined);
+ $this->assertStringContainsString('wp_botcat_tags', $joined);
+ $this->assertStringContainsString('wp_botcat_subscriber_tags', $joined);
+ $this->assertStringContainsString('wp_botcat_short_links', $joined);
+ $this->assertStringContainsString('wp_botcat_short_link_clicks', $joined);
+ }
+}
diff --git a/tests/Unit/Foundation/SchemaVersionTest.php b/tests/Unit/Foundation/SchemaVersionTest.php
new file mode 100644
index 0000000..e7e0b27
--- /dev/null
+++ b/tests/Unit/Foundation/SchemaVersionTest.php
@@ -0,0 +1,63 @@
+with('botcat_db_version', '0.0.0')
+ ->andReturn('0.0.0');
+ Functions\expect('update_option')->once()
+ ->with('botcat_db_version', '1.0.0');
+
+ $schema = $this->createMock(Schema::class);
+ $schema->expects($this->once())->method('install');
+
+ $version = new SchemaVersion($schema, '1.0.0');
+ $version->maybe_upgrade();
+ }
+
+ public function testNoUpgradeWhenStoredVersionMatchesBundledVersion(): void
+ {
+ Functions\expect('get_option')
+ ->with('botcat_db_version', '0.0.0')
+ ->andReturn('1.0.0');
+ Functions\expect('update_option')->never();
+
+ $schema = $this->createMock(Schema::class);
+ $schema->expects($this->never())->method('install');
+
+ $version = new SchemaVersion($schema, '1.0.0');
+ $version->maybe_upgrade();
+ }
+
+ public function testUpgradeRunsWhenStoredVersionIsOlder(): void
+ {
+ Functions\expect('get_option')
+ ->with('botcat_db_version', '0.0.0')
+ ->andReturn('0.9.0');
+ Functions\expect('update_option')->once()
+ ->with('botcat_db_version', '1.2.0');
+
+ $schema = $this->createMock(Schema::class);
+ $schema->expects($this->once())->method('install');
+
+ $version = new SchemaVersion($schema, '1.2.0');
+ $version->maybe_upgrade();
+ }
+}
diff --git a/tests/Unit/Foundation/UninstallerTest.php b/tests/Unit/Foundation/UninstallerTest.php
new file mode 100644
index 0000000..94c3a75
--- /dev/null
+++ b/tests/Unit/Foundation/UninstallerTest.php
@@ -0,0 +1,45 @@
+queries[] = $sql;
+ return 0;
+ }
+ };
+
+ Functions\expect('delete_option')->atLeast()->once()
+ ->with($this->stringStartsWith('botcat_'));
+ Functions\expect('wp_clear_scheduled_hook')->once()
+ ->with('botcat_cleanup_old_logs');
+
+ Uninstaller::uninstall();
+
+ $joined = implode("\n", $wpdb->queries);
+ $this->assertStringContainsString('DROP TABLE', $joined);
+ $this->assertStringContainsString('wp_botcat_subscribers', $joined);
+ $this->assertStringContainsString('wp_botcat_push_jobs', $joined);
+ $this->assertStringContainsString('wp_botcat_push_logs', $joined);
+ }
+}
diff --git a/tests/Unit/License/LicenseGateTest.php b/tests/Unit/License/LicenseGateTest.php
new file mode 100644
index 0000000..3a4f18c
--- /dev/null
+++ b/tests/Unit/License/LicenseGateTest.php
@@ -0,0 +1,64 @@
+createMock(LicenseRepository::class);
+ $repo->method('get_cache')->willReturn($cache);
+
+ $gate = new LicenseGate($repo, new LicenseStatusEvaluator(), fn() => $now);
+ $this->assertTrue($gate->is_pro_active());
+ }
+
+ public function testReturnsFalseWhenNeverActivated(): void
+ {
+ $repo = $this->createMock(LicenseRepository::class);
+ $repo->method('get_cache')->willReturn(LicenseCache::empty());
+
+ $gate = new LicenseGate($repo, new LicenseStatusEvaluator(), fn() => 1_700_000_000);
+ $this->assertFalse($gate->is_pro_active());
+ }
+
+ public function testReturnsFalseWhenExpiredBeyondGrace(): void
+ {
+ $now = 1_700_000_000;
+ $cache = new LicenseCache(true, $now - 9 * 86400, null, 5, 'Pro', null);
+
+ $repo = $this->createMock(LicenseRepository::class);
+ $repo->method('get_cache')->willReturn($cache);
+
+ $gate = new LicenseGate($repo, new LicenseStatusEvaluator(), fn() => $now);
+ $this->assertFalse($gate->is_pro_active());
+ }
+
+ public function testFilterCallbackOrsExistingValue(): void
+ {
+ $now = 1_700_000_000;
+ $repo = $this->createMock(LicenseRepository::class);
+ $repo->method('get_cache')->willReturn(LicenseCache::empty());
+
+ $gate = new LicenseGate($repo, new LicenseStatusEvaluator(), fn() => $now);
+
+ // No license + filter starts true (e.g. dev override) → still true.
+ $this->assertTrue($gate->filter_is_pro(true));
+ // No license + filter starts false → false.
+ $this->assertFalse($gate->filter_is_pro(false));
+ }
+}
diff --git a/tests/Unit/License/LicenseStatusEvaluatorTest.php b/tests/Unit/License/LicenseStatusEvaluatorTest.php
new file mode 100644
index 0000000..0bc5060
--- /dev/null
+++ b/tests/Unit/License/LicenseStatusEvaluatorTest.php
@@ -0,0 +1,99 @@
+evaluate(LicenseCache::empty(), 1_700_000_000);
+ $this->assertSame(LicenseStatus::INACTIVE, $status);
+ }
+
+ public function testRecentlyVerifiedEntitledIsActive(): void
+ {
+ $now = 1_700_000_000;
+ $cache = new LicenseCache(
+ entitled: true,
+ verified_at: $now - 86400, // 1 day ago
+ last_failure_at: null,
+ consecutive_failures: 0,
+ plan_name: 'Pro',
+ renews_at: '2026-12-01'
+ );
+
+ $this->assertSame(LicenseStatus::ACTIVE, (new LicenseStatusEvaluator())->evaluate($cache, $now));
+ }
+
+ public function testOutageWithinGracePeriodKeepsActive(): void
+ {
+ $now = 1_700_000_000;
+ $cache = new LicenseCache(
+ entitled: true,
+ verified_at: $now - 3 * 86400, // 3 days ago
+ last_failure_at: $now - 86400,
+ consecutive_failures: 1,
+ plan_name: 'Pro',
+ renews_at: null
+ );
+
+ $this->assertSame(LicenseStatus::ACTIVE, (new LicenseStatusEvaluator())->evaluate($cache, $now));
+ }
+
+ public function testOutageBeyondGracePeriodExpires(): void
+ {
+ $now = 1_700_000_000;
+ $cache = new LicenseCache(
+ entitled: true,
+ verified_at: $now - 8 * 86400, // 8 days ago
+ last_failure_at: $now - 86400,
+ consecutive_failures: 6,
+ plan_name: 'Pro',
+ renews_at: null
+ );
+
+ $this->assertSame(LicenseStatus::EXPIRED, (new LicenseStatusEvaluator())->evaluate($cache, $now));
+ }
+
+ public function testRevokedLicenseFlowsToExpiredAfterGrace(): void
+ {
+ $now = 1_700_000_000;
+ // Once Polar starts saying entitled=false, verified_at stops advancing.
+ // After 7 days, the cache crosses the grace threshold → EXPIRED.
+ $cache = new LicenseCache(
+ entitled: false,
+ verified_at: $now - 8 * 86400,
+ last_failure_at: $now,
+ consecutive_failures: 7,
+ plan_name: 'Pro',
+ renews_at: null
+ );
+
+ $this->assertSame(LicenseStatus::EXPIRED, (new LicenseStatusEvaluator())->evaluate($cache, $now));
+ }
+
+ public function testWithinGracePeriodWindowEdgeCase(): void
+ {
+ $now = 1_700_000_000;
+ $cache = new LicenseCache(
+ entitled: true,
+ verified_at: $now - LicenseStatusEvaluator::GRACE_SECONDS,
+ last_failure_at: null,
+ consecutive_failures: 0,
+ plan_name: 'Pro',
+ renews_at: null
+ );
+
+ $this->assertSame(LicenseStatus::ACTIVE, (new LicenseStatusEvaluator())->evaluate($cache, $now));
+ }
+}
diff --git a/tests/Unit/Push/BatchRunnerTest.php b/tests/Unit/Push/BatchRunnerTest.php
new file mode 100644
index 0000000..89232d8
--- /dev/null
+++ b/tests/Unit/Push/BatchRunnerTest.php
@@ -0,0 +1,223 @@
+build_deps();
+
+ $deps['jobs']->method('find_by_id')->willReturn($this->job_pending(7));
+
+ $deps['logs']->expects($this->once())->method('mark_status')
+ ->with($this->equalTo([1, 2]), PushLog::STATUS_SENT, null);
+ $deps['jobs']->expects($this->once())->method('increment_counts')
+ ->with(7, 2, 0);
+
+ $deps['client']->expects($this->once())->method('send')
+ ->willReturn(new MulticastResult(ok: true, http_status: 200));
+
+ $this->expect_logs_loaded($deps['logs'], 7, [1, 2], [
+ ['id' => '1', 'job_id' => '7', 'line_user_id' => 'U1', 'subscriber_id' => null, 'status' => 'pending', 'error' => null, 'attempts' => '0', 'is_test' => '0', 'created_at' => ''],
+ ['id' => '2', 'job_id' => '7', 'line_user_id' => 'U2', 'subscriber_id' => null, 'status' => 'pending', 'error' => null, 'attempts' => '0', 'is_test' => '0', 'created_at' => ''],
+ ]);
+
+ Functions\expect('as_schedule_single_action')->never();
+
+ $this->runner($deps)->run(7, [1, 2], 1);
+ }
+
+ public function testFiveHundredAttemptOneSchedulesRetryAtThirtySeconds(): void
+ {
+ $deps = $this->build_deps();
+
+ $deps['jobs']->method('find_by_id')->willReturn($this->job_pending(7));
+ $deps['client']->method('send')->willReturn(new MulticastResult(
+ ok: false,
+ http_status: 502,
+ error_code: MulticastClient::ERROR_SERVER,
+ error_message: 'Bad Gateway'
+ ));
+
+ $this->expect_logs_loaded($deps['logs'], 7, [1, 2], [
+ ['id' => '1', 'line_user_id' => 'U1', 'job_id' => '7', 'status' => 'pending'],
+ ['id' => '2', 'line_user_id' => 'U2', 'job_id' => '7', 'status' => 'pending'],
+ ]);
+
+ $deps['logs']->expects($this->never())->method('mark_status');
+ $deps['jobs']->expects($this->never())->method('increment_counts');
+
+ Functions\expect('as_schedule_single_action')->once()
+ ->with(
+ $this->isType('int'),
+ PushJobScheduler::ACTION_RUN_BATCH,
+ $this->equalTo([7, [1, 2], 2]),
+ PushJobScheduler::GROUP
+ );
+
+ $this->runner($deps)->run(7, [1, 2], 1);
+ }
+
+ public function testAuthFailureAbortsJobAndDoesNotRetry(): void
+ {
+ $deps = $this->build_deps();
+
+ $deps['jobs']->method('find_by_id')->willReturn($this->job_pending(7));
+ $deps['client']->method('send')->willReturn(new MulticastResult(
+ ok: false,
+ http_status: 401,
+ error_code: MulticastClient::ERROR_AUTH,
+ error_message: 'Authentication failed'
+ ));
+
+ $this->expect_logs_loaded($deps['logs'], 7, [1], [
+ ['id' => '1', 'line_user_id' => 'U1', 'job_id' => '7', 'status' => 'pending'],
+ ]);
+
+ $deps['jobs']->expects($this->once())->method('mark_status')
+ ->with(7, PushJob::STATUS_ABORTED_AUTH, $this->stringContains('Authentication failed'));
+ Functions\expect('as_schedule_single_action')->never();
+
+ $this->runner($deps)->run(7, [1], 1);
+ }
+
+ public function testFinalFailureMarksLogsAsFailed(): void
+ {
+ $deps = $this->build_deps();
+ $deps['jobs']->method('find_by_id')->willReturn($this->job_pending(7));
+ $deps['client']->method('send')->willReturn(new MulticastResult(
+ ok: false,
+ http_status: 502,
+ error_code: MulticastClient::ERROR_SERVER,
+ error_message: '5xx persistent'
+ ));
+
+ $this->expect_logs_loaded($deps['logs'], 7, [1, 2], [
+ ['id' => '1', 'line_user_id' => 'U1', 'job_id' => '7', 'status' => 'pending'],
+ ['id' => '2', 'line_user_id' => 'U2', 'job_id' => '7', 'status' => 'pending'],
+ ]);
+
+ $deps['logs']->expects($this->once())->method('mark_status')
+ ->with([1, 2], PushLog::STATUS_FAILED, $this->stringContains('5xx persistent'));
+ $deps['jobs']->expects($this->once())->method('increment_counts')
+ ->with(7, 0, 2);
+
+ Functions\expect('as_schedule_single_action')->never();
+
+ $this->runner($deps)->run(7, [1, 2], 4);
+ }
+
+ public function testTerminalJobIsNoOp(): void
+ {
+ $deps = $this->build_deps();
+ $deps['jobs']->method('find_by_id')->willReturn($this->job_with_status(7, PushJob::STATUS_ABORTED_AUTH));
+
+ $deps['client']->expects($this->never())->method('send');
+ $deps['logs']->expects($this->never())->method('mark_status');
+
+ $this->runner($deps)->run(7, [1, 2], 1);
+ }
+
+ public function testUnconfiguredChannelAbortsJob(): void
+ {
+ $deps = $this->build_deps(channel_secret: '', access_token: '');
+ $deps['jobs']->method('find_by_id')->willReturn($this->job_pending(7));
+
+ $deps['client']->expects($this->never())->method('send');
+ $deps['jobs']->expects($this->once())->method('mark_status')
+ ->with(7, PushJob::STATUS_FAILED, $this->stringContains('channel'));
+
+ $this->runner($deps)->run(7, [1], 1);
+ }
+
+ /** @return array{jobs:PushJobRepository,logs:PushLogRepository,channel:ChannelSettingsRepository,client:MulticastClient,policy:RetryPolicy,messages:MessageBuilder} */
+ private function build_deps(string $channel_secret = 'sec', string $access_token = 'tok'): array
+ {
+ $channel = $this->createMock(ChannelSettingsRepository::class);
+ $channel->method('get')->willReturn(new ChannelSettings('1', $channel_secret, $access_token));
+
+ $messages = $this->createMock(MessageBuilder::class);
+ $messages->method('build_for_job')->willReturn([['type' => 'text', 'text' => 'hi']]);
+
+ return [
+ 'jobs' => $this->createMock(PushJobRepository::class),
+ 'logs' => $this->createMock(PushLogRepository::class),
+ 'channel' => $channel,
+ 'client' => $this->createMock(MulticastClient::class),
+ 'policy' => new RetryPolicy(),
+ 'messages' => $messages,
+ ];
+ }
+
+ /**
+ * @param array $deps
+ */
+ private function runner(array $deps): BatchRunner
+ {
+ return new BatchRunner(
+ $deps['jobs'],
+ $deps['logs'],
+ $deps['channel'],
+ $deps['client'],
+ $deps['policy'],
+ $deps['messages']
+ );
+ }
+
+ private function job_pending(int $id): PushJob
+ {
+ return $this->job_with_status($id, PushJob::STATUS_PENDING);
+ }
+
+ private function job_with_status(int $id, string $status): PushJob
+ {
+ return new PushJob(
+ id: $id,
+ post_id: 100,
+ post_type: 'post',
+ status: $status,
+ recipient_count: 2,
+ sent_count: 0,
+ failed_count: 0,
+ is_test: false,
+ last_error: null,
+ created_at: '2026-05-20 00:00:00',
+ triggered_at: '2026-05-20 00:00:00',
+ started_at: null,
+ finished_at: null
+ );
+ }
+
+ /**
+ * @param list $ids
+ * @param list> $rows
+ */
+ private function expect_logs_loaded(PushLogRepository $logs, int $job_id, array $ids, array $rows): void
+ {
+ $log_objects = array_map(static fn(array $row): PushLog => PushLog::from_row($row), $rows);
+ $logs->method('find_by_ids_for_job')
+ ->with($job_id, $ids)
+ ->willReturn($log_objects);
+ }
+}
diff --git a/tests/Unit/Push/EligiblePostTypesTest.php b/tests/Unit/Push/EligiblePostTypesTest.php
new file mode 100644
index 0000000..97c3db5
--- /dev/null
+++ b/tests/Unit/Push/EligiblePostTypesTest.php
@@ -0,0 +1,55 @@
+once()
+ ->with(EligiblePostTypes::OPTION, ['post'])
+ ->andReturn(['post']);
+ Filters\expectApplied(EligiblePostTypes::FILTER)->once()->andReturnFirstArg();
+
+ $types = (new EligiblePostTypes())->get();
+
+ $this->assertSame(['post'], $types);
+ }
+
+ public function testIsEligibleReturnsTrueForListed(): void
+ {
+ Functions\expect('get_option')->andReturn(['post', 'event']);
+ Filters\expectApplied(EligiblePostTypes::FILTER)->andReturnFirstArg();
+
+ $this->assertTrue((new EligiblePostTypes())->is_eligible('event'));
+ }
+
+ public function testIsEligibleReturnsFalseForUnlisted(): void
+ {
+ Functions\expect('get_option')->andReturn(['post']);
+ Filters\expectApplied(EligiblePostTypes::FILTER)->andReturnFirstArg();
+
+ $this->assertFalse((new EligiblePostTypes())->is_eligible('page'));
+ }
+
+ public function testFilterCanExtendList(): void
+ {
+ Functions\expect('get_option')->andReturn(['post']);
+ Filters\expectApplied(EligiblePostTypes::FILTER)
+ ->once()
+ ->with(['post'])
+ ->andReturn(['post', 'event']);
+
+ $this->assertSame(['post', 'event'], (new EligiblePostTypes())->get());
+ }
+}
diff --git a/tests/Unit/Push/MessageBuilderFlexTest.php b/tests/Unit/Push/MessageBuilderFlexTest.php
new file mode 100644
index 0000000..f619dc9
--- /dev/null
+++ b/tests/Unit/Push/MessageBuilderFlexTest.php
@@ -0,0 +1,103 @@
+alias(fn() => $this->fake_post());
+
+ $edition = $this->createMock(Edition::class);
+ $edition->method('is_pro')->willReturn(true);
+
+ $flex_repo = $this->createMock(FlexTemplateRepository::class);
+ $flex_repo->method('get')->willReturn(new FlexTemplate(FlexTemplate::HERO_NONE, '', 'h', 'b', []));
+
+ $flex_builder = $this->createMock(FlexMessageBuilder::class);
+ $flex_builder->method('build')->willReturn(['type' => 'flex', 'altText' => 'x']);
+
+ $builder = new MessageBuilder(null, null, $flex_repo, $flex_builder, $edition);
+ $result = $builder->build_for_job($this->job_with_post(7));
+
+ $this->assertSame('flex', $result[0]['type']);
+ }
+
+ public function testFlexExceptionTriggersTextFallbackAndFiresAction(): void
+ {
+ Functions\when('get_post')->alias(fn() => $this->fake_post());
+ Functions\when('get_the_title')->alias(fn() => 'Title');
+ Functions\when('get_permalink')->alias(fn() => 'https://x.test/p/7');
+
+ $edition = $this->createMock(Edition::class);
+ $edition->method('is_pro')->willReturn(true);
+
+ $flex_repo = $this->createMock(FlexTemplateRepository::class);
+ $flex_repo->method('get')->willReturn(new FlexTemplate(FlexTemplate::HERO_NONE, '', 'h', 'b', []));
+
+ $flex_builder = $this->createMock(FlexMessageBuilder::class);
+ $flex_builder->method('build')->willThrowException(new \RuntimeException('boom'));
+
+ Actions\expectDone('botcat_flex_render_failed')->once();
+
+ $builder = new MessageBuilder(null, null, $flex_repo, $flex_builder, $edition);
+ $result = $builder->build_for_job($this->job_with_post(7));
+
+ $this->assertSame('text', $result[0]['type']);
+ $this->assertStringContainsString('Title', $result[0]['text']);
+ }
+
+ public function testFreeEditionAlwaysUsesText(): void
+ {
+ Functions\when('get_the_title')->alias(fn() => 'Title');
+ Functions\when('get_permalink')->alias(fn() => 'https://x.test/p/7');
+
+ $edition = $this->createMock(Edition::class);
+ $edition->method('is_pro')->willReturn(false);
+
+ $flex_repo = $this->createMock(FlexTemplateRepository::class);
+ $flex_repo->expects($this->never())->method('get');
+
+ $flex_builder = $this->createMock(FlexMessageBuilder::class);
+ $flex_builder->expects($this->never())->method('build');
+
+ $builder = new MessageBuilder(null, null, $flex_repo, $flex_builder, $edition);
+ $result = $builder->build_for_job($this->job_with_post(7));
+
+ $this->assertSame('text', $result[0]['type']);
+ }
+
+ private function fake_post(): object
+ {
+ $p = new \stdClass();
+ $p->ID = 7;
+ return $p;
+ }
+
+ private function job_with_post(int $post_id): PushJob
+ {
+ return new PushJob(
+ id: 1, post_id: $post_id, post_type: 'post', status: 'pending',
+ recipient_count: 0, sent_count: 0, failed_count: 0,
+ is_test: false, last_error: null,
+ created_at: '', triggered_at: null, started_at: null, finished_at: null
+ );
+ }
+}
diff --git a/tests/Unit/Push/MulticastClientTest.php b/tests/Unit/Push/MulticastClientTest.php
new file mode 100644
index 0000000..14daf22
--- /dev/null
+++ b/tests/Unit/Push/MulticastClientTest.php
@@ -0,0 +1,116 @@
+once()
+ ->andReturnUsing(function (string $url, array $args) use (&$captured_args) {
+ $captured_args = ['url' => $url, 'args' => $args];
+ return ['ok'];
+ });
+ Functions\expect('is_wp_error')->andReturn(false);
+ Functions\expect('wp_remote_retrieve_response_code')->andReturn(200);
+ Functions\expect('wp_remote_retrieve_body')->andReturn('{}');
+ Functions\expect('wp_remote_retrieve_header')->andReturn('');
+
+ $client = new MulticastClient();
+ $result = $client->send('tok', ['U1', 'U2'], [['type' => 'text', 'text' => 'Hi']]);
+
+ $this->assertTrue($result->ok);
+ $this->assertSame(200, $result->http_status);
+
+ $this->assertSame(MulticastClient::ENDPOINT, $captured_args['url']);
+ $this->assertSame('Bearer tok', $captured_args['args']['headers']['Authorization']);
+ $this->assertSame('application/json', $captured_args['args']['headers']['Content-Type']);
+
+ $body = json_decode((string) $captured_args['args']['body'], true);
+ $this->assertSame(['U1', 'U2'], $body['to']);
+ $this->assertSame([['type' => 'text', 'text' => 'Hi']], $body['messages']);
+ }
+
+ public function testAuthFailureSurfacesAuthErrorCode(): void
+ {
+ Functions\expect('wp_remote_post')->andReturn(['ok']);
+ Functions\expect('is_wp_error')->andReturn(false);
+ Functions\expect('wp_remote_retrieve_response_code')->andReturn(401);
+ Functions\expect('wp_remote_retrieve_body')->andReturn('{"message":"Authentication failed"}');
+ Functions\expect('wp_remote_retrieve_header')->andReturn('');
+
+ $result = (new MulticastClient())->send('tok', ['U1'], [['type' => 'text', 'text' => 'hi']]);
+
+ $this->assertFalse($result->ok);
+ $this->assertSame(401, $result->http_status);
+ $this->assertSame(MulticastClient::ERROR_AUTH, $result->error_code);
+ $this->assertSame('Authentication failed', $result->error_message);
+ }
+
+ public function testServerErrorIsRetriable(): void
+ {
+ Functions\expect('wp_remote_post')->andReturn(['ok']);
+ Functions\expect('is_wp_error')->andReturn(false);
+ Functions\expect('wp_remote_retrieve_response_code')->andReturn(502);
+ Functions\expect('wp_remote_retrieve_body')->andReturn('Bad Gateway');
+ Functions\expect('wp_remote_retrieve_header')->andReturn('');
+
+ $result = (new MulticastClient())->send('tok', ['U1'], [['type' => 'text', 'text' => 'hi']]);
+
+ $this->assertFalse($result->ok);
+ $this->assertSame(502, $result->http_status);
+ $this->assertSame(MulticastClient::ERROR_SERVER, $result->error_code);
+ }
+
+ public function testTooManyRequestsExtractsRetryAfterHeader(): void
+ {
+ Functions\expect('wp_remote_post')->andReturn(['ok']);
+ Functions\expect('is_wp_error')->andReturn(false);
+ Functions\expect('wp_remote_retrieve_response_code')->andReturn(429);
+ Functions\expect('wp_remote_retrieve_body')->andReturn('Too many');
+ Functions\expect('wp_remote_retrieve_header')->once()
+ ->with(\Mockery::any(), 'retry-after')
+ ->andReturn('120');
+
+ $result = (new MulticastClient())->send('tok', ['U1'], [['type' => 'text', 'text' => 'hi']]);
+
+ $this->assertFalse($result->ok);
+ $this->assertSame(429, $result->http_status);
+ $this->assertSame(MulticastClient::ERROR_RATE_LIMITED, $result->error_code);
+ $this->assertSame(120, $result->retry_after);
+ }
+
+ public function testNetworkErrorSurfacesAsErrorCodeWithoutHttpStatus(): void
+ {
+ $wp_error = new \stdClass();
+ Functions\expect('wp_remote_post')->andReturn($wp_error);
+ Functions\expect('is_wp_error')->once()->with($wp_error)->andReturn(true);
+ Functions\expect('wp_remote_retrieve_response_code')->never();
+
+ $result = (new MulticastClient())->send('tok', ['U1'], [['type' => 'text', 'text' => 'hi']]);
+
+ $this->assertFalse($result->ok);
+ $this->assertNull($result->http_status);
+ $this->assertSame(MulticastClient::ERROR_NETWORK, $result->error_code);
+ }
+
+ public function testEmptyRecipientListShortCircuits(): void
+ {
+ Functions\expect('wp_remote_post')->never();
+
+ $result = (new MulticastClient())->send('tok', [], [['type' => 'text']]);
+
+ $this->assertFalse($result->ok);
+ $this->assertSame(MulticastClient::ERROR_INVALID_INPUT, $result->error_code);
+ }
+}
diff --git a/tests/Unit/Push/PostPublishObserverTest.php b/tests/Unit/Push/PostPublishObserverTest.php
new file mode 100644
index 0000000..9958def
--- /dev/null
+++ b/tests/Unit/Push/PostPublishObserverTest.php
@@ -0,0 +1,131 @@
+createMock(EligiblePostTypes::class);
+ $eligible->method('is_eligible')->with('post')->willReturn(true);
+
+ $scheduler = $this->createMock(PushJobScheduler::class);
+ $scheduler->expects($this->once())->method('enqueue')
+ ->with($this->equalTo(42), $this->equalTo('post'));
+
+ $observer = new PostPublishObserver($eligible, $scheduler);
+ $observer->on_transition('publish', 'draft', $this->post(42, 'post'));
+ }
+
+ public function testRepublishingDoesNotEnqueue(): void
+ {
+ $eligible = $this->createMock(EligiblePostTypes::class);
+ $eligible->expects($this->never())->method('is_eligible');
+
+ $scheduler = $this->createMock(PushJobScheduler::class);
+ $scheduler->expects($this->never())->method('enqueue');
+
+ (new PostPublishObserver($eligible, $scheduler))
+ ->on_transition('publish', 'publish', $this->post(42, 'post'));
+ }
+
+ public function testIneligiblePostTypeIsSkipped(): void
+ {
+ $eligible = $this->createMock(EligiblePostTypes::class);
+ $eligible->method('is_eligible')->with('event')->willReturn(false);
+
+ $scheduler = $this->createMock(PushJobScheduler::class);
+ $scheduler->expects($this->never())->method('enqueue');
+
+ (new PostPublishObserver($eligible, $scheduler))
+ ->on_transition('publish', 'draft', $this->post(99, 'event'));
+ }
+
+ public function testNonPublishTransitionIsSkipped(): void
+ {
+ $eligible = $this->createMock(EligiblePostTypes::class);
+ $eligible->expects($this->never())->method('is_eligible');
+
+ $scheduler = $this->createMock(PushJobScheduler::class);
+ $scheduler->expects($this->never())->method('enqueue');
+
+ (new PostPublishObserver($eligible, $scheduler))
+ ->on_transition('draft', 'auto-draft', $this->post(1, 'post'));
+ }
+
+ public function testScheduledPostBecomingPublishedEnqueues(): void
+ {
+ $eligible = $this->createMock(EligiblePostTypes::class);
+ $eligible->method('is_eligible')->with('post')->willReturn(true);
+
+ $scheduler = $this->createMock(PushJobScheduler::class);
+ $scheduler->expects($this->once())->method('enqueue')
+ ->with($this->equalTo(7), $this->equalTo('post'));
+
+ (new PostPublishObserver($eligible, $scheduler))
+ ->on_transition('publish', 'future', $this->post(7, 'post'));
+ }
+
+ public function testExcludedCategorySuppressesEnqueue(): void
+ {
+ $eligible = $this->createMock(EligiblePostTypes::class);
+ $eligible->method('is_eligible')->willReturn(true);
+
+ $excluded = $this->createMock(ExcludedCategories::class);
+ $excluded->method('is_excluded')->with(42)->willReturn(true);
+
+ $scheduler = $this->createMock(PushJobScheduler::class);
+ $scheduler->expects($this->never())->method('enqueue');
+
+ (new PostPublishObserver($eligible, $scheduler, $excluded))
+ ->on_transition('publish', 'draft', $this->post(42, 'post'));
+ }
+
+ public function testOptedOutPostSkipsEnqueue(): void
+ {
+ $eligible = $this->createMock(EligiblePostTypes::class);
+ $eligible->method('is_eligible')->willReturn(true);
+
+ $opt_out = $this->createMock(PerPostOptOut::class);
+ $opt_out->method('is_opted_in')->with(42)->willReturn(false);
+
+ $scheduler = $this->createMock(PushJobScheduler::class);
+ $scheduler->expects($this->never())->method('enqueue');
+
+ (new PostPublishObserver($eligible, $scheduler, null, $opt_out))
+ ->on_transition('publish', 'draft', $this->post(42, 'post'));
+ }
+
+ public function testMissingPostObjectIsTolerated(): void
+ {
+ $eligible = $this->createMock(EligiblePostTypes::class);
+ $scheduler = $this->createMock(PushJobScheduler::class);
+ $scheduler->expects($this->never())->method('enqueue');
+
+ (new PostPublishObserver($eligible, $scheduler))
+ ->on_transition('publish', 'draft', null);
+ }
+
+ private function post(int $id, string $type): object
+ {
+ $post = new \stdClass();
+ $post->ID = $id;
+ $post->post_type = $type;
+ return $post;
+ }
+}
diff --git a/tests/Unit/Push/PushJobRepositoryTest.php b/tests/Unit/Push/PushJobRepositoryTest.php
new file mode 100644
index 0000000..816eeea
--- /dev/null
+++ b/tests/Unit/Push/PushJobRepositoryTest.php
@@ -0,0 +1,153 @@
+}> */
+ public array $queries = [];
+ /** @var array> */
+ public array $rows = [];
+ public string $count = '0';
+ public function prepare(string $query, ...$args): string
+ {
+ $this->queries[] = ['prepare', $query, $args];
+ return $query;
+ }
+ public function get_row(string $sql, $output = OBJECT): ?array
+ {
+ $this->queries[] = ['get_row', $sql, []];
+ return array_shift($this->rows);
+ }
+ public function get_var(string $sql): ?string
+ {
+ $this->queries[] = ['get_var', $sql, []];
+ return $this->count;
+ }
+ public function get_results(string $sql, $output = OBJECT): array
+ {
+ $this->queries[] = ['get_results', $sql, []];
+ return array_values($this->rows);
+ }
+ public function query(string $sql): int
+ {
+ $this->queries[] = ['query', $sql, []];
+ $this->insert_id = $this->insert_id ?: 99;
+ return 1;
+ }
+ public function insert(string $table, array $data, array $formats): int
+ {
+ $this->queries[] = ['insert', $table, $data];
+ $this->insert_id = 123;
+ return 1;
+ }
+ public function update(string $table, array $data, array $where, array $df = array(), array $wf = array()): int
+ {
+ $this->queries[] = ['update', $table . ':' . implode(',', array_keys($where)), array_merge($data, $where)];
+ return 1;
+ }
+ };
+ $this->wpdb = $wpdb;
+ }
+
+ public function testCreatePendingInsertsRowAndReturnsId(): void
+ {
+ $id = (new PushJobRepository())->create_pending(post_id: 42, post_type: 'post', is_test: false);
+
+ $this->assertSame(123, $id);
+
+ $insert_found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'insert' && $q[1] === 'wp_botcat_push_jobs') {
+ $insert_found = true;
+ $this->assertSame(42, $q[2]['post_id']);
+ $this->assertSame('post', $q[2]['post_type']);
+ $this->assertSame(PushJob::STATUS_PENDING, $q[2]['status']);
+ $this->assertSame(0, $q[2]['is_test']);
+ }
+ }
+ $this->assertTrue($insert_found);
+ }
+
+ public function testFindCompletedForPostReturnsSentOrPartialOnly(): void
+ {
+ (new PushJobRepository())->find_completed_for_post(42);
+
+ $found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'prepare' && str_contains($q[1], 'post_id = %d') && str_contains($q[1], 'status IN')) {
+ $found = true;
+ $this->assertContains(42, $q[2]);
+ $this->assertContains(PushJob::STATUS_SENT, $q[2]);
+ $this->assertContains(PushJob::STATUS_PARTIAL, $q[2]);
+ }
+ }
+ $this->assertTrue($found, 'idempotency lookup must filter by post_id + completed statuses');
+ }
+
+ public function testMarkRunningSetsStatusTriggeredAndRecipientCount(): void
+ {
+ (new PushJobRepository())->mark_running(99, recipient_count: 1200);
+
+ $found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'update' && str_starts_with($q[1], 'wp_botcat_push_jobs:')) {
+ $found = true;
+ $this->assertSame(99, $q[2]['id']);
+ $this->assertSame(PushJob::STATUS_RUNNING, $q[2]['status']);
+ $this->assertSame(1200, $q[2]['recipient_count']);
+ $this->assertNotEmpty($q[2]['started_at']);
+ }
+ }
+ $this->assertTrue($found);
+ }
+
+ public function testMarkStatusUpdatesStatusAndOptionalLastError(): void
+ {
+ (new PushJobRepository())->mark_status(99, PushJob::STATUS_ABORTED_AUTH, last_error: 'Authentication failed');
+
+ $found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'update' && str_starts_with($q[1], 'wp_botcat_push_jobs:')) {
+ $found = true;
+ $this->assertSame(PushJob::STATUS_ABORTED_AUTH, $q[2]['status']);
+ $this->assertSame('Authentication failed', $q[2]['last_error']);
+ }
+ }
+ $this->assertTrue($found);
+ }
+
+ public function testIncrementCountsUsesAtomicSqlUpdate(): void
+ {
+ (new PushJobRepository())->increment_counts(99, sent_delta: 498, failed_delta: 2);
+
+ $found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'prepare' && str_contains($q[1], 'UPDATE') && str_contains($q[1], 'sent_count = sent_count')) {
+ $found = true;
+ $this->assertContains(498, $q[2]);
+ $this->assertContains(2, $q[2]);
+ $this->assertContains(99, $q[2]);
+ }
+ }
+ $this->assertTrue($found, 'increment_counts must be a single atomic UPDATE');
+ }
+}
diff --git a/tests/Unit/Push/PushJobRunnerTest.php b/tests/Unit/Push/PushJobRunnerTest.php
new file mode 100644
index 0000000..4cc8727
--- /dev/null
+++ b/tests/Unit/Push/PushJobRunnerTest.php
@@ -0,0 +1,163 @@
+createMock(PushJobRepository::class);
+ $jobs->method('find_by_id')->willReturn($this->job(7, 42, PushJob::STATUS_PENDING));
+ $jobs->method('find_completed_for_post')
+ ->with(42)
+ ->willReturn($this->job(5, 42, PushJob::STATUS_SENT));
+ $jobs->expects($this->once())->method('mark_status')
+ ->with(7, PushJob::STATUS_SKIPPED_DUPLICATE);
+
+ $logs = $this->createMock(PushLogRepository::class);
+ $logs->expects($this->never())->method('create_for_job');
+
+ $subscribers = $this->createMock(SubscriberRepository::class);
+
+ Functions\expect('as_schedule_single_action')->never();
+
+ (new PushJobRunner($jobs, $logs, $subscribers))->run(7);
+ }
+
+ public function testEmptyAudienceMarksJobSentImmediately(): void
+ {
+ $jobs = $this->createMock(PushJobRepository::class);
+ $jobs->method('find_by_id')->willReturn($this->job(7, 42, PushJob::STATUS_PENDING));
+ $jobs->method('find_completed_for_post')->willReturn(null);
+ $jobs->expects($this->once())->method('mark_running')->with(7, 0);
+ $jobs->expects($this->once())->method('mark_status')->with(7, PushJob::STATUS_SENT);
+
+ $logs = $this->createMock(PushLogRepository::class);
+ $logs->expects($this->never())->method('create_for_job');
+
+ $subscribers = $this->createMock(SubscriberRepository::class);
+ $subscribers->method('active_ids')->willReturnCallback(fn() => yield from []);
+
+ Functions\expect('as_schedule_single_action')->never();
+
+ (new PushJobRunner($jobs, $logs, $subscribers))->run(7);
+ }
+
+ public function testTwelveHundredSubscribersFanOutIntoThreeBatchesOfFiveHundredFiveHundredAndTwoHundred(): void
+ {
+ $jobs = $this->createMock(PushJobRepository::class);
+ $jobs->method('find_by_id')->willReturn($this->job(7, 42, PushJob::STATUS_PENDING));
+ $jobs->method('find_completed_for_post')->willReturn(null);
+ $jobs->expects($this->once())->method('mark_running')->with(7, 1200);
+
+ $logs = $this->createMock(PushLogRepository::class);
+ $batch_sizes = [];
+ $logs->method('create_for_job')
+ ->willReturnCallback(function (int $job_id, array $line_user_ids) use (&$batch_sizes) {
+ $batch_sizes[] = count($line_user_ids);
+ return range(1, count($line_user_ids));
+ });
+
+ $subscribers = $this->createMock(SubscriberRepository::class);
+ $subscribers->method('active_ids')->willReturnCallback(function () {
+ for ($i = 1; $i <= 1200; $i++) {
+ yield 'U' . $i;
+ }
+ });
+
+ Functions\expect('as_schedule_single_action')->times(3)
+ ->with(
+ $this->isType('int'),
+ PushJobScheduler::ACTION_RUN_BATCH,
+ $this->callback(static fn(array $args): bool => $args[0] === 7 && is_array($args[1])),
+ PushJobScheduler::GROUP
+ );
+
+ (new PushJobRunner($jobs, $logs, $subscribers))->run(7);
+
+ $this->assertSame([500, 500, 200], $batch_sizes);
+ }
+
+ public function testTestPushUsesProvidedUserIdsAndSkipsSubscriberRepository(): void
+ {
+ $jobs = $this->createMock(PushJobRepository::class);
+ $jobs->method('find_by_id')->willReturn($this->test_job(7));
+ $jobs->method('find_completed_for_post')->willReturn(null);
+ $jobs->expects($this->once())->method('mark_running')->with(7, 2);
+
+ $logs = $this->createMock(PushLogRepository::class);
+ $logs->expects($this->once())->method('create_for_job')
+ ->with(7, ['U1', 'U2'], $this->anything(), true)
+ ->willReturn([1, 2]);
+
+ $subscribers = $this->createMock(SubscriberRepository::class);
+ $subscribers->expects($this->never())->method('active_ids');
+
+ Functions\expect('as_schedule_single_action')->once();
+
+ (new PushJobRunner($jobs, $logs, $subscribers))->run(7, ['U1', 'U2']);
+ }
+
+ public function testNonPendingJobIsNoOp(): void
+ {
+ $jobs = $this->createMock(PushJobRepository::class);
+ $jobs->method('find_by_id')->willReturn($this->job(7, 42, PushJob::STATUS_RUNNING));
+
+ $jobs->expects($this->never())->method('find_completed_for_post');
+ $jobs->expects($this->never())->method('mark_running');
+
+ (new PushJobRunner($jobs, $this->createMock(PushLogRepository::class), $this->createMock(SubscriberRepository::class)))->run(7);
+ }
+
+ private function job(int $id, int $post_id, string $status): PushJob
+ {
+ return new PushJob(
+ id: $id,
+ post_id: $post_id,
+ post_type: 'post',
+ status: $status,
+ recipient_count: 0,
+ sent_count: 0,
+ failed_count: 0,
+ is_test: false,
+ last_error: null,
+ created_at: '2026-05-20 00:00:00',
+ triggered_at: '2026-05-20 00:00:00',
+ started_at: null,
+ finished_at: null
+ );
+ }
+
+ private function test_job(int $id): PushJob
+ {
+ return new PushJob(
+ id: $id,
+ post_id: 0,
+ post_type: 'test',
+ status: PushJob::STATUS_PENDING,
+ recipient_count: 0,
+ sent_count: 0,
+ failed_count: 0,
+ is_test: true,
+ last_error: null,
+ created_at: '2026-05-20 00:00:00',
+ triggered_at: '2026-05-20 00:00:00',
+ started_at: null,
+ finished_at: null
+ );
+ }
+}
diff --git a/tests/Unit/Push/PushJobSchedulerTest.php b/tests/Unit/Push/PushJobSchedulerTest.php
new file mode 100644
index 0000000..9fa379d
--- /dev/null
+++ b/tests/Unit/Push/PushJobSchedulerTest.php
@@ -0,0 +1,86 @@
+createMock(PushJobRepository::class);
+ $repo->expects($this->once())->method('create_pending')
+ ->with($this->equalTo(42), $this->equalTo('post'), $this->equalTo(false))
+ ->willReturn(99);
+
+ Functions\expect('as_schedule_single_action')->once()
+ ->with(
+ $this->isType('int'),
+ PushJobScheduler::ACTION_RUN_JOB,
+ $this->equalTo([99]),
+ PushJobScheduler::GROUP
+ );
+
+ $scheduler = new PushJobScheduler($repo);
+ $id = $scheduler->enqueue(42, 'post');
+
+ $this->assertSame(99, $id);
+ }
+
+ public function testThrottleScheduledTimeReplacesImmediateTime(): void
+ {
+ $repo = $this->createMock(PushJobRepository::class);
+ $repo->method('create_pending')->willReturn(99);
+
+ $throttle = $this->createMock(PushThrottle::class);
+ $throttle->expects($this->once())->method('next_slot')->willReturn(1_800_000_000);
+
+ Functions\expect('as_schedule_single_action')->once()
+ ->with(
+ $this->equalTo(1_800_000_000),
+ PushJobScheduler::ACTION_RUN_JOB,
+ $this->equalTo([99]),
+ PushJobScheduler::GROUP
+ );
+
+ (new PushJobScheduler($repo, $throttle))->enqueue(42, 'post');
+ }
+
+ public function testTestPushBypassesThrottle(): void
+ {
+ $repo = $this->createMock(PushJobRepository::class);
+ $repo->method('create_pending')->willReturn(99);
+
+ $throttle = $this->createMock(PushThrottle::class);
+ $throttle->expects($this->never())->method('next_slot');
+
+ Functions\expect('as_schedule_single_action')->once();
+
+ (new PushJobScheduler($repo, $throttle))->enqueue(42, 'post', is_test: true);
+
+ $this->assertTrue(true);
+ }
+
+ public function testEnqueueTestPushFlagsTheJob(): void
+ {
+ $repo = $this->createMock(PushJobRepository::class);
+ $repo->expects($this->once())->method('create_pending')
+ ->with($this->equalTo(0), $this->equalTo('test'), $this->equalTo(true))
+ ->willReturn(7);
+
+ Functions\expect('as_schedule_single_action')->once();
+
+ (new PushJobScheduler($repo))->enqueue_test(0, ['U1', 'U2']);
+
+ $this->assertTrue(true);
+ }
+}
diff --git a/tests/Unit/Push/PushJobTest.php b/tests/Unit/Push/PushJobTest.php
new file mode 100644
index 0000000..662fcb5
--- /dev/null
+++ b/tests/Unit/Push/PushJobTest.php
@@ -0,0 +1,87 @@
+assertSame('pending', PushJob::STATUS_PENDING);
+ $this->assertSame('running', PushJob::STATUS_RUNNING);
+ $this->assertSame('sent', PushJob::STATUS_SENT);
+ $this->assertSame('partial', PushJob::STATUS_PARTIAL);
+ $this->assertSame('skipped_duplicate', PushJob::STATUS_SKIPPED_DUPLICATE);
+ $this->assertSame('aborted_auth', PushJob::STATUS_ABORTED_AUTH);
+ $this->assertSame('failed', PushJob::STATUS_FAILED);
+ }
+
+ public function testFromRowMapsScalarColumns(): void
+ {
+ $job = PushJob::from_row([
+ 'id' => '17',
+ 'post_id' => '42',
+ 'post_type' => 'post',
+ 'status' => 'running',
+ 'recipient_count' => '500',
+ 'sent_count' => '250',
+ 'failed_count' => '0',
+ 'is_test' => '0',
+ 'last_error' => null,
+ 'created_at' => '2026-05-20 12:00:00',
+ 'triggered_at' => '2026-05-20 12:00:01',
+ 'started_at' => '2026-05-20 12:00:05',
+ 'finished_at' => null,
+ ]);
+
+ $this->assertSame(17, $job->id);
+ $this->assertSame(42, $job->post_id);
+ $this->assertSame('post', $job->post_type);
+ $this->assertSame('running', $job->status);
+ $this->assertSame(500, $job->recipient_count);
+ $this->assertSame(250, $job->sent_count);
+ $this->assertSame(0, $job->failed_count);
+ $this->assertFalse($job->is_test);
+ $this->assertNull($job->finished_at);
+ }
+
+ public function testIsTerminalReturnsTrueForCompletedStates(): void
+ {
+ foreach ([PushJob::STATUS_SENT, PushJob::STATUS_PARTIAL, PushJob::STATUS_SKIPPED_DUPLICATE, PushJob::STATUS_ABORTED_AUTH, PushJob::STATUS_FAILED] as $status) {
+ $job = $this->job_with_status($status);
+ $this->assertTrue($job->is_terminal(), "$status should be terminal");
+ }
+
+ foreach ([PushJob::STATUS_PENDING, PushJob::STATUS_RUNNING] as $status) {
+ $job = $this->job_with_status($status);
+ $this->assertFalse($job->is_terminal(), "$status should NOT be terminal");
+ }
+ }
+
+ private function job_with_status(string $status): PushJob
+ {
+ return new PushJob(
+ id: 1,
+ post_id: 1,
+ post_type: 'post',
+ status: $status,
+ recipient_count: 0,
+ sent_count: 0,
+ failed_count: 0,
+ is_test: false,
+ last_error: null,
+ created_at: '2026-05-20 12:00:00',
+ triggered_at: null,
+ started_at: null,
+ finished_at: null
+ );
+ }
+}
diff --git a/tests/Unit/Push/PushLogRepositoryTest.php b/tests/Unit/Push/PushLogRepositoryTest.php
new file mode 100644
index 0000000..36bf5ec
--- /dev/null
+++ b/tests/Unit/Push/PushLogRepositoryTest.php
@@ -0,0 +1,158 @@
+ */
+ public array $queries = [];
+ /** @var array> */
+ public array $rows = [];
+ public string $count = '0';
+ public function prepare(string $query, ...$args): string
+ {
+ $this->queries[] = ['prepare', $query, $args];
+ return $query;
+ }
+ public function get_var(string $sql): ?string
+ {
+ $this->queries[] = ['get_var', $sql, []];
+ return $this->count;
+ }
+ public function get_results(string $sql, $output = OBJECT): array
+ {
+ $this->queries[] = ['get_results', $sql, []];
+ return array_values($this->rows);
+ }
+ public function query(string $sql): int
+ {
+ $this->queries[] = ['query', $sql, []];
+ return 1;
+ }
+ public function insert(string $table, array $data, array $formats): int
+ {
+ $this->queries[] = ['insert', $table, $data];
+ $this->insert_id = ($this->insert_id ?: 0) + 1;
+ return 1;
+ }
+ };
+ $this->wpdb = $wpdb;
+ }
+
+ public function testCreateForJobInsertsOneRowPerRecipientAndReturnsIds(): void
+ {
+ $repo = new PushLogRepository();
+
+ $ids = $repo->create_for_job(
+ job_id: 7,
+ line_user_ids: ['U1', 'U2', 'U3'],
+ subscriber_ids: [101, 102, null],
+ is_test: false
+ );
+
+ $this->assertCount(3, $ids);
+ $insert_count = 0;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'insert' && $q[1] === 'wp_botcat_push_logs') {
+ $insert_count++;
+ $this->assertSame(7, $q[2]['job_id']);
+ $this->assertSame(PushLog::STATUS_PENDING, $q[2]['status']);
+ }
+ }
+ $this->assertSame(3, $insert_count);
+ }
+
+ public function testMarkStatusUpdatesOnlyTheGivenIds(): void
+ {
+ (new PushLogRepository())->mark_status([10, 11, 12], PushLog::STATUS_SENT);
+
+ $update_found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'prepare' && str_contains($q[1], 'UPDATE') && str_contains($q[1], 'IN (')) {
+ $update_found = true;
+ $this->assertContains(PushLog::STATUS_SENT, $q[2]);
+ $this->assertContains(10, $q[2]);
+ $this->assertContains(11, $q[2]);
+ $this->assertContains(12, $q[2]);
+ }
+ }
+ $this->assertTrue($update_found);
+ }
+
+ public function testMarkStatusStoresErrorWhenProvided(): void
+ {
+ (new PushLogRepository())->mark_status([10], PushLog::STATUS_FAILED, 'HTTP 500');
+
+ $found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'prepare' && str_contains($q[1], 'error = %s')) {
+ $found = true;
+ $this->assertContains('HTTP 500', $q[2]);
+ }
+ }
+ $this->assertTrue($found, 'error column must be updated when error is supplied');
+ }
+
+ public function testCountForJobByStatusReturnsInteger(): void
+ {
+ $this->wpdb->count = '7';
+ $this->assertSame(7, (new PushLogRepository())->count_for_job(42, PushLog::STATUS_FAILED));
+
+ $found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'prepare' && str_contains($q[1], 'COUNT')) {
+ $found = true;
+ $this->assertContains(42, $q[2]);
+ $this->assertContains(PushLog::STATUS_FAILED, $q[2]);
+ }
+ }
+ $this->assertTrue($found);
+ }
+
+ public function testFailedLogsForJobReturnsTypedRows(): void
+ {
+ $this->wpdb->rows = [
+ ['id' => '1', 'job_id' => '42', 'subscriber_id' => '101', 'line_user_id' => 'U1', 'status' => 'failed', 'error' => '500', 'attempts' => '3', 'is_test' => '0', 'created_at' => '2026-05-20 00:00:00'],
+ ];
+
+ $logs = (new PushLogRepository())->failed_for_job(42);
+
+ $this->assertCount(1, $logs);
+ $this->assertInstanceOf(PushLog::class, $logs[0]);
+ $this->assertSame('U1', $logs[0]->line_user_id);
+ }
+
+ public function testResetForResendBringsFailedRowsBackToPending(): void
+ {
+ (new PushLogRepository())->reset_for_resend([10, 11]);
+
+ $found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'prepare' && str_contains($q[1], 'UPDATE') && str_contains($q[1], 'IN (')) {
+ $found = true;
+ $this->assertContains(PushLog::STATUS_PENDING, $q[2]);
+ $this->assertContains(10, $q[2]);
+ $this->assertContains(11, $q[2]);
+ }
+ }
+ $this->assertTrue($found);
+ }
+}
diff --git a/tests/Unit/Push/RateLimiterTest.php b/tests/Unit/Push/RateLimiterTest.php
new file mode 100644
index 0000000..8567a3b
--- /dev/null
+++ b/tests/Unit/Push/RateLimiterTest.php
@@ -0,0 +1,42 @@
+assertSame($base, (new RateLimiter($base))->slot_for(0));
+ }
+
+ public function testSecondBatchIsTwoSecondsAfterFirst(): void
+ {
+ $base = 1_700_000_000;
+ $limiter = new RateLimiter($base);
+
+ $this->assertSame($base + 2, $limiter->slot_for(1));
+ }
+
+ public function testThirtyFirstBatchSitsAtSixtyOneSecondsToKeepBelowThirtyPerMinute(): void
+ {
+ $base = 1_700_000_000;
+ $limiter = new RateLimiter($base);
+
+ $slot = $limiter->slot_for(30);
+
+ $this->assertGreaterThanOrEqual($base + 60, $slot, '30 requests cannot fit inside the first 60s window');
+ }
+}
diff --git a/tests/Unit/Push/RetryPolicyTest.php b/tests/Unit/Push/RetryPolicyTest.php
new file mode 100644
index 0000000..1a94097
--- /dev/null
+++ b/tests/Unit/Push/RetryPolicyTest.php
@@ -0,0 +1,132 @@
+decide(1, new MulticastResult(ok: true, http_status: 200));
+
+ $this->assertSame(RetryDecision::ACTION_DONE, $decision->action);
+ }
+
+ public function testFiveHundredAttemptOneRetriesIn30Seconds(): void
+ {
+ $decision = (new RetryPolicy())->decide(1, $this->server_error(502));
+
+ $this->assertSame(RetryDecision::ACTION_RETRY, $decision->action);
+ $this->assertSame(30, $decision->delay_seconds);
+ }
+
+ public function testFiveHundredAttemptTwoRetriesIn300Seconds(): void
+ {
+ $decision = (new RetryPolicy())->decide(2, $this->server_error(502));
+
+ $this->assertSame(RetryDecision::ACTION_RETRY, $decision->action);
+ $this->assertSame(300, $decision->delay_seconds);
+ }
+
+ public function testFiveHundredAttemptThreeRetriesIn1800Seconds(): void
+ {
+ $decision = (new RetryPolicy())->decide(3, $this->server_error(502));
+
+ $this->assertSame(RetryDecision::ACTION_RETRY, $decision->action);
+ $this->assertSame(1800, $decision->delay_seconds);
+ }
+
+ public function testFiveHundredAfterFourthAttemptIsFailed(): void
+ {
+ $decision = (new RetryPolicy())->decide(4, $this->server_error(503, 'Service Unavailable'));
+
+ $this->assertSame(RetryDecision::ACTION_FAIL, $decision->action);
+ $this->assertSame('Service Unavailable', $decision->error);
+ }
+
+ public function testFourOhOneImmediatelyAbortsTheJob(): void
+ {
+ $decision = (new RetryPolicy())->decide(1, new MulticastResult(
+ ok: false,
+ http_status: 401,
+ error_code: MulticastClient::ERROR_AUTH,
+ error_message: 'Authentication failed'
+ ));
+
+ $this->assertSame(RetryDecision::ACTION_ABORT_AUTH, $decision->action);
+ $this->assertSame('Authentication failed', $decision->error);
+ }
+
+ public function testFourHundredErrorFailsWithoutRetry(): void
+ {
+ $decision = (new RetryPolicy())->decide(1, new MulticastResult(
+ ok: false,
+ http_status: 400,
+ error_code: MulticastClient::ERROR_CLIENT,
+ error_message: 'Invalid user ID'
+ ));
+
+ $this->assertSame(RetryDecision::ACTION_FAIL, $decision->action);
+ }
+
+ public function testFourTwentyNineHonorsRetryAfterHeader(): void
+ {
+ $decision = (new RetryPolicy())->decide(1, new MulticastResult(
+ ok: false,
+ http_status: 429,
+ retry_after: 120,
+ error_code: MulticastClient::ERROR_RATE_LIMITED
+ ));
+
+ $this->assertSame(RetryDecision::ACTION_RETRY, $decision->action);
+ $this->assertSame(120, $decision->delay_seconds);
+ }
+
+ public function testFourTwentyNineWithoutRetryAfterFallsBackToBackoff(): void
+ {
+ $decision = (new RetryPolicy())->decide(1, new MulticastResult(
+ ok: false,
+ http_status: 429,
+ error_code: MulticastClient::ERROR_RATE_LIMITED
+ ));
+
+ $this->assertSame(RetryDecision::ACTION_RETRY, $decision->action);
+ $this->assertSame(30, $decision->delay_seconds);
+ }
+
+ public function testNetworkErrorIsRetriedLikeServerError(): void
+ {
+ $decision = (new RetryPolicy())->decide(1, new MulticastResult(
+ ok: false,
+ error_code: MulticastClient::ERROR_NETWORK
+ ));
+
+ $this->assertSame(RetryDecision::ACTION_RETRY, $decision->action);
+ $this->assertSame(30, $decision->delay_seconds);
+ }
+
+ private function server_error(int $code, string $message = 'Bad Gateway'): MulticastResult
+ {
+ return new MulticastResult(
+ ok: false,
+ http_status: $code,
+ error_code: MulticastClient::ERROR_SERVER,
+ error_message: $message
+ );
+ }
+}
diff --git a/tests/Unit/Rules/ExcludedCategoriesTest.php b/tests/Unit/Rules/ExcludedCategoriesTest.php
new file mode 100644
index 0000000..749b8c0
--- /dev/null
+++ b/tests/Unit/Rules/ExcludedCategoriesTest.php
@@ -0,0 +1,50 @@
+andReturn([]);
+
+ $this->assertFalse((new ExcludedCategories())->is_excluded(42));
+ }
+
+ public function testPostWithExcludedCategoryIsExcluded(): void
+ {
+ Functions\expect('get_option')->andReturn([5, 9]);
+ Functions\expect('wp_get_post_categories')
+ ->with(42, ['fields' => 'ids'])
+ ->andReturn([3, 5, 7]);
+
+ $this->assertTrue((new ExcludedCategories())->is_excluded(42));
+ }
+
+ public function testPostWithoutExcludedCategoryIsNotExcluded(): void
+ {
+ Functions\expect('get_option')->andReturn([5, 9]);
+ Functions\expect('wp_get_post_categories')->andReturn([3, 7]);
+
+ $this->assertFalse((new ExcludedCategories())->is_excluded(42));
+ }
+
+ public function testSaveStoresUniqueIntegerIds(): void
+ {
+ Functions\expect('update_option')->once()
+ ->with(ExcludedCategories::OPTION, [3, 7, 9]);
+
+ (new ExcludedCategories())->save(['3', 7, '9', '7']);
+
+ $this->assertTrue(true);
+ }
+}
diff --git a/tests/Unit/Rules/PerPostOptOutTest.php b/tests/Unit/Rules/PerPostOptOutTest.php
new file mode 100644
index 0000000..39e51e9
--- /dev/null
+++ b/tests/Unit/Rules/PerPostOptOutTest.php
@@ -0,0 +1,65 @@
+with(42, PerPostOptOut::META_KEY, true)->andReturn('');
+ Functions\expect('get_option')->with(PerPostOptOut::DEFAULT_OPTION, true)->andReturn(true);
+
+ $this->assertTrue((new PerPostOptOut())->is_opted_in(42));
+ }
+
+ public function testGlobalDefaultOffMakesOptOut(): void
+ {
+ Functions\expect('get_post_meta')->andReturn('');
+ Functions\expect('get_option')->andReturn(false);
+
+ $this->assertFalse((new PerPostOptOut())->is_opted_in(42));
+ }
+
+ public function testMetaExplicitlyOneMeansOptedIn(): void
+ {
+ Functions\expect('get_post_meta')->andReturn('1');
+
+ $this->assertTrue((new PerPostOptOut())->is_opted_in(42));
+ }
+
+ public function testMetaExplicitlyZeroMeansOptedOutRegardlessOfGlobal(): void
+ {
+ Functions\expect('get_post_meta')->andReturn('0');
+
+ $this->assertFalse((new PerPostOptOut())->is_opted_in(42));
+ }
+
+ public function testSaveForPostWritesOneOrZero(): void
+ {
+ Functions\expect('update_post_meta')->once()
+ ->with(42, PerPostOptOut::META_KEY, '1');
+
+ (new PerPostOptOut())->save_for_post(42, true);
+
+ $this->assertTrue(true);
+ }
+
+ public function testSaveForPostFalseWritesZero(): void
+ {
+ Functions\expect('update_post_meta')->once()
+ ->with(42, PerPostOptOut::META_KEY, '0');
+
+ (new PerPostOptOut())->save_for_post(42, false);
+
+ $this->assertTrue(true);
+ }
+}
diff --git a/tests/Unit/Rules/PushThrottleTest.php b/tests/Unit/Rules/PushThrottleTest.php
new file mode 100644
index 0000000..2c53eae
--- /dev/null
+++ b/tests/Unit/Rules/PushThrottleTest.php
@@ -0,0 +1,73 @@
+alias(static fn($name, $default) => $name === PushThrottle::OPTION_INTERVAL_MIN ? 0 : $default);
+
+ $slot = (new PushThrottle())->next_slot(now: 1_000);
+
+ $this->assertSame(1_000, $slot);
+ }
+
+ public function testThrottleDelaysSubsequentPushesByIntervalSeconds(): void
+ {
+ Functions\when('get_option')->alias(static fn($name, $default) => match ($name) {
+ PushThrottle::OPTION_INTERVAL_MIN => 30,
+ PushThrottle::OPTION_NEXT_ALLOWED_AT => 0,
+ default => $default,
+ });
+ Functions\expect('update_option')->with(PushThrottle::OPTION_NEXT_ALLOWED_AT, $this->isType('int'));
+
+ $slot = (new PushThrottle())->next_slot(now: 1_000);
+
+ $this->assertSame(1_000, $slot, 'first push when no prior slot stored fires immediately');
+ }
+
+ public function testSecondPushWithinWindowIsPushedToWindowEnd(): void
+ {
+ Functions\when('get_option')->alias(static fn($name, $default) => match ($name) {
+ PushThrottle::OPTION_INTERVAL_MIN => 30,
+ PushThrottle::OPTION_NEXT_ALLOWED_AT => 1_300,
+ default => $default,
+ });
+ $stored = null;
+ Functions\expect('update_option')
+ ->with(PushThrottle::OPTION_NEXT_ALLOWED_AT, \Mockery::on(function ($v) use (&$stored) {
+ $stored = $v;
+ return true;
+ }));
+
+ $slot = (new PushThrottle())->next_slot(now: 1_100);
+
+ $this->assertSame(1_300, $slot);
+ $this->assertSame(1_300 + 30 * 60, $stored, 'next allowed slot must move forward by one interval');
+ }
+
+ public function testThirdPushCascadesAfterSecond(): void
+ {
+ $interval_sec = 30 * 60;
+ Functions\when('get_option')->alias(static fn($name, $default) => match ($name) {
+ PushThrottle::OPTION_INTERVAL_MIN => 30,
+ PushThrottle::OPTION_NEXT_ALLOWED_AT => 1_000 + $interval_sec,
+ default => $default,
+ });
+ Functions\expect('update_option');
+
+ $slot = (new PushThrottle())->next_slot(now: 1_400);
+
+ $this->assertSame(1_000 + $interval_sec, $slot);
+ }
+}
diff --git a/tests/Unit/Shortener/ClickLogRepositoryTest.php b/tests/Unit/Shortener/ClickLogRepositoryTest.php
new file mode 100644
index 0000000..0ea12db
--- /dev/null
+++ b/tests/Unit/Shortener/ClickLogRepositoryTest.php
@@ -0,0 +1,87 @@
+queries[] = ['prepare', $query, $args];
+ return $query;
+ }
+ public function get_var(string $sql): ?string
+ {
+ $this->queries[] = ['get_var', $sql, []];
+ return $this->next_var;
+ }
+ public function insert(string $table, array $data, array $formats): int
+ {
+ $this->queries[] = ['insert', $table, $data];
+ return 1;
+ }
+ };
+ $this->wpdb = $wpdb;
+ }
+
+ public function testLogInsertsRowAndTruncatesUaAndReferer(): void
+ {
+ $long_ua = str_repeat('a', 400);
+ $long_ref = str_repeat('b', 400);
+
+ (new ClickLogRepository())->log(7, 101, str_repeat('h', 64), $long_ua, $long_ref);
+
+ $found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'insert' && $q[1] === 'wp_botcat_short_link_clicks') {
+ $found = true;
+ $this->assertSame(7, $q[2]['short_link_id']);
+ $this->assertSame(101, $q[2]['subscriber_id']);
+ $this->assertSame(64, strlen($q[2]['ip_hash']));
+ $this->assertLessThanOrEqual(255, strlen($q[2]['user_agent']));
+ $this->assertLessThanOrEqual(255, strlen($q[2]['referer']));
+ }
+ }
+ $this->assertTrue($found);
+ }
+
+ public function testLogAcceptsNullSubscriberId(): void
+ {
+ (new ClickLogRepository())->log(7, null, str_repeat('h', 64), 'curl/8.0', null);
+
+ $found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'insert') {
+ $found = true;
+ $this->assertNull($q[2]['subscriber_id']);
+ }
+ }
+ $this->assertTrue($found);
+ }
+
+ public function testCountTotalAndUnique(): void
+ {
+ $this->wpdb->next_var = '125';
+ $this->assertSame(125, (new ClickLogRepository())->count_total(7));
+
+ $this->wpdb->next_var = '37';
+ $this->assertSame(37, (new ClickLogRepository())->count_unique(7));
+ }
+}
diff --git a/tests/Unit/Shortener/ClickRedirectHandlerTest.php b/tests/Unit/Shortener/ClickRedirectHandlerTest.php
new file mode 100644
index 0000000..205104f
--- /dev/null
+++ b/tests/Unit/Shortener/ClickRedirectHandlerTest.php
@@ -0,0 +1,119 @@
+createMock(ShortLinkRepository::class);
+ $links->method('find_by_code')->willReturn(new ShortLink(1, 'abc123', 42, 'https://x.test/p/42', ''));
+
+ $clicks = $this->createMock(ClickLogRepository::class);
+ $clicks->expects($this->once())->method('log')->with(
+ 1,
+ null,
+ $this->matchesRegularExpression('/^[a-f0-9]{64}$/'),
+ 'curl/8',
+ null
+ );
+
+ $signer = $this->createMock(SubscriberTokenSigner::class);
+
+ $result = (new ClickRedirectHandler($links, $clicks, $signer, 'salt'))
+ ->handle('abc123', null, '203.0.113.1', 'curl/8', null);
+
+ $this->assertSame(301, $result['status']);
+ $this->assertSame('https://x.test/p/42', $result['location']);
+ }
+
+ public function testUnknownCodeReturns404AndDoesNotLog(): void
+ {
+ $links = $this->createMock(ShortLinkRepository::class);
+ $links->method('find_by_code')->willReturn(null);
+
+ $clicks = $this->createMock(ClickLogRepository::class);
+ $clicks->expects($this->never())->method('log');
+
+ $result = (new ClickRedirectHandler(
+ $links,
+ $clicks,
+ $this->createMock(SubscriberTokenSigner::class),
+ 'salt'
+ ))->handle('zzzzzz', null, '127.0.0.1', '', null);
+
+ $this->assertSame(404, $result['status']);
+ $this->assertNull($result['location']);
+ }
+
+ public function testValidTokenAttributesSubscriber(): void
+ {
+ $links = $this->createMock(ShortLinkRepository::class);
+ $links->method('find_by_code')->willReturn(new ShortLink(1, 'abc123', 42, 'https://x.test/p/42', ''));
+
+ $signer = $this->createMock(SubscriberTokenSigner::class);
+ $signer->method('verify')->with('valid-token')->willReturn(101);
+
+ $clicks = $this->createMock(ClickLogRepository::class);
+ $clicks->expects($this->once())->method('log')->with(1, 101, $this->anything(), $this->anything(), null);
+
+ (new ClickRedirectHandler($links, $clicks, $signer, 'salt'))
+ ->handle('abc123', 'valid-token', '127.0.0.1', null, null);
+ }
+
+ public function testTamperedTokenStillRedirectsButAnonymizesAttribution(): void
+ {
+ $links = $this->createMock(ShortLinkRepository::class);
+ $links->method('find_by_code')->willReturn(new ShortLink(1, 'abc123', 42, 'https://x.test/p/42', ''));
+
+ $signer = $this->createMock(SubscriberTokenSigner::class);
+ $signer->method('verify')->willReturn(null);
+
+ $clicks = $this->createMock(ClickLogRepository::class);
+ $clicks->expects($this->once())->method('log')->with(1, null, $this->anything(), null, null);
+
+ $result = (new ClickRedirectHandler($links, $clicks, $signer, 'salt'))
+ ->handle('abc123', 'bad-token', '127.0.0.1', null, null);
+
+ $this->assertSame(301, $result['status']);
+ $this->assertSame('https://x.test/p/42', $result['location']);
+ }
+
+ public function testSameIpProducesSameHash(): void
+ {
+ $links = $this->createMock(ShortLinkRepository::class);
+ $links->method('find_by_code')->willReturn(new ShortLink(1, 'abc123', 42, 'https://x.test/p/42', ''));
+
+ $hashes = [];
+ $clicks = $this->createMock(ClickLogRepository::class);
+ $clicks->method('log')->willReturnCallback(function (...$args) use (&$hashes) {
+ $hashes[] = $args[2];
+ });
+
+ $handler = new ClickRedirectHandler(
+ $links,
+ $clicks,
+ $this->createMock(SubscriberTokenSigner::class),
+ 'salt'
+ );
+
+ $handler->handle('abc123', null, '203.0.113.1', null, null);
+ $handler->handle('abc123', null, '203.0.113.1', null, null);
+
+ $this->assertCount(2, $hashes);
+ $this->assertSame($hashes[0], $hashes[1], 'same IP must produce same hash');
+ $this->assertStringNotContainsString('203.0.113.1', $hashes[0], 'raw IP must NOT appear in hash');
+ }
+}
diff --git a/tests/Unit/Shortener/LinkRewriterTest.php b/tests/Unit/Shortener/LinkRewriterTest.php
new file mode 100644
index 0000000..0345c0f
--- /dev/null
+++ b/tests/Unit/Shortener/LinkRewriterTest.php
@@ -0,0 +1,76 @@
+createMock(ShortLinkRepository::class);
+ $links->method('create_for_post')
+ ->with(42, 'https://x.test/2026/05/foo')
+ ->willReturn(new ShortLink(1, 'aB3xY9', 42, 'https://x.test/2026/05/foo', ''));
+
+ $rewriter = new LinkRewriter($links, 'https://x.test', 'https://x.test/l/');
+ $result = $rewriter->rewrite(
+ 'Title • https://x.test/2026/05/foo',
+ 42,
+ 'https://x.test/2026/05/foo'
+ );
+
+ $this->assertSame('Title • https://x.test/l/aB3xY9', $result);
+ }
+
+ public function testRewritesMultipleOccurrencesOfPermalink(): void
+ {
+ $links = $this->createMock(ShortLinkRepository::class);
+ $links->method('create_for_post')->willReturn(new ShortLink(1, 'aB3xY9', 42, 'https://x.test/2026/05/foo', ''));
+
+ $rewriter = new LinkRewriter($links, 'https://x.test', 'https://x.test/l/');
+ $result = $rewriter->rewrite(
+ 'See https://x.test/2026/05/foo or https://x.test/2026/05/foo again.',
+ 42,
+ 'https://x.test/2026/05/foo'
+ );
+
+ $this->assertStringNotContainsString('2026/05/foo', $result);
+ $this->assertEquals(2, substr_count($result, 'https://x.test/l/aB3xY9'));
+ }
+
+ public function testExternalUrlsAreLeftAlone(): void
+ {
+ $links = $this->createMock(ShortLinkRepository::class);
+ $links->method('create_for_post')->willReturn(new ShortLink(1, 'aB3xY9', 42, 'https://x.test/post/42', ''));
+
+ $rewriter = new LinkRewriter($links, 'https://x.test', 'https://x.test/l/');
+ $result = $rewriter->rewrite(
+ 'See https://other-site.test/article and https://x.test/post/42',
+ 42,
+ 'https://x.test/post/42'
+ );
+
+ $this->assertStringContainsString('https://other-site.test/article', $result);
+ $this->assertStringContainsString('https://x.test/l/aB3xY9', $result);
+ }
+
+ public function testEmptyPermalinkIsNoOp(): void
+ {
+ $links = $this->createMock(ShortLinkRepository::class);
+ $links->expects($this->never())->method('create_for_post');
+
+ $rewriter = new LinkRewriter($links, 'https://x.test', 'https://x.test/l/');
+ $result = $rewriter->rewrite('no urls here', 42, '');
+
+ $this->assertSame('no urls here', $result);
+ }
+}
diff --git a/tests/Unit/Shortener/ShortCodeGeneratorTest.php b/tests/Unit/Shortener/ShortCodeGeneratorTest.php
new file mode 100644
index 0000000..6031569
--- /dev/null
+++ b/tests/Unit/Shortener/ShortCodeGeneratorTest.php
@@ -0,0 +1,33 @@
+generate();
+
+ $this->assertSame(6, strlen($code));
+ $this->assertMatchesRegularExpression('/^[0-9A-Za-z]+$/', $code);
+ }
+
+ public function testGeneratesDistinctCodesAcrossInvocations(): void
+ {
+ $gen = new ShortCodeGenerator();
+ $codes = [];
+ for ($i = 0; $i < 100; $i++) {
+ $codes[$gen->generate()] = true;
+ }
+
+ $this->assertGreaterThan(95, count($codes), 'random generator should rarely repeat over 100 trials');
+ }
+}
diff --git a/tests/Unit/Shortener/ShortLinkRepositoryTest.php b/tests/Unit/Shortener/ShortLinkRepositoryTest.php
new file mode 100644
index 0000000..61ee8a8
--- /dev/null
+++ b/tests/Unit/Shortener/ShortLinkRepositoryTest.php
@@ -0,0 +1,116 @@
+ */
+ public array $queries = [];
+ /** @var array|null */
+ public ?array $next_row = null;
+ public function prepare(string $query, ...$args): string
+ {
+ $this->queries[] = ['prepare', $query, $args];
+ return $query;
+ }
+ public function get_row(string $sql, $output = OBJECT): ?array
+ {
+ $this->queries[] = ['get_row', $sql, []];
+ return $this->next_row;
+ }
+ public function get_results(string $sql, $output = OBJECT): array
+ {
+ $this->queries[] = ['get_results', $sql, []];
+ return [];
+ }
+ public function get_var(string $sql): ?string
+ {
+ $this->queries[] = ['get_var', $sql, []];
+ return null;
+ }
+ public function insert(string $table, array $data, array $formats): int
+ {
+ $this->queries[] = ['insert', $table, $data];
+ $this->insert_id = 42;
+ return 1;
+ }
+ };
+ $this->wpdb = $wpdb;
+ }
+
+ public function testCreateForPostInsertsNewRowWhenAbsent(): void
+ {
+ $generator = $this->createMock(ShortCodeGenerator::class);
+ $generator->method('generate')->willReturn('aB3xY9');
+
+ $repo = new ShortLinkRepository($generator);
+ $link = $repo->create_for_post(42, 'https://x.test/p/42');
+
+ $this->assertSame('aB3xY9', $link->code);
+ $this->assertSame(42, $link->id);
+
+ $found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'insert' && $q[1] === 'wp_botcat_short_links') {
+ $found = true;
+ $this->assertSame('aB3xY9', $q[2]['code']);
+ $this->assertSame(42, $q[2]['post_id']);
+ $this->assertSame('https://x.test/p/42', $q[2]['original_url']);
+ }
+ }
+ $this->assertTrue($found);
+ }
+
+ public function testCreateForPostReusesExistingRowWhenUrlUnchanged(): void
+ {
+ $this->wpdb->next_row = [
+ 'id' => '7', 'code' => 'aB3xY9', 'post_id' => '42',
+ 'original_url' => 'https://x.test/p/42', 'created_at' => '2026-05-26',
+ ];
+
+ $generator = $this->createMock(ShortCodeGenerator::class);
+ $generator->expects($this->never())->method('generate');
+
+ $repo = new ShortLinkRepository($generator);
+ $link = $repo->create_for_post(42, 'https://x.test/p/42');
+
+ $this->assertSame(7, $link->id);
+ $this->assertSame('aB3xY9', $link->code);
+ }
+
+ public function testFindByCodeReturnsLinkOrNull(): void
+ {
+ $this->wpdb->next_row = [
+ 'id' => '7', 'code' => 'aB3xY9', 'post_id' => '42',
+ 'original_url' => 'https://x.test/p/42', 'created_at' => '',
+ ];
+
+ $repo = new ShortLinkRepository($this->createMock(ShortCodeGenerator::class));
+ $link = $repo->find_by_code('aB3xY9');
+
+ $this->assertInstanceOf(ShortLink::class, $link);
+ $this->assertSame('https://x.test/p/42', $link->original_url);
+
+ $this->wpdb->next_row = null;
+ $this->assertNull($repo->find_by_code('zzzzzz'));
+ }
+}
diff --git a/tests/Unit/Shortener/SubscriberTokenSignerTest.php b/tests/Unit/Shortener/SubscriberTokenSignerTest.php
new file mode 100644
index 0000000..90f6a64
--- /dev/null
+++ b/tests/Unit/Shortener/SubscriberTokenSignerTest.php
@@ -0,0 +1,57 @@
+sign(42);
+ $this->assertSame(42, $signer->verify($token));
+ }
+
+ public function testTokenIsUrlSafe(): void
+ {
+ $token = (new SubscriberTokenSigner('site-key'))->sign(42);
+
+ $this->assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $token);
+ }
+
+ public function testTamperedTokenFailsVerification(): void
+ {
+ $signer = new SubscriberTokenSigner('site-key');
+ $token = $signer->sign(42);
+
+ // Flip a single character.
+ $tampered = $token[0] === 'A' ? 'B' . substr($token, 1) : 'A' . substr($token, 1);
+
+ $this->assertNull($signer->verify($tampered));
+ }
+
+ public function testTokenSignedByOtherKeyDoesNotVerify(): void
+ {
+ $alice = new SubscriberTokenSigner('alice-key');
+ $bob = new SubscriberTokenSigner('bob-key');
+
+ $this->assertNull($bob->verify($alice->sign(42)));
+ }
+
+ public function testEmptyOrMalformedTokenReturnsNull(): void
+ {
+ $signer = new SubscriberTokenSigner('site-key');
+
+ $this->assertNull($signer->verify(''));
+ $this->assertNull($signer->verify('garbage'));
+ $this->assertNull($signer->verify('no.dot.expected'));
+ }
+}
diff --git a/tests/Unit/Subscribers/FollowHandlerTest.php b/tests/Unit/Subscribers/FollowHandlerTest.php
new file mode 100644
index 0000000..ee9d169
--- /dev/null
+++ b/tests/Unit/Subscribers/FollowHandlerTest.php
@@ -0,0 +1,143 @@
+createMock(SubscriberRepository::class);
+ $subscribers->expects($this->once())->method('upsert_active')->with(
+ $this->equalTo('U123'),
+ $this->equalTo('Eric'),
+ $this->equalTo('https://line/p.jpg'),
+ $this->isType('string')
+ );
+
+ $channel = $this->createMock(ChannelSettingsRepository::class);
+ $channel->method('get')->willReturn(new ChannelSettings('1', 'sec', 'tok'));
+
+ $profiles = $this->createMock(LineProfileFetcher::class);
+ $profiles->expects($this->once())->method('fetch')
+ ->with('U123', 'tok')
+ ->willReturn(new LineProfileResult(ok: true, display_name: 'Eric', picture_url: 'https://line/p.jpg'));
+
+ Functions\expect('as_enqueue_async_action')->never();
+
+ $handler = new FollowHandler($subscribers, $channel, $profiles);
+ $handler->handle('U123', 1_715_000_000);
+ }
+
+ public function testFollowStillCreatesSubscriberWhenProfileFetchFails(): void
+ {
+ $subscribers = $this->createMock(SubscriberRepository::class);
+ $subscribers->expects($this->once())->method('upsert_active')->with(
+ 'U123',
+ null,
+ null,
+ $this->isType('string')
+ );
+
+ $channel = $this->createMock(ChannelSettingsRepository::class);
+ $channel->method('get')->willReturn(new ChannelSettings('1', 'sec', 'tok'));
+
+ $profiles = $this->createMock(LineProfileFetcher::class);
+ $profiles->expects($this->once())->method('fetch')
+ ->willReturn(new LineProfileResult(ok: false, error_code: 'http_500'));
+
+ Functions\expect('as_enqueue_async_action')->once()
+ ->with(
+ 'botcat_retry_profile_fetch',
+ $this->callback(static fn(array $args): bool => in_array('U123', $args, true)),
+ $this->isType('string')
+ );
+
+ $handler = new FollowHandler($subscribers, $channel, $profiles);
+ $handler->handle('U123', 1_715_000_000);
+ }
+
+ public function testFollowSkipsProfileFetchWhenChannelNotConfigured(): void
+ {
+ $subscribers = $this->createMock(SubscriberRepository::class);
+ $subscribers->expects($this->once())->method('upsert_active');
+
+ $channel = $this->createMock(ChannelSettingsRepository::class);
+ $channel->method('get')->willReturn(new ChannelSettings('', '', ''));
+
+ $profiles = $this->createMock(LineProfileFetcher::class);
+ $profiles->expects($this->never())->method('fetch');
+
+ Functions\expect('as_enqueue_async_action')->never();
+
+ $handler = new FollowHandler($subscribers, $channel, $profiles);
+ $handler->handle('U123', 1_715_000_000);
+ }
+
+ public function testWelcomeReplyIsSentOnFirstFollowWhenWelcomeBuilderProvided(): void
+ {
+ $subscribers = $this->createMock(SubscriberRepository::class);
+ $subscribers->method('find_by_line_id')->willReturn(null); // brand new
+ $subscribers->expects($this->once())->method('upsert_active');
+
+ $channel = $this->createMock(ChannelSettingsRepository::class);
+ $channel->method('get')->willReturn(new ChannelSettings('1', 'sec', 'tok'));
+
+ $profiles = $this->createMock(LineProfileFetcher::class);
+ $profiles->method('fetch')->willReturn(new LineProfileResult(ok: true, display_name: 'Eric'));
+
+ $welcome = $this->createMock(WelcomeMessageBuilder::class);
+ $welcome->method('build')->willReturn('Welcome 🐱');
+
+ $reply_client = $this->createMock(ReplyClient::class);
+ $reply_client->expects($this->once())->method('reply')
+ ->with('tok', 'rt-abc', 'Welcome 🐱');
+
+ $handler = new FollowHandler($subscribers, $channel, $profiles, $welcome, $reply_client);
+ $handler->handle('U123', 1_715_000_000, 'rt-abc');
+ }
+
+ public function testWelcomeReplyIsSkippedOnRefollow(): void
+ {
+ $existing = new \BotCat\Subscribers\Subscriber(
+ id: 1, line_user_id: 'U123', display_name: null, picture_url: null,
+ status: 'unfollowed', followed_at: null, unfollowed_at: '2026-05-01 00:00:00'
+ );
+
+ $subscribers = $this->createMock(SubscriberRepository::class);
+ $subscribers->method('find_by_line_id')->willReturn($existing);
+
+ $channel = $this->createMock(ChannelSettingsRepository::class);
+ $channel->method('get')->willReturn(new ChannelSettings('1', 'sec', 'tok'));
+
+ $profiles = $this->createMock(LineProfileFetcher::class);
+ $profiles->method('fetch')->willReturn(new LineProfileResult(ok: true));
+
+ $welcome = $this->createMock(WelcomeMessageBuilder::class);
+ $welcome->expects($this->never())->method('build');
+
+ $reply_client = $this->createMock(ReplyClient::class);
+ $reply_client->expects($this->never())->method('reply');
+
+ $handler = new FollowHandler($subscribers, $channel, $profiles, $welcome, $reply_client);
+ $handler->handle('U123', 1_715_000_000, 'rt-abc');
+ }
+}
diff --git a/tests/Unit/Subscribers/RecentLogsTest.php b/tests/Unit/Subscribers/RecentLogsTest.php
new file mode 100644
index 0000000..dd3baa8
--- /dev/null
+++ b/tests/Unit/Subscribers/RecentLogsTest.php
@@ -0,0 +1,132 @@
+}> */
+ public array $queries = [];
+ /** @var list> */
+ public array $rows_to_return = [];
+ public function prepare(string $query, ...$args): string
+ {
+ $this->queries[] = ['prepare', $query, $args];
+ return $query;
+ }
+ public function get_results(string $sql, $output = OBJECT): array
+ {
+ $this->queries[] = ['get_results', $sql, []];
+ return $this->rows_to_return;
+ }
+ };
+ $this->wpdb = $wpdb;
+ }
+
+ public function testReturnsAtMostTwentyRowsByDefault(): void
+ {
+ $this->wpdb->rows_to_return = $this->fixture_rows(20);
+
+ $logs = (new SubscriberRepository())->recent_logs_for_subscriber(42);
+
+ $this->assertCount(20, $logs);
+ $this->assertInstanceOf(PushLogEntry::class, $logs[0]);
+
+ $limit_found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'prepare' && str_contains($q[1], 'LIMIT')) {
+ $this->assertContains(20, $q[2]);
+ $this->assertContains(42, $q[2]);
+ $limit_found = true;
+ }
+ }
+ $this->assertTrue($limit_found, 'recent_logs should apply LIMIT and the subscriber id');
+ }
+
+ public function testCustomLimitIsRespected(): void
+ {
+ $this->wpdb->rows_to_return = $this->fixture_rows(5);
+
+ (new SubscriberRepository())->recent_logs_for_subscriber(42, limit: 5);
+
+ $limit_found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'prepare' && str_contains($q[1], 'LIMIT')) {
+ $this->assertContains(5, $q[2]);
+ $limit_found = true;
+ }
+ }
+ $this->assertTrue($limit_found);
+ }
+
+ public function testOrdersByMostRecentFirst(): void
+ {
+ $this->wpdb->rows_to_return = [];
+
+ (new SubscriberRepository())->recent_logs_for_subscriber(42);
+
+ $order_found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'prepare' && preg_match('/ORDER BY .*created_at DESC/i', $q[1])) {
+ $order_found = true;
+ }
+ }
+ $this->assertTrue($order_found, 'logs should be ordered created_at DESC');
+ }
+
+ public function testJoinsPushJobsForPostMetadata(): void
+ {
+ $this->wpdb->rows_to_return = [];
+
+ (new SubscriberRepository())->recent_logs_for_subscriber(42);
+
+ $join_found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'prepare' && str_contains($q[1], 'wp_botcat_push_jobs')) {
+ $join_found = true;
+ }
+ }
+ $this->assertTrue($join_found, 'recent_logs must join wp_botcat_push_jobs for post info');
+ }
+
+ /**
+ * @return list>
+ */
+ private function fixture_rows(int $count): array
+ {
+ $rows = [];
+ for ($i = 1; $i <= $count; $i++) {
+ $rows[] = [
+ 'id' => (string) $i,
+ 'job_id' => (string) (100 + $i),
+ 'subscriber_id' => '42',
+ 'line_user_id' => 'U42',
+ 'status' => 'sent',
+ 'error' => null,
+ 'created_at' => '2026-05-' . str_pad((string) $i, 2, '0', STR_PAD_LEFT) . ' 00:00:00',
+ 'post_id' => (string) (200 + $i),
+ ];
+ }
+ return $rows;
+ }
+}
diff --git a/tests/Unit/Subscribers/SubscriberRepositoryTest.php b/tests/Unit/Subscribers/SubscriberRepositoryTest.php
new file mode 100644
index 0000000..422aadf
--- /dev/null
+++ b/tests/Unit/Subscribers/SubscriberRepositoryTest.php
@@ -0,0 +1,169 @@
+}> */
+ public array $queries = [];
+ /** @var list> */
+ public array $rows_to_return = [];
+ public function prepare(string $query, ...$args): string
+ {
+ $this->queries[] = ['prepare', $query, $args];
+ return $query;
+ }
+ public function get_var(string $sql): ?string
+ {
+ return '0';
+ }
+ public function get_results(string $sql, $output = OBJECT): array
+ {
+ $batch = array_shift($this->rows_to_return);
+ return $batch ?: [];
+ }
+ public function get_row(string $sql, $output = OBJECT): ?array
+ {
+ $batch = array_shift($this->rows_to_return);
+ return $batch[0] ?? null;
+ }
+ public function query(string $sql): int
+ {
+ $this->queries[] = ['query', $sql, []];
+ return 1;
+ }
+ };
+ $this->wpdb = $wpdb;
+ }
+
+ public function testFindByLineIdReturnsSubscriberWhenRowExists(): void
+ {
+ $this->wpdb->rows_to_return = [[[
+ 'id' => '42',
+ 'line_user_id' => 'U123',
+ 'display_name' => 'Eric',
+ 'picture_url' => null,
+ 'status' => 'active',
+ 'followed_at' => '2026-05-01 00:00:00',
+ 'unfollowed_at' => null,
+ ]]];
+
+ $repo = new SubscriberRepository();
+ $sub = $repo->find_by_line_id('U123');
+
+ $this->assertNotNull($sub);
+ $this->assertSame(42, $sub->id);
+ $this->assertSame('U123', $sub->line_user_id);
+
+ $found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if (str_contains($q[1], 'wp_botcat_subscribers') && in_array('U123', $q[2], true)) {
+ $found = true;
+ }
+ }
+ $this->assertTrue($found, 'query should filter by line_user_id = U123');
+ }
+
+ public function testFindByLineIdReturnsNullWhenAbsent(): void
+ {
+ $this->wpdb->rows_to_return = [[]];
+
+ $repo = new SubscriberRepository();
+
+ $this->assertNull($repo->find_by_line_id('Uabsent'));
+ }
+
+ public function testActiveIdsIteratesInChunksToBoundMemory(): void
+ {
+ // 2.5 chunks worth of data — repo should issue >= 3 LIMIT queries.
+ $page_size = SubscriberRepository::CHUNK_SIZE;
+ $rows1 = array_map(fn($i) => ['line_user_id' => 'U' . $i], range(1, $page_size));
+ $rows2 = array_map(fn($i) => ['line_user_id' => 'U' . $i], range($page_size + 1, $page_size * 2));
+ $rows3 = array_map(fn($i) => ['line_user_id' => 'U' . $i], range($page_size * 2 + 1, $page_size * 2 + 5));
+ $this->wpdb->rows_to_return = [$rows1, $rows2, $rows3, []];
+
+ $repo = new SubscriberRepository();
+ $ids = iterator_to_array($repo->active_ids(), false);
+
+ $this->assertCount($page_size * 2 + 5, $ids);
+ $this->assertSame('U1', $ids[0]);
+
+ $limit_queries = array_filter(
+ $this->wpdb->queries,
+ static fn(array $q): bool => str_contains($q[1], 'LIMIT') && str_contains($q[1], 'wp_botcat_subscribers')
+ );
+ $this->assertGreaterThanOrEqual(3, count($limit_queries), 'active_ids must page via multiple LIMIT queries');
+ }
+
+ public function testUpsertOnFollowCreatesOrReactivatesByLineId(): void
+ {
+ $captured_args = null;
+ $this->wpdb->rows_to_return = [];
+
+ $repo = new SubscriberRepository();
+ $repo->upsert_active(
+ line_user_id: 'U123',
+ display_name: 'Eric',
+ picture_url: null,
+ followed_at: '2026-05-20 10:00:00'
+ );
+
+ $found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] !== 'prepare') {
+ continue;
+ }
+ if (str_contains($q[1], 'INSERT') && str_contains($q[1], 'ON DUPLICATE KEY UPDATE')) {
+ $found = true;
+ $this->assertContains('U123', $q[2]);
+ $this->assertContains('active', $q[2]);
+ }
+ }
+ $this->assertTrue($found, 'upsert should use INSERT ... ON DUPLICATE KEY UPDATE');
+ }
+
+ public function testMarkUnfollowedUpdatesStatusButDoesNotDelete(): void
+ {
+ $repo = new SubscriberRepository();
+ $repo->mark_unfollowed('U123', '2026-05-21 00:00:00');
+
+ $update_found = false;
+ $delete_found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] !== 'prepare') {
+ continue;
+ }
+ if (str_starts_with(ltrim($q[1]), 'UPDATE')) {
+ $update_found = true;
+ $this->assertContains('unfollowed', $q[2]);
+ $this->assertContains('U123', $q[2]);
+ }
+ if (str_starts_with(ltrim($q[1]), 'DELETE')) {
+ $delete_found = true;
+ }
+ }
+ $this->assertTrue($update_found, 'mark_unfollowed must issue an UPDATE');
+ $this->assertFalse($delete_found, 'unfollow MUST NOT delete the subscriber row');
+ }
+}
diff --git a/tests/Unit/Subscribers/SubscriberSearchTest.php b/tests/Unit/Subscribers/SubscriberSearchTest.php
new file mode 100644
index 0000000..059206c
--- /dev/null
+++ b/tests/Unit/Subscribers/SubscriberSearchTest.php
@@ -0,0 +1,121 @@
+}> */
+ public array $queries = [];
+ /** @var list> */
+ public array $rows_to_return = [];
+ public string $count_to_return = '0';
+ public function prepare(string $query, ...$args): string
+ {
+ $this->queries[] = ['prepare', $query, $args];
+ return $query;
+ }
+ public function get_var(string $sql): ?string
+ {
+ $this->queries[] = ['get_var', $sql, []];
+ return $this->count_to_return;
+ }
+ public function get_results(string $sql, $output = OBJECT): array
+ {
+ $this->queries[] = ['get_results', $sql, []];
+ return $this->rows_to_return;
+ }
+ public function query(string $sql): int
+ {
+ return 0;
+ }
+ };
+ $this->wpdb = $wpdb;
+ }
+
+ public function testSearchReturnsRowsAndTotal(): void
+ {
+ $this->wpdb->count_to_return = '125';
+ $this->wpdb->rows_to_return = [
+ [
+ 'id' => '1', 'line_user_id' => 'U1', 'display_name' => 'Eric',
+ 'picture_url' => null, 'status' => 'active',
+ 'followed_at' => '2026-05-01 00:00:00', 'unfollowed_at' => null,
+ ],
+ ];
+
+ $page = (new SubscriberRepository())->search(new SubscribersQuery());
+
+ $this->assertSame(125, $page->total);
+ $this->assertCount(1, $page->rows);
+ $this->assertInstanceOf(Subscriber::class, $page->rows[0]);
+ $this->assertSame('Eric', $page->rows[0]->display_name);
+ }
+
+ public function testSearchIncludesLikePatternWhenSearchTermProvided(): void
+ {
+ (new SubscriberRepository())->search(new SubscribersQuery(search: 'Eric'));
+
+ $found_like = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'prepare' && str_contains($q[1], 'LIKE')) {
+ $this->assertContains('%Eric%', $q[2]);
+ $found_like = true;
+ }
+ }
+ $this->assertTrue($found_like, 'search term must be passed to a LIKE clause');
+ }
+
+ public function testStatusFilterNarrowsResults(): void
+ {
+ (new SubscriberRepository())->search(new SubscribersQuery(status: 'unfollowed'));
+
+ $found_status = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'prepare' && str_contains($q[1], 'status = %s')) {
+ $this->assertContains('unfollowed', $q[2]);
+ $found_status = true;
+ }
+ }
+ $this->assertTrue($found_status, 'status filter must reach the prepared statement');
+ }
+
+ public function testOrderbyAndPaginationAreApplied(): void
+ {
+ (new SubscriberRepository())->search(
+ new SubscribersQuery(orderby: 'status', order: 'ASC', page: 3, per_page: 20)
+ );
+
+ $found_limit = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'prepare' && str_contains($q[1], 'ORDER BY status ASC') && str_contains($q[1], 'LIMIT')) {
+ $this->assertContains(20, $q[2]);
+ $this->assertContains(40, $q[2], 'offset for page 3 with 20 per_page should be 40');
+ $found_limit = true;
+ }
+ }
+ $this->assertTrue($found_limit, 'orderby + pagination must appear in the SELECT statement');
+ }
+}
diff --git a/tests/Unit/Subscribers/SubscriberTest.php b/tests/Unit/Subscribers/SubscriberTest.php
new file mode 100644
index 0000000..33f0d9e
--- /dev/null
+++ b/tests/Unit/Subscribers/SubscriberTest.php
@@ -0,0 +1,65 @@
+assertTrue($subscriber->is_active());
+ $this->assertFalse($subscriber->is_unfollowed());
+ }
+
+ public function testIsUnfollowedTrueWhenStatusUnfollowed(): void
+ {
+ $subscriber = new Subscriber(
+ id: 2,
+ line_user_id: 'U456',
+ display_name: null,
+ picture_url: null,
+ status: Subscriber::STATUS_UNFOLLOWED,
+ followed_at: '2026-05-01 00:00:00',
+ unfollowed_at: '2026-05-10 00:00:00'
+ );
+
+ $this->assertFalse($subscriber->is_active());
+ $this->assertTrue($subscriber->is_unfollowed());
+ }
+
+ public function testFromRowMapsDatabaseColumnsToTypedFields(): void
+ {
+ $row = [
+ 'id' => '7',
+ 'line_user_id' => 'U789',
+ 'display_name' => 'Sarah',
+ 'picture_url' => 'https://line/p.jpg',
+ 'status' => 'active',
+ 'followed_at' => '2026-05-15 12:00:00',
+ 'unfollowed_at' => null,
+ ];
+
+ $subscriber = Subscriber::from_row($row);
+
+ $this->assertSame(7, $subscriber->id);
+ $this->assertSame('U789', $subscriber->line_user_id);
+ $this->assertSame('Sarah', $subscriber->display_name);
+ }
+}
diff --git a/tests/Unit/Subscribers/SubscribersQueryTest.php b/tests/Unit/Subscribers/SubscribersQueryTest.php
new file mode 100644
index 0000000..4451d19
--- /dev/null
+++ b/tests/Unit/Subscribers/SubscribersQueryTest.php
@@ -0,0 +1,71 @@
+assertSame('', $query->search);
+ $this->assertNull($query->status);
+ $this->assertSame('followed_at', $query->orderby);
+ $this->assertSame('DESC', $query->order);
+ $this->assertSame(1, $query->page);
+ $this->assertSame(50, $query->per_page);
+ }
+
+ public function testOffsetIsZeroIndexedFromPage(): void
+ {
+ $this->assertSame(0, (new SubscribersQuery(page: 1, per_page: 50))->offset());
+ $this->assertSame(50, (new SubscribersQuery(page: 2, per_page: 50))->offset());
+ $this->assertSame(60, (new SubscribersQuery(page: 4, per_page: 20))->offset());
+ }
+
+ public function testOrderbyIsClampedToAllowList(): void
+ {
+ $bad = new SubscribersQuery(orderby: 'DROP TABLE');
+
+ $this->assertSame('followed_at', $bad->orderby, 'unknown orderby must fall back to default');
+ }
+
+ public function testOrderIsClampedToAscOrDesc(): void
+ {
+ $bad = new SubscribersQuery(order: 'sideways');
+
+ $this->assertSame('DESC', $bad->order);
+ }
+
+ public function testPerPageHasUpperBound(): void
+ {
+ $crazy = new SubscribersQuery(per_page: 99999);
+
+ $this->assertLessThanOrEqual(500, $crazy->per_page);
+ }
+
+ public function testFromRequestParsesGetParameters(): void
+ {
+ $query = SubscribersQuery::from_request([
+ 's' => 'Eric',
+ 'status' => 'unfollowed',
+ 'orderby' => 'status',
+ 'order' => 'asc',
+ 'paged' => '3',
+ ]);
+
+ $this->assertSame('Eric', $query->search);
+ $this->assertSame('unfollowed', $query->status);
+ $this->assertSame('status', $query->orderby);
+ $this->assertSame('ASC', $query->order);
+ $this->assertSame(3, $query->page);
+ }
+}
diff --git a/tests/Unit/Subscribers/UnfollowHandlerTest.php b/tests/Unit/Subscribers/UnfollowHandlerTest.php
new file mode 100644
index 0000000..019f592
--- /dev/null
+++ b/tests/Unit/Subscribers/UnfollowHandlerTest.php
@@ -0,0 +1,30 @@
+createMock(SubscriberRepository::class);
+ $subscribers->expects($this->once())->method('mark_unfollowed')->with(
+ $this->equalTo('U123'),
+ $this->isType('string')
+ );
+
+ $handler = new UnfollowHandler($subscribers);
+ $handler->handle('U123', 1_715_000_000);
+ }
+}
diff --git a/tests/Unit/Tags/AudienceResolverTest.php b/tests/Unit/Tags/AudienceResolverTest.php
new file mode 100644
index 0000000..1fbbffd
--- /dev/null
+++ b/tests/Unit/Tags/AudienceResolverTest.php
@@ -0,0 +1,119 @@
+alias(static fn() => [3]);
+
+ $tech = new Tag(7, 'tech', '科技', '科技', [3], '', '');
+
+ $tags = $this->createMock(TagRepository::class);
+ $tags->method('find_for_categories')->with([3])->willReturn([$tech]);
+
+ $subs = $this->createMock(SubscriberRepository::class);
+ $subs->expects($this->never())->method('active_ids');
+
+ $joins = $this->createMock(SubscriberTagRepository::class);
+ $joins->expects($this->once())->method('active_line_user_ids_for_tags')->with([7])
+ ->willReturnCallback(fn() => yield 'U1');
+
+ $resolver = new AudienceResolver($subs, $tags, $joins);
+ $audience = iterator_to_array($resolver->iterator_for_job($this->job(100)), false);
+
+ $this->assertSame(['U1'], $audience);
+ }
+
+ public function testUncategorizedPostFallsBackToFullAudience(): void
+ {
+ Functions\when('wp_get_post_categories')->alias(static fn() => []);
+
+ $tags = $this->createMock(TagRepository::class);
+ $tags->expects($this->never())->method('find_for_categories');
+
+ $subs = $this->createMock(SubscriberRepository::class);
+ $subs->method('active_ids')->willReturnCallback(fn() => yield 'U_all');
+
+ $joins = $this->createMock(SubscriberTagRepository::class);
+
+ $audience = iterator_to_array(
+ (new AudienceResolver($subs, $tags, $joins))->iterator_for_job($this->job(100)),
+ false
+ );
+
+ $this->assertSame(['U_all'], $audience);
+ }
+
+ public function testPostWithoutAnyMatchingTagsFallsBack(): void
+ {
+ Functions\when('wp_get_post_categories')->alias(static fn() => [99]);
+
+ $tags = $this->createMock(TagRepository::class);
+ $tags->method('find_for_categories')->willReturn([]);
+
+ $subs = $this->createMock(SubscriberRepository::class);
+ $subs->method('active_ids')->willReturnCallback(fn() => yield 'U_all');
+
+ $joins = $this->createMock(SubscriberTagRepository::class);
+ $joins->expects($this->never())->method('active_line_user_ids_for_tags');
+
+ $audience = iterator_to_array(
+ (new AudienceResolver($subs, $tags, $joins))->iterator_for_job($this->job(100)),
+ false
+ );
+
+ $this->assertSame(['U_all'], $audience);
+ }
+
+ public function testTestJobSkipsAudienceResolution(): void
+ {
+ $subs = $this->createMock(SubscriberRepository::class);
+ $subs->expects($this->never())->method('active_ids');
+ $tags = $this->createMock(TagRepository::class);
+ $joins = $this->createMock(SubscriberTagRepository::class);
+
+ $resolver = new AudienceResolver($subs, $tags, $joins);
+ $audience = iterator_to_array(
+ $resolver->iterator_for_job($this->test_job()),
+ false
+ );
+
+ $this->assertSame([], $audience);
+ }
+
+ private function job(int $post_id): PushJob
+ {
+ return new PushJob(
+ id: 1, post_id: $post_id, post_type: 'post', status: 'pending',
+ recipient_count: 0, sent_count: 0, failed_count: 0,
+ is_test: false, last_error: null,
+ created_at: '', triggered_at: null, started_at: null, finished_at: null
+ );
+ }
+
+ private function test_job(): PushJob
+ {
+ return new PushJob(
+ id: 1, post_id: 0, post_type: 'test', status: 'pending',
+ recipient_count: 0, sent_count: 0, failed_count: 0,
+ is_test: true, last_error: null,
+ created_at: '', triggered_at: null, started_at: null, finished_at: null
+ );
+ }
+}
diff --git a/tests/Unit/Tags/SubscriberTagRepositoryTest.php b/tests/Unit/Tags/SubscriberTagRepositoryTest.php
new file mode 100644
index 0000000..32b85e4
--- /dev/null
+++ b/tests/Unit/Tags/SubscriberTagRepositoryTest.php
@@ -0,0 +1,123 @@
+ */
+ public array $queries = [];
+ /** @var list>|null */
+ public ?array $next_rows = null;
+ public function prepare(string $query, ...$args): string
+ {
+ $this->queries[] = ['prepare', $query, $args];
+ return $query;
+ }
+ public function get_results(string $sql, $output = OBJECT): array
+ {
+ $this->queries[] = ['get_results', $sql, []];
+ return $this->next_rows ?? [];
+ }
+ public function query(string $sql): int
+ {
+ $this->queries[] = ['query', $sql, []];
+ return 1;
+ }
+ };
+ $this->wpdb = $wpdb;
+ }
+
+ public function testSubscribeUpsertsJoinRow(): void
+ {
+ (new SubscriberTagRepository())->subscribe(101, 7);
+
+ $found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'prepare' && str_contains($q[1], 'INSERT') && str_contains($q[1], 'ON DUPLICATE KEY UPDATE')) {
+ $found = true;
+ $this->assertContains(101, $q[2]);
+ $this->assertContains(7, $q[2]);
+ }
+ }
+ $this->assertTrue($found, 'subscribe should use INSERT...ON DUPLICATE KEY UPDATE');
+ }
+
+ public function testUnsubscribeRemovesJoinRow(): void
+ {
+ (new SubscriberTagRepository())->unsubscribe(101, 7);
+
+ $found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'prepare' && str_contains($q[1], 'DELETE') && str_contains($q[1], 'subscriber_id = %d')) {
+ $found = true;
+ $this->assertContains(101, $q[2]);
+ $this->assertContains(7, $q[2]);
+ }
+ }
+ $this->assertTrue($found);
+ }
+
+ public function testTagsForSubscriberReturnsTagIds(): void
+ {
+ $this->wpdb->next_rows = [
+ ['tag_id' => '7'],
+ ['tag_id' => '9'],
+ ];
+
+ $ids = (new SubscriberTagRepository())->tag_ids_for_subscriber(101);
+
+ $this->assertSame([7, 9], $ids);
+ }
+
+ public function testActiveLineUserIdsForTagsStreamsInChunks(): void
+ {
+ $page = SubscriberTagRepository::CHUNK_SIZE;
+ $batch1 = array_map(static fn($i) => ['line_user_id' => 'U' . $i], range(1, $page));
+ $batch2 = array_map(static fn($i) => ['line_user_id' => 'U' . $i], range($page + 1, $page + 3));
+
+ $calls = [$batch1, $batch2, []];
+ $this->wpdb = $this->wpdb;
+ $this->wpdb->next_rows = $batch1;
+
+ // Make the mock cycle through batches via get_results side effects:
+ $wpdb_mock = $this->wpdb;
+ $wpdb_mock->next_rows = $batch1;
+ $wpdb_mock_swap = function () use ($wpdb_mock, &$calls) {
+ $wpdb_mock->next_rows = array_shift($calls) ?? [];
+ };
+
+ // Reimplement get_results dynamically — simpler: instead use a counter on the mock by replacing the class.
+ global $wpdb;
+ $wpdb = new class () {
+ public string $prefix = 'wp_';
+ public array $queries = [];
+ /** @var list>> */
+ public array $batches = [];
+ public function prepare(string $query, ...$args): string { $this->queries[] = ['prepare', $query, $args]; return $query; }
+ public function get_results(string $sql, $output = OBJECT): array { $this->queries[] = ['get_results', $sql, []]; return array_shift($this->batches) ?? []; }
+ public function query(string $sql): int { return 1; }
+ };
+ $wpdb->batches = [$batch1, $batch2, []];
+
+ $ids = iterator_to_array((new SubscriberTagRepository())->active_line_user_ids_for_tags([7, 9]), false);
+
+ $this->assertCount($page + 3, $ids);
+ $this->assertSame('U1', $ids[0]);
+ }
+}
diff --git a/tests/Unit/Tags/TagCommandHandlerTest.php b/tests/Unit/Tags/TagCommandHandlerTest.php
new file mode 100644
index 0000000..3359e6d
--- /dev/null
+++ b/tests/Unit/Tags/TagCommandHandlerTest.php
@@ -0,0 +1,149 @@
+handler();
+
+ $this->assertNull($handler->handle('U1', TagCommand::not_a_command()));
+ }
+
+ public function testSubscribeByKeyword(): void
+ {
+ $tag = $this->build_tag(7, 'tech', '科技', '科技');
+
+ $tags = $this->createMock(TagRepository::class);
+ $tags->method('find_by_keyword')->with('科技')->willReturn($tag);
+ $tags->method('list_all')->willReturn([$tag]);
+
+ $subs = $this->createMock(SubscriberRepository::class);
+ $subs->method('find_by_line_id')->willReturn($this->subscriber(101));
+
+ $joins = $this->createMock(SubscriberTagRepository::class);
+ $joins->expects($this->once())->method('subscribe')->with(101, 7);
+
+ $reply = (new TagCommandHandler($tags, $joins, $subs))
+ ->handle('U1', TagCommand::subscribe('科技'));
+
+ $this->assertNotNull($reply);
+ $this->assertStringContainsString('科技', $reply);
+ }
+
+ public function testUnsubscribeByKeyword(): void
+ {
+ $tag = $this->build_tag(7, 'tech', '科技', '科技');
+
+ $tags = $this->createMock(TagRepository::class);
+ $tags->method('find_by_keyword')->willReturn($tag);
+ $tags->method('list_all')->willReturn([$tag]);
+
+ $subs = $this->createMock(SubscriberRepository::class);
+ $subs->method('find_by_line_id')->willReturn($this->subscriber(101));
+
+ $joins = $this->createMock(SubscriberTagRepository::class);
+ $joins->expects($this->once())->method('unsubscribe')->with(101, 7);
+
+ $reply = (new TagCommandHandler($tags, $joins, $subs))
+ ->handle('U1', TagCommand::unsubscribe('科技'));
+
+ $this->assertNotNull($reply);
+ $this->assertStringContainsString('科技', $reply);
+ }
+
+ public function testUnknownKeywordRepliesWithAvailableList(): void
+ {
+ $tags = $this->createMock(TagRepository::class);
+ $tags->method('find_by_keyword')->willReturn(null);
+ $tags->method('list_all')->willReturn([
+ $this->build_tag(7, 'tech', '科技', '科技'),
+ $this->build_tag(8, 'finance', '財經', '財經'),
+ ]);
+
+ $subs = $this->createMock(SubscriberRepository::class);
+ $joins = $this->createMock(SubscriberTagRepository::class);
+ $joins->expects($this->never())->method('subscribe');
+
+ $reply = (new TagCommandHandler($tags, $joins, $subs))
+ ->handle('U1', TagCommand::subscribe('unknown'));
+
+ $this->assertNotNull($reply);
+ $this->assertStringContainsString('科技', $reply);
+ $this->assertStringContainsString('財經', $reply);
+ }
+
+ public function testListShowsCurrentSubscriptions(): void
+ {
+ $tech = $this->build_tag(7, 'tech', '科技', '科技');
+ $finance = $this->build_tag(8, 'finance', '財經', '財經');
+
+ $tags = $this->createMock(TagRepository::class);
+ $tags->method('list_all')->willReturn([$tech, $finance]);
+
+ $subs = $this->createMock(SubscriberRepository::class);
+ $subs->method('find_by_line_id')->willReturn($this->subscriber(101));
+
+ $joins = $this->createMock(SubscriberTagRepository::class);
+ $joins->method('tag_ids_for_subscriber')->with(101)->willReturn([8]);
+
+ $reply = (new TagCommandHandler($tags, $joins, $subs))
+ ->handle('U1', TagCommand::list());
+
+ $this->assertNotNull($reply);
+ $this->assertStringContainsString('財經', $reply);
+ $this->assertStringNotContainsString('科技', $reply);
+ }
+
+ public function testListWithNoSubscriptionsReturnsNoneMessage(): void
+ {
+ $tags = $this->createMock(TagRepository::class);
+ $tags->method('list_all')->willReturn([]);
+ $subs = $this->createMock(SubscriberRepository::class);
+ $subs->method('find_by_line_id')->willReturn($this->subscriber(101));
+ $joins = $this->createMock(SubscriberTagRepository::class);
+ $joins->method('tag_ids_for_subscriber')->willReturn([]);
+
+ $reply = (new TagCommandHandler($tags, $joins, $subs))
+ ->handle('U1', TagCommand::list());
+
+ $this->assertNotNull($reply);
+ }
+
+ private function handler(): TagCommandHandler
+ {
+ return new TagCommandHandler(
+ $this->createMock(TagRepository::class),
+ $this->createMock(SubscriberTagRepository::class),
+ $this->createMock(SubscriberRepository::class)
+ );
+ }
+
+ private function build_tag(int $id, string $slug, string $display, string $keyword): Tag
+ {
+ return new Tag($id, $slug, $display, $keyword, [], '', '');
+ }
+
+ private function subscriber(int $id): Subscriber
+ {
+ return new Subscriber(
+ id: $id, line_user_id: 'U1', display_name: null, picture_url: null,
+ status: 'active', followed_at: '', unfollowed_at: null
+ );
+ }
+}
diff --git a/tests/Unit/Tags/TagCommandParserTest.php b/tests/Unit/Tags/TagCommandParserTest.php
new file mode 100644
index 0000000..5557c7a
--- /dev/null
+++ b/tests/Unit/Tags/TagCommandParserTest.php
@@ -0,0 +1,89 @@
+parse('hello world');
+
+ $this->assertSame(TagCommand::TYPE_NOT_A_COMMAND, $cmd->type);
+ }
+
+ public function testSubscribeChinese(): void
+ {
+ $cmd = (new TagCommandParser())->parse('/tag 訂閱 科技');
+
+ $this->assertSame(TagCommand::TYPE_SUBSCRIBE, $cmd->type);
+ $this->assertSame('科技', $cmd->keyword);
+ }
+
+ public function testSubscribeEnglish(): void
+ {
+ $cmd = (new TagCommandParser())->parse('/tag subscribe tech');
+
+ $this->assertSame(TagCommand::TYPE_SUBSCRIBE, $cmd->type);
+ $this->assertSame('tech', $cmd->keyword);
+ }
+
+ public function testUnsubscribeChinese(): void
+ {
+ $cmd = (new TagCommandParser())->parse('/tag 取消 科技');
+
+ $this->assertSame(TagCommand::TYPE_UNSUBSCRIBE, $cmd->type);
+ $this->assertSame('科技', $cmd->keyword);
+ }
+
+ public function testUnsubscribeEnglish(): void
+ {
+ $cmd = (new TagCommandParser())->parse('/tag unsubscribe tech');
+
+ $this->assertSame(TagCommand::TYPE_UNSUBSCRIBE, $cmd->type);
+ $this->assertSame('tech', $cmd->keyword);
+ }
+
+ public function testListChinese(): void
+ {
+ $cmd = (new TagCommandParser())->parse('/tag 列表');
+
+ $this->assertSame(TagCommand::TYPE_LIST, $cmd->type);
+ }
+
+ public function testListEnglish(): void
+ {
+ $cmd = (new TagCommandParser())->parse('/tag list');
+
+ $this->assertSame(TagCommand::TYPE_LIST, $cmd->type);
+ }
+
+ public function testLeadingWhitespaceIsForgiving(): void
+ {
+ $cmd = (new TagCommandParser())->parse(" /tag list ");
+
+ $this->assertSame(TagCommand::TYPE_LIST, $cmd->type);
+ }
+
+ public function testTagPrefixWithUnknownSubcommandIsUnknown(): void
+ {
+ $cmd = (new TagCommandParser())->parse('/tag foo bar');
+
+ $this->assertSame(TagCommand::TYPE_UNKNOWN, $cmd->type);
+ }
+
+ public function testKeywordWithSpacesIsCapturedVerbatim(): void
+ {
+ $cmd = (new TagCommandParser())->parse('/tag 訂閱 多字 keyword');
+
+ $this->assertSame('多字 keyword', $cmd->keyword);
+ }
+}
diff --git a/tests/Unit/Tags/TagRepositoryTest.php b/tests/Unit/Tags/TagRepositoryTest.php
new file mode 100644
index 0000000..ed06922
--- /dev/null
+++ b/tests/Unit/Tags/TagRepositoryTest.php
@@ -0,0 +1,161 @@
+ */
+ public array $queries = [];
+ /** @var list>|null */
+ public ?array $next_rows = null;
+ /** @var array|null */
+ public ?array $next_row = null;
+ public function prepare(string $query, ...$args): string
+ {
+ $this->queries[] = ['prepare', $query, $args];
+ return $query;
+ }
+ public function get_row(string $sql, $output = OBJECT): ?array
+ {
+ $this->queries[] = ['get_row', $sql, []];
+ return $this->next_row;
+ }
+ public function get_results(string $sql, $output = OBJECT): array
+ {
+ $this->queries[] = ['get_results', $sql, []];
+ return $this->next_rows ?? [];
+ }
+ public function get_var(string $sql): ?string
+ {
+ $this->queries[] = ['get_var', $sql, []];
+ return null;
+ }
+ public function query(string $sql): int
+ {
+ $this->queries[] = ['query', $sql, []];
+ return 1;
+ }
+ public function insert(string $table, array $data, array $formats): int
+ {
+ $this->queries[] = ['insert', $table, $data];
+ $this->insert_id = 7;
+ return 1;
+ }
+ public function update(string $table, array $data, array $where, array $df = array(), array $wf = array()): int
+ {
+ $this->queries[] = ['update', $table, array_merge($data, $where)];
+ return 1;
+ }
+ public function delete(string $table, array $where, array $wf = array()): int
+ {
+ $this->queries[] = ['delete', $table, $where];
+ return 1;
+ }
+ };
+ $this->wpdb = $wpdb;
+ }
+
+ public function testCreateInsertsAndReturnsId(): void
+ {
+ $id = (new TagRepository())->create('tech', '科技', '科技', [3, 4]);
+
+ $this->assertSame(7, $id);
+
+ $found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'insert' && $q[1] === 'wp_botcat_tags') {
+ $found = true;
+ $this->assertSame('tech', $q[2]['slug']);
+ $this->assertSame('科技', $q[2]['display_name']);
+ $this->assertSame('科技', $q[2]['keyword']);
+ $this->assertSame('[3,4]', $q[2]['mapped_categories']);
+ }
+ }
+ $this->assertTrue($found);
+ }
+
+ public function testFindBySlugReturnsTagWhenPresent(): void
+ {
+ $this->wpdb->next_row = [
+ 'id' => '1', 'slug' => 'tech', 'display_name' => '科技',
+ 'keyword' => '科技', 'mapped_categories' => '[3,4]',
+ 'created_at' => '2026-05-26', 'updated_at' => '2026-05-26',
+ ];
+
+ $tag = (new TagRepository())->find_by_slug('tech');
+
+ $this->assertInstanceOf(Tag::class, $tag);
+ $this->assertSame('tech', $tag->slug);
+ $this->assertSame([3, 4], $tag->mapped_categories);
+ }
+
+ public function testFindByKeywordIsCaseInsensitive(): void
+ {
+ $this->wpdb->next_row = [
+ 'id' => '1', 'slug' => 'tech', 'display_name' => '科技',
+ 'keyword' => '科技', 'mapped_categories' => '[]',
+ 'created_at' => '', 'updated_at' => '',
+ ];
+
+ (new TagRepository())->find_by_keyword('科技');
+
+ $found = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'prepare' && str_contains($q[1], 'keyword')) {
+ $found = true;
+ $this->assertContains('科技', $q[2]);
+ }
+ }
+ $this->assertTrue($found);
+ }
+
+ public function testFindForCategoriesReturnsTagsWithOverlap(): void
+ {
+ $this->wpdb->next_rows = [
+ ['id' => '1', 'slug' => 'tech', 'display_name' => '科技', 'keyword' => '科技', 'mapped_categories' => '[3,4]', 'created_at' => '', 'updated_at' => ''],
+ ['id' => '2', 'slug' => 'finance', 'display_name' => '財經', 'keyword' => '財經', 'mapped_categories' => '[10]', 'created_at' => '', 'updated_at' => ''],
+ ];
+
+ $tags = (new TagRepository())->find_for_categories([3, 99]);
+
+ $this->assertCount(1, $tags);
+ $this->assertSame('tech', $tags[0]->slug);
+ }
+
+ public function testDeleteAlsoCleansSubscriberTagJoinRows(): void
+ {
+ (new TagRepository())->delete(7);
+
+ $deleted_tag = false;
+ $deleted_joins = false;
+ foreach ($this->wpdb->queries as $q) {
+ if ($q[0] === 'delete' && $q[1] === 'wp_botcat_tags' && ($q[2]['id'] ?? null) === 7) {
+ $deleted_tag = true;
+ }
+ if ($q[0] === 'prepare' && str_contains($q[1], 'DELETE FROM wp_botcat_subscriber_tags')) {
+ $deleted_joins = true;
+ $this->assertContains(7, $q[2]);
+ }
+ }
+ $this->assertTrue($deleted_tag);
+ $this->assertTrue($deleted_joins);
+ }
+}
diff --git a/tests/Unit/Template/ExcerptFallbackTest.php b/tests/Unit/Template/ExcerptFallbackTest.php
new file mode 100644
index 0000000..4713a7b
--- /dev/null
+++ b/tests/Unit/Template/ExcerptFallbackTest.php
@@ -0,0 +1,51 @@
+assertSame('hand-written summary', ExcerptFallback::derive('hand-written summary', 'body
'));
+ }
+
+ public function testStripsHtmlFromContentFallback(): void
+ {
+ $this->assertSame('Hello world!', ExcerptFallback::derive('', 'Hello world!
'));
+ }
+
+ public function testTruncatesAtOneHundredCharsWithEllipsis(): void
+ {
+ $content = str_repeat('a', 150);
+ $excerpt = ExcerptFallback::derive('', $content);
+
+ $this->assertSame(str_repeat('a', 100) . '…', $excerpt);
+ }
+
+ public function testNoTruncationIfContentExactlyAtLimit(): void
+ {
+ $content = str_repeat('b', 100);
+ $this->assertSame($content, ExcerptFallback::derive('', $content));
+ }
+
+ public function testPreservesEmojiAndMultibyteWhileTruncating(): void
+ {
+ $content = str_repeat('貓', 120); // 120 multibyte chars
+ $excerpt = ExcerptFallback::derive('', $content);
+
+ $this->assertSame(str_repeat('貓', 100) . '…', $excerpt);
+ }
+
+ public function testEmptyContentProducesEmptyString(): void
+ {
+ $this->assertSame('', ExcerptFallback::derive('', ''));
+ }
+}
diff --git a/tests/Unit/Template/TemplateRendererTest.php b/tests/Unit/Template/TemplateRendererTest.php
new file mode 100644
index 0000000..740ff8b
--- /dev/null
+++ b/tests/Unit/Template/TemplateRendererTest.php
@@ -0,0 +1,68 @@
+createMock(TokenResolver::class);
+ $resolver->method('resolve_for_post')->willReturn([
+ 'title' => 'Hello World',
+ 'excerpt' => 'Short summary.',
+ 'permalink' => 'https://x.test/p/1',
+ 'author' => 'Eric',
+ 'category' => 'News',
+ 'date' => '2026-05-26 10:00',
+ 'site_name' => 'Cat Site',
+ ]);
+
+ $rendered = (new TemplateRenderer($resolver))->render(
+ "{title}\n{excerpt}\n{permalink}\nby {author} in {category} · {date} · {site_name}",
+ new \stdClass()
+ );
+
+ $this->assertSame(
+ "Hello World\nShort summary.\nhttps://x.test/p/1\nby Eric in News · 2026-05-26 10:00 · Cat Site",
+ $rendered
+ );
+ }
+
+ public function testUnknownTokensAreLeftAsLiteralText(): void
+ {
+ $resolver = $this->createMock(TokenResolver::class);
+ $resolver->method('resolve_for_post')->willReturn(['title' => 'X']);
+
+ $rendered = (new TemplateRenderer($resolver))->render('{title} and {foo}', new \stdClass());
+
+ $this->assertSame('X and {foo}', $rendered, 'unknown tokens stay literal per spec');
+ }
+
+ public function testPreservesNewlinesAndEmojiAsUtf8(): void
+ {
+ $resolver = $this->createMock(TokenResolver::class);
+ $resolver->method('resolve_for_post')->willReturn(['title' => "Hello 🐱\nMultiline"]);
+
+ $rendered = (new TemplateRenderer($resolver))->render('Title: {title}', new \stdClass());
+
+ $this->assertSame("Title: Hello 🐱\nMultiline", $rendered);
+ $this->assertTrue(mb_check_encoding($rendered, 'UTF-8'));
+ }
+
+ public function testEmptyTemplateProducesEmptyOutput(): void
+ {
+ $resolver = $this->createMock(TokenResolver::class);
+ $resolver->method('resolve_for_post')->willReturn(['title' => 'X']);
+
+ $this->assertSame('', (new TemplateRenderer($resolver))->render('', new \stdClass()));
+ }
+}
diff --git a/tests/Unit/Template/TemplateRepositoryTest.php b/tests/Unit/Template/TemplateRepositoryTest.php
new file mode 100644
index 0000000..4cff04b
--- /dev/null
+++ b/tests/Unit/Template/TemplateRepositoryTest.php
@@ -0,0 +1,59 @@
+with(TemplateRepository::OPTION, TemplateRepository::DEFAULT_TEMPLATE)
+ ->andReturn(TemplateRepository::DEFAULT_TEMPLATE);
+
+ $template = (new TemplateRepository())->get();
+
+ $this->assertSame("{title}\n\n{excerpt}\n\n{permalink}", $template);
+ }
+
+ public function testSaveDelegatesToUpdateOption(): void
+ {
+ Functions\expect('update_option')->once()
+ ->with(TemplateRepository::OPTION, '{title}\n{permalink}');
+
+ (new TemplateRepository())->save('{title}\n{permalink}');
+
+ $this->assertTrue(true, 'expectation on update_option is the real assertion');
+ }
+
+ public function testLengthCheckReturnsNullForUnderLimit(): void
+ {
+ $this->assertNull((new TemplateRepository())->length_check('Hello World'));
+ }
+
+ public function testLengthCheckReturnsErrorForOversize(): void
+ {
+ $long = str_repeat('a', TemplateRepository::MAX_RENDERED_LENGTH + 1);
+ $error = (new TemplateRepository())->length_check($long);
+
+ $this->assertNotNull($error);
+ $this->assertStringContainsString('5000', $error);
+ }
+
+ public function testStatusForLengthGivesGreenYellowRedBands(): void
+ {
+ $repo = new TemplateRepository();
+
+ $this->assertSame(TemplateRepository::STATUS_GREEN, $repo->status_for_length(1234));
+ $this->assertSame(TemplateRepository::STATUS_YELLOW, $repo->status_for_length(4500));
+ $this->assertSame(TemplateRepository::STATUS_RED, $repo->status_for_length(5300));
+ }
+}
diff --git a/tests/Unit/Template/TokenResolverTest.php b/tests/Unit/Template/TokenResolverTest.php
new file mode 100644
index 0000000..d785b88
--- /dev/null
+++ b/tests/Unit/Template/TokenResolverTest.php
@@ -0,0 +1,105 @@
+build_post();
+
+ Functions\expect('get_the_title')->andReturn('Title 1');
+ Functions\expect('get_permalink')->andReturn('https://example.test/post/1');
+ Functions\expect('get_the_author_meta')->with('display_name', 17)->andReturn('Eric');
+ Functions\expect('wp_get_post_categories')->with(1, ['fields' => 'names'])->andReturn(['News']);
+ Functions\expect('get_bloginfo')->with('name')->andReturn('Cat Site');
+ Functions\stubs(['get_date_from_gmt' => static fn(string $g, string $f): string => '2026-05-26 10:00']);
+
+ $values = (new TokenResolver())->resolve_for_post($post);
+
+ $this->assertSame('Title 1', $values['title']);
+ $this->assertSame('https://example.test/post/1', $values['permalink']);
+ $this->assertSame('Eric', $values['author']);
+ $this->assertSame('News', $values['category']);
+ $this->assertSame('2026-05-26 10:00', $values['date']);
+ $this->assertSame('Cat Site', $values['site_name']);
+ }
+
+ public function testUsesAuthoredExcerptWhenPresent(): void
+ {
+ $post = $this->build_post(excerpt: 'hand-written summary', content: 'body
');
+ $this->stub_basics();
+
+ $values = (new TokenResolver())->resolve_for_post($post);
+
+ $this->assertSame('hand-written summary', $values['excerpt']);
+ }
+
+ public function testFallsBackToContentForExcerpt(): void
+ {
+ $post = $this->build_post(excerpt: '', content: 'Hello world
');
+ $this->stub_basics();
+
+ $values = (new TokenResolver())->resolve_for_post($post);
+
+ $this->assertSame('Hello world', $values['excerpt']);
+ }
+
+ public function testCategoryReturnsFirstWhenMultiple(): void
+ {
+ $post = $this->build_post();
+ Functions\expect('get_the_title')->andReturn('t');
+ Functions\expect('get_permalink')->andReturn('u');
+ Functions\expect('get_the_author_meta')->andReturn('a');
+ Functions\expect('wp_get_post_categories')->andReturn(['Primary', 'Secondary']);
+ Functions\expect('get_bloginfo')->andReturn('s');
+ Functions\stubs(['get_date_from_gmt' => static fn(string $g, string $f): string => '2026']);
+
+ $values = (new TokenResolver())->resolve_for_post($post);
+
+ $this->assertSame('Primary', $values['category']);
+ }
+
+ public function testCategoryReturnsEmptyWhenNoCategories(): void
+ {
+ $post = $this->build_post();
+ Functions\expect('get_the_title')->andReturn('t');
+ Functions\expect('get_permalink')->andReturn('u');
+ Functions\expect('get_the_author_meta')->andReturn('a');
+ Functions\expect('wp_get_post_categories')->andReturn([]);
+ Functions\expect('get_bloginfo')->andReturn('s');
+ Functions\stubs(['get_date_from_gmt' => static fn(string $g, string $f): string => '2026']);
+
+ $this->assertSame('', (new TokenResolver())->resolve_for_post($post)['category']);
+ }
+
+ private function stub_basics(): void
+ {
+ Functions\expect('get_the_title')->andReturn('t');
+ Functions\expect('get_permalink')->andReturn('u');
+ Functions\expect('get_the_author_meta')->andReturn('a');
+ Functions\expect('wp_get_post_categories')->andReturn(['c']);
+ Functions\expect('get_bloginfo')->andReturn('s');
+ Functions\stubs(['get_date_from_gmt' => static fn(string $g, string $f): string => '2026']);
+ }
+
+ private function build_post(string $excerpt = '', string $content = ''): object
+ {
+ $p = new \stdClass();
+ $p->ID = 1;
+ $p->post_author = 17;
+ $p->post_excerpt = $excerpt;
+ $p->post_content = $content;
+ $p->post_date_gmt = '2026-05-26 02:00:00';
+ return $p;
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..690672d
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,41 @@
+
- * Jordi Boggiano
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace Composer\Autoload;
-
-/**
- * ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
- *
- * $loader = new \Composer\Autoload\ClassLoader();
- *
- * // register classes with namespaces
- * $loader->add('Symfony\Component', __DIR__.'/component');
- * $loader->add('Symfony', __DIR__.'/framework');
- *
- * // activate the autoloader
- * $loader->register();
- *
- * // to enable searching the include path (eg. for PEAR packages)
- * $loader->setUseIncludePath(true);
- *
- * In this example, if you try to use a class in the Symfony\Component
- * namespace or one of its children (Symfony\Component\Console for instance),
- * the autoloader will first look for the class under the component/
- * directory, and it will then fallback to the framework/ directory if not
- * found before giving up.
- *
- * This class is loosely based on the Symfony UniversalClassLoader.
- *
- * @author Fabien Potencier
- * @author Jordi Boggiano
- * @see https://www.php-fig.org/psr/psr-0/
- * @see https://www.php-fig.org/psr/psr-4/
- */
-class ClassLoader
-{
- /** @var \Closure(string):void */
- private static $includeFile;
-
- /** @var string|null */
- private $vendorDir;
-
- // PSR-4
- /**
- * @var array>
- */
- private $prefixLengthsPsr4 = array();
- /**
- * @var array>
- */
- private $prefixDirsPsr4 = array();
- /**
- * @var list
- */
- private $fallbackDirsPsr4 = array();
-
- // PSR-0
- /**
- * List of PSR-0 prefixes
- *
- * Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2')))
- *
- * @var array>>
- */
- private $prefixesPsr0 = array();
- /**
- * @var list
- */
- private $fallbackDirsPsr0 = array();
-
- /** @var bool */
- private $useIncludePath = false;
-
- /**
- * @var array
- */
- private $classMap = array();
-
- /** @var bool */
- private $classMapAuthoritative = false;
-
- /**
- * @var array
- */
- private $missingClasses = array();
-
- /** @var string|null */
- private $apcuPrefix;
-
- /**
- * @var array
- */
- private static $registeredLoaders = array();
-
- /**
- * @param string|null $vendorDir
- */
- public function __construct($vendorDir = null)
- {
- $this->vendorDir = $vendorDir;
- self::initializeIncludeClosure();
- }
-
- /**
- * @return array>
- */
- public function getPrefixes()
- {
- if (!empty($this->prefixesPsr0)) {
- return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
- }
-
- return array();
- }
-
- /**
- * @return array>
- */
- public function getPrefixesPsr4()
- {
- return $this->prefixDirsPsr4;
- }
-
- /**
- * @return list
- */
- public function getFallbackDirs()
- {
- return $this->fallbackDirsPsr0;
- }
-
- /**
- * @return list
- */
- public function getFallbackDirsPsr4()
- {
- return $this->fallbackDirsPsr4;
- }
-
- /**
- * @return array Array of classname => path
- */
- public function getClassMap()
- {
- return $this->classMap;
- }
-
- /**
- * @param array $classMap Class to filename map
- *
- * @return void
- */
- public function addClassMap(array $classMap)
- {
- if ($this->classMap) {
- $this->classMap = array_merge($this->classMap, $classMap);
- } else {
- $this->classMap = $classMap;
- }
- }
-
- /**
- * Registers a set of PSR-0 directories for a given prefix, either
- * appending or prepending to the ones previously set for this prefix.
- *
- * @param string $prefix The prefix
- * @param list|string $paths The PSR-0 root directories
- * @param bool $prepend Whether to prepend the directories
- *
- * @return void
- */
- public function add($prefix, $paths, $prepend = false)
- {
- $paths = (array) $paths;
- if (!$prefix) {
- if ($prepend) {
- $this->fallbackDirsPsr0 = array_merge(
- $paths,
- $this->fallbackDirsPsr0
- );
- } else {
- $this->fallbackDirsPsr0 = array_merge(
- $this->fallbackDirsPsr0,
- $paths
- );
- }
-
- return;
- }
-
- $first = $prefix[0];
- if (!isset($this->prefixesPsr0[$first][$prefix])) {
- $this->prefixesPsr0[$first][$prefix] = $paths;
-
- return;
- }
- if ($prepend) {
- $this->prefixesPsr0[$first][$prefix] = array_merge(
- $paths,
- $this->prefixesPsr0[$first][$prefix]
- );
- } else {
- $this->prefixesPsr0[$first][$prefix] = array_merge(
- $this->prefixesPsr0[$first][$prefix],
- $paths
- );
- }
- }
-
- /**
- * Registers a set of PSR-4 directories for a given namespace, either
- * appending or prepending to the ones previously set for this namespace.
- *
- * @param string $prefix The prefix/namespace, with trailing '\\'
- * @param list|string $paths The PSR-4 base directories
- * @param bool $prepend Whether to prepend the directories
- *
- * @throws \InvalidArgumentException
- *
- * @return void
- */
- public function addPsr4($prefix, $paths, $prepend = false)
- {
- $paths = (array) $paths;
- if (!$prefix) {
- // Register directories for the root namespace.
- if ($prepend) {
- $this->fallbackDirsPsr4 = array_merge(
- $paths,
- $this->fallbackDirsPsr4
- );
- } else {
- $this->fallbackDirsPsr4 = array_merge(
- $this->fallbackDirsPsr4,
- $paths
- );
- }
- } elseif (!isset($this->prefixDirsPsr4[$prefix])) {
- // Register directories for a new namespace.
- $length = strlen($prefix);
- if ('\\' !== $prefix[$length - 1]) {
- throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
- }
- $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
- $this->prefixDirsPsr4[$prefix] = $paths;
- } elseif ($prepend) {
- // Prepend directories for an already registered namespace.
- $this->prefixDirsPsr4[$prefix] = array_merge(
- $paths,
- $this->prefixDirsPsr4[$prefix]
- );
- } else {
- // Append directories for an already registered namespace.
- $this->prefixDirsPsr4[$prefix] = array_merge(
- $this->prefixDirsPsr4[$prefix],
- $paths
- );
- }
- }
-
- /**
- * Registers a set of PSR-0 directories for a given prefix,
- * replacing any others previously set for this prefix.
- *
- * @param string $prefix The prefix
- * @param list|string $paths The PSR-0 base directories
- *
- * @return void
- */
- public function set($prefix, $paths)
- {
- if (!$prefix) {
- $this->fallbackDirsPsr0 = (array) $paths;
- } else {
- $this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
- }
- }
-
- /**
- * Registers a set of PSR-4 directories for a given namespace,
- * replacing any others previously set for this namespace.
- *
- * @param string $prefix The prefix/namespace, with trailing '\\'
- * @param list|string $paths The PSR-4 base directories
- *
- * @throws \InvalidArgumentException
- *
- * @return void
- */
- public function setPsr4($prefix, $paths)
- {
- if (!$prefix) {
- $this->fallbackDirsPsr4 = (array) $paths;
- } else {
- $length = strlen($prefix);
- if ('\\' !== $prefix[$length - 1]) {
- throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
- }
- $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
- $this->prefixDirsPsr4[$prefix] = (array) $paths;
- }
- }
-
- /**
- * Turns on searching the include path for class files.
- *
- * @param bool $useIncludePath
- *
- * @return void
- */
- public function setUseIncludePath($useIncludePath)
- {
- $this->useIncludePath = $useIncludePath;
- }
-
- /**
- * Can be used to check if the autoloader uses the include path to check
- * for classes.
- *
- * @return bool
- */
- public function getUseIncludePath()
- {
- return $this->useIncludePath;
- }
-
- /**
- * Turns off searching the prefix and fallback directories for classes
- * that have not been registered with the class map.
- *
- * @param bool $classMapAuthoritative
- *
- * @return void
- */
- public function setClassMapAuthoritative($classMapAuthoritative)
- {
- $this->classMapAuthoritative = $classMapAuthoritative;
- }
-
- /**
- * Should class lookup fail if not found in the current class map?
- *
- * @return bool
- */
- public function isClassMapAuthoritative()
- {
- return $this->classMapAuthoritative;
- }
-
- /**
- * APCu prefix to use to cache found/not-found classes, if the extension is enabled.
- *
- * @param string|null $apcuPrefix
- *
- * @return void
- */
- public function setApcuPrefix($apcuPrefix)
- {
- $this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null;
- }
-
- /**
- * The APCu prefix in use, or null if APCu caching is not enabled.
- *
- * @return string|null
- */
- public function getApcuPrefix()
- {
- return $this->apcuPrefix;
- }
-
- /**
- * Registers this instance as an autoloader.
- *
- * @param bool $prepend Whether to prepend the autoloader or not
- *
- * @return void
- */
- public function register($prepend = false)
- {
- spl_autoload_register(array($this, 'loadClass'), true, $prepend);
-
- if (null === $this->vendorDir) {
- return;
- }
-
- if ($prepend) {
- self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders;
- } else {
- unset(self::$registeredLoaders[$this->vendorDir]);
- self::$registeredLoaders[$this->vendorDir] = $this;
- }
- }
-
- /**
- * Unregisters this instance as an autoloader.
- *
- * @return void
- */
- public function unregister()
- {
- spl_autoload_unregister(array($this, 'loadClass'));
-
- if (null !== $this->vendorDir) {
- unset(self::$registeredLoaders[$this->vendorDir]);
- }
- }
-
- /**
- * Loads the given class or interface.
- *
- * @param string $class The name of the class
- * @return true|null True if loaded, null otherwise
- */
- public function loadClass($class)
- {
- if ($file = $this->findFile($class)) {
- $includeFile = self::$includeFile;
- $includeFile($file);
-
- return true;
- }
-
- return null;
- }
-
- /**
- * Finds the path to the file where the class is defined.
- *
- * @param string $class The name of the class
- *
- * @return string|false The path if found, false otherwise
- */
- public function findFile($class)
- {
- // class map lookup
- if (isset($this->classMap[$class])) {
- return $this->classMap[$class];
- }
- if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
- return false;
- }
- if (null !== $this->apcuPrefix) {
- $file = apcu_fetch($this->apcuPrefix.$class, $hit);
- if ($hit) {
- return $file;
- }
- }
-
- $file = $this->findFileWithExtension($class, '.php');
-
- // Search for Hack files if we are running on HHVM
- if (false === $file && defined('HHVM_VERSION')) {
- $file = $this->findFileWithExtension($class, '.hh');
- }
-
- if (null !== $this->apcuPrefix) {
- apcu_add($this->apcuPrefix.$class, $file);
- }
-
- if (false === $file) {
- // Remember that this class does not exist.
- $this->missingClasses[$class] = true;
- }
-
- return $file;
- }
-
- /**
- * Returns the currently registered loaders keyed by their corresponding vendor directories.
- *
- * @return array
- */
- public static function getRegisteredLoaders()
- {
- return self::$registeredLoaders;
- }
-
- /**
- * @param string $class
- * @param string $ext
- * @return string|false
- */
- private function findFileWithExtension($class, $ext)
- {
- // PSR-4 lookup
- $logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
-
- $first = $class[0];
- if (isset($this->prefixLengthsPsr4[$first])) {
- $subPath = $class;
- while (false !== $lastPos = strrpos($subPath, '\\')) {
- $subPath = substr($subPath, 0, $lastPos);
- $search = $subPath . '\\';
- if (isset($this->prefixDirsPsr4[$search])) {
- $pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
- foreach ($this->prefixDirsPsr4[$search] as $dir) {
- if (file_exists($file = $dir . $pathEnd)) {
- return $file;
- }
- }
- }
- }
- }
-
- // PSR-4 fallback dirs
- foreach ($this->fallbackDirsPsr4 as $dir) {
- if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
- return $file;
- }
- }
-
- // PSR-0 lookup
- if (false !== $pos = strrpos($class, '\\')) {
- // namespaced class name
- $logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
- . strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
- } else {
- // PEAR-like class name
- $logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
- }
-
- if (isset($this->prefixesPsr0[$first])) {
- foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
- if (0 === strpos($class, $prefix)) {
- foreach ($dirs as $dir) {
- if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
- return $file;
- }
- }
- }
- }
- }
-
- // PSR-0 fallback dirs
- foreach ($this->fallbackDirsPsr0 as $dir) {
- if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
- return $file;
- }
- }
-
- // PSR-0 include paths.
- if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
- return $file;
- }
-
- return false;
- }
-
- /**
- * @return void
- */
- private static function initializeIncludeClosure()
- {
- if (self::$includeFile !== null) {
- return;
- }
-
- /**
- * Scope isolated include.
- *
- * Prevents access to $this/self from included files.
- *
- * @param string $file
- * @return void
- */
- self::$includeFile = \Closure::bind(static function($file) {
- include $file;
- }, null, null);
- }
-}
diff --git a/vendor/composer/LICENSE b/vendor/composer/LICENSE
deleted file mode 100644
index f27399a..0000000
--- a/vendor/composer/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-
-Copyright (c) Nils Adermann, Jordi Boggiano
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is furnished
-to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-
diff --git a/vendor/composer/autoload_classmap.php b/vendor/composer/autoload_classmap.php
deleted file mode 100644
index eee737d..0000000
--- a/vendor/composer/autoload_classmap.php
+++ /dev/null
@@ -1,32 +0,0 @@
- $baseDir . '/includes/Api/BotCatLineAuthApi.php',
- 'BotCat\\Api\\BotCatLineNotifyAuthApi' => $baseDir . '/includes/Api/BotCatLineNotifyAuthApi.php',
- 'BotCat\\Api\\BotCatMessageApi' => $baseDir . '/includes/Api/BotCatMessageApi.php',
- 'BotCat\\Api\\BotCatTelegramAuthApi' => $baseDir . '/includes/Api/BotCatTelegramAuthApi.php',
- 'BotCat\\BotCatInitializer' => $baseDir . '/includes/BotCatInitializer.php',
- 'BotCat\\Service\\Api\\BotCatLineNotifyService' => $baseDir . '/includes/Service/Api/BotCatLineNotifyService.php',
- 'BotCat\\Service\\Api\\BotCatLineService' => $baseDir . '/includes/Service/Api/BotCatLineService.php',
- 'BotCat\\Service\\Api\\BotCatSlackService' => $baseDir . '/includes/Service/Api/BotCatSlackService.php',
- 'BotCat\\Service\\Api\\BotCatTelegramService' => $baseDir . '/includes/Service/Api/BotCatTelegramService.php',
- 'BotCat\\Service\\BotCatAuthService' => $baseDir . '/includes/Service/BotCatAuthService.php',
- 'BotCat\\Service\\BotCatMessageService' => $baseDir . '/includes/Service/BotCatMessageService.php',
- 'BotCat\\Service\\BotCatNotificationService' => $baseDir . '/includes/Service/BotCatNotificationService.php',
- 'BotCat\\Service\\BotCatOAuthService' => $baseDir . '/includes/Service/BotCatOAuthService.php',
- 'BotCat\\Service\\BotCatRoleService' => $baseDir . '/includes/Service/BotCatRoleService.php',
- 'BotCat\\Service\\BotCatShortcodeService' => $baseDir . '/includes/Service/BotCatShortcodeService.php',
- 'BotCat\\View\\Admin\\BotCatAdminView' => $baseDir . '/includes/View/Admin/BotCatAdminView.php',
- 'BotCat\\View\\Admin\\BotCatLineAdminView' => $baseDir . '/includes/View/Admin/BotCatLineAdminView.php',
- 'BotCat\\View\\Admin\\BotCatLineNotifyAdminView' => $baseDir . '/includes/View/Admin/BotCatLineNotifyAdminView.php',
- 'BotCat\\View\\Admin\\BotCatSlackAdminView' => $baseDir . '/includes/View/Admin/BotCatSlackAdminView.php',
- 'BotCat\\View\\Admin\\BotCatTelegramAdminView' => $baseDir . '/includes/View/Admin/BotCatTelegramAdminView.php',
- 'BotCat\\View\\Admin\\Partial\\BotCatTargetOptions' => $baseDir . '/includes/View/Admin/Partial/BotCatTargetOptions.php',
- 'BotCat\\View\\BotCatProfileView' => $baseDir . '/includes/View/BotCatProfileView.php',
- 'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
-);
diff --git a/vendor/composer/autoload_namespaces.php b/vendor/composer/autoload_namespaces.php
deleted file mode 100644
index 15a2ff3..0000000
--- a/vendor/composer/autoload_namespaces.php
+++ /dev/null
@@ -1,9 +0,0 @@
- array($baseDir . '/includes'),
-);
diff --git a/vendor/composer/autoload_real.php b/vendor/composer/autoload_real.php
deleted file mode 100644
index 305afcd..0000000
--- a/vendor/composer/autoload_real.php
+++ /dev/null
@@ -1,36 +0,0 @@
-register(true);
-
- return $loader;
- }
-}
diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php
deleted file mode 100644
index 8e067f9..0000000
--- a/vendor/composer/autoload_static.php
+++ /dev/null
@@ -1,58 +0,0 @@
-
- array (
- 'BotCat\\' => 7,
- ),
- );
-
- public static $prefixDirsPsr4 = array (
- 'BotCat\\' =>
- array (
- 0 => __DIR__ . '/../..' . '/includes',
- ),
- );
-
- public static $classMap = array (
- 'BotCat\\Api\\BotCatLineAuthApi' => __DIR__ . '/../..' . '/includes/Api/BotCatLineAuthApi.php',
- 'BotCat\\Api\\BotCatLineNotifyAuthApi' => __DIR__ . '/../..' . '/includes/Api/BotCatLineNotifyAuthApi.php',
- 'BotCat\\Api\\BotCatMessageApi' => __DIR__ . '/../..' . '/includes/Api/BotCatMessageApi.php',
- 'BotCat\\Api\\BotCatTelegramAuthApi' => __DIR__ . '/../..' . '/includes/Api/BotCatTelegramAuthApi.php',
- 'BotCat\\BotCatInitializer' => __DIR__ . '/../..' . '/includes/BotCatInitializer.php',
- 'BotCat\\Service\\Api\\BotCatLineNotifyService' => __DIR__ . '/../..' . '/includes/Service/Api/BotCatLineNotifyService.php',
- 'BotCat\\Service\\Api\\BotCatLineService' => __DIR__ . '/../..' . '/includes/Service/Api/BotCatLineService.php',
- 'BotCat\\Service\\Api\\BotCatSlackService' => __DIR__ . '/../..' . '/includes/Service/Api/BotCatSlackService.php',
- 'BotCat\\Service\\Api\\BotCatTelegramService' => __DIR__ . '/../..' . '/includes/Service/Api/BotCatTelegramService.php',
- 'BotCat\\Service\\BotCatAuthService' => __DIR__ . '/../..' . '/includes/Service/BotCatAuthService.php',
- 'BotCat\\Service\\BotCatMessageService' => __DIR__ . '/../..' . '/includes/Service/BotCatMessageService.php',
- 'BotCat\\Service\\BotCatNotificationService' => __DIR__ . '/../..' . '/includes/Service/BotCatNotificationService.php',
- 'BotCat\\Service\\BotCatOAuthService' => __DIR__ . '/../..' . '/includes/Service/BotCatOAuthService.php',
- 'BotCat\\Service\\BotCatRoleService' => __DIR__ . '/../..' . '/includes/Service/BotCatRoleService.php',
- 'BotCat\\Service\\BotCatShortcodeService' => __DIR__ . '/../..' . '/includes/Service/BotCatShortcodeService.php',
- 'BotCat\\View\\Admin\\BotCatAdminView' => __DIR__ . '/../..' . '/includes/View/Admin/BotCatAdminView.php',
- 'BotCat\\View\\Admin\\BotCatLineAdminView' => __DIR__ . '/../..' . '/includes/View/Admin/BotCatLineAdminView.php',
- 'BotCat\\View\\Admin\\BotCatLineNotifyAdminView' => __DIR__ . '/../..' . '/includes/View/Admin/BotCatLineNotifyAdminView.php',
- 'BotCat\\View\\Admin\\BotCatSlackAdminView' => __DIR__ . '/../..' . '/includes/View/Admin/BotCatSlackAdminView.php',
- 'BotCat\\View\\Admin\\BotCatTelegramAdminView' => __DIR__ . '/../..' . '/includes/View/Admin/BotCatTelegramAdminView.php',
- 'BotCat\\View\\Admin\\Partial\\BotCatTargetOptions' => __DIR__ . '/../..' . '/includes/View/Admin/Partial/BotCatTargetOptions.php',
- 'BotCat\\View\\BotCatProfileView' => __DIR__ . '/../..' . '/includes/View/BotCatProfileView.php',
- 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
- );
-
- public static function getInitializer(ClassLoader $loader)
- {
- return \Closure::bind(function () use ($loader) {
- $loader->prefixLengthsPsr4 = ComposerStaticInitb64166cd8e123dae50e093c18eb1113e::$prefixLengthsPsr4;
- $loader->prefixDirsPsr4 = ComposerStaticInitb64166cd8e123dae50e093c18eb1113e::$prefixDirsPsr4;
- $loader->classMap = ComposerStaticInitb64166cd8e123dae50e093c18eb1113e::$classMap;
-
- }, null, ClassLoader::class);
- }
-}