From 6d029186bf847541caa118bc590dd763b6ad391a Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 26 May 2026 10:13:45 +0800 Subject: [PATCH 1/8] chore(openspec): initialize OpenSpec workflow - Add OpenSpec instructions block to CLAUDE.md (managed by openspec update) - Ignore .DS_Store, .claude/, AGENTS.md, openspec/ for local-only usage Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 6 +++++- CLAUDE.md | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 757fee3..ac1ef63 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ -/.idea \ No newline at end of file +/.idea +.DS_Store +.claude/ +AGENTS.md +openspec/ diff --git a/CLAUDE.md b/CLAUDE.md index c074ca7..1566657 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,22 @@ + +# OpenSpec Instructions + +These instructions are for AI assistants working in this project. + +Always open `@/openspec/AGENTS.md` when the request: +- Mentions planning or proposals (words like proposal, spec, change, plan) +- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work +- Sounds ambiguous and you need the authoritative spec before coding + +Use `@/openspec/AGENTS.md` to learn: +- How to create and apply change proposals +- Spec format and conventions +- Project structure and guidelines + +Keep this managed block so 'openspec update' can refresh the instructions. + + + # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. From 5bc3850053b2bf9dc417d6a31bed08de081467c5 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 26 May 2026 14:24:12 +0800 Subject: [PATCH 2/8] feat(rewrite): rebuild plugin around BotCat\ namespace for W1 capabilities Wipes the old WordPress/Telegram/Slack notification plumbing and lays down the spec-driven foundation for the LINE Official Account push-notification plugin defined in openspec/specs/. W1 capabilities now covered by 61 unit tests (135 assertions, WPCS clean): - plugin-foundation: PSR-4 autoload, PHP 8.1+ / WordPress 7+ activation guards, dbDelta schema with versioned migrations, 6-page admin menu (License page gated by Edition::is_pro), i18n loader, daily retention cron with botcat_log_retention_days filter. - line-channel: ChannelSettings value object + repository, libsodium AUTH_KEY-derived encryption-at-rest for secret/token, timing-safe X-Line-Signature verification, REST POST botcat/v1/webhook that dispatches follow/unfollow events, /v2/bot/info test-connection action. - subscriber-management: SubscriberRepository with chunked active_ids() generator + soft-delete mark_unfollowed + ON DUPLICATE KEY upsert, FollowHandler (with Action Scheduler retry on profile fetch failure), UnfollowHandler, scaffolded Subscribers admin page. Tooling: composer.json with phpunit/brain-monkey/mockery/wpcs/phpcompat + woocommerce/action-scheduler. phpcs.xml.dist excludes rules that fight PSR-4 (file naming) or modern style (Yoda, exhaustive Squiz docblocks). vendor/ gitignored; composer.lock committed for deterministic installs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 2 + bot-cat.php | 59 +- composer.json | 64 +- composer.lock | 2617 +++++++++++++++++ includes/Api/BotCatLineAuthApi.php | 70 - includes/Api/BotCatLineWebhookApi.php | 146 - includes/Api/BotCatMessageApi.php | 41 - includes/Api/BotCatTelegramAuthApi.php | 76 - includes/BotCatConstants.php | 9 - includes/BotCatInitializer.php | 156 - includes/Service/Api/BotCatLineService.php | 134 - includes/Service/Api/BotCatSlackService.php | 152 - .../Service/Api/BotCatTelegramService.php | 46 - includes/Service/BotCatMessageService.php | 138 - .../Service/BotCatNotificationService.php | 176 -- includes/Service/BotCatOAuthService.php | 145 - includes/Service/BotCatRoleService.php | 159 - includes/Service/BotCatShortcodeService.php | 24 - includes/View/Admin/BotCatAdminView.php | 63 - includes/View/Admin/BotCatLineAdminView.php | 140 - includes/View/Admin/BotCatSlackAdminView.php | 147 - .../View/Admin/BotCatTelegramAdminView.php | 86 - .../Admin/Partial/BotCatTargetOptions.php | 219 -- includes/View/BotCatProfileView.php | 35 - phpcs.xml.dist | 55 + phpunit.xml.dist | 20 + src/Channel/ChannelSettings.php | 64 + src/Channel/ChannelSettingsRepository.php | 44 + src/Channel/CredentialEncryptor.php | 63 + src/Channel/SettingsPage.php | 148 + src/Channel/SignatureVerifier.php | 30 + src/Channel/TestConnection.php | 70 + src/Channel/TestConnectionResult.php | 24 + src/Channel/WebhookEndpoint.php | 103 + src/Foundation/Activator.php | 78 + src/Foundation/AdminMenu.php | 90 + src/Foundation/Deactivator.php | 21 + src/Foundation/Edition.php | 21 + src/Foundation/I18n.php | 27 + src/Foundation/Plugin.php | 86 + src/Foundation/RetentionCron.php | 36 + src/Foundation/Schema.php | 119 + src/Foundation/SchemaVersion.php | 34 + src/Foundation/Uninstaller.php | 39 + src/Subscribers/FollowHandler.php | 62 + src/Subscribers/LineProfileFetcher.php | 56 + src/Subscribers/LineProfileResult.php | 22 + src/Subscribers/Subscriber.php | 51 + src/Subscribers/SubscriberRepository.php | 179 ++ src/Subscribers/SubscribersPage.php | 48 + src/Subscribers/UnfollowHandler.php | 27 + tests/TestCase.php | 46 + .../Channel/ChannelSettingsRepositoryTest.php | 102 + tests/Unit/Channel/ChannelSettingsTest.php | 62 + .../Unit/Channel/CredentialEncryptorTest.php | 59 + tests/Unit/Channel/SignatureVerifierTest.php | 58 + tests/Unit/Channel/TestConnectionTest.php | 86 + tests/Unit/Channel/WebhookEndpointTest.php | 147 + tests/Unit/Foundation/ActivatorTest.php | 82 + tests/Unit/Foundation/AdminMenuTest.php | 68 + tests/Unit/Foundation/DeactivatorTest.php | 43 + tests/Unit/Foundation/EditionTest.php | 40 + tests/Unit/Foundation/I18nTest.php | 33 + tests/Unit/Foundation/RetentionCronTest.php | 81 + tests/Unit/Foundation/SchemaTest.php | 84 + tests/Unit/Foundation/SchemaVersionTest.php | 63 + tests/Unit/Foundation/UninstallerTest.php | 45 + tests/Unit/Subscribers/FollowHandlerTest.php | 92 + .../Subscribers/SubscriberRepositoryTest.php | 169 ++ tests/Unit/Subscribers/SubscriberTest.php | 65 + .../Unit/Subscribers/UnfollowHandlerTest.php | 30 + tests/bootstrap.php | 41 + uninstall.php | 19 + vendor/autoload.php | 25 - vendor/composer/ClassLoader.php | 579 ---- vendor/composer/LICENSE | 21 - vendor/composer/autoload_classmap.php | 32 - vendor/composer/autoload_namespaces.php | 9 - vendor/composer/autoload_psr4.php | 10 - vendor/composer/autoload_real.php | 36 - vendor/composer/autoload_static.php | 58 - 81 files changed, 5834 insertions(+), 2972 deletions(-) create mode 100644 composer.lock delete mode 100644 includes/Api/BotCatLineAuthApi.php delete mode 100644 includes/Api/BotCatLineWebhookApi.php delete mode 100644 includes/Api/BotCatMessageApi.php delete mode 100644 includes/Api/BotCatTelegramAuthApi.php delete mode 100644 includes/BotCatConstants.php delete mode 100644 includes/BotCatInitializer.php delete mode 100644 includes/Service/Api/BotCatLineService.php delete mode 100644 includes/Service/Api/BotCatSlackService.php delete mode 100644 includes/Service/Api/BotCatTelegramService.php delete mode 100644 includes/Service/BotCatMessageService.php delete mode 100644 includes/Service/BotCatNotificationService.php delete mode 100644 includes/Service/BotCatOAuthService.php delete mode 100644 includes/Service/BotCatRoleService.php delete mode 100644 includes/Service/BotCatShortcodeService.php delete mode 100644 includes/View/Admin/BotCatAdminView.php delete mode 100644 includes/View/Admin/BotCatLineAdminView.php delete mode 100644 includes/View/Admin/BotCatSlackAdminView.php delete mode 100644 includes/View/Admin/BotCatTelegramAdminView.php delete mode 100644 includes/View/Admin/Partial/BotCatTargetOptions.php delete mode 100644 includes/View/BotCatProfileView.php create mode 100644 phpcs.xml.dist create mode 100644 phpunit.xml.dist create mode 100644 src/Channel/ChannelSettings.php create mode 100644 src/Channel/ChannelSettingsRepository.php create mode 100644 src/Channel/CredentialEncryptor.php create mode 100644 src/Channel/SettingsPage.php create mode 100644 src/Channel/SignatureVerifier.php create mode 100644 src/Channel/TestConnection.php create mode 100644 src/Channel/TestConnectionResult.php create mode 100644 src/Channel/WebhookEndpoint.php create mode 100644 src/Foundation/Activator.php create mode 100644 src/Foundation/AdminMenu.php create mode 100644 src/Foundation/Deactivator.php create mode 100644 src/Foundation/Edition.php create mode 100644 src/Foundation/I18n.php create mode 100644 src/Foundation/Plugin.php create mode 100644 src/Foundation/RetentionCron.php create mode 100644 src/Foundation/Schema.php create mode 100644 src/Foundation/SchemaVersion.php create mode 100644 src/Foundation/Uninstaller.php create mode 100644 src/Subscribers/FollowHandler.php create mode 100644 src/Subscribers/LineProfileFetcher.php create mode 100644 src/Subscribers/LineProfileResult.php create mode 100644 src/Subscribers/Subscriber.php create mode 100644 src/Subscribers/SubscriberRepository.php create mode 100644 src/Subscribers/SubscribersPage.php create mode 100644 src/Subscribers/UnfollowHandler.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/Channel/ChannelSettingsRepositoryTest.php create mode 100644 tests/Unit/Channel/ChannelSettingsTest.php create mode 100644 tests/Unit/Channel/CredentialEncryptorTest.php create mode 100644 tests/Unit/Channel/SignatureVerifierTest.php create mode 100644 tests/Unit/Channel/TestConnectionTest.php create mode 100644 tests/Unit/Channel/WebhookEndpointTest.php create mode 100644 tests/Unit/Foundation/ActivatorTest.php create mode 100644 tests/Unit/Foundation/AdminMenuTest.php create mode 100644 tests/Unit/Foundation/DeactivatorTest.php create mode 100644 tests/Unit/Foundation/EditionTest.php create mode 100644 tests/Unit/Foundation/I18nTest.php create mode 100644 tests/Unit/Foundation/RetentionCronTest.php create mode 100644 tests/Unit/Foundation/SchemaTest.php create mode 100644 tests/Unit/Foundation/SchemaVersionTest.php create mode 100644 tests/Unit/Foundation/UninstallerTest.php create mode 100644 tests/Unit/Subscribers/FollowHandlerTest.php create mode 100644 tests/Unit/Subscribers/SubscriberRepositoryTest.php create mode 100644 tests/Unit/Subscribers/SubscriberTest.php create mode 100644 tests/Unit/Subscribers/UnfollowHandlerTest.php create mode 100644 tests/bootstrap.php create mode 100644 uninstall.php delete mode 100644 vendor/autoload.php delete mode 100644 vendor/composer/ClassLoader.php delete mode 100644 vendor/composer/LICENSE delete mode 100644 vendor/composer/autoload_classmap.php delete mode 100644 vendor/composer/autoload_namespaces.php delete mode 100644 vendor/composer/autoload_psr4.php delete mode 100644 vendor/composer/autoload_real.php delete mode 100644 vendor/composer/autoload_static.php diff --git a/.gitignore b/.gitignore index ac1ef63..69a58a2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ .claude/ AGENTS.md openspec/ +vendor/ +/.phpunit.cache/ diff --git a/bot-cat.php b/bot-cat.php index 1c37358..c2a63ec 100644 --- a/bot-cat.php +++ b/bot-cat.php @@ -1,22 +1,55 @@

%s

', + esc_html__( 'bot-cat: Composer dependencies are missing. Run "composer install" inside the plugin directory.', 'bot-cat' ) + ); + } + ); + return; +} + +require_once BOT_CAT_DIR . 'vendor/autoload.php'; -require_once __DIR__ . '/includes/BotCatConstants.php'; -require_once __DIR__ . '/vendor/autoload.php'; +register_activation_hook( __FILE__, array( \BotCat\Foundation\Activator::class, 'activate' ) ); +register_deactivation_hook( __FILE__, array( \BotCat\Foundation\Deactivator::class, 'deactivate' ) ); -BotCatInitializer::bot_cat_init(); \ No newline at end of file +add_action( + 'plugins_loaded', + static function () { + \BotCat\Foundation\Plugin::boot( BOT_CAT_FILE ); + } +); diff --git a/composer.json b/composer.json index ff3cdb6..2c0b228 100644 --- a/composer.json +++ b/composer.json @@ -1,30 +1,40 @@ { - "name": "eric0324/bot-cat", - "description": "", - "license": "GPL-2.0", - "keywords": [], - "homepage": "https://ericwu.asias/", - "type": "wordpress-plugin", - "authors": [ - { - "name": "EricWu", - "email": "smart032410@gmail.com" + "name": "bot-cat/bot-cat", + "description": "LINE Official Account push-notification plugin for WordPress.", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "require": { + "php": ">=8.1", + "woocommerce/action-scheduler": "^3.7" + }, + "require-dev": { + "phpunit/phpunit": "^10.5", + "brain/monkey": "^2.6", + "mockery/mockery": "^1.6", + "squizlabs/php_codesniffer": "^3.10", + "wp-coding-standards/wpcs": "^3.1", + "phpcompatibility/phpcompatibility-wp": "^2.1", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0" + }, + "autoload": { + "psr-4": { + "BotCat\\": "src/" } - ], - "support": { - "issues": "https://wordpress.org/support/plugin/bot-cat/" - }, - "config": { - "platform": { - "php": "8.2.1" - } - }, - "minimum-stability": "dev", - "prefer-stable": true, - "require": {}, - "autoload": { - "psr-4": { - "BotCat\\": "includes/" + }, + "autoload-dev": { + "psr-4": { + "BotCat\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + }, + "sort-packages": true + }, + "scripts": { + "test": "phpunit", + "lint": "phpcs", + "fix": "phpcbf" } - } -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..0a65129 --- /dev/null +++ b/composer.lock @@ -0,0 +1,2617 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "b79bed5bdb7222e528ae685bf1536b46", + "packages": [ + { + "name": "woocommerce/action-scheduler", + "version": "3.9.3", + "source": { + "type": "git", + "url": "https://github.com/woocommerce/action-scheduler.git", + "reference": "c58cdbab17651303d406cd3b22cf9d75c71c986c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/woocommerce/action-scheduler/zipball/c58cdbab17651303d406cd3b22cf9d75c71c986c", + "reference": "c58cdbab17651303d406cd3b22cf9d75c71c986c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "require-dev": { + "phpunit/phpunit": "^8.5", + "woocommerce/woocommerce-sniffs": "0.1.0", + "wp-cli/wp-cli": "~2.5.0", + "yoast/phpunit-polyfills": "^2.0" + }, + "type": "wordpress-plugin", + "extra": { + "scripts-description": { + "test": "Run unit tests", + "phpcs": "Analyze code against the WordPress coding standards with PHP_CodeSniffer", + "phpcbf": "Fix coding standards warnings/errors automatically with PHP Code Beautifier" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-3.0-or-later" + ], + "description": "Action Scheduler for WordPress and WooCommerce", + "homepage": "https://actionscheduler.org/", + "support": { + "issues": "https://github.com/woocommerce/action-scheduler/issues", + "source": "https://github.com/woocommerce/action-scheduler/tree/3.9.3" + }, + "time": "2025-07-15T09:32:30+00:00" + } + ], + "packages-dev": [ + { + "name": "antecedent/patchwork", + "version": "2.2.3", + "source": { + "type": "git", + "url": "https://github.com/antecedent/patchwork.git", + "reference": "8b6b235f405af175259c8f56aea5fc23ab9f03ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antecedent/patchwork/zipball/8b6b235f405af175259c8f56aea5fc23ab9f03ce", + "reference": "8b6b235f405af175259c8f56aea5fc23ab9f03ce", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": ">=4" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignas Rudaitis", + "email": "ignas.rudaitis@gmail.com" + } + ], + "description": "Method redefinition (monkey-patching) functionality for PHP.", + "homepage": "https://antecedent.github.io/patchwork/", + "keywords": [ + "aop", + "aspect", + "interception", + "monkeypatching", + "redefinition", + "runkit", + "testing" + ], + "support": { + "issues": "https://github.com/antecedent/patchwork/issues", + "source": "https://github.com/antecedent/patchwork/tree/2.2.3" + }, + "time": "2025-09-17T09:00:56+00:00" + }, + { + "name": "brain/monkey", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/Brain-WP/BrainMonkey.git", + "reference": "ea3aeb3d559ba3c0930b3f4d210b665a4c044d83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Brain-WP/BrainMonkey/zipball/ea3aeb3d559ba3c0930b3f4d210b665a4c044d83", + "reference": "ea3aeb3d559ba3c0930b3f4d210b665a4c044d83", + "shasum": "" + }, + "require": { + "antecedent/patchwork": "^2.1.17", + "mockery/mockery": "~1.3.6 || ~1.4.4 || ~1.5.1 || ^1.6.10", + "php": ">=5.6.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", + "phpcompatibility/php-compatibility": "^9.3.0", + "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.49 || ^9.6.30" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev", + "dev-version/1": "1.x-dev" + } + }, + "autoload": { + "files": [ + "inc/api.php" + ], + "psr-4": { + "Brain\\Monkey\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Giuseppe Mazzapica", + "email": "giuseppe.mazzapica@gmail.com", + "homepage": "https://gmazzap.me", + "role": "Developer" + } + ], + "description": "Mocking utility for PHP functions and WordPress plugin API", + "keywords": [ + "Monkey Patching", + "interception", + "mock", + "mock functions", + "mockery", + "patchwork", + "redefinition", + "runkit", + "test", + "testing" + ], + "support": { + "issues": "https://github.com/Brain-WP/BrainMonkey/issues", + "source": "https://github.com/Brain-WP/BrainMonkey" + }, + "time": "2026-02-05T09:22:14+00:00" + }, + { + "name": "dealerdirect/phpcodesniffer-composer-installer", + "version": "v1.2.1", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/composer-installer.git", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.2", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.1.0 || ^4.0" + }, + "require-dev": { + "composer/composer": "^2.2", + "ext-json": "*", + "ext-zip": "*", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev", + "yoast/phpunit-polyfills": "^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Franck Nijhof", + "email": "opensource@frenck.dev", + "homepage": "https://frenck.dev", + "role": "Open source developer" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer Standards Composer Installer Plugin", + "keywords": [ + "PHPCodeSniffer", + "PHP_CodeSniffer", + "code quality", + "codesniffer", + "composer", + "installer", + "phpcbf", + "phpcs", + "plugin", + "qa", + "quality", + "standard", + "standards", + "style guide", + "stylecheck", + "tests" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/composer-installer/issues", + "security": "https://github.com/PHPCSStandards/composer-installer/security/policy", + "source": "https://github.com/PHPCSStandards/composer-installer" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-05-06T08:26:05+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpcompatibility/php-compatibility", + "version": "9.3.5", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibility.git", + "reference": "9fb324479acf6f39452e0655d2429cc0d3914243" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/9fb324479acf6f39452e0655d2429cc0d3914243", + "reference": "9fb324479acf6f39452e0655d2429cc0d3914243", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "squizlabs/php_codesniffer": "^2.3 || ^3.0.2" + }, + "conflict": { + "squizlabs/php_codesniffer": "2.6.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.5 || ^5.0 || ^6.0 || ^7.0" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.5 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "homepage": "https://github.com/wimg", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCompatibility/PHPCompatibility/graphs/contributors" + } + ], + "description": "A set of sniffs for PHP_CodeSniffer that checks for PHP cross-version compatibility.", + "homepage": "http://techblog.wimgodden.be/tag/codesniffer/", + "keywords": [ + "compatibility", + "phpcs", + "standards" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibility/issues", + "source": "https://github.com/PHPCompatibility/PHPCompatibility" + }, + "time": "2019-12-27T09:44:58+00:00" + }, + { + "name": "phpcompatibility/phpcompatibility-paragonie", + "version": "1.3.4", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie.git", + "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", + "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", + "shasum": "" + }, + "require": { + "phpcompatibility/php-compatibility": "^9.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "paragonie/random_compat": "dev-master", + "paragonie/sodium_compat": "dev-master" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "lead" + } + ], + "description": "A set of rulesets for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by the Paragonie polyfill libraries.", + "homepage": "http://phpcompatibility.com/", + "keywords": [ + "compatibility", + "paragonie", + "phpcs", + "polyfill", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/issues", + "security": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/security/policy", + "source": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie" + }, + "funding": [ + { + "url": "https://github.com/PHPCompatibility", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcompatibility", + "type": "thanks_dev" + } + ], + "time": "2025-09-19T17:43:28+00:00" + }, + { + "name": "phpcompatibility/phpcompatibility-wp", + "version": "2.1.8", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibilityWP.git", + "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/7c8d18b4d90dac9e86b0869a608fa09158e168fa", + "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa", + "shasum": "" + }, + "require": { + "phpcompatibility/php-compatibility": "^9.0", + "phpcompatibility/phpcompatibility-paragonie": "^1.0", + "squizlabs/php_codesniffer": "^3.3" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "lead" + } + ], + "description": "A ruleset for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by WordPress.", + "homepage": "http://phpcompatibility.com/", + "keywords": [ + "compatibility", + "phpcs", + "standards", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibilityWP/issues", + "security": "https://github.com/PHPCompatibility/PHPCompatibilityWP/security/policy", + "source": "https://github.com/PHPCompatibility/PHPCompatibilityWP" + }, + "funding": [ + { + "url": "https://github.com/PHPCompatibility", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcompatibility", + "type": "thanks_dev" + } + ], + "time": "2025-10-18T00:05:59+00:00" + }, + { + "name": "phpcsstandards/phpcsextra", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", + "reference": "b598aa890815b8df16363271b659d73280129101" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/b598aa890815b8df16363271b659d73280129101", + "reference": "b598aa890815b8df16363271b659d73280129101", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "phpcsstandards/phpcsutils": "^1.2.0", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", + "phpcsstandards/phpcsdevtools": "^1.2.1", + "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-stable": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHPCSExtra/graphs/contributors" + } + ], + "description": "A collection of sniffs and standards for use with PHP_CodeSniffer.", + "keywords": [ + "PHP_CodeSniffer", + "phpcbf", + "phpcodesniffer-standard", + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHPCSExtra/issues", + "security": "https://github.com/PHPCSStandards/PHPCSExtra/security/policy", + "source": "https://github.com/PHPCSStandards/PHPCSExtra" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-12T23:06:57+00:00" + }, + { + "name": "phpcsstandards/phpcsutils", + "version": "1.2.2", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", + "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", + "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", + "shasum": "" + }, + "require": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" + }, + "require-dev": { + "ext-filter": "*", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", + "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-stable": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "autoload": { + "classmap": [ + "PHPCSUtils/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHPCSUtils/graphs/contributors" + } + ], + "description": "A suite of utility functions for use with PHP_CodeSniffer", + "homepage": "https://phpcsutils.com/", + "keywords": [ + "PHP_CodeSniffer", + "phpcbf", + "phpcodesniffer-standard", + "phpcs", + "phpcs3", + "phpcs4", + "standards", + "static analysis", + "tokens", + "utility" + ], + "support": { + "docs": "https://phpcsutils.com/", + "issues": "https://github.com/PHPCSStandards/PHPCSUtils/issues", + "security": "https://github.com/PHPCSStandards/PHPCSUtils/security/policy", + "source": "https://github.com/PHPCSStandards/PHPCSUtils" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-12-08T14:27:58+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "10.1.16", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=8.1", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-text-template": "^3.0.1", + "sebastian/code-unit-reverse-lookup": "^3.0.0", + "sebastian/complexity": "^3.2.0", + "sebastian/environment": "^6.1.0", + "sebastian/lines-of-code": "^2.0.2", + "sebastian/version": "^4.0.1", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^10.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:31:57+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T06:24:48+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:56:09+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T14:07:24+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:57:52+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "10.5.63", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "33198268dad71e926626b618f3ec3966661e4d90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/33198268dad71e926626b618f3ec3966661e4d90", + "reference": "33198268dad71e926626b618f3ec3966661e4d90", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.1", + "phpunit/php-code-coverage": "^10.1.16", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-invoker": "^4.0.0", + "phpunit/php-text-template": "^3.0.1", + "phpunit/php-timer": "^6.0.0", + "sebastian/cli-parser": "^2.0.1", + "sebastian/code-unit": "^2.0.0", + "sebastian/comparator": "^5.0.5", + "sebastian/diff": "^5.1.1", + "sebastian/environment": "^6.1.0", + "sebastian/exporter": "^5.1.4", + "sebastian/global-state": "^6.0.2", + "sebastian/object-enumerator": "^5.0.0", + "sebastian/recursion-context": "^5.0.1", + "sebastian/type": "^4.0.0", + "sebastian/version": "^4.0.1" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.63" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-01-27T05:48:37+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:12:49+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "abandoned": true, + "time": "2023-02-03T06:58:43+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "abandoned": true, + "time": "2023-02-03T06:59:15+00:00" + }, + { + "name": "sebastian/comparator", + "version": "5.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/diff": "^5.0", + "sebastian/exporter": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:25:16+00:00" + }, + { + "name": "sebastian/complexity", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "68ff824baeae169ec9f2137158ee529584553799" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799", + "reference": "68ff824baeae169ec9f2137158ee529584553799", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:37:17+00:00" + }, + { + "name": "sebastian/diff", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "symfony/process": "^6.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:15:17+00:00" + }, + { + "name": "sebastian/environment", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-23T08:47:14+00:00" + }, + { + "name": "sebastian/exporter", + "version": "5.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "0735b90f4da94969541dac1da743446e276defa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", + "reference": "0735b90f4da94969541dac1da743446e276defa6", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:09:11+00:00" + }, + { + "name": "sebastian/global-state", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:19:19+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:38:20+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:08:32+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:06:18+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/47e34210757a2f37a97dcd207d032e1b01e64c7a", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-10T07:50:56+00:00" + }, + { + "name": "sebastian/type", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:10:45+00:00" + }, + { + "name": "sebastian/version", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-07T11:34:05+00:00" + }, + { + "name": "squizlabs/php_codesniffer", + "version": "3.13.5", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-04T16:30:35+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + }, + { + "name": "wp-coding-standards/wpcs", + "version": "3.3.0", + "source": { + "type": "git", + "url": "https://github.com/WordPress/WordPress-Coding-Standards.git", + "reference": "7795ec6fa05663d716a549d0b44e47ffc8b0d4a6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/7795ec6fa05663d716a549d0b44e47ffc8b0d4a6", + "reference": "7795ec6fa05663d716a549d0b44e47ffc8b0d4a6", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "ext-libxml": "*", + "ext-tokenizer": "*", + "ext-xmlreader": "*", + "php": ">=7.2", + "phpcsstandards/phpcsextra": "^1.5.0", + "phpcsstandards/phpcsutils": "^1.1.0", + "squizlabs/php_codesniffer": "^3.13.4" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^10.0.0@dev", + "phpcsstandards/phpcsdevtools": "^1.2.0", + "phpunit/phpunit": "^8.0 || ^9.0" + }, + "suggest": { + "ext-iconv": "For improved results", + "ext-mbstring": "For improved results" + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Contributors", + "homepage": "https://github.com/WordPress/WordPress-Coding-Standards/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer rules (sniffs) to enforce WordPress coding conventions", + "keywords": [ + "phpcs", + "standards", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/WordPress/WordPress-Coding-Standards/issues", + "source": "https://github.com/WordPress/WordPress-Coding-Standards", + "wiki": "https://github.com/WordPress/WordPress-Coding-Standards/wiki" + }, + "funding": [ + { + "url": "https://opencollective.com/php_codesniffer", + "type": "custom" + } + ], + "time": "2025-11-25T12:08:04+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.1" + }, + "platform-dev": [], + "plugin-api-version": "2.3.0" +} diff --git a/includes/Api/BotCatLineAuthApi.php b/includes/Api/BotCatLineAuthApi.php deleted file mode 100644 index b8e8b77..0000000 --- a/includes/Api/BotCatLineAuthApi.php +++ /dev/null @@ -1,70 +0,0 @@ - 'POST', - 'callback' => [ &$this, 'bot_cat_store_token' ], - 'permission_callback' => '__return_true' - ] ); - - register_rest_route( BOT_CAT_REST_NAMESPACE_PREFIX, '/line/uuid', [ - 'methods' => 'POST', - 'callback' => [ &$this, 'bot_cat_store_uuid' ], - 'permission_callback' => '__return_true' - ] ); - } - - /** - * Store LINE Bot access token - * - * @param $request - * - * @return void - */ - public function bot_cat_store_token( $request ): void { - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json( [ 'Message' => 'Unauthorized' ], 401 ); - } - - $options = get_option( BOT_CAT_OPTION_PREFIX . 'line' ); - - $options = array_merge( $options, [ - 'channel_access_token' => sanitize_text_field( $request['channel_access_token'] ), - ] ); - - update_option( BOT_CAT_OPTION_PREFIX . 'line', $options ); - - wp_send_json( [ 'Message' => 'Success' ], 200 ); - } - - /** - * Store UUID for a specific user. - * - * @param mixed $request The request object containing the UUID. - * - * @return void - */ - public function bot_cat_store_uuid( $request ): void { - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json( [ 'Message' => 'Unauthorized' ], 401 ); - } - - $user_id = get_current_user_id(); - if ( $user_id && isset( $request['uuid'] ) ) { - update_user_meta( $user_id, BOT_CAT_OPTION_PREFIX . 'line_uuid', sanitize_text_field( $request['uuid'] ) ); - } - - wp_send_json( [ 'Message' => 'Success' ], 200 ); - } -} \ No newline at end of file diff --git a/includes/Api/BotCatLineWebhookApi.php b/includes/Api/BotCatLineWebhookApi.php deleted file mode 100644 index 2ad21d8..0000000 --- a/includes/Api/BotCatLineWebhookApi.php +++ /dev/null @@ -1,146 +0,0 @@ - 'POST', - 'callback' => [ &$this, 'handle_webhook' ], - 'permission_callback' => '__return_true' - ] ); - } - - /** - * Handle LINE Bot webhook events - * - * @param $request - * - * @return void - * @throws JsonException - */ - public function handle_webhook( $request ): void { - $body = $request->get_body(); - - if ( empty( $body ) ) { - wp_send_json( [ 'status' => 'error', 'message' => 'Empty request body' ], 400 ); - return; - } - - try { - $data = json_decode( $body, true, 512, JSON_THROW_ON_ERROR ); - } catch ( JsonException $e ) { - wp_send_json( [ 'status' => 'error', 'message' => 'Invalid JSON' ], 400 ); - return; - } - - // Validate LINE webhook signature (optional but recommended) - $options = get_option( BOT_CAT_OPTION_PREFIX . 'line' ); - if ( isset( $options['channel_secret'] ) && ! empty( $options['channel_secret'] ) ) { - $signature = $request->get_header( 'x-line-signature' ); - if ( ! $this->validate_signature( $body, $signature, $options['channel_secret'] ) ) { - wp_send_json( [ 'status' => 'error', 'message' => 'Invalid signature' ], 401 ); - return; - } - } - - // Process events - if ( isset( $data['events'] ) && is_array( $data['events'] ) ) { - foreach ( $data['events'] as $event ) { - $this->process_event( $event ); - } - } - - wp_send_json( [ 'status' => 'success' ], 200 ); - } - - /** - * Process individual LINE webhook event - * - * @param array $event - * @return void - */ - private function process_event( array $event ): void { - // Only handle message events for now - if ( ! isset( $event['type'] ) || $event['type'] !== 'message' ) { - return; - } - - // Only handle text messages - if ( ! isset( $event['message']['type'] ) || $event['message']['type'] !== 'text' ) { - return; - } - - $user_id = $event['source']['userId'] ?? null; - if ( empty( $user_id ) ) { - return; - } - - // Check if this user ID is already connected to any WordPress user - $existing_users = get_users( [ - 'meta_key' => BOT_CAT_OPTION_PREFIX . 'line_uuid', - 'meta_value' => $user_id, - 'number' => 1 - ] ); - - if ( ! empty( $existing_users ) ) { - // User already connected - return; - } - - // For demo purposes, we'll connect to the first admin user - // In a real implementation, you might want a different strategy - $admin_users = get_users( [ 'role' => 'administrator', 'number' => 1 ] ); - - if ( ! empty( $admin_users ) ) { - $admin_user = $admin_users[0]; - update_user_meta( $admin_user->ID, BOT_CAT_OPTION_PREFIX . 'line_uuid', $user_id ); - - // Optionally send a welcome message - $this->send_welcome_message( $user_id ); - } - } - - /** - * Validate LINE webhook signature - * - * @param string $body - * @param string $signature - * @param string $channel_secret - * @return bool - */ - private function validate_signature( string $body, string $signature, string $channel_secret ): bool { - if ( empty( $signature ) ) { - return false; - } - - $expected_signature = base64_encode( hash_hmac( 'sha256', $body, $channel_secret, true ) ); - return hash_equals( $signature, $expected_signature ); - } - - /** - * Send welcome message to newly connected user - * - * @param string $user_id - * @return void - */ - private function send_welcome_message( string $user_id ): void { - $line_service = new \BotCat\Service\Api\BotCatLineService(); - $welcome_message = __( 'Welcome! Your LINE account has been connected to receive notifications.', 'bot-cat' ); - - try { - $line_service->bot_cat_send_push_message( $user_id, $welcome_message ); - } catch ( \Exception $e ) { - error_log( 'BotCat: Failed to send welcome message - ' . $e->getMessage() ); - } - } -} \ No newline at end of file diff --git a/includes/Api/BotCatMessageApi.php b/includes/Api/BotCatMessageApi.php deleted file mode 100644 index 5ec369e..0000000 --- a/includes/Api/BotCatMessageApi.php +++ /dev/null @@ -1,41 +0,0 @@ - 'POST', - 'callback' => [ &$this, 'bot_cat_store_messages' ], - 'permission_callback' => '__return_true' - ] ); - } - - /** - * Stores messages in the bot_cat_messages option. - * - * @param array $request The request data containing messages. - * - * @return void - */ - public function bot_cat_store_messages( $request ): void { - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json( [ 'Message' => 'Unauthorized' ], 401 ); - } - - if ( isset( $request['messages'] ) && is_array( $request['messages'] ) ) { - update_option( BOT_CAT_OPTION_PREFIX . 'messages', $request['messages'] ); - wp_send_json( [ 'Message' => 'Success' ], 200 ); - } else { - wp_send_json( [ 'Message' => 'Invalid messages data' ], 400 ); - } - } -} \ No newline at end of file diff --git a/includes/Api/BotCatTelegramAuthApi.php b/includes/Api/BotCatTelegramAuthApi.php deleted file mode 100644 index 1eb8ef8..0000000 --- a/includes/Api/BotCatTelegramAuthApi.php +++ /dev/null @@ -1,76 +0,0 @@ - 'POST', - 'callback' => [ &$this, 'bot_cat_store_token' ], - 'permission_callback' => '__return_true' - ] ); - - register_rest_route( BOT_CAT_REST_NAMESPACE_PREFIX, '/telegram/uuid', [ - 'methods' => 'POST', - 'callback' => [ &$this, 'bot_cat_store_uuid' ], - 'permission_callback' => '__return_true' - ] ); - } - - /** - * Store the API token and chat ID for the Telegram bot. - * - * @param array $request The request data containing the API token and chat ID. - * - * @return void - */ - public function bot_cat_store_token( $request ): void { - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json( [ 'Message' => 'Unauthorized' ], 401 ); - } - - $options = get_option( BOT_CAT_OPTION_PREFIX . 'telegram' ); - - $options = array_merge( $options, [ - 'api_token' => sanitize_text_field( $request['api_token'] ), - 'chat_id' => sanitize_text_field( $request['chat_id'] ), - ] ); - - update_option( BOT_CAT_OPTION_PREFIX . 'telegram', $options ); - - wp_send_json( [ 'Message' => 'Success' ], 200 ); - } - - /** - * Store the UUID for a user. - * - * @param array $request The request data containing the UUID. - * - * @return void - */ - public function bot_cat_store_uuid( $request ): void { - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json( [ 'Message' => 'Unauthorized' ], 401 ); - } - - $user_id = get_current_user_id(); - if ( $user_id && isset( $request['uuid'] ) ) { - update_user_meta( $user_id, BOT_CAT_OPTION_PREFIX . 'telegram_uuid', sanitize_text_field( $request['uuid'] ) ); - } - - wp_send_json( [ 'Message' => 'Success' ], 200 ); - } -} \ No newline at end of file diff --git a/includes/BotCatConstants.php b/includes/BotCatConstants.php deleted file mode 100644 index 482e5ba..0000000 --- a/includes/BotCatConstants.php +++ /dev/null @@ -1,9 +0,0 @@ -multicast_message_url = 'https://api.line.me/v2/bot/message/multicast'; - $this->push_message_url = 'https://api.line.me/v2/bot/message/push'; - } - - /** - * Sends a text message through the LINE Messaging API to multiple recipients. - * - * @param array $to Array of recipient LINE User IDs. - * @param string $message The text message to send. - * - * @return void - * @throws JsonException - */ - public function bot_cat_send_text_message( array $to, string $message ): void { - // Skip if no recipients - if ( empty( $to ) ) { - return; - } - - $options = get_option( BOT_CAT_OPTION_PREFIX . 'line' ); - - // Check if access token is configured - if ( ! isset( $options['channel_access_token'] ) || empty( $options['channel_access_token'] ) ) { - error_log( 'BotCat: LINE Channel Access Token not configured' ); - return; - } - - $request_body = [ - 'to' => $to, - 'messages' => [ - [ - 'type' => 'text', - 'text' => $message - ] - ] - ]; - - $response = wp_remote_post( $this->multicast_message_url, [ - 'method' => 'POST', - 'headers' => [ - 'Content-Type' => 'application/json; charset=utf-8', - 'Authorization' => 'Bearer ' . $options['channel_access_token'] - ], - 'body' => json_encode( $request_body, JSON_THROW_ON_ERROR ), - 'timeout' => 30 - ] ); - - // Handle response - if ( is_wp_error( $response ) ) { - error_log( 'BotCat LINE API Error: ' . $response->get_error_message() ); - return; - } - - $response_code = wp_remote_retrieve_response_code( $response ); - if ( $response_code !== 200 ) { - $response_body = wp_remote_retrieve_body( $response ); - error_log( 'BotCat LINE API Error: HTTP ' . $response_code . ' - ' . $response_body ); - } - } - - /** - * Send a text message to a single LINE user - * - * @param string $to LINE User ID - * @param string $message Text message to send - * - * @return void - * @throws JsonException - */ - public function bot_cat_send_push_message( string $to, string $message ): void { - if ( empty( $to ) ) { - return; - } - - $options = get_option( BOT_CAT_OPTION_PREFIX . 'line' ); - - // Check if access token is configured - if ( ! isset( $options['channel_access_token'] ) || empty( $options['channel_access_token'] ) ) { - error_log( 'BotCat: LINE Channel Access Token not configured' ); - return; - } - - $request_body = [ - 'to' => $to, - 'messages' => [ - [ - 'type' => 'text', - 'text' => $message - ] - ] - ]; - - $response = wp_remote_post( $this->push_message_url, [ - 'method' => 'POST', - 'headers' => [ - 'Content-Type' => 'application/json; charset=utf-8', - 'Authorization' => 'Bearer ' . $options['channel_access_token'] - ], - 'body' => json_encode( $request_body, JSON_THROW_ON_ERROR ), - 'timeout' => 30 - ] ); - - // Handle response - if ( is_wp_error( $response ) ) { - error_log( 'BotCat LINE API Error: ' . $response->get_error_message() ); - return; - } - - $response_code = wp_remote_retrieve_response_code( $response ); - if ( $response_code !== 200 ) { - $response_body = wp_remote_retrieve_body( $response ); - error_log( 'BotCat LINE API Error: HTTP ' . $response_code . ' - ' . $response_body ); - } - } -} \ No newline at end of file diff --git a/includes/Service/Api/BotCatSlackService.php b/includes/Service/Api/BotCatSlackService.php deleted file mode 100644 index 18a6007..0000000 --- a/includes/Service/Api/BotCatSlackService.php +++ /dev/null @@ -1,152 +0,0 @@ - $message - ]; - - // Add optional channel if configured - if ( ! empty( $options['channel'] ) ) { - $payload['channel'] = $options['channel']; - } - - // Add optional username if configured - if ( ! empty( $options['username'] ) ) { - $payload['username'] = $options['username']; - } - - // Add bot icon - $payload['icon_emoji'] = ':robot_face:'; - - $response = wp_remote_post( $webhook_url, [ - 'method' => 'POST', - 'headers' => [ - 'Content-Type' => 'application/json; charset=utf-8', - ], - 'body' => json_encode( $payload, JSON_THROW_ON_ERROR ), - 'timeout' => 30 - ] ); - - // Handle response - if ( is_wp_error( $response ) ) { - error_log( 'BotCat Slack Webhook Error: ' . $response->get_error_message() ); - return; - } - - $response_code = wp_remote_retrieve_response_code( $response ); - if ( $response_code !== 200 ) { - $response_body = wp_remote_retrieve_body( $response ); - error_log( 'BotCat Slack Webhook Error: HTTP ' . $response_code . ' - ' . $response_body ); - } - } - - /** - * Send a formatted message with attachments to Slack - * - * @param string $title - * @param string $message - * @param string $color - * @param array $fields - * - * @return void - * @throws JsonException - */ - public function bot_cat_send_rich_message( string $title, string $message, string $color = 'good', array $fields = [] ): void { - $options = get_option( BOT_CAT_OPTION_PREFIX . 'slack' ); - - // Check if webhook URL is configured - if ( ! isset( $options['webhook_url'] ) || empty( $options['webhook_url'] ) ) { - error_log( 'BotCat: Slack webhook URL not configured' ); - return; - } - - $webhook_url = $options['webhook_url']; - - // Prepare attachment - $attachment = [ - 'title' => $title, - 'text' => $message, - 'color' => $color, - 'fields' => $fields, - 'footer' => 'BotCat', - 'ts' => time() - ]; - - // Prepare payload - $payload = [ - 'attachments' => [ $attachment ] - ]; - - // Add optional channel if configured - if ( ! empty( $options['channel'] ) ) { - $payload['channel'] = $options['channel']; - } - - // Add optional username if configured - if ( ! empty( $options['username'] ) ) { - $payload['username'] = $options['username']; - } - - // Add bot icon - $payload['icon_emoji'] = ':robot_face:'; - - $response = wp_remote_post( $webhook_url, [ - 'method' => 'POST', - 'headers' => [ - 'Content-Type' => 'application/json; charset=utf-8', - ], - 'body' => json_encode( $payload, JSON_THROW_ON_ERROR ), - 'timeout' => 30 - ] ); - - // Handle response - if ( is_wp_error( $response ) ) { - error_log( 'BotCat Slack Webhook Error: ' . $response->get_error_message() ); - return; - } - - $response_code = wp_remote_retrieve_response_code( $response ); - if ( $response_code !== 200 ) { - $response_body = wp_remote_retrieve_body( $response ); - error_log( 'BotCat Slack Webhook Error: HTTP ' . $response_code . ' - ' . $response_body ); - } - } -} \ No newline at end of file diff --git a/includes/Service/Api/BotCatTelegramService.php b/includes/Service/Api/BotCatTelegramService.php deleted file mode 100644 index c6031b9..0000000 --- a/includes/Service/Api/BotCatTelegramService.php +++ /dev/null @@ -1,46 +0,0 @@ -bot_api_url = 'https://api.telegram.org/bot'; - $this->send_message_path = '/sendMessage'; - } - - /** - * Sends a text message to the specified recipients using the Telegram Bot API. - * - * @param array $to An array of recipient UUIDs. - * @param string $message The text message to send. - * - * @return void - */ - public function bot_cat_send_text_message( array $to, string $message ): void { - - $options = get_option( BOT_CAT_OPTION_PREFIX . 'telegram' ); - - foreach ( $to as $uuid ) { - $url = $this->bot_api_url . $options['api_token'] . $this->send_message_path . "?chat_id=" . $uuid . "&text=" . urlencode( $message ); - - wp_remote_post( $url, - [ - 'method' => 'GET', - 'headers' => [ - 'Content-Type' => 'application/x-www-form-urlencoded' - ] - ] - ); - } - } -} \ No newline at end of file diff --git a/includes/Service/BotCatMessageService.php b/includes/Service/BotCatMessageService.php deleted file mode 100644 index c0360b9..0000000 --- a/includes/Service/BotCatMessageService.php +++ /dev/null @@ -1,138 +0,0 @@ -bot_cat_message = get_option( BOT_CAT_OPTION_PREFIX . 'messages' ); - } - - /** - * Generate post type text - * - * @param string $action_name The action name. - * @param WP_Post $post The post object. - * - * @return array The generated post type text. - */ - public function bot_cat_generate_post_type_text( string $action_name, WP_Post $post ): array { - - $userdata = get_userdata( $post->post_author ); - - $keyword_text = [ - '[title]' => $post->post_title, - '[content]' => $post->post_content, - '[date]' => $post->post_date, - '[id]' => $post->ID, - '[link]' => get_permalink( $post->ID ), - '[author]' => $userdata->display_name - ]; - - return $this->bot_cat_str_replace_message( - __( '[Admin] Post type message', 'bot-cat' ), - __( 'Post type message', 'bot-cat' ), - $keyword_text, - $this->bot_cat_message['admin'][ $action_name ], - $this->bot_cat_message['user'][ $action_name ] - ); - } - - /** - * Replaces keywords in the admin and user messages with corresponding values from the keyword list. - * - * @param string $admin_message The admin message to replace keywords in. - * @param string $user_message The user message to replace keywords in. - * @param array $keyword_list The list of keywords and their corresponding values. - * @param string|null $admin_message_template The admin message template that contains keywords to be replaced. - * @param string|null $user_message_template The user message template that contains keywords to be replaced. - * - * @return array The admin and user messages with replaced keywords. - */ - private function bot_cat_str_replace_message( - string $admin_message, - string $user_message, - array $keyword_list, - ?string $admin_message_template, - ?string $user_message_template - ): array { - if ( $admin_message_template ) { - $admin_message = str_ireplace( array_keys( $keyword_list ), $keyword_list, $admin_message_template ); - } - - if ( $user_message_template ) { - $user_message = str_ireplace( array_keys( $keyword_list ), $keyword_list, $user_message_template ); - } - - return [ - 'admin' => $admin_message, - 'user' => $user_message - ]; - } - - /** - * Generate comment type text - * - * @param string $action_name The action name. - * @param object $comment The comment object. - * - * @return array An array of keyword text replacements. - */ - public function bot_cat_generate_comment_type_text( string $action_name, object $comment ): array { - - $userdata = get_userdata( $comment->user_id ); - - $keyword_text = [ - '[author_email]' => $comment->comment_author_email, - '[content]' => $comment->comment_content, - '[date]' => $comment->comment_date, - '[id]' => $comment->comment_ID, - '[author_ip]' => $comment->comment_author_ip, - '[author_name]' => $userdata->display_name - ]; - - return $this->bot_cat_str_replace_message( - __( '[Admin] Comment type message', 'bot-cat' ), - __( 'Comment type message', 'bot-cat' ), - $keyword_text, - $this->bot_cat_message['admin'][ $action_name ], - $this->bot_cat_message['user'][ $action_name ] - ); - } - - /** - * Generate user type text - * - * @param string $action_name The name of the action - * @param object $user The user object - * - * @return array The generated user type text - */ - public function bot_cat_generate_user_type_text( string $action_name, object $user ): array { - $keyword_text = [ - '[username]' => $user->user_nicename, - '[name]' => $user->display_name, - '[id]' => $user->ID, - '[registered_date]' => $user->user_registered, - '[email]' => $user->user_email - ]; - - return $this->bot_cat_str_replace_message( - __( '[Admin] User type message', 'bot-cat' ), - __( 'User type message', 'bot-cat' ), - $keyword_text, - $this->bot_cat_message['admin'][ $action_name ], - $this->bot_cat_message['user'][ $action_name ] - ); - } -} \ No newline at end of file diff --git a/includes/Service/BotCatNotificationService.php b/includes/Service/BotCatNotificationService.php deleted file mode 100644 index 364f43a..0000000 --- a/includes/Service/BotCatNotificationService.php +++ /dev/null @@ -1,176 +0,0 @@ -bot_cat_role_service = new BotCatRoleService(); - $this->enable_service = $this->bot_cat_role_service->get_enable_services(); - - $this->bot_cat_message_service = new BotCatMessageService(); - $this->bot_cat_line_service = new BotCatLineService(); - $this->bot_cat_telegram_service = new BotCatTelegramService(); - $this->bot_cat_slack_service = new BotCatSlackService(); - } - - /** - * Sends a notification when a post is published. - * - * @param int $post_ID The ID of the published post. - * @param WP_Post $post The published post object. - * @param bool $update Whether this is an existing post being updated or a new post being published. - * - * @return void - * @throws JsonException - */ - public function bot_cat_post_publish_alert( int $post_ID, WP_Post $post, bool $update ): void { - if ( $post->post_type !== 'post' ) { - return; - } - - if ( $post->post_status !== 'publish' ) { - return; - } - - $uuids = $this->bot_cat_role_service->bot_cat_get_can_receive_post_type_uuids( 'publish_post', $post ); - - $messages = $this->bot_cat_message_service->bot_cat_generate_post_type_text( 'publish_post', $post ); - - $this->bot_cat_send_text_message( $uuids, $messages ); - } - - /** - * Sends a text message to the users who can receive it through various services. - * - * @param array $uuids The UUIDs of the users who can receive the message for each service. - * @param array $messages The messages to be sent for each service. - * - * @return void - * @throws JsonException - */ - private function bot_cat_send_text_message( array $uuids, array $messages ): void { - foreach ( $this->enable_service as $service ) { - if ( in_array( $service, $this->enable_service, false ) ) { - if ( $service === 'line' ) { - if ( isset( $uuids[ $service ]['admin'] ) && count( $uuids[ $service ]['admin'] ) > 0 ) { - $this->bot_cat_line_service->bot_cat_send_text_message( $uuids[ $service ]['admin'], $messages['admin'] ); - } - - if ( isset( $uuids[ $service ]['user'] ) && count( $uuids[ $service ]['user'] ) > 0 ) { - $this->bot_cat_line_service->bot_cat_send_text_message( $uuids[ $service ]['user'], $messages['user'] ); - } - } - - if ( $service === 'telegram' ) { - if ( isset( $uuids[ $service ]['admin'] ) && count( $uuids[ $service ]['admin'] ) > 0 ) { - $this->bot_cat_telegram_service->bot_cat_send_text_message( $uuids[ $service ]['admin'], $messages['admin'] ); - } - - if ( isset( $uuids[ $service ]['user'] ) && count( $uuids[ $service ]['user'] ) > 0 ) { - $this->bot_cat_telegram_service->bot_cat_send_text_message( $uuids[ $service ]['user'], $messages['user'] ); - } - } - - if ( $service === 'slack' ) { - // For Slack, we use webhook so we don't need specific user UUIDs - // We send both admin and user messages if there are any recipients configured - $has_recipients = ( isset( $uuids[ $service ]['admin'] ) && count( $uuids[ $service ]['admin'] ) > 0 ) || - ( isset( $uuids[ $service ]['user'] ) && count( $uuids[ $service ]['user'] ) > 0 ); - - if ( $has_recipients ) { - // Send admin message if there are admin recipients - if ( isset( $uuids[ $service ]['admin'] ) && count( $uuids[ $service ]['admin'] ) > 0 ) { - $this->bot_cat_slack_service->bot_cat_send_text_message( [], $messages['admin'] ); - } - // Send user message if there are user recipients (and it's different from admin message) - if ( isset( $uuids[ $service ]['user'] ) && count( $uuids[ $service ]['user'] ) > 0 && $messages['admin'] !== $messages['user'] ) { - $this->bot_cat_slack_service->bot_cat_send_text_message( [], $messages['user'] ); - } - } - } - } - } - } - - /** - * Sends a post review alert. - * - * @param int $post_ID The ID of the post. - * @param WP_Post $post The post object. - * @param bool $update Whether this is an existing post being updated or not. - * - * @return void - * @throws JsonException If there is an error encoding the message into JSON. - */ - public function bot_cat_post_review_alert( int $post_ID, WP_Post $post, bool $update ): void { - if ( $post->post_type !== 'post' ) { - return; - } - - if ( $post->post_status !== 'pending' ) { - return; - } - - $uuids = $this->bot_cat_role_service->bot_cat_get_can_receive_post_type_uuids( 'review_post', $post ); - - $messages = $this->bot_cat_message_service->bot_cat_generate_post_type_text( 'review_post', $post ); - - $this->bot_cat_send_text_message( $uuids, $messages ); - } - - /** - * Alerts the bot about a new comment. - * - * @param int $comment_ID The ID of the comment. - * - * @return void - * @throws JsonException if there is an error while generating the text message. - */ - public function bot_cat_new_comment_alert( int $comment_ID ): void { - $comment = get_comment( $comment_ID ); - - $uuids = $this->bot_cat_role_service->bot_cat_get_can_receive_comment_type_uuids( 'new_comment', $comment ); - - $messages = $this->bot_cat_message_service->bot_cat_generate_comment_type_text( 'new_comment', $comment ); - - $this->bot_cat_send_text_message( $uuids, $messages ); - } - - /** - * Sends a new user alert to specified UUIDs. - * - * @param int $user_ID The ID of the user. - * - * @return void - * @throws JsonException - */ - public function bot_cat_new_user_alert( int $user_ID ): void { - $user = get_userdata( $user_ID ); - - $uuids = $this->bot_cat_role_service->bot_cat_get_can_receive_user_type_uuids( 'new_user', $user ); - - $messages = $this->bot_cat_message_service->bot_cat_generate_user_type_text( 'new_user', $user ); - - $this->bot_cat_send_text_message( $uuids, $messages ); - } -} \ No newline at end of file diff --git a/includes/Service/BotCatOAuthService.php b/includes/Service/BotCatOAuthService.php deleted file mode 100644 index e140024..0000000 --- a/includes/Service/BotCatOAuthService.php +++ /dev/null @@ -1,145 +0,0 @@ -data->ID; - - $user = wp_get_current_user(); - $roles = []; - if ( isset( $user ) ) { - $roles = $user->roles; - } - - $html = ''; - - // LINE - $options = get_option( BOT_CAT_OPTION_PREFIX . 'line' ); - - // Check user role need show OAuth section - $oauth_show_profile = false; - if ( isset( $options['oauth_show_profile'] ) && $options['oauth_show_profile'] ) { - foreach ( $roles as $role ) { - if ( isset( $options['oauth_show_profile'][ $role ] ) ) { - $oauth_show_profile = true; - break; - } - } - } - - // LINE - if ( isset( $options['is_enable'] ) && $oauth_show_profile ) { - $line_uuid = get_user_meta( $user_id, BOT_CAT_OPTION_PREFIX . 'line_uuid', true ); - - $html .= '"; - } - - // Telegram - $options = get_option( BOT_CAT_OPTION_PREFIX . 'telegram' ); - - // Check user role need show OAuth section - $oauth_show_profile = false; - if ( isset( $options['oauth_show_profile'] ) && $options['oauth_show_profile'] ) { - foreach ( $roles as $role ) { - if ( isset( $options['oauth_show_profile'][ $role ] ) ) { - $oauth_show_profile = true; - break; - } - } - } - - if ( isset( $options['is_enable'] ) && $oauth_show_profile ) { - $telegram_uuid = get_user_meta( $user_id, BOT_CAT_OPTION_PREFIX . 'telegram_uuid', true ); - $html .= '"; - } - - // Slack - $options = get_option( BOT_CAT_OPTION_PREFIX . 'slack' ); - - // Check user role need show OAuth section - $oauth_show_profile = false; - if ( isset( $options['oauth_show_profile'] ) && $options['oauth_show_profile'] ) { - foreach ( $roles as $role ) { - if ( isset( $options['oauth_show_profile'][ $role ] ) ) { - $oauth_show_profile = true; - break; - } - } - } - - if ( isset( $options['is_enable'] ) && $oauth_show_profile ) { - $slack_webhook = isset( $options['webhook_url'] ) ? $options['webhook_url'] : ''; - $html .= '"; - } - - $html .= '
LINE'; - - if ( $line_uuid ) { - $html .= '✓ ' . __( 'Connected', 'bot-cat' ) . ''; - $html .= '
' . __( 'User ID:', 'bot-cat' ) . ' ' . esc_html( $line_uuid ) . ''; - } else { - $html .= '
'; - $html .= '

' . __( 'Connect your LINE account', 'bot-cat' ) . '

'; - $html .= '

' . __( 'To receive notifications via LINE, you need to:', 'bot-cat' ) . '

'; - $html .= '
    '; - $html .= '
  1. ' . __( 'Add your LINE Bot as a friend', 'bot-cat' ) . '
  2. '; - $html .= '
  3. ' . __( 'Send any message to the bot', 'bot-cat' ) . '
  4. '; - $html .= '
  5. ' . __( 'Your account will be automatically connected', 'bot-cat' ) . '
  6. '; - $html .= '
'; - $html .= '

' . __( 'Note: Administrator must configure the LINE Bot Channel Access Token first.', 'bot-cat' ) . '

'; - $html .= '
'; - } - - $html .= "
Telegram'; - - if ( $telegram_uuid ) { - $html .= __( 'Connected', 'bot-cat' ); - } else { - $html .= '

' . __( 'To connect Telegram, please configure your Telegram Bot credentials in the Telegram settings.', 'bot-cat' ) . '

'; - } - - $html .= "
Slack'; - - if ( $slack_webhook ) { - $html .= '✓ ' . __( 'Connected', 'bot-cat' ) . ''; - $html .= '
' . __( 'Webhook configured', 'bot-cat' ) . ''; - if ( ! empty( $options['channel'] ) ) { - $html .= '
' . __( 'Channel:', 'bot-cat' ) . ' ' . esc_html( $options['channel'] ) . ''; - } - } else { - $html .= '
'; - $html .= '

' . __( 'Connect Slack workspace', 'bot-cat' ) . '

'; - $html .= '

' . __( 'To receive notifications via Slack:', 'bot-cat' ) . '

'; - $html .= '
    '; - $html .= '
  1. ' . __( 'Go to Slack settings and configure the webhook URL', 'bot-cat' ) . '
  2. '; - $html .= '
  3. ' . __( 'Choose a channel and bot username', 'bot-cat' ) . '
  4. '; - $html .= '
  5. ' . __( 'Enable Slack notifications', 'bot-cat' ) . '
  6. '; - $html .= '
'; - $html .= '

' . __( 'Note: Administrator must configure the Slack webhook URL first.', 'bot-cat' ) . '

'; - $html .= '
'; - } - - $html .= "
'; - - return $html; - } -} \ No newline at end of file diff --git a/includes/Service/BotCatRoleService.php b/includes/Service/BotCatRoleService.php deleted file mode 100644 index 7d1d520..0000000 --- a/includes/Service/BotCatRoleService.php +++ /dev/null @@ -1,159 +0,0 @@ -options = []; - $this->enable_services = []; - - foreach ( SERVICES as $service ) { - $option = get_option( BOT_CAT_OPTION_PREFIX . $service ); - - if ( isset( $option['is_enable'] ) ) { - $this->options[ $service ] = $option; - $this->enable_services[] = $service; - } - } - - $this->admin_type_array = [ 'administrator', 'editor', 'author', 'contributor' ]; - $this->user_type_array = [ 'subscriber' ]; - } - - /** - * Returns the array of enable services. - * - * @return array The array of enable services. - */ - public function get_enable_services(): array { - return $this->enable_services; - } - - /** - * Get the UUIDs for post types that can receive notifications for a specific action. - * - * @param string $action_name The name of the action. - * - * @return array The array of UUIDs for each enable service. The UUIDs are grouped by 'admin' and 'user'. - */ - public function bot_cat_get_can_receive_post_type_uuids( string $action_name ): array { - $uuids = []; - - foreach ( $this->enable_services as $enable_service ) { - $admin_roles = []; - $user_roles = []; - foreach ( $this->options[ $enable_service ][ $action_name ] as $role => $need_send ) { - if ( in_array( $role, $this->admin_type_array, true ) ) { - $admin_roles[] = $role; - } - if ( in_array( $role, $this->user_type_array, true ) ) { - $user_roles[] = $role; - } - } - $uuids[ $enable_service ]['admin'] = $this->bot_cat_get_uuids_by_role_array( $admin_roles, $enable_service ); - $uuids[ $enable_service ]['user'] = $this->bot_cat_get_uuids_by_role_array( $user_roles, $enable_service ); - } - - return $uuids; - } - - /** - * Get the UUIDs for the given role array and message type - * - * @param array $role_array An array containing the roles - * @param string $message_type The type of the message - * - * @return array An array containing the UUIDs for the given roles and message type - */ - private function bot_cat_get_uuids_by_role_array( array $role_array, string $message_type ): array { - global $wpdb; - - $uuids = []; - - if ( count( $role_array ) === 0 ) { - return $uuids; - } - - $user_array = get_users( [ 'role__in' => $role_array ] ); - - $ids = []; - foreach ( $user_array as $user ) { - $ids[] = $user->ID; - } - - $in_str_arr = array_fill( 0, count( $ids ), '%s' ); - $in_str = implode( ',', $in_str_arr ); - - $sql = "SELECT meta_value FROM {$wpdb->usermeta} WHERE meta_key = '" . BOT_CAT_OPTION_PREFIX . $message_type . "_uuid' AND user_id IN ($in_str);"; - - $sql = $wpdb->prepare( $sql, $ids ); - $sql_uuids = $wpdb->get_results( $sql ); - - foreach ( $sql_uuids as $sql_uuid ) { - $uuids[] = $sql_uuid->meta_value; - } - - return $uuids; - } - - /** - * Get the UUIDs of the comment types that can receive the specified action. - * - * @param string $action_name the name of the action. - * - * @return array An array of UUIDs. - */ - public function bot_cat_get_can_receive_comment_type_uuids( string $action_name ): array { - $uuids = []; - - foreach ( $this->enable_services as $enable_service ) { - $admin_roles = []; - foreach ( $this->options[ $enable_service ][ $action_name ] as $role => $need_send ) { - if ( in_array( $role, $this->admin_type_array, true ) ) { - $admin_roles[] = $role; - } - } - - $uuids[ $enable_service ]['admin'] = $this->bot_cat_get_uuids_by_role_array( $admin_roles, $enable_service ); - - } - - return $uuids; - } - - /** - * Retrieves the UUIDs of users who can receive a specific action for each enabled service. - * - * @param string $action_name The name of the action. - * - * @return array The UUIDs of users who can receive the action for each enabled service. - */ - public function bot_cat_get_can_receive_user_type_uuids( string $action_name ): array { - $uuids = []; - - foreach ( $this->enable_services as $enable_service ) { - $admin_roles = []; - - foreach ( $this->options[ $enable_service ][ $action_name ] as $role => $need_send ) { - $admin_roles[] = $role; - } - - $uuids[ $enable_service ]['admin'] = $this->bot_cat_get_uuids_by_role_array( $admin_roles, $enable_service ); - } - - return $uuids; - } - -} \ No newline at end of file diff --git a/includes/Service/BotCatShortcodeService.php b/includes/Service/BotCatShortcodeService.php deleted file mode 100644 index 0361a9f..0000000 --- a/includes/Service/BotCatShortcodeService.php +++ /dev/null @@ -1,24 +0,0 @@ -bot_cat_oauth_view(); - } -} \ No newline at end of file diff --git a/includes/View/Admin/BotCatAdminView.php b/includes/View/Admin/BotCatAdminView.php deleted file mode 100644 index 3a377a6..0000000 --- a/includes/View/Admin/BotCatAdminView.php +++ /dev/null @@ -1,63 +0,0 @@ - -
- -
-

-
- - - - - - - -
-

-
- -
- ' . __( 'Please configure your LINE Bot Channel Access Token below to enable LINE notifications.', 'bot-cat' ) . '
'; - } - ?> -

- - - - - - - - -
- - - -

Messaging API > Channel access token', 'bot-cat' ) ?>

-
- - - - - - -
- - - -

Basic settings > Channel secret (optional for security)', 'bot-cat' ) ?>

-
- - - - - - -
- -

Messaging API > Webhook settings', 'bot-cat' ) ?>

-
- - - - - - -
- - > - - -
- -
- -

-
-

-
    -
  1. -
  2. -
  3. -
  4. -
  5. -
  6. -
-
- -
- bot_cat_role_can_receive_message(); - } -} \ No newline at end of file diff --git a/includes/View/Admin/BotCatSlackAdminView.php b/includes/View/Admin/BotCatSlackAdminView.php deleted file mode 100644 index efe8d4f..0000000 --- a/includes/View/Admin/BotCatSlackAdminView.php +++ /dev/null @@ -1,147 +0,0 @@ - -
- ' . __( 'Please configure your Slack webhook URL below to enable Slack notifications.', 'bot-cat' ) . '
'; - } - ?> -

- - - - - - - - -
- - - -

-
- - - - - - -
- - - -

-
- - - - - - -
- - - -

-
- - - - - - -
- - > - -
- -
- -

-
-

-
    -
  1. -
  2. Incoming Webhooks', 'bot-cat' ) ?>
  3. -
  4. -
  5. -
  6. -
  7. -
-
- -
- bot_cat_role_can_receive_message(); - } -} \ No newline at end of file diff --git a/includes/View/Admin/BotCatTelegramAdminView.php b/includes/View/Admin/BotCatTelegramAdminView.php deleted file mode 100644 index 00eebc3..0000000 --- a/includes/View/Admin/BotCatTelegramAdminView.php +++ /dev/null @@ -1,86 +0,0 @@ - -
- Bot Cat Console to input settings .', 'bot-cat' ), array( 'a' => array( 'href' => array() ) ) ), esc_url( $url ) ); - echo '
' . $link . '
'; - } - ?> -

- - - - - - - - - - -
- - > - -
- -
- bot_cat_role_can_receive_message(); - } -} \ No newline at end of file diff --git a/includes/View/Admin/Partial/BotCatTargetOptions.php b/includes/View/Admin/Partial/BotCatTargetOptions.php deleted file mode 100644 index 027ebc5..0000000 --- a/includes/View/Admin/Partial/BotCatTargetOptions.php +++ /dev/null @@ -1,219 +0,0 @@ -service_name = $service_name; - } - - public function bot_cat_role_can_receive_message(): void { - global $wp_roles; - $roles = $wp_roles->get_names(); - $options = get_option( BOT_CAT_OPTION_PREFIX . $this->service_name ); - ?> -

- - - - - -
- $role ) { - ?> - " - value="1" - - > - - -
-
-

- - - - - -
-
- $role ) { - ?> - " - value="1" - - > - - -
- - - - - -
-
- $role ) { - ?> - " - value="1" - - > - - -
- - - - - - -
-
- $role ) { - $role_attrs = $wp_roles->get_role( $name ); - if ( $role_attrs->has_cap( 'moderate_comments' ) ) { - ?> - " - value="1" - - > - - -
- - - - - - -
-
- $role ) { - $role_attrs = $wp_roles->get_role( $name ); - if ( $role_attrs->has_cap( 'list_users' ) ) { - ?> - " - value="1" - - > - - -
- - - -
- - service_name ); - ?> -

- - - - - -
-
- " - value="" - placeholder="" - > -
- - - - - -
-
- " - value="" - placeholder="" - > -
- - - - - - -
-
- " - value="" - placeholder="" - > -
- - - - - - -
-
- " - value="" - placeholder="" - > -
- - - -
- - bot_cat_oauth_service = new BotCatOAuthService(); - } - - /** - * Function to display extra fields for the user profile. - * - * This function retrieves the HTML code for the OAuth view and - * displays it on the user profile page. - * - * @return void - */ - public function bot_cat_extra_user_profile_fields(): void { - ?>bot_cat_oauth_service->bot_cat_oauth_view(); - echo( $html ); - ?> + + Coding standards for the bot-cat plugin. + + bot-cat.php + uninstall.php + src + + */vendor/* + */node_modules/* + */tests/* + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..ab086ef --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,20 @@ + + + + + tests/Unit + + + + + src + + + 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/SettingsPage.php b/src/Channel/SettingsPage.php new file mode 100644 index 0000000..e3cf28d --- /dev/null +++ b/src/Channel/SettingsPage.php @@ -0,0 +1,148 @@ + '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 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 ); + } elseif ( $type === 'unfollow' ) { + $this->unfollow->handle( $line_user_id, $ts ); + } + } +} 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..f047f2f --- /dev/null +++ b/src/Foundation/AdminMenu.php @@ -0,0 +1,90 @@ +edition->is_pro() ) { + $pages[] = array( 'bot-cat-license', __( 'License', 'bot-cat' ), 'render_license' ); + } + + foreach ( $pages as [$slug, $title, $callback] ) { + add_submenu_page( + self::MENU_SLUG, + $title, + $title, + self::CAPABILITY, + $slug, + array( $this, $callback ) + ); + } + } + + public function render_dashboard(): void { + $this->render_placeholder( __( 'Dashboard', 'bot-cat' ) ); + } + + public function render_subscribers(): void { + $this->render_placeholder( __( 'Subscribers', 'bot-cat' ) ); + } + + public function render_templates(): void { + $this->render_placeholder( __( 'Templates', 'bot-cat' ) ); + } + + public function render_logs(): void { + $this->render_placeholder( __( 'Push Logs', 'bot-cat' ) ); + } + + public function render_settings(): void { + $this->render_placeholder( __( 'Settings', 'bot-cat' ) ); + } + + public function render_license(): void { + $this->render_placeholder( __( 'License', 'bot-cat' ) ); + } + + private function render_placeholder( string $title ): void { + printf( + '

%s

', + esc_html( $title ) + ); + } +} 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..7bab1a8 --- /dev/null +++ b/src/Foundation/Plugin.php @@ -0,0 +1,86 @@ +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' ) ); + + $edition = new Edition(); + $menu = new AdminMenu( $edition ); + add_action( 'admin_menu', array( $menu, 'register' ) ); + + $cron = new RetentionCron(); + add_action( Activator::CRON_HOOK, array( $cron, 'run' ) ); + + $channel_repo = $this->channel_repository(); + $settings_page = new ChannelSettingsPage( $channel_repo ); + add_action( 'admin_init', array( $settings_page, 'register_settings' ) ); + add_action( 'admin_post_botcat_test_connection', array( $settings_page, 'handle_test_connection' ) ); + + $subscribers = new SubscriberRepository(); + $profile_fetcher = new LineProfileFetcher(); + $follow_handler = new FollowHandler( $subscribers, $channel_repo, $profile_fetcher ); + $unfollow_handler = new UnfollowHandler( $subscribers ); + + $webhook = new WebhookEndpoint( $channel_repo, $follow_handler, $unfollow_handler ); + add_action( 'rest_api_init', array( $webhook, 'register' ) ); + + $subscribers_page = new SubscribersPage( $subscribers ); + add_action( 'admin_menu', array( $subscribers_page, 'register' ), 20 ); + + add_action( + FollowHandler::RETRY_HOOK, + function ( string $line_user_id ) use ( $follow_handler ): void { + $follow_handler->handle( $line_user_id, time() ); + } + ); + } + + 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..5b99d44 --- /dev/null +++ b/src/Foundation/Schema.php @@ -0,0 +1,119 @@ +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' ); + + /** + * @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/Subscribers/FollowHandler.php b/src/Subscribers/FollowHandler.php new file mode 100644 index 0000000..967cef9 --- /dev/null +++ b/src/Subscribers/FollowHandler.php @@ -0,0 +1,62 @@ +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 + ); + return; + } + + $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' + ); + } + } +} 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 @@ +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/SubscriberRepository.php b/src/Subscribers/SubscriberRepository.php new file mode 100644 index 0000000..d621b9a --- /dev/null +++ b/src/Subscribers/SubscriberRepository.php @@ -0,0 +1,179 @@ +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 ); + } + + /** + * @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..ba4578b --- /dev/null +++ b/src/Subscribers/SubscribersPage.php @@ -0,0 +1,48 @@ +

%s

%s

', + esc_html__( 'Subscribers', 'bot-cat' ), + esc_html( + sprintf( + /* translators: %d: number of active subscribers. */ + __( 'Active subscribers: %d', 'bot-cat' ), + $this->subscribers->count_active() + ) + ) + ); + } +} 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/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..3e89805 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,46 @@ + 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)), + ]); + } + + protected function tearDown(): void + { + Monkey\tearDown(); + parent::tearDown(); + } +} 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/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..fc34947 --- /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(6); + + $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..d73828f --- /dev/null +++ b/tests/Unit/Foundation/SchemaTest.php @@ -0,0 +1,84 @@ +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 testPushLogsHasIndexesOnForeignKeys(): void + { + $schema = new Schema(); + $sql = $schema->sql_for('push_logs'); + + $this->assertMatchesRegularExpression('/KEY\s+\w*job_id\w*\s*\(\s*job_id\s*\)/i', $sql); + $this->assertMatchesRegularExpression('/KEY\s+\w*subscriber_id\w*\s*\(\s*subscriber_id\s*\)/i', $sql); + } + + public function testInstallCallsDbDeltaWithEverySchemaStatement(): void + { + $captured = []; + Functions\expect('dbDelta') + ->times(3) + ->andReturnUsing(function ($sql) use (&$captured) { + $captured[] = $sql; + return []; + }); + + $schema = new Schema(); + $schema->install(); + + $this->assertCount(3, $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); + } +} 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/Subscribers/FollowHandlerTest.php b/tests/Unit/Subscribers/FollowHandlerTest.php new file mode 100644 index 0000000..c7ad8e2 --- /dev/null +++ b/tests/Unit/Subscribers/FollowHandlerTest.php @@ -0,0 +1,92 @@ +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); + } +} 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/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/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/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); - } -} From 0250e77a51a2302db2f6c897ad8719587d996eb9 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 26 May 2026 15:32:41 +0800 Subject: [PATCH 3/8] feat(w1-ui): complete subscribers list table, detail page, and settings UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fleshes out the W1 admin surface so each spec scenario has an interactive counterpart. AdminMenu becomes a thin registry; rendering responsibilities move to the feature-owned classes injected from Plugin::register_hooks. Subscribers: - SubscribersQuery: immutable query value object with orderby allow-list, ASC/DESC clamp, per_page upper bound, from_request($_GET) parser - SubscriberPage: search-result envelope (rows + total) - SubscriberRepository::search(): WHERE / LIKE / ORDER BY / LIMIT / OFFSET composition; recent_logs_for_subscriber() joins push_logs + push_jobs - PushLogEntry: typed view for the detail-page log rows - SubscriberListTable (extends WP_List_Table): cb / display name link / truncated LINE id / status pill / followed_at, status filter, search, bulk delete with nonce check - SubscriberDetailPage: identity card + last 20 deliveries - SubscribersPage: front controller — dispatches to detail when ?subscriber= is set, otherwise renders the list Channel: - SettingsPage::render(): full page wrap, Settings API form, separate admin-post.php form for Test connection, success/error banner driven by the redirect query args from handle_test_connection Foundation: - AdminMenu accepts a renderer map; slugs without a callback fall back to a "not implemented yet" placeholder - Plugin::register_hooks wires SubscribersPage and ChannelSettingsPage into the menu in a single place Tests: +14 (75 total, 175 assertions). List table / detail page UI is intentionally not unit-tested (depends on WP_List_Table and admin context); their data paths are covered by SubscriberSearchTest, RecentLogsTest, and SubscriberRepositoryTest. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Channel/SettingsPage.php | 64 ++++++ src/Foundation/AdminMenu.php | 79 ++++---- src/Foundation/Plugin.php | 25 ++- src/Subscribers/PushLogEntry.php | 45 +++++ src/Subscribers/SubscriberDetailPage.php | 118 +++++++++++ src/Subscribers/SubscriberListTable.php | 187 ++++++++++++++++++ src/Subscribers/SubscriberPage.php | 23 +++ src/Subscribers/SubscriberRepository.php | 74 +++++++ src/Subscribers/SubscribersPage.php | 60 +++--- src/Subscribers/SubscribersQuery.php | 61 ++++++ tests/Unit/Subscribers/RecentLogsTest.php | 132 +++++++++++++ .../Unit/Subscribers/SubscriberSearchTest.php | 121 ++++++++++++ .../Unit/Subscribers/SubscribersQueryTest.php | 71 +++++++ 13 files changed, 985 insertions(+), 75 deletions(-) create mode 100644 src/Subscribers/PushLogEntry.php create mode 100644 src/Subscribers/SubscriberDetailPage.php create mode 100644 src/Subscribers/SubscriberListTable.php create mode 100644 src/Subscribers/SubscriberPage.php create mode 100644 src/Subscribers/SubscribersQuery.php create mode 100644 tests/Unit/Subscribers/RecentLogsTest.php create mode 100644 tests/Unit/Subscribers/SubscriberSearchTest.php create mode 100644 tests/Unit/Subscribers/SubscribersQueryTest.php diff --git a/src/Channel/SettingsPage.php b/src/Channel/SettingsPage.php index e3cf28d..1ddef18 100644 --- a/src/Channel/SettingsPage.php +++ b/src/Channel/SettingsPage.php @@ -122,6 +122,70 @@ public function render_field( array $args ): void { ); } + public function render(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'Insufficient permissions.', 'bot-cat' ), '', array( 'response' => 403 ) ); + } + + $this->render_test_result_banner(); + + echo '
'; + echo '

' . esc_html__( 'bot-cat Settings', 'bot-cat' ) . '

'; + + echo '
'; + settings_fields( self::OPTION_GROUP ); + do_settings_sections( 'bot-cat-settings' ); + submit_button(); + echo '
'; + + $this->render_test_connection_form(); + + echo '
'; + } + + 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( + '

%s

', + 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( + '

%1$s %2$s

', + 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 ) ); diff --git a/src/Foundation/AdminMenu.php b/src/Foundation/AdminMenu.php index f047f2f..2620e66 100644 --- a/src/Foundation/AdminMenu.php +++ b/src/Foundation/AdminMenu.php @@ -14,12 +14,18 @@ * Settings, License. The License page only registers when the Pro * edition is active. */ -final class AdminMenu { +class AdminMenu { public const MENU_SLUG = 'bot-cat'; public const CAPABILITY = 'manage_options'; - public function __construct( private readonly Edition $edition ) { + /** + * @param array $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 { @@ -28,63 +34,54 @@ public function register(): void { __( 'bot-cat', 'bot-cat' ), self::CAPABILITY, self::MENU_SLUG, - array( $this, 'render_dashboard' ), + $this->renderer_for( self::MENU_SLUG ), 'dashicons-format-chat', 30 ); - $pages = array( - array( 'bot-cat', __( 'Dashboard', 'bot-cat' ), 'render_dashboard' ), - array( 'bot-cat-subscribers', __( 'Subscribers', 'bot-cat' ), 'render_subscribers' ), - array( 'bot-cat-templates', __( 'Templates', 'bot-cat' ), 'render_templates' ), - array( 'bot-cat-logs', __( 'Push Logs', 'bot-cat' ), 'render_logs' ), - array( 'bot-cat-settings', __( 'Settings', 'bot-cat' ), 'render_settings' ), - ); - - if ( $this->edition->is_pro() ) { - $pages[] = array( 'bot-cat-license', __( 'License', 'bot-cat' ), 'render_license' ); - } - - foreach ( $pages as [$slug, $title, $callback] ) { + foreach ( $this->pages() as $page ) { + [ $slug, $title ] = $page; add_submenu_page( self::MENU_SLUG, $title, $title, self::CAPABILITY, $slug, - array( $this, $callback ) + $this->renderer_for( $slug ) ); } } - public function render_dashboard(): void { - $this->render_placeholder( __( 'Dashboard', 'bot-cat' ) ); - } - - public function render_subscribers(): void { - $this->render_placeholder( __( 'Subscribers', 'bot-cat' ) ); - } - - public function render_templates(): void { - $this->render_placeholder( __( 'Templates', 'bot-cat' ) ); - } + /** + * @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' ) ), + ); - public function render_logs(): void { - $this->render_placeholder( __( 'Push Logs', 'bot-cat' ) ); - } + if ( $this->edition->is_pro() ) { + $pages[] = array( 'bot-cat-license', __( 'License', 'bot-cat' ) ); + } - public function render_settings(): void { - $this->render_placeholder( __( 'Settings', 'bot-cat' ) ); + return $pages; } - public function render_license(): void { - $this->render_placeholder( __( 'License', 'bot-cat' ) ); - } + private function renderer_for( string $slug ): callable { + if ( isset( $this->renderers[ $slug ] ) ) { + return $this->renderers[ $slug ]; + } - private function render_placeholder( string $title ): void { - printf( - '

%s

', - esc_html( $title ) - ); + return function () use ( $slug ): void { + printf( + '

%s

%s

', + esc_html( $slug ), + esc_html__( 'This page is not implemented yet.', 'bot-cat' ) + ); + }; } } diff --git a/src/Foundation/Plugin.php b/src/Foundation/Plugin.php index 7bab1a8..a375cae 100644 --- a/src/Foundation/Plugin.php +++ b/src/Foundation/Plugin.php @@ -48,19 +48,27 @@ public function register_hooks(): void { $schema_version = new SchemaVersion( new Schema(), BOT_CAT_VERSION ); add_action( 'admin_init', array( $schema_version, 'maybe_upgrade' ) ); - $edition = new Edition(); - $menu = new AdminMenu( $edition ); - add_action( 'admin_menu', array( $menu, 'register' ) ); - - $cron = new RetentionCron(); - add_action( Activator::CRON_HOOK, array( $cron, 'run' ) ); - $channel_repo = $this->channel_repository(); $settings_page = new ChannelSettingsPage( $channel_repo ); add_action( 'admin_init', array( $settings_page, 'register_settings' ) ); add_action( 'admin_post_botcat_test_connection', array( $settings_page, 'handle_test_connection' ) ); $subscribers = new SubscriberRepository(); + $subscribers_page = new SubscribersPage( $subscribers ); + + $edition = new Edition(); + $menu = new AdminMenu( + $edition, + array( + 'bot-cat-subscribers' => array( $subscribers_page, 'render' ), + 'bot-cat-settings' => array( $settings_page, 'render' ), + ) + ); + add_action( 'admin_menu', array( $menu, 'register' ) ); + + $cron = new RetentionCron(); + add_action( Activator::CRON_HOOK, array( $cron, 'run' ) ); + $profile_fetcher = new LineProfileFetcher(); $follow_handler = new FollowHandler( $subscribers, $channel_repo, $profile_fetcher ); $unfollow_handler = new UnfollowHandler( $subscribers ); @@ -68,9 +76,6 @@ public function register_hooks(): void { $webhook = new WebhookEndpoint( $channel_repo, $follow_handler, $unfollow_handler ); add_action( 'rest_api_init', array( $webhook, 'register' ) ); - $subscribers_page = new SubscribersPage( $subscribers ); - add_action( 'admin_menu', array( $subscribers_page, 'register' ), 20 ); - add_action( FollowHandler::RETRY_HOOK, function ( string $line_user_id ) use ( $follow_handler ): void { diff --git a/src/Subscribers/PushLogEntry.php b/src/Subscribers/PushLogEntry.php new file mode 100644 index 0000000..5e3e489 --- /dev/null +++ b/src/Subscribers/PushLogEntry.php @@ -0,0 +1,45 @@ + $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/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 ''; + $this->row( __( 'LINE User ID', 'bot-cat' ), '' . esc_html( $subscriber->line_user_id ) . '' ); + $this->row( __( 'Display Name', 'bot-cat' ), esc_html( $subscriber->display_name ?? '—' ) ); + if ( $subscriber->picture_url !== null && $subscriber->picture_url !== '' ) { + $this->row( + __( 'Picture', 'bot-cat' ), + '' + ); + } + $this->row( __( 'Status', 'bot-cat' ), esc_html( ucfirst( $subscriber->status ) ) ); + $this->row( __( 'Followed at', 'bot-cat' ), esc_html( (string) $subscriber->followed_at ) ); + if ( $subscriber->unfollowed_at !== null ) { + $this->row( __( 'Unfollowed at', 'bot-cat' ), esc_html( $subscriber->unfollowed_at ) ); + } + 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( '', esc_html__( 'Sent at', 'bot-cat' ) ); + printf( '', esc_html__( 'Post', 'bot-cat' ) ); + printf( '', esc_html__( 'Status', 'bot-cat' ) ); + printf( '', esc_html__( 'Error', 'bot-cat' ) ); + echo ''; + + foreach ( $logs as $log ) { + echo ''; + printf( '', esc_html( $log->created_at ) ); + printf( + '', + $log->post_id !== null + ? sprintf( + '#%2$d', + esc_url( (string) get_edit_post_link( (int) $log->post_id ) ), + (int) $log->post_id + ) + : '—' + ); + printf( '', esc_html( $log->status ) ); + printf( '', esc_html( $log->error ?? '' ) ); + echo ''; + } + + echo '
%s%s%s%s
%s%s%s%s
'; + } + + 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 index d621b9a..bea7499 100644 --- a/src/Subscribers/SubscriberRepository.php +++ b/src/Subscribers/SubscriberRepository.php @@ -148,6 +148,80 @@ public function mark_unfollowed( string $line_user_id, string $unfollowed_at ): $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 */ diff --git a/src/Subscribers/SubscribersPage.php b/src/Subscribers/SubscribersPage.php index ba4578b..958df65 100644 --- a/src/Subscribers/SubscribersPage.php +++ b/src/Subscribers/SubscribersPage.php @@ -8,41 +8,53 @@ namespace BotCat\Subscribers; /** - * Hooks the Subscribers list-table render onto the bot-cat-subscribers - * submenu page registered by AdminMenu. + * Front controller for the bot-cat-subscribers admin page. * - * The list table itself is covered by manual acceptance — the data - * access pipeline it builds on is unit-tested in - * {@see SubscriberRepositoryTest}. + * Dispatches to {@see SubscriberDetailPage} when `?subscriber=` is + * present in the URL, otherwise renders the full list-table view. */ class SubscribersPage { public const PAGE_SLUG = 'bot-cat-subscribers'; - public function __construct( private readonly SubscriberRepository $subscribers ) { + public function __construct( private readonly SubscriberRepository $repository ) { } - public function register(): void { - // AdminMenu owns add_submenu_page; we just attach the renderer. - add_action( 'load-bot-cat_page_' . self::PAGE_SLUG, array( $this, 'prepare' ) ); - } + public function render(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'Insufficient permissions.', 'bot-cat' ), '', array( 'response' => 403 ) ); + } - public function prepare(): void { - // List-table preparation hook (screen options, bulk action handling) - // will land here when the WP_List_Table subclass is fleshed out. - } + $subscriber_id = isset( $_GET['subscriber'] ) ? (int) $_GET['subscriber'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended - public function render(): void { + 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( - '

%s

%s

', - esc_html__( 'Subscribers', 'bot-cat' ), - esc_html( - sprintf( - /* translators: %d: number of active subscribers. */ - __( 'Active subscribers: %d', 'bot-cat' ), - $this->subscribers->count_active() - ) - ) + '', + 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/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/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/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); + } +} From 3149130e057eed743af58f68e195e5cf217c7c65 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 26 May 2026 15:49:38 +0800 Subject: [PATCH 4/8] feat(w2): end-to-end push pipeline with Action Scheduler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements push-notification-core (W2 spec): publish → enqueue → chunk → multicast → log, with retries, rate limiting, idempotency, and the admin surface to inspect what shipped. Schema (v1.1.0): - push_jobs gains post_type, is_test, triggered_at, last_error and composite indexes (post_id, status) / (status, triggered_at) for idempotency + admin list queries - push_logs gains attempts, is_test and (job_id, status) composite Trigger path: - EligiblePostTypes: option-backed allow-list (default ['post']) plus botcat_eligible_post_types filter - PostPublishObserver: hooks transition_post_status, lets only non-publish → publish transitions of eligible types through - PushJobScheduler: writes pending job row + queues botcat_push_job_run Pipeline: - PushJobRunner: idempotency check (find_completed_for_post → skipped_duplicate), 500-recipient chunking, one pending push_logs row per recipient, RateLimiter-spaced botcat_push_batch_run actions - BatchRunner: loads logs, calls MulticastClient, consults RetryPolicy: success → mark logs sent + auto-finalize parent job retry → re-enqueue with attempt+1, delay 30s/5min/30min fail → mark logs failed abort_auth → mark job aborted_auth (subsequent batches no-op) - MulticastClient: POST /v2/bot/message/multicast, classifies 401/403 as auth, 429 with Retry-After honored, 5xx vs 4xx split - RetryPolicy: pure decision table (30s/5min/30min, 3 attempts) - RateLimiter: even-spacing scheduler (30 multicasts/minute ceiling) - MessageBuilder: W2 default text message; W3 will override via botcat_push_messages filter Admin UI: - PushJobsListTable: index of jobs (post, triggered_at, sent/total, failed, status), 50 rows per page - PushJobDetailPage: summary + per-recipient log table + "Resend to failed" form (POSTs to admin-post, resets failed rows to pending and queues a single batch with their ids) - PushLogsPage: front controller wired into AdminMenu Send-test action: - SettingsPage adds a "Send test push" form taking comma/newline separated LINE user IDs; handle_send_test parses ids and calls PushJobScheduler::enqueue_test which flags is_test on both job and log rows (excluded from analytics) Tests: +59 (134 total, 335 assertions). UI classes (PushJobsListTable, PushJobDetailPage) are intentionally not unit-tested — they depend on WP_List_Table and admin globals; their data paths are covered by the repository + runner + retry tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- bot-cat.php | 2 +- src/Channel/SettingsPage.php | 83 +++++++- src/Foundation/Plugin.php | 72 ++++++- src/Foundation/Schema.php | 14 +- src/Push/BatchRunner.php | 113 ++++++++++ src/Push/EligiblePostTypes.php | 58 +++++ src/Push/MessageBuilder.php | 60 ++++++ src/Push/MulticastClient.php | 121 +++++++++++ src/Push/MulticastResult.php | 26 +++ src/Push/PostPublishObserver.php | 41 ++++ src/Push/PushJob.php | 80 +++++++ src/Push/PushJobDetailPage.php | 174 +++++++++++++++ src/Push/PushJobRepository.php | 155 ++++++++++++++ src/Push/PushJobRunner.php | 122 +++++++++++ src/Push/PushJobScheduler.php | 56 +++++ src/Push/PushJobsListTable.php | 107 ++++++++++ src/Push/PushLog.php | 53 +++++ src/Push/PushLogRepository.php | 211 ++++++++++++++++++ src/Push/PushLogsPage.php | 59 ++++++ src/Push/RateLimiter.php | 29 +++ src/Push/RetryDecision.php | 47 +++++ src/Push/RetryPolicy.php | 52 +++++ tests/Unit/Foundation/SchemaTest.php | 28 ++- tests/Unit/Push/BatchRunnerTest.php | 223 ++++++++++++++++++++ tests/Unit/Push/EligiblePostTypesTest.php | 55 +++++ tests/Unit/Push/MulticastClientTest.php | 116 ++++++++++ tests/Unit/Push/PostPublishObserverTest.php | 99 +++++++++ tests/Unit/Push/PushJobRepositoryTest.php | 153 ++++++++++++++ tests/Unit/Push/PushJobRunnerTest.php | 163 ++++++++++++++ tests/Unit/Push/PushJobSchedulerTest.php | 51 +++++ tests/Unit/Push/PushJobTest.php | 87 ++++++++ tests/Unit/Push/PushLogRepositoryTest.php | 158 ++++++++++++++ tests/Unit/Push/RateLimiterTest.php | 42 ++++ tests/Unit/Push/RetryPolicyTest.php | 132 ++++++++++++ 34 files changed, 3028 insertions(+), 14 deletions(-) create mode 100644 src/Push/BatchRunner.php create mode 100644 src/Push/EligiblePostTypes.php create mode 100644 src/Push/MessageBuilder.php create mode 100644 src/Push/MulticastClient.php create mode 100644 src/Push/MulticastResult.php create mode 100644 src/Push/PostPublishObserver.php create mode 100644 src/Push/PushJob.php create mode 100644 src/Push/PushJobDetailPage.php create mode 100644 src/Push/PushJobRepository.php create mode 100644 src/Push/PushJobRunner.php create mode 100644 src/Push/PushJobScheduler.php create mode 100644 src/Push/PushJobsListTable.php create mode 100644 src/Push/PushLog.php create mode 100644 src/Push/PushLogRepository.php create mode 100644 src/Push/PushLogsPage.php create mode 100644 src/Push/RateLimiter.php create mode 100644 src/Push/RetryDecision.php create mode 100644 src/Push/RetryPolicy.php create mode 100644 tests/Unit/Push/BatchRunnerTest.php create mode 100644 tests/Unit/Push/EligiblePostTypesTest.php create mode 100644 tests/Unit/Push/MulticastClientTest.php create mode 100644 tests/Unit/Push/PostPublishObserverTest.php create mode 100644 tests/Unit/Push/PushJobRepositoryTest.php create mode 100644 tests/Unit/Push/PushJobRunnerTest.php create mode 100644 tests/Unit/Push/PushJobSchedulerTest.php create mode 100644 tests/Unit/Push/PushJobTest.php create mode 100644 tests/Unit/Push/PushLogRepositoryTest.php create mode 100644 tests/Unit/Push/RateLimiterTest.php create mode 100644 tests/Unit/Push/RetryPolicyTest.php diff --git a/bot-cat.php b/bot-cat.php index c2a63ec..0fdd9f1 100644 --- a/bot-cat.php +++ b/bot-cat.php @@ -25,7 +25,7 @@ define( 'BOT_CAT_FILE', __FILE__ ); define( 'BOT_CAT_DIR', plugin_dir_path( __FILE__ ) ); define( 'BOT_CAT_URL', plugin_dir_url( __FILE__ ) ); -define( 'BOT_CAT_VERSION', '1.0.0' ); +define( 'BOT_CAT_VERSION', '1.1.0' ); define( 'BOT_CAT_MIN_PHP', '8.1' ); define( 'BOT_CAT_MIN_WP', '7.0' ); diff --git a/src/Channel/SettingsPage.php b/src/Channel/SettingsPage.php index 1ddef18..e1056e7 100644 --- a/src/Channel/SettingsPage.php +++ b/src/Channel/SettingsPage.php @@ -17,13 +17,22 @@ */ class SettingsPage { - public const OPTION_GROUP = 'botcat_channel_settings_group'; - public const SECTION_ID = 'botcat_channel_section'; - public const NONCE_ACTION = 'botcat_test_connection'; + public const OPTION_GROUP = 'botcat_channel_settings_group'; + public const SECTION_ID = 'botcat_channel_section'; + public const NONCE_ACTION = 'botcat_test_connection'; + public const SEND_TEST_NONCE = 'botcat_send_test_push'; + public const SEND_TEST_ACTION = 'botcat_send_test_push'; + + /** @var \BotCat\Push\PushJobScheduler|null */ + private ?\BotCat\Push\PushJobScheduler $push_scheduler = null; public function __construct( private readonly ChannelSettingsRepository $repository ) { } + public function set_push_scheduler( \BotCat\Push\PushJobScheduler $scheduler ): void { + $this->push_scheduler = $scheduler; + } + public function register_settings(): void { register_setting( self::OPTION_GROUP, @@ -139,10 +148,78 @@ public function render(): void { echo ''; $this->render_test_connection_form(); + $this->render_send_test_form(); 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 '
'; + printf( '', esc_attr( self::SEND_TEST_ACTION ) ); + wp_nonce_field( self::SEND_TEST_NONCE ); + echo ''; + submit_button( __( 'Send test push', 'bot-cat' ), 'secondary', 'submit', false ); + 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; diff --git a/src/Foundation/Plugin.php b/src/Foundation/Plugin.php index a375cae..d63d2c5 100644 --- a/src/Foundation/Plugin.php +++ b/src/Foundation/Plugin.php @@ -11,6 +11,18 @@ use BotCat\Channel\CredentialEncryptor; use BotCat\Channel\SettingsPage as ChannelSettingsPage; use BotCat\Channel\WebhookEndpoint; +use BotCat\Push\BatchRunner; +use BotCat\Push\EligiblePostTypes; +use BotCat\Push\MessageBuilder; +use BotCat\Push\MulticastClient; +use BotCat\Push\PostPublishObserver; +use BotCat\Push\PushJobDetailPage; +use BotCat\Push\PushJobRepository; +use BotCat\Push\PushJobRunner; +use BotCat\Push\PushJobScheduler; +use BotCat\Push\PushLogRepository; +use BotCat\Push\PushLogsPage; +use BotCat\Push\RetryPolicy; use BotCat\Subscribers\FollowHandler; use BotCat\Subscribers\LineProfileFetcher; use BotCat\Subscribers\SubscriberRepository; @@ -48,27 +60,43 @@ public function register_hooks(): void { $schema_version = new SchemaVersion( new Schema(), BOT_CAT_VERSION ); add_action( 'admin_init', array( $schema_version, 'maybe_upgrade' ) ); - $channel_repo = $this->channel_repository(); + $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(); + $scheduler = new PushJobScheduler( $jobs ); + + // Channel settings UI. $settings_page = new ChannelSettingsPage( $channel_repo ); + $settings_page->set_push_scheduler( $scheduler ); 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' ) ); - $subscribers = new SubscriberRepository(); + // Subscribers pages. $subscribers_page = new SubscribersPage( $subscribers ); + // Push logs pages. + $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' ) ); + + // Top-level menu. $edition = new Edition(); $menu = new AdminMenu( $edition, array( 'bot-cat-subscribers' => array( $subscribers_page, 'render' ), + 'bot-cat-logs' => array( $push_logs_page, 'render' ), 'bot-cat-settings' => array( $settings_page, 'render' ), ) ); add_action( 'admin_menu', array( $menu, 'register' ) ); - $cron = new RetentionCron(); - add_action( Activator::CRON_HOOK, array( $cron, 'run' ) ); - + // Webhook + subscriber event handlers. $profile_fetcher = new LineProfileFetcher(); $follow_handler = new FollowHandler( $subscribers, $channel_repo, $profile_fetcher ); $unfollow_handler = new UnfollowHandler( $subscribers ); @@ -82,6 +110,40 @@ function ( string $line_user_id ) use ( $follow_handler ): void { $follow_handler->handle( $line_user_id, time() ); } ); + + // Push pipeline. + $eligible = new EligiblePostTypes(); + $observer = new PostPublishObserver( $eligible, $scheduler ); + add_action( 'transition_post_status', array( $observer, 'on_transition' ), 10, 3 ); + + $job_runner = new PushJobRunner( $jobs, $logs, $subscribers ); + 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 + ); + + $batch_runner = new BatchRunner( + $jobs, + $logs, + $channel_repo, + new MulticastClient(), + new RetryPolicy(), + new MessageBuilder() + ); + 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 { diff --git a/src/Foundation/Schema.php b/src/Foundation/Schema.php index 5b99d44..5027e7b 100644 --- a/src/Foundation/Schema.php +++ b/src/Foundation/Schema.php @@ -71,16 +71,21 @@ public function sql_for( string $table ): string { CREATE TABLE {$prefix}push_jobs ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL, + post_type VARCHAR(40) NOT NULL DEFAULT 'post', status VARCHAR(20) NOT NULL DEFAULT 'pending', recipient_count INT UNSIGNED NOT NULL DEFAULT 0, sent_count INT UNSIGNED NOT NULL DEFAULT 0, failed_count INT UNSIGNED NOT NULL DEFAULT 0, + is_test TINYINT(1) NOT NULL DEFAULT 0, + last_error TEXT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + triggered_at DATETIME NULL, started_at DATETIME NULL, finished_at DATETIME NULL, PRIMARY KEY (id), - KEY post_id (post_id), - KEY status (status) + KEY post_status (post_id, status), + KEY status_triggered (status, triggered_at), + KEY triggered_at (triggered_at) ) {$charset_collate}; SQL , @@ -92,9 +97,12 @@ public function sql_for( string $table ): string { line_user_id VARCHAR(64) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending', error TEXT NULL, + attempts TINYINT UNSIGNED NOT NULL DEFAULT 0, + is_test TINYINT(1) NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + sent_at DATETIME NULL, PRIMARY KEY (id), - KEY job_id (job_id), + KEY job_status (job_id, status), KEY subscriber_id (subscriber_id), KEY created_at (created_at) ) {$charset_collate}; 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..fc4b242 --- /dev/null +++ b/src/Push/MessageBuilder.php @@ -0,0 +1,60 @@ +> + */ + public function build_for_job( PushJob $job ): array { + if ( $job->is_test ) { + return array( + array( + 'type' => 'text', + 'text' => __( 'bot-cat connection test — if you see this, your channel is wired up correctly.', 'bot-cat' ), + ), + ); + } + + $title = (string) get_the_title( $job->post_id ); + $url = (string) get_permalink( $job->post_id ); + + if ( $title === '' && $url === '' ) { + return array( + array( + 'type' => 'text', + 'text' => __( 'A new post has been published.', 'bot-cat' ), + ), + ); + } + + $body = trim( $title . "\n" . $url ); + + /** @var list> $messages */ + $messages = apply_filters( + 'botcat_push_messages', + array( + array( + 'type' => 'text', + 'text' => $body, + ), + ), + $job + ); + + return $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_type = (string) $post->post_type; + if ( ! $this->eligible->is_eligible( $post_type ) ) { + return; + } + + $this->scheduler->enqueue( (int) $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 ''; + $this->row( __( 'Post ID', 'bot-cat' ), esc_html( (string) $job->post_id ) ); + $this->row( __( 'Status', 'bot-cat' ), esc_html( $job->status ) ); + $this->row( __( 'Triggered at', 'bot-cat' ), esc_html( (string) $job->triggered_at ) ); + $this->row( __( 'Recipients', 'bot-cat' ), esc_html( (string) $job->recipient_count ) ); + $this->row( __( 'Sent', 'bot-cat' ), esc_html( (string) $job->sent_count ) ); + $this->row( __( 'Failed', 'bot-cat' ), esc_html( (string) $job->failed_count ) ); + if ( $job->last_error !== null && $job->last_error !== '' ) { + $this->row( __( 'Last error', 'bot-cat' ), esc_html( $job->last_error ) ); + } + 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( '', esc_html__( 'LINE User ID', 'bot-cat' ) ); + printf( '', esc_html__( 'Status', 'bot-cat' ) ); + printf( '', esc_html__( 'Attempts', 'bot-cat' ) ); + printf( '', esc_html__( 'Error', 'bot-cat' ) ); + echo ''; + + foreach ( $rows as $row ) { + echo ''; + printf( '', esc_html( $row->line_user_id ) ); + printf( '', esc_html( $row->status ) ); + printf( '', (int) $row->attempts ); + printf( '', esc_html( $row->error ?? '' ) ); + echo ''; + } + + echo '
%s%s%s%s
%s%s%d%s
'; + } + + 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..1531017 --- /dev/null +++ b/src/Push/PushJobRepository.php @@ -0,0 +1,155 @@ +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 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..fb71279 --- /dev/null +++ b/src/Push/PushJobRunner.php @@ -0,0 +1,122 @@ + $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(), + ); + + $iterator = $job->is_test && $custom_user_ids !== array() + ? ( function () use ( $custom_user_ids ) { + foreach ( $custom_user_ids as $id ) { + yield (string) $id; + } + } )() + : $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..8f48d92 --- /dev/null +++ b/src/Push/PushJobScheduler.php @@ -0,0 +1,56 @@ +jobs->create_pending( $post_id, $post_type, $is_test ); + + if ( function_exists( 'as_schedule_single_action' ) ) { + as_schedule_single_action( time(), 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/tests/Unit/Foundation/SchemaTest.php b/tests/Unit/Foundation/SchemaTest.php index d73828f..5fefc93 100644 --- a/tests/Unit/Foundation/SchemaTest.php +++ b/tests/Unit/Foundation/SchemaTest.php @@ -53,15 +53,39 @@ public function testTablesIncludeSubscribersPushJobsPushLogs(): void $this->assertContains('wp_botcat_push_logs', $tables); } - public function testPushLogsHasIndexesOnForeignKeys(): void + public function testPushLogsHasIndexesOnForeignKeysAndJobStatus(): void { $schema = new Schema(); $sql = $schema->sql_for('push_logs'); - $this->assertMatchesRegularExpression('/KEY\s+\w*job_id\w*\s*\(\s*job_id\s*\)/i', $sql); + $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 = []; 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/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..70f1c46 --- /dev/null +++ b/tests/Unit/Push/PostPublishObserverTest.php @@ -0,0 +1,99 @@ +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 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..59a7b28 --- /dev/null +++ b/tests/Unit/Push/PushJobSchedulerTest.php @@ -0,0 +1,51 @@ +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 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 + ); + } +} From cb9a4a4cdf1177eb2f9b2b2f977bf3d389500d79 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 26 May 2026 16:01:55 +0800 Subject: [PATCH 5/8] feat(w3): message templates + push rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the two W3 capabilities: a templated message body with a server- rendered preview, and a Push rules surface (eligible post types, excluded categories, per-post opt-out, throttle). message-template: - TokenResolver: maps {title/excerpt/permalink/author/category/date/ site_name} to post data; unknown tokens remain literal. - ExcerptFallback: when post_excerpt is empty, strip HTML from post_content and truncate at 100 multibyte chars with `…`. - TemplateRenderer: pure-function strtr substitution. - TemplateRepository: option I/O + length status (green/yellow/red) + 5000-char hard limit; sanitize callback rejects oversize. - TemplatePage: admin editor with token help, server-rendered preview against the latest eligible published post (or synthetic when none), Save disabled at red. - MessageBuilder now renders the template at push time; the botcat_push_messages filter still allows W4 flex-message to override. push-rules: - ExcludedCategories: option-backed list; is_excluded(post_id) checks the post's full category list (not just the primary). - PerPostOptOut: post meta `_botcat_send_push` with explicit 0/1 precedence over the global default option botcat_default_push_on. Registers a sidebar meta box on eligible post types and a save_post handler with nonce + capability checks. - PushThrottle: cascading scheduler — each call to next_slot() advances the stored "next allowed" timestamp by one interval, so three publishes within the window fan out to time(0), +1×, +2×. - RulesSettingsTab: registers the four push-rules options (eligible_post_types, excluded_categories, throttle minutes, default opt-in) and renders the form. page/attachment are unselectable. - SettingsPage now uses a tabbed layout (Channel / Push rules). - PostPublishObserver consults ExcludedCategories + PerPostOptOut on top of the existing eligibility check. - PushJobScheduler runs real publishes through PushThrottle (test pushes bypass). Tests: +38 (172 total, 384 assertions). Template + rules pure-logic classes have full unit coverage; UI classes (TemplatePage, RulesSettingsTab) are manual acceptance — their persistence and sanitization paths are covered through the repository tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Channel/SettingsPage.php | 67 +++++- src/Foundation/Plugin.php | 58 ++++- src/Push/MessageBuilder.php | 77 ++++-- src/Push/PostPublishObserver.php | 25 +- src/Push/PushJobScheduler.php | 14 +- src/Rules/ExcludedCategories.php | 59 +++++ src/Rules/PerPostOptOut.php | 87 +++++++ src/Rules/PushThrottle.php | 44 ++++ src/Rules/RulesSettingsTab.php | 222 ++++++++++++++++++ src/Template/ExcerptFallback.php | 39 +++ src/Template/TemplatePage.php | 200 ++++++++++++++++ src/Template/TemplateRenderer.php | 36 +++ src/Template/TemplateRepository.php | 63 +++++ src/Template/TokenResolver.php | 56 +++++ tests/TestCase.php | 1 + tests/Unit/Push/PostPublishObserverTest.php | 32 +++ tests/Unit/Push/PushJobSchedulerTest.php | 35 +++ tests/Unit/Rules/ExcludedCategoriesTest.php | 50 ++++ tests/Unit/Rules/PerPostOptOutTest.php | 65 +++++ tests/Unit/Rules/PushThrottleTest.php | 73 ++++++ tests/Unit/Template/ExcerptFallbackTest.php | 51 ++++ tests/Unit/Template/TemplateRendererTest.php | 68 ++++++ .../Unit/Template/TemplateRepositoryTest.php | 59 +++++ tests/Unit/Template/TokenResolverTest.php | 105 +++++++++ 24 files changed, 1536 insertions(+), 50 deletions(-) create mode 100644 src/Rules/ExcludedCategories.php create mode 100644 src/Rules/PerPostOptOut.php create mode 100644 src/Rules/PushThrottle.php create mode 100644 src/Rules/RulesSettingsTab.php create mode 100644 src/Template/ExcerptFallback.php create mode 100644 src/Template/TemplatePage.php create mode 100644 src/Template/TemplateRenderer.php create mode 100644 src/Template/TemplateRepository.php create mode 100644 src/Template/TokenResolver.php create mode 100644 tests/Unit/Rules/ExcludedCategoriesTest.php create mode 100644 tests/Unit/Rules/PerPostOptOutTest.php create mode 100644 tests/Unit/Rules/PushThrottleTest.php create mode 100644 tests/Unit/Template/ExcerptFallbackTest.php create mode 100644 tests/Unit/Template/TemplateRendererTest.php create mode 100644 tests/Unit/Template/TemplateRepositoryTest.php create mode 100644 tests/Unit/Template/TokenResolverTest.php diff --git a/src/Channel/SettingsPage.php b/src/Channel/SettingsPage.php index e1056e7..28d208f 100644 --- a/src/Channel/SettingsPage.php +++ b/src/Channel/SettingsPage.php @@ -22,10 +22,15 @@ class SettingsPage { public const NONCE_ACTION = 'botcat_test_connection'; public const SEND_TEST_NONCE = 'botcat_send_test_push'; public const SEND_TEST_ACTION = 'botcat_send_test_push'; + public const TAB_CHANNEL = 'channel'; + public const TAB_RULES = 'rules'; /** @var \BotCat\Push\PushJobScheduler|null */ private ?\BotCat\Push\PushJobScheduler $push_scheduler = null; + /** @var \BotCat\Rules\RulesSettingsTab|null */ + private ?\BotCat\Rules\RulesSettingsTab $rules_tab = null; + public function __construct( private readonly ChannelSettingsRepository $repository ) { } @@ -33,6 +38,10 @@ public function set_push_scheduler( \BotCat\Push\PushJobScheduler $scheduler ): $this->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, @@ -136,23 +145,63 @@ public function render(): void { wp_die( esc_html__( 'Insufficient permissions.', 'bot-cat' ), '', array( 'response' => 403 ) ); } - $this->render_test_result_banner(); + $tab = $this->current_tab(); echo '
'; echo '

' . esc_html__( 'bot-cat Settings', 'bot-cat' ) . '

'; - echo '
'; - settings_fields( self::OPTION_GROUP ); - do_settings_sections( 'bot-cat-settings' ); - submit_button(); - echo '
'; + $this->render_tab_nav( $tab ); + + if ( $tab === self::TAB_RULES && $this->rules_tab !== null ) { + $this->rules_tab->render(); + } else { + $this->render_test_result_banner(); - $this->render_test_connection_form(); - $this->render_send_test_form(); + echo '
'; + settings_fields( self::OPTION_GROUP ); + do_settings_sections( 'bot-cat-settings' ); + submit_button(); + 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 ''; + } + private function render_send_test_form(): void { if ( $this->push_scheduler === null ) { return; @@ -177,7 +226,7 @@ public function handle_send_test(): void { check_admin_referer( self::SEND_TEST_NONCE ); - $raw = isset( $_POST['user_ids'] ) ? (string) wp_unslash( $_POST['user_ids'] ) : ''; + $raw = isset( $_POST['user_ids'] ) ? (string) wp_unslash( $_POST['user_ids'] ) : ''; $tokens = preg_split( '/[\s,]+/', $raw ); if ( ! is_array( $tokens ) ) { $tokens = array(); diff --git a/src/Foundation/Plugin.php b/src/Foundation/Plugin.php index d63d2c5..70776d6 100644 --- a/src/Foundation/Plugin.php +++ b/src/Foundation/Plugin.php @@ -23,11 +23,19 @@ use BotCat\Push\PushLogRepository; use BotCat\Push\PushLogsPage; use BotCat\Push\RetryPolicy; +use BotCat\Rules\ExcludedCategories; +use BotCat\Rules\PerPostOptOut; +use BotCat\Rules\PushThrottle; +use BotCat\Rules\RulesSettingsTab; use BotCat\Subscribers\FollowHandler; use BotCat\Subscribers\LineProfileFetcher; use BotCat\Subscribers\SubscriberRepository; use BotCat\Subscribers\SubscribersPage; use BotCat\Subscribers\UnfollowHandler; +use BotCat\Template\TemplatePage; +use BotCat\Template\TemplateRenderer; +use BotCat\Template\TemplateRepository; +use BotCat\Template\TokenResolver; /** * Central bootstrap: instantiates feature classes and wires them to @@ -67,19 +75,47 @@ public function register_hooks(): void { $jobs = new PushJobRepository(); $logs = new PushLogRepository(); $subscribers = new SubscriberRepository(); - $scheduler = new PushJobScheduler( $jobs ); - // Channel settings UI. + // Templates. + $template_repo = new TemplateRepository(); + $template_renderer = new TemplateRenderer( new TokenResolver() ); + + // Push rules. + $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' ) ); + + // Scheduler with throttle. + $scheduler = new PushJobScheduler( $jobs, $throttle ); + + // Template editor page. + $template_page = new TemplatePage( $template_repo, $template_renderer, $eligible ); + 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' ) ); - // Subscribers pages. - $subscribers_page = new SubscribersPage( $subscribers ); + // 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' ) ); - // Push logs pages. + // 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' ) ); @@ -90,6 +126,7 @@ public function register_hooks(): void { $edition, array( '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' ), ) @@ -111,11 +148,11 @@ function ( string $line_user_id ) use ( $follow_handler ): void { } ); - // Push pipeline. - $eligible = new EligiblePostTypes(); - $observer = new PostPublishObserver( $eligible, $scheduler ); + // Publish observer (consults eligibility + excluded categories + per-post opt-out). + $observer = new PostPublishObserver( $eligible, $scheduler, $excluded, $opt_out ); add_action( 'transition_post_status', array( $observer, 'on_transition' ), 10, 3 ); + // Push runners. $job_runner = new PushJobRunner( $jobs, $logs, $subscribers ); add_action( PushJobScheduler::ACTION_RUN_JOB, @@ -127,13 +164,14 @@ static function ( $job_id, $custom_user_ids = array() ) use ( $job_runner ): voi 2 ); - $batch_runner = new BatchRunner( + $message_builder = new MessageBuilder( $template_repo, $template_renderer ); + $batch_runner = new BatchRunner( $jobs, $logs, $channel_repo, new MulticastClient(), new RetryPolicy(), - new MessageBuilder() + $message_builder ); add_action( PushJobScheduler::ACTION_RUN_BATCH, diff --git a/src/Push/MessageBuilder.php b/src/Push/MessageBuilder.php index fc4b242..cb47c42 100644 --- a/src/Push/MessageBuilder.php +++ b/src/Push/MessageBuilder.php @@ -7,54 +7,81 @@ namespace BotCat\Push; +use BotCat\Template\TemplateRenderer; +use BotCat\Template\TemplateRepository; + /** * Produces the LINE message payload for a given push job. * - * Default (W2) implementation emits a plain text message with the post - * title and permalink. W3 will swap in a Templates-based builder via the - * `botcat_message_builder` filter without disturbing this contract. + * Real publishes render the stored template (message-template + * capability). Test pushes use a canned probe message. W4's + * flex-message capability can override the whole payload via the + * `botcat_push_messages` filter. */ class MessageBuilder { + public function __construct( + private readonly ?TemplateRepository $templates = null, + private readonly ?TemplateRenderer $renderer = null + ) { + } + /** * @return list> */ public function build_for_job( PushJob $job ): array { if ( $job->is_test ) { - return array( + return $this->filtered( + $job, array( - 'type' => 'text', - 'text' => __( 'bot-cat connection test — if you see this, your channel is wired up correctly.', 'bot-cat' ), - ), + array( + 'type' => 'text', + 'text' => __( 'bot-cat connection test — if you see this, your channel is wired up correctly.', 'bot-cat' ), + ), + ) ); } - $title = (string) get_the_title( $job->post_id ); - $url = (string) get_permalink( $job->post_id ); + $text = $this->render_for_post( $job->post_id ); - if ( $title === '' && $url === '' ) { - return array( - array( - 'type' => 'text', - 'text' => __( 'A new post has been published.', 'bot-cat' ), - ), - ); + if ( $text === '' ) { + $text = (string) __( 'A new post has been published.', 'bot-cat' ); } - $body = trim( $title . "\n" . $url ); - - /** @var list> $messages */ - $messages = apply_filters( - 'botcat_push_messages', + return $this->filtered( + $job, array( array( 'type' => 'text', - 'text' => $body, + 'text' => $text, ), - ), - $job + ) ); + } + + private function render_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 $messages; + return is_array( $filtered ) ? $filtered : $messages; } } diff --git a/src/Push/PostPublishObserver.php b/src/Push/PostPublishObserver.php index f9a30a6..f6cfaf0 100644 --- a/src/Push/PostPublishObserver.php +++ b/src/Push/PostPublishObserver.php @@ -7,18 +7,25 @@ namespace BotCat\Push; +use BotCat\Rules\ExcludedCategories; +use BotCat\Rules\PerPostOptOut; + /** * Listens to `transition_post_status` and forwards eligible * non-publish → publish transitions to {@see PushJobScheduler}. * - * Re-publishing (publish → publish) does not trigger — only the first - * time a post becomes publicly visible. + * Consults push-rules capability filters when wired: + * - EligiblePostTypes::is_eligible + * - ExcludedCategories::is_excluded + * - PerPostOptOut::is_opted_in */ class PostPublishObserver { public function __construct( private readonly EligiblePostTypes $eligible, - private readonly PushJobScheduler $scheduler + private readonly PushJobScheduler $scheduler, + private readonly ?ExcludedCategories $excluded = null, + private readonly ?PerPostOptOut $opt_out = null ) { } @@ -31,11 +38,21 @@ public function on_transition( string $new_status, string $old_status, mixed $po return; } + $post_id = (int) $post->ID; $post_type = (string) $post->post_type; + if ( ! $this->eligible->is_eligible( $post_type ) ) { return; } - $this->scheduler->enqueue( (int) $post->ID, $post_type ); + 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/PushJobScheduler.php b/src/Push/PushJobScheduler.php index 8f48d92..fc9e997 100644 --- a/src/Push/PushJobScheduler.php +++ b/src/Push/PushJobScheduler.php @@ -7,6 +7,8 @@ namespace BotCat\Push; +use BotCat\Rules\PushThrottle; + /** * Creates a pending push job row and queues the Action Scheduler action * that will fan it out into multicast batches. @@ -14,6 +16,10 @@ * Spec hooks (group `bot-cat`): * botcat_push_job_run — top-level orchestration * botcat_push_batch_run — single batch of ≤500 recipients + * + * When wired with a PushThrottle, real publishes (not test pushes) + * schedule the run for the next available slot so consecutive + * publishes cascade by the throttle interval. */ class PushJobScheduler { @@ -21,14 +27,18 @@ class PushJobScheduler { public const ACTION_RUN_BATCH = 'botcat_push_batch_run'; public const GROUP = 'bot-cat'; - public function __construct( private readonly PushJobRepository $jobs ) { + public function __construct( + private readonly PushJobRepository $jobs, + private readonly ?PushThrottle $throttle = null + ) { } public function enqueue( int $post_id, string $post_type, bool $is_test = false ): int { $job_id = $this->jobs->create_pending( $post_id, $post_type, $is_test ); if ( function_exists( 'as_schedule_single_action' ) ) { - as_schedule_single_action( time(), self::ACTION_RUN_JOB, array( $job_id ), self::GROUP ); + $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; 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 ''; + + $this->render_post_types_row(); + $this->render_excluded_categories_row(); + $this->render_throttle_row(); + $this->render_default_opt_in_row(); + + 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( + '', + 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( + '', + 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 ''; + 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( + '', + 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/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, + ) + ); + } + + 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 ) ); + } + + $active = $this->templates->get(); + $preview = $this->render_preview( $active ); + $length = mb_strlen( $preview, 'UTF-8' ); + $status = $this->templates->status_for_length( $length ); + + echo '
'; + echo '

' . esc_html__( 'Message Template', 'bot-cat' ) . '

'; + + echo '
'; + settings_fields( self::OPTION_GROUP ); + + echo ''; + $this->render_textarea_row( $active ); + $this->render_token_help_row(); + 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 '
'; + echo '
'; + } + + private function render_textarea_row( string $template ): void { + echo ''; + echo ''; + echo ''; + printf( + '', + 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 index 3e89805..66e5016 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -35,6 +35,7 @@ protected function setUp(): void '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)), ]); } diff --git a/tests/Unit/Push/PostPublishObserverTest.php b/tests/Unit/Push/PostPublishObserverTest.php index 70f1c46..9958def 100644 --- a/tests/Unit/Push/PostPublishObserverTest.php +++ b/tests/Unit/Push/PostPublishObserverTest.php @@ -10,6 +10,8 @@ use BotCat\Push\EligiblePostTypes; use BotCat\Push\PostPublishObserver; use BotCat\Push\PushJobScheduler; +use BotCat\Rules\ExcludedCategories; +use BotCat\Rules\PerPostOptOut; use BotCat\Tests\TestCase; /** @@ -79,6 +81,36 @@ public function testScheduledPostBecomingPublishedEnqueues(): void ->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); diff --git a/tests/Unit/Push/PushJobSchedulerTest.php b/tests/Unit/Push/PushJobSchedulerTest.php index 59a7b28..9fa379d 100644 --- a/tests/Unit/Push/PushJobSchedulerTest.php +++ b/tests/Unit/Push/PushJobSchedulerTest.php @@ -9,6 +9,7 @@ use BotCat\Push\PushJobRepository; use BotCat\Push\PushJobScheduler; +use BotCat\Rules\PushThrottle; use BotCat\Tests\TestCase; use Brain\Monkey\Functions; @@ -35,6 +36,40 @@ public function testEnqueueCreatesPendingRowAndQueuesActionSchedulerAction(): vo $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); 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/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; + } +} From fcc4285cd177cb54d5ebea92e29bdd0440d71b5e Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 26 May 2026 16:25:59 +0800 Subject: [PATCH 6/8] =?UTF-8?q?feat(w4):=20Pro=20edition=20=E2=80=94=20Fle?= =?UTF-8?q?x=20Message=20+=20tag=20segmentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the two Pro-edition W4 capabilities, all gated behind Edition::is_pro() so the Free build keeps the same surface. tag-segmentation: - Schema v1.2.0: tags (unique slug + keyword index) and subscriber_tags (composite PK + reverse tag_id index). - Tag / TagRepository: CRUD with JSON mapped_categories, find_by_slug / find_by_keyword / find_for_categories; delete cascades the join rows. - SubscriberTagRepository: subscribe (ON DUPLICATE KEY UPDATE), unsubscribe, tag_ids_for_subscriber, chunked active_line_user_ids_for_tags generator. - TagCommand / TagCommandParser: parses /tag 訂閱 | /tag subscribe /tag 取消 | /tag unsubscribe /tag 列表 | /tag list - TagCommandHandler: executes the parsed command and returns the reply text (or null for non-commands). - MessageEventHandler + ReplyClient: glue the LINE webhook's `message` events into the command flow and POST replies via the LINE reply endpoint. - AudienceResolver: at push time, compute the union of tags whose mapped_categories intersect the post; restrict to subscribers of any matching tag, otherwise fall back to the full active audience. Injected as an optional dependency on PushJobRunner. - WelcomeMessageBuilder + FollowHandler: brand-new follows on a Pro site with ≥1 tag receive a single welcome reply listing the available keywords; re-follows do not. - TagsPage: admin CRUD form for tags (Pro only). flex-message: - FlexTemplate value object (hero source + default image URL + headline + body + up to 3 CTAs). - FlexTemplateRepository: option-backed, caps CTAs at MAX_CTAS. - HeroImageResolver: featured → first attached → default URL → null (omit hero block). HERO_NONE strategy skips entirely. - FlexMessageBuilder: produces LINE Flex v2 bubble JSON with altText capped at 400 chars; renders headline/body/CTA url templates through TemplateRenderer. - MessageBuilder: when Edition::is_pro, tries Flex first; any Throwable from the builder fires `botcat_flex_render_failed` and falls through to the text template so delivery survives. - TemplatePage: gains a "Flex Message" tab on Pro builds with hero / headline / body / CTA fields. - Plugin: listens for `botcat_flex_render_failed` and writes the message to the push job's last_error column for admin visibility. Tests: +49 (221 total, 490 assertions). UI-only classes (TagsPage, the Flex tab section of TemplatePage) are manual acceptance; their persistence and parsing paths are covered by the repository / parser / handler tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- bot-cat.php | 2 +- src/Channel/ReplyClient.php | 54 +++++ src/Channel/WebhookEndpoint.php | 21 +- src/Flex/FlexMessageBuilder.php | 152 ++++++++++++ src/Flex/FlexTemplate.php | 46 ++++ src/Flex/FlexTemplateRepository.php | 77 ++++++ src/Flex/HeroImageResolver.php | 78 ++++++ src/Foundation/AdminMenu.php | 1 + src/Foundation/Plugin.php | 122 ++++++++-- src/Foundation/Schema.php | 27 ++- src/Push/MessageBuilder.php | 77 ++++-- src/Push/PushJobRepository.php | 12 + src/Push/PushJobRunner.php | 16 +- src/Subscribers/FollowHandler.php | 41 ++-- src/Tags/AudienceResolver.php | 64 +++++ src/Tags/MessageEventHandler.php | 44 ++++ src/Tags/SubscriberTagRepository.php | 114 +++++++++ src/Tags/Tag.php | 46 ++++ src/Tags/TagCommand.php | 47 ++++ src/Tags/TagCommandHandler.php | 122 ++++++++++ src/Tags/TagCommandParser.php | 41 ++++ src/Tags/TagRepository.php | 162 +++++++++++++ src/Tags/TagsPage.php | 224 ++++++++++++++++++ src/Tags/WelcomeMessageBuilder.php | 39 +++ src/Template/TemplatePage.php | 186 ++++++++++++++- tests/Unit/Flex/FlexMessageBuilderTest.php | 119 ++++++++++ .../Unit/Flex/FlexTemplateRepositoryTest.php | 69 ++++++ tests/Unit/Flex/HeroImageResolverTest.php | 73 ++++++ tests/Unit/Foundation/AdminMenuTest.php | 2 +- tests/Unit/Foundation/SchemaTest.php | 28 ++- tests/Unit/Push/MessageBuilderFlexTest.php | 103 ++++++++ tests/Unit/Subscribers/FollowHandlerTest.php | 51 ++++ tests/Unit/Tags/AudienceResolverTest.php | 119 ++++++++++ .../Unit/Tags/SubscriberTagRepositoryTest.php | 123 ++++++++++ tests/Unit/Tags/TagCommandHandlerTest.php | 149 ++++++++++++ tests/Unit/Tags/TagCommandParserTest.php | 89 +++++++ tests/Unit/Tags/TagRepositoryTest.php | 161 +++++++++++++ 37 files changed, 2828 insertions(+), 73 deletions(-) create mode 100644 src/Channel/ReplyClient.php create mode 100644 src/Flex/FlexMessageBuilder.php create mode 100644 src/Flex/FlexTemplate.php create mode 100644 src/Flex/FlexTemplateRepository.php create mode 100644 src/Flex/HeroImageResolver.php create mode 100644 src/Tags/AudienceResolver.php create mode 100644 src/Tags/MessageEventHandler.php create mode 100644 src/Tags/SubscriberTagRepository.php create mode 100644 src/Tags/Tag.php create mode 100644 src/Tags/TagCommand.php create mode 100644 src/Tags/TagCommandHandler.php create mode 100644 src/Tags/TagCommandParser.php create mode 100644 src/Tags/TagRepository.php create mode 100644 src/Tags/TagsPage.php create mode 100644 src/Tags/WelcomeMessageBuilder.php create mode 100644 tests/Unit/Flex/FlexMessageBuilderTest.php create mode 100644 tests/Unit/Flex/FlexTemplateRepositoryTest.php create mode 100644 tests/Unit/Flex/HeroImageResolverTest.php create mode 100644 tests/Unit/Push/MessageBuilderFlexTest.php create mode 100644 tests/Unit/Tags/AudienceResolverTest.php create mode 100644 tests/Unit/Tags/SubscriberTagRepositoryTest.php create mode 100644 tests/Unit/Tags/TagCommandHandlerTest.php create mode 100644 tests/Unit/Tags/TagCommandParserTest.php create mode 100644 tests/Unit/Tags/TagRepositoryTest.php diff --git a/bot-cat.php b/bot-cat.php index 0fdd9f1..fbfd590 100644 --- a/bot-cat.php +++ b/bot-cat.php @@ -25,7 +25,7 @@ define( 'BOT_CAT_FILE', __FILE__ ); define( 'BOT_CAT_DIR', plugin_dir_path( __FILE__ ) ); define( 'BOT_CAT_URL', plugin_dir_url( __FILE__ ) ); -define( 'BOT_CAT_VERSION', '1.1.0' ); +define( 'BOT_CAT_VERSION', '1.2.0' ); define( 'BOT_CAT_MIN_PHP', '8.1' ); define( 'BOT_CAT_MIN_WP', '7.0' ); 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/WebhookEndpoint.php b/src/Channel/WebhookEndpoint.php index 278e1da..f52f7d1 100644 --- a/src/Channel/WebhookEndpoint.php +++ b/src/Channel/WebhookEndpoint.php @@ -9,6 +9,7 @@ use BotCat\Subscribers\FollowHandler; use BotCat\Subscribers\UnfollowHandler; +use BotCat\Tags\MessageEventHandler; use WP_REST_Request; use WP_REST_Response; @@ -29,7 +30,8 @@ final class WebhookEndpoint { public function __construct( private readonly ChannelSettingsRepository $channel_settings, private readonly FollowHandler $follow, - private readonly UnfollowHandler $unfollow + private readonly UnfollowHandler $unfollow, + private readonly ?MessageEventHandler $messages = null ) { } @@ -96,8 +98,23 @@ private function dispatch_event( array $event ): void { if ( $type === 'follow' ) { $this->follow->handle( $line_user_id, $ts ); - } elseif ( $type === 'unfollow' ) { + 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/AdminMenu.php b/src/Foundation/AdminMenu.php index 2620e66..03d8a17 100644 --- a/src/Foundation/AdminMenu.php +++ b/src/Foundation/AdminMenu.php @@ -65,6 +65,7 @@ private function pages(): array { ); if ( $this->edition->is_pro() ) { + $pages[] = array( 'bot-cat-tags', __( 'Tags', 'bot-cat' ) ); $pages[] = array( 'bot-cat-license', __( 'License', 'bot-cat' ) ); } diff --git a/src/Foundation/Plugin.php b/src/Foundation/Plugin.php index 70776d6..70ff43b 100644 --- a/src/Foundation/Plugin.php +++ b/src/Foundation/Plugin.php @@ -9,13 +9,18 @@ use BotCat\Channel\ChannelSettingsRepository; use BotCat\Channel\CredentialEncryptor; +use BotCat\Channel\ReplyClient; use BotCat\Channel\SettingsPage as ChannelSettingsPage; use BotCat\Channel\WebhookEndpoint; +use BotCat\Flex\FlexMessageBuilder; +use BotCat\Flex\FlexTemplateRepository; +use BotCat\Flex\HeroImageResolver; use BotCat\Push\BatchRunner; use BotCat\Push\EligiblePostTypes; use BotCat\Push\MessageBuilder; use BotCat\Push\MulticastClient; use BotCat\Push\PostPublishObserver; +use BotCat\Push\PushJob; use BotCat\Push\PushJobDetailPage; use BotCat\Push\PushJobRepository; use BotCat\Push\PushJobRunner; @@ -32,10 +37,19 @@ use BotCat\Subscribers\SubscriberRepository; use BotCat\Subscribers\SubscribersPage; use BotCat\Subscribers\UnfollowHandler; +use BotCat\Tags\AudienceResolver; +use BotCat\Tags\MessageEventHandler; +use BotCat\Tags\SubscriberTagRepository; +use BotCat\Tags\TagCommandHandler; +use BotCat\Tags\TagCommandParser; +use BotCat\Tags\TagRepository; +use BotCat\Tags\TagsPage; +use BotCat\Tags\WelcomeMessageBuilder; use BotCat\Template\TemplatePage; use BotCat\Template\TemplateRenderer; use BotCat\Template\TemplateRepository; use BotCat\Template\TokenResolver; +use Throwable; /** * Central bootstrap: instantiates feature classes and wires them to @@ -71,16 +85,23 @@ public function register_hooks(): void { $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(); + $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() ); - // Push rules. + // Pro Flex template. + $flex_template_repo = new FlexTemplateRepository(); + $flex_builder = new FlexMessageBuilder( $template_renderer, new HeroImageResolver() ); + + // Edition + rules. + $edition = new Edition(); $eligible = new EligiblePostTypes(); $excluded = new ExcludedCategories(); $opt_out = new PerPostOptOut(); @@ -88,11 +109,11 @@ public function register_hooks(): void { $rules_tab = new RulesSettingsTab( $eligible, $excluded ); add_action( 'admin_init', array( $rules_tab, 'register_settings' ) ); - // Scheduler with throttle. + // Push scheduler with throttle. $scheduler = new PushJobScheduler( $jobs, $throttle ); - // Template editor page. - $template_page = new TemplatePage( $template_repo, $template_renderer, $eligible ); + // 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). @@ -120,25 +141,49 @@ function () use ( $opt_out, $eligible ): void { $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' ) ); + // Top-level menu. - $edition = new Edition(); - $menu = new AdminMenu( - $edition, - array( - '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' ), - ) + $menu_renderers = array( + '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 = new AdminMenu( $edition, $menu_renderers ); add_action( 'admin_menu', array( $menu, 'register' ) ); - // Webhook + subscriber event handlers. + // Webhook + subscriber + tag event handlers. $profile_fetcher = new LineProfileFetcher(); - $follow_handler = new FollowHandler( $subscribers, $channel_repo, $profile_fetcher ); + $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 ); - $webhook = new WebhookEndpoint( $channel_repo, $follow_handler, $unfollow_handler ); + $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( @@ -148,12 +193,17 @@ function ( string $line_user_id ) use ( $follow_handler ): void { } ); - // Publish observer (consults eligibility + excluded categories + per-post opt-out). + // 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 ); + $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 { @@ -164,8 +214,32 @@ static function ( $job_id, $custom_user_ids = array() ) use ( $job_runner ): voi 2 ); - $message_builder = new MessageBuilder( $template_repo, $template_renderer ); - $batch_runner = new BatchRunner( + $message_builder = new MessageBuilder( + $template_repo, + $template_renderer, + $edition->is_pro() ? $flex_template_repo : null, + $edition->is_pro() ? $flex_builder : null, + $edition + ); + + // 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, diff --git a/src/Foundation/Schema.php b/src/Foundation/Schema.php index 5027e7b..9caff31 100644 --- a/src/Foundation/Schema.php +++ b/src/Foundation/Schema.php @@ -16,7 +16,7 @@ */ class Schema { - public const TABLES = array( 'subscribers', 'push_jobs', 'push_logs' ); + public const TABLES = array( 'subscribers', 'push_jobs', 'push_logs', 'tags', 'subscriber_tags' ); /** * @return list Fully qualified table names (prefix included). @@ -107,6 +107,31 @@ public function sql_for( string $table ): string { KEY created_at (created_at) ) {$charset_collate}; SQL +, + 'tags' => << << '', }; diff --git a/src/Push/MessageBuilder.php b/src/Push/MessageBuilder.php index cb47c42..2a3d889 100644 --- a/src/Push/MessageBuilder.php +++ b/src/Push/MessageBuilder.php @@ -7,22 +7,33 @@ namespace BotCat\Push; +use BotCat\Flex\FlexMessageBuilder; +use BotCat\Flex\FlexTemplateRepository; +use BotCat\Foundation\Edition; use BotCat\Template\TemplateRenderer; use BotCat\Template\TemplateRepository; +use Throwable; /** * Produces the LINE message payload for a given push job. * - * Real publishes render the stored template (message-template - * capability). Test pushes use a canned probe message. W4's - * flex-message capability can override the whole payload via the - * `botcat_push_messages` filter. + * Edition behavior: + * - Test jobs always render a canned probe message. + * - Pro builds render the Flex template; any exception inside the + * Flex builder fires `botcat_flex_render_failed` and we fall through + * to the free-edition text template so delivery still happens. + * - Free builds render the text template. + * + * Either result can be overridden by the `botcat_push_messages` filter. */ class MessageBuilder { public function __construct( private readonly ?TemplateRepository $templates = null, - private readonly ?TemplateRenderer $renderer = null + private readonly ?TemplateRenderer $renderer = null, + private readonly ?FlexTemplateRepository $flex_templates = null, + private readonly ?FlexMessageBuilder $flex_builder = null, + private readonly ?Edition $edition = null ) { } @@ -42,24 +53,60 @@ public function build_for_job( PushJob $job ): array { ); } - $text = $this->render_for_post( $job->post_id ); + if ( $this->should_attempt_flex() ) { + $flex = $this->build_flex( $job ); + if ( $flex !== null ) { + return $this->filtered( $job, array( $flex ) ); + } + } + + return $this->filtered( $job, array( $this->build_text( $job ) ) ); + } + + 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 $this->filtered( - $job, - array( - array( - 'type' => 'text', - 'text' => $text, - ), - ) + return array( + 'type' => 'text', + 'text' => $text, ); } - private function render_for_post( int $post_id ): string { + 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 ); diff --git a/src/Push/PushJobRepository.php b/src/Push/PushJobRepository.php index 1531017..f81404b 100644 --- a/src/Push/PushJobRepository.php +++ b/src/Push/PushJobRepository.php @@ -105,6 +105,18 @@ public function mark_status( int $id, string $status, ?string $last_error = null ); } + 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; diff --git a/src/Push/PushJobRunner.php b/src/Push/PushJobRunner.php index fb71279..35a3d14 100644 --- a/src/Push/PushJobRunner.php +++ b/src/Push/PushJobRunner.php @@ -8,6 +8,7 @@ namespace BotCat\Push; use BotCat\Subscribers\SubscriberRepository; +use BotCat\Tags\AudienceResolver; /** * Top-level orchestrator for `botcat_push_job_run`. @@ -27,7 +28,8 @@ class PushJobRunner { public function __construct( private readonly PushJobRepository $jobs, private readonly PushLogRepository $logs, - private readonly SubscriberRepository $subscribers + private readonly SubscriberRepository $subscribers, + private readonly ?AudienceResolver $audience = null ) { } @@ -92,13 +94,17 @@ private function collect_batches( PushJob $job, array $custom_user_ids ): array 'subscriber_ids' => array(), ); - $iterator = $job->is_test && $custom_user_ids !== array() - ? ( function () use ( $custom_user_ids ) { + if ( $job->is_test && $custom_user_ids !== array() ) { + $iterator = ( function () use ( $custom_user_ids ) { foreach ( $custom_user_ids as $id ) { yield (string) $id; } - } )() - : $this->subscribers->active_ids(); + } )(); + } 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; diff --git a/src/Subscribers/FollowHandler.php b/src/Subscribers/FollowHandler.php index 967cef9..2291305 100644 --- a/src/Subscribers/FollowHandler.php +++ b/src/Subscribers/FollowHandler.php @@ -8,14 +8,15 @@ namespace BotCat\Subscribers; use BotCat\Channel\ChannelSettingsRepository; +use BotCat\Channel\ReplyClient; +use BotCat\Tags\WelcomeMessageBuilder; /** * Handles the LINE `follow` webhook event. * - * Upserts the subscriber row with `status = active`, then best-effort - * fetches the LINE profile (display name, picture). If the profile fetch - * fails the subscriber still exists; a retry is enqueued via Action - * Scheduler. + * Upserts the subscriber row with `status = active`, best-effort + * fetches the LINE profile, and (when wired with WelcomeMessageBuilder) + * sends a one-time welcome reply on first follow. */ class FollowHandler { @@ -24,11 +25,15 @@ class FollowHandler { public function __construct( private readonly SubscriberRepository $subscribers, private readonly ChannelSettingsRepository $channel_settings, - private readonly LineProfileFetcher $profiles + private readonly LineProfileFetcher $profiles, + private readonly ?WelcomeMessageBuilder $welcome = null, + private readonly ?ReplyClient $reply_client = null ) { } - public function handle( string $line_user_id, int $event_timestamp ): void { + public function handle( string $line_user_id, int $event_timestamp, ?string $reply_token = null ): void { + $existing = $this->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(); @@ -46,17 +51,23 @@ public function handle( string $line_user_id, int $event_timestamp ): void { $profile->picture_url, $followed_at ); - return; - } + } else { + $this->subscribers->upsert_active( $line_user_id, null, null, $followed_at ); - $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 ( 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/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( '', esc_html__( 'Display name', 'bot-cat' ) ); + printf( '', esc_html__( 'Slug', 'bot-cat' ) ); + printf( '', esc_html__( 'Keyword', 'bot-cat' ) ); + printf( '', esc_html__( 'Mapped categories', 'bot-cat' ) ); + printf( '', 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( '', esc_html( $tag->display_name ) ); + printf( '', esc_html( $tag->slug ) ); + printf( '', esc_html( $tag->keyword ) ); + printf( '', esc_html( implode( ', ', array_map( 'strval', $tag->mapped_categories ) ) ) ); + printf( + '', + esc_url( $edit_url ), + esc_html__( 'Edit', 'bot-cat' ), + $this->delete_form_html( $tag ) + ); + echo ''; + } + + echo '
%s%s%s%s%s
%s%s%s%s%2$s · %3$s
'; + } + + 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 ''; + $this->text_row( 'display_name', __( 'Display name', 'bot-cat' ), $editing?->display_name ?? '' ); + $this->text_row( 'slug', __( 'Slug (kebab-case, ASCII)', 'bot-cat' ), $editing?->slug ?? '' ); + $this->text_row( 'keyword', __( 'Subscribe keyword', 'bot-cat' ), $editing?->keyword ?? '' ); + $this->categories_row( $editing?->mapped_categories ?? array() ); + 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( + '', + 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( + '', + (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 ''; + 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/TemplatePage.php b/src/Template/TemplatePage.php index 93e9c92..6466f12 100644 --- a/src/Template/TemplatePage.php +++ b/src/Template/TemplatePage.php @@ -7,6 +7,9 @@ namespace BotCat\Template; +use BotCat\Flex\FlexTemplate; +use BotCat\Flex\FlexTemplateRepository; +use BotCat\Foundation\Edition; use BotCat\Push\EligiblePostTypes; /** @@ -19,13 +22,18 @@ */ class TemplatePage { - public const PAGE_SLUG = 'bot-cat-templates'; - public const OPTION_GROUP = 'botcat_template_settings_group'; + public const PAGE_SLUG = 'bot-cat-templates'; + public const OPTION_GROUP = 'botcat_template_settings_group'; + public const FLEX_OPTION_GROUP = 'botcat_flex_template_settings_group'; + public const TAB_TEXT = 'text'; + public const TAB_FLEX = 'flex'; public function __construct( private readonly TemplateRepository $templates, private readonly TemplateRenderer $renderer, - private readonly EligiblePostTypes $eligible + private readonly EligiblePostTypes $eligible, + private readonly ?Edition $edition = null, + private readonly ?FlexTemplateRepository $flex_templates = null ) { } @@ -39,6 +47,18 @@ public function register_settings(): void { '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 { @@ -60,14 +80,30 @@ public function render(): void { 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 '
'; - echo '

' . esc_html__( 'Message Template', 'bot-cat' ) . '

'; - echo '
'; settings_fields( self::OPTION_GROUP ); @@ -82,7 +118,143 @@ public function render(): void { submit_button( __( 'Save Template', 'bot-cat' ), 'primary', 'submit', true, $status === TemplateRepository::STATUS_RED ? array( 'disabled' => 'disabled' ) : array() ); echo '
'; - echo '
'; + } + + private function render_flex_form(): void { + $template = $this->flex_templates->get(); + + echo '
'; + settings_fields( self::FLEX_OPTION_GROUP ); + + echo ''; + $this->flex_select_row( $template ); + $this->flex_text_row( 'default_image_url', __( 'Default image URL', 'bot-cat' ), $template->default_image_url ); + $this->flex_text_row( 'headline', __( 'Headline', 'bot-cat' ), $template->headline ); + $this->flex_text_row( 'body', __( 'Body', 'bot-cat' ), $template->body ); + $this->flex_ctas_row( $template->ctas ); + echo ''; + + submit_button( __( 'Save Flex Template', 'bot-cat' ) ); + echo '
'; + } + + private function flex_select_row( FlexTemplate $template ): void { + echo ''; + printf( ''; + } + + private function flex_text_row( string $field, string $label, string $value ): void { + printf( + '', + 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 ''; } private function render_textarea_row( string $template ): void { 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/AdminMenuTest.php b/tests/Unit/Foundation/AdminMenuTest.php index fc34947..e5fc4d5 100644 --- a/tests/Unit/Foundation/AdminMenuTest.php +++ b/tests/Unit/Foundation/AdminMenuTest.php @@ -58,7 +58,7 @@ public function testRegistersLicenseSubmenuOnlyWhenProEditionActive(): void $edition->method('is_pro')->willReturn(true); Functions\expect('add_menu_page')->once(); - Functions\expect('add_submenu_page')->times(6); + Functions\expect('add_submenu_page')->times(7); $menu = new AdminMenu($edition); $menu->register(); diff --git a/tests/Unit/Foundation/SchemaTest.php b/tests/Unit/Foundation/SchemaTest.php index 5fefc93..56bc3df 100644 --- a/tests/Unit/Foundation/SchemaTest.php +++ b/tests/Unit/Foundation/SchemaTest.php @@ -53,6 +53,28 @@ public function testTablesIncludeSubscribersPushJobsPushLogs(): void $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(); @@ -90,7 +112,7 @@ public function testInstallCallsDbDeltaWithEverySchemaStatement(): void { $captured = []; Functions\expect('dbDelta') - ->times(3) + ->times(count(Schema::TABLES)) ->andReturnUsing(function ($sql) use (&$captured) { $captured[] = $sql; return []; @@ -99,10 +121,12 @@ public function testInstallCallsDbDeltaWithEverySchemaStatement(): void $schema = new Schema(); $schema->install(); - $this->assertCount(3, $captured); + $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); } } 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/Subscribers/FollowHandlerTest.php b/tests/Unit/Subscribers/FollowHandlerTest.php index c7ad8e2..ee9d169 100644 --- a/tests/Unit/Subscribers/FollowHandlerTest.php +++ b/tests/Unit/Subscribers/FollowHandlerTest.php @@ -9,10 +9,12 @@ use BotCat\Channel\ChannelSettings; use BotCat\Channel\ChannelSettingsRepository; +use BotCat\Channel\ReplyClient; use BotCat\Subscribers\FollowHandler; use BotCat\Subscribers\LineProfileFetcher; use BotCat\Subscribers\LineProfileResult; use BotCat\Subscribers\SubscriberRepository; +use BotCat\Tags\WelcomeMessageBuilder; use BotCat\Tests\TestCase; use Brain\Monkey\Functions; @@ -89,4 +91,53 @@ public function testFollowSkipsProfileFetchWhenChannelNotConfigured(): void $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/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); + } +} From 6040d7b6c64aee2fb66c7ef7c71e21a491967cd9 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 26 May 2026 16:39:51 +0800 Subject: [PATCH 7/8] =?UTF-8?q?feat(w5):=20Pro=20=E2=80=94=20link=20shorte?= =?UTF-8?q?ner=20+=20analytics=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the two Pro-edition W5 capabilities. All new wiring is gated on Edition::is_pro(); the Free build is unchanged. Schema v1.3.0: - botcat_short_links (id, unique code, post_id, original_url, created_at) - botcat_short_link_clicks (id, short_link_id, subscriber_id NULL, ip_hash, user_agent, referer, clicked_at) with (short_link_id, clicked_at) and ip_hash indexes. link-shortener: - ShortCodeGenerator: 6-char base-62 via random_int. - ShortLinkRepository: find-or-create per post (same post + URL reuses the existing code), find_by_code, list_paged. Collision retry up to COLLISION_MAX. - ClickLogRepository: writes ip_hash + truncated UA/referer (raw IP is never stored). count_total / count_unique(DISTINCT ip_hash). - SubscriberTokenSigner: HKDF-derived key, HMAC-SHA256 truncated to 16 chars, base64url payload \`.\`. Timing-safe verify returns null on any malformed/tampered input. Wired up for future push-API attribution — LINE multicast can't per-recipient substitute, so W5 multicast batches send the same URL to all 500 recipients and attribute anonymously via ip_hash. - ClickRedirectHandler: code lookup, optional token verify, salted SHA-256 IP hash, click insert, 301 + Location (or 404). - RewriteRuleRegistrar: registers \`^l/([0-9A-Za-z]{6})/?$\` rewrite + query var, dispatches on template_redirect. Pro only. - LinkRewriter: rewrites the post's own permalink to its short URL in text bodies AND Flex JSON (via encode/decode round-trip). - MessageBuilder: optional LinkRewriter post-processes both text and Flex outputs. - ShortLinksPage: admin list — code, target post, created at, total clicks, unique clicks. analytics-dashboard: - AnalyticsWindow: 7/30/90 day presets, default 30, clamped. - DashboardKpiCalculator: active subscribers, deliveries-in-window, clicks-in-window, CTR%, median click latency. - PostPerformanceQuery: per-post delivered/failed/clicks/CTR within the window, default sort by CTR descending per spec. - SubscriberGrowthQuery: daily active series across the window. - CsvExporter: UTF-8 with BOM so Excel auto-detects CJK without mojibake. - DashboardPage: KPI cards + window selector (persisted in user meta) + growth + per-post table + CSV download. Server-side HTML (no Chart.js bundle yet — data layer in place). - Plugin: on Pro the bot-cat top-level landing is the Dashboard; AdminMenu adds Short Links submenu. Tests: +29 (250 total, 559 assertions). Co-Authored-By: Claude Opus 4.7 (1M context) --- bot-cat.php | 2 +- src/Analytics/AnalyticsWindow.php | 30 +++ src/Analytics/CsvExporter.php | 53 +++++ src/Analytics/DashboardKpiCalculator.php | 84 ++++++++ src/Analytics/DashboardPage.php | 203 ++++++++++++++++++ src/Analytics/PostPerformanceQuery.php | 66 ++++++ src/Analytics/SubscriberGrowthQuery.php | 54 +++++ src/Foundation/Plugin.php | 50 ++++- src/Foundation/Schema.php | 30 ++- src/Push/MessageBuilder.php | 36 +++- src/Shortener/ClickLogRepository.php | 70 ++++++ src/Shortener/ClickRedirectHandler.php | 63 ++++++ src/Shortener/LinkRewriter.php | 40 ++++ src/Shortener/RewriteRuleRegistrar.php | 63 ++++++ src/Shortener/ShortCodeGenerator.php | 29 +++ src/Shortener/ShortLink.php | 36 ++++ src/Shortener/ShortLinkRepository.php | 113 ++++++++++ src/Shortener/ShortLinksPage.php | 79 +++++++ src/Shortener/SubscriberTokenSigner.php | 72 +++++++ tests/Unit/Analytics/AnalyticsWindowTest.php | 34 +++ tests/Unit/Analytics/CsvExporterTest.php | 46 ++++ tests/Unit/Foundation/SchemaTest.php | 2 + .../Unit/Shortener/ClickLogRepositoryTest.php | 87 ++++++++ .../Shortener/ClickRedirectHandlerTest.php | 119 ++++++++++ tests/Unit/Shortener/LinkRewriterTest.php | 76 +++++++ .../Unit/Shortener/ShortCodeGeneratorTest.php | 33 +++ .../Shortener/ShortLinkRepositoryTest.php | 116 ++++++++++ .../Shortener/SubscriberTokenSignerTest.php | 57 +++++ 28 files changed, 1736 insertions(+), 7 deletions(-) create mode 100644 src/Analytics/AnalyticsWindow.php create mode 100644 src/Analytics/CsvExporter.php create mode 100644 src/Analytics/DashboardKpiCalculator.php create mode 100644 src/Analytics/DashboardPage.php create mode 100644 src/Analytics/PostPerformanceQuery.php create mode 100644 src/Analytics/SubscriberGrowthQuery.php create mode 100644 src/Shortener/ClickLogRepository.php create mode 100644 src/Shortener/ClickRedirectHandler.php create mode 100644 src/Shortener/LinkRewriter.php create mode 100644 src/Shortener/RewriteRuleRegistrar.php create mode 100644 src/Shortener/ShortCodeGenerator.php create mode 100644 src/Shortener/ShortLink.php create mode 100644 src/Shortener/ShortLinkRepository.php create mode 100644 src/Shortener/ShortLinksPage.php create mode 100644 src/Shortener/SubscriberTokenSigner.php create mode 100644 tests/Unit/Analytics/AnalyticsWindowTest.php create mode 100644 tests/Unit/Analytics/CsvExporterTest.php create mode 100644 tests/Unit/Shortener/ClickLogRepositoryTest.php create mode 100644 tests/Unit/Shortener/ClickRedirectHandlerTest.php create mode 100644 tests/Unit/Shortener/LinkRewriterTest.php create mode 100644 tests/Unit/Shortener/ShortCodeGeneratorTest.php create mode 100644 tests/Unit/Shortener/ShortLinkRepositoryTest.php create mode 100644 tests/Unit/Shortener/SubscriberTokenSignerTest.php diff --git a/bot-cat.php b/bot-cat.php index fbfd590..b9f7b85 100644 --- a/bot-cat.php +++ b/bot-cat.php @@ -25,7 +25,7 @@ define( 'BOT_CAT_FILE', __FILE__ ); define( 'BOT_CAT_DIR', plugin_dir_path( __FILE__ ) ); define( 'BOT_CAT_URL', plugin_dir_url( __FILE__ ) ); -define( 'BOT_CAT_VERSION', '1.2.0' ); +define( 'BOT_CAT_VERSION', '1.3.0' ); define( 'BOT_CAT_MIN_PHP', '8.1' ); define( 'BOT_CAT_MIN_WP', '7.0' ); diff --git a/src/Analytics/AnalyticsWindow.php b/src/Analytics/AnalyticsWindow.php new file mode 100644 index 0000000..6b6c35d --- /dev/null +++ b/src/Analytics/AnalyticsWindow.php @@ -0,0 +1,30 @@ +days = in_array( $days, self::ALLOWED_DAYS, true ) ? $days : self::DEFAULT_DAYS; + $end = $now ?? time(); + $this->end_gmt = gmdate( 'Y-m-d H:i:s', $end ); + $this->start_gmt = gmdate( 'Y-m-d H:i:s', $end - $this->days * 86400 ); + } +} diff --git a/src/Analytics/CsvExporter.php b/src/Analytics/CsvExporter.php new file mode 100644 index 0000000..498c428 --- /dev/null +++ b/src/Analytics/CsvExporter.php @@ -0,0 +1,53 @@ + $headers + * @param list> $rows + */ + public function to_string( array $headers, array $rows ): string { + $fh = fopen( 'php://temp', 'r+' ); + if ( ! is_resource( $fh ) ) { + return self::BOM; + } + + fputcsv( $fh, $headers ); + foreach ( $rows as $row ) { + fputcsv( $fh, array_map( static fn( $v ): string => (string) $v, $row ) ); + } + + rewind( $fh ); + $body = (string) stream_get_contents( $fh ); + fclose( $fh ); + + return self::BOM . $body; + } + + /** + * @param list $headers + * @param list> $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( + '
%1$s
%2$s
', + 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( '', esc_html__( 'Date', 'bot-cat' ), esc_html__( 'Active', 'bot-cat' ) ); + echo ''; + foreach ( $series as $row ) { + echo ''; + printf( '', esc_html( $row['date'] ) ); + printf( '', (int) $row['active'] ); + echo ''; + } + echo '
%s%s
%s%d
'; + } + + private function render_post_table( AnalyticsWindow $window ): void { + $rows = $this->posts->run( $window ); + + echo '

' . esc_html__( 'Per-post performance', 'bot-cat' ) . '

'; + + echo '
'; + printf( '', esc_attr( self::CSV_ACTION ) ); + wp_nonce_field( self::CSV_NONCE ); + submit_button( __( 'Download CSV', 'bot-cat' ), 'secondary', 'submit', false ); + echo '
'; + + if ( $rows === array() ) { + echo '

' . esc_html__( 'No pushes in this window yet.', 'bot-cat' ) . '

'; + return; + } + + echo ''; + printf( '', esc_html__( 'Post', 'bot-cat' ) ); + printf( '', esc_html__( 'Triggered', 'bot-cat' ) ); + printf( '', esc_html__( 'Delivered', 'bot-cat' ) ); + printf( '', esc_html__( 'Failed', 'bot-cat' ) ); + printf( '', esc_html__( 'Clicks', 'bot-cat' ) ); + printf( '', esc_html__( 'CTR', 'bot-cat' ) ); + echo ''; + foreach ( $rows as $r ) { + echo ''; + printf( '', esc_html( $r['post_title'] !== '' ? $r['post_title'] : '#' . $r['post_id'] ) ); + printf( '', esc_html( $r['triggered_at'] ) ); + printf( '', (int) $r['delivered'] ); + printf( '', (int) $r['failed'] ); + printf( '', (int) $r['clicks'] ); + printf( '', esc_html( (string) $r['ctr'] ) ); + echo ''; + } + echo '
%s%s%s%s%s%s
%s%s%d%d%d%s%%
'; + } +} 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/Foundation/Plugin.php b/src/Foundation/Plugin.php index 70ff43b..2851791 100644 --- a/src/Foundation/Plugin.php +++ b/src/Foundation/Plugin.php @@ -15,6 +15,11 @@ use BotCat\Flex\FlexMessageBuilder; use BotCat\Flex\FlexTemplateRepository; use BotCat\Flex\HeroImageResolver; +use BotCat\Analytics\CsvExporter; +use BotCat\Analytics\DashboardKpiCalculator; +use BotCat\Analytics\DashboardPage; +use BotCat\Analytics\PostPerformanceQuery; +use BotCat\Analytics\SubscriberGrowthQuery; use BotCat\Push\BatchRunner; use BotCat\Push\EligiblePostTypes; use BotCat\Push\MessageBuilder; @@ -44,6 +49,14 @@ use BotCat\Tags\TagCommandParser; use BotCat\Tags\TagRepository; use BotCat\Tags\TagsPage; +use BotCat\Shortener\ClickLogRepository; +use BotCat\Shortener\ClickRedirectHandler; +use BotCat\Shortener\LinkRewriter; +use BotCat\Shortener\RewriteRuleRegistrar; +use BotCat\Shortener\ShortCodeGenerator; +use BotCat\Shortener\ShortLinkRepository; +use BotCat\Shortener\ShortLinksPage; +use BotCat\Shortener\SubscriberTokenSigner; use BotCat\Tags\WelcomeMessageBuilder; use BotCat\Template\TemplatePage; use BotCat\Template\TemplateRenderer; @@ -146,15 +159,47 @@ function () use ( $opt_out, $eligible ): void { 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-tags'] = array( $tags_page, 'render' ); + $menu_renderers['bot-cat-short-links'] = array( $short_links_page, 'render' ); } $menu = new AdminMenu( $edition, $menu_renderers ); add_action( 'admin_menu', array( $menu, 'register' ) ); @@ -219,7 +264,8 @@ static function ( $job_id, $custom_user_ids = array() ) use ( $job_runner ): voi $template_renderer, $edition->is_pro() ? $flex_template_repo : null, $edition->is_pro() ? $flex_builder : null, - $edition + $edition, + $edition->is_pro() ? $link_rewriter : null ); // Surface Flex render failures on the Push Job detail page. diff --git a/src/Foundation/Schema.php b/src/Foundation/Schema.php index 9caff31..fd37127 100644 --- a/src/Foundation/Schema.php +++ b/src/Foundation/Schema.php @@ -16,7 +16,7 @@ */ class Schema { - public const TABLES = array( 'subscribers', 'push_jobs', 'push_logs', 'tags', 'subscriber_tags' ); + 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). @@ -132,6 +132,34 @@ public function sql_for( string $table ): string { KEY tag_subscriber (tag_id, subscriber_id) ) {$charset_collate}; SQL +, + 'short_links' => << << '', }; diff --git a/src/Push/MessageBuilder.php b/src/Push/MessageBuilder.php index 2a3d889..0ae7893 100644 --- a/src/Push/MessageBuilder.php +++ b/src/Push/MessageBuilder.php @@ -10,6 +10,7 @@ use BotCat\Flex\FlexMessageBuilder; use BotCat\Flex\FlexTemplateRepository; use BotCat\Foundation\Edition; +use BotCat\Shortener\LinkRewriter; use BotCat\Template\TemplateRenderer; use BotCat\Template\TemplateRepository; use Throwable; @@ -33,7 +34,8 @@ public function __construct( private readonly ?TemplateRenderer $renderer = null, private readonly ?FlexTemplateRepository $flex_templates = null, private readonly ?FlexMessageBuilder $flex_builder = null, - private readonly ?Edition $edition = null + private readonly ?Edition $edition = null, + private readonly ?LinkRewriter $link_rewriter = null ) { } @@ -56,11 +58,39 @@ public function build_for_job( PushJob $job ): array { if ( $this->should_attempt_flex() ) { $flex = $this->build_flex( $job ); if ( $flex !== null ) { - return $this->filtered( $job, array( $flex ) ); + return $this->filtered( $job, $this->rewrite_messages( array( $flex ), $job ) ); } } - return $this->filtered( $job, array( $this->build_text( $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 { 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( '', esc_html__( 'Code', 'bot-cat' ) ); + printf( '', esc_html__( 'Post', 'bot-cat' ) ); + printf( '', esc_html__( 'Short URL', 'bot-cat' ) ); + printf( '', esc_html__( 'Created', 'bot-cat' ) ); + printf( '', 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( '', esc_html( $link->code ) ); + printf( '', esc_html( $title ) ); + printf( '', esc_url( $short ) ); + printf( '', esc_html( $link->created_at ) ); + printf( + '', + esc_html( + sprintf( + /* translators: 1: total clicks, 2: unique clicks. */ + __( '%1$d clicks · %2$d unique', 'bot-cat' ), + $total, + $unique + ) + ) + ); + echo ''; + } + + echo '
%s%s%s%s%s
%s%s%1$s%s%s
'; + 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/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/Foundation/SchemaTest.php b/tests/Unit/Foundation/SchemaTest.php index 56bc3df..f77b6f5 100644 --- a/tests/Unit/Foundation/SchemaTest.php +++ b/tests/Unit/Foundation/SchemaTest.php @@ -128,5 +128,7 @@ public function testInstallCallsDbDeltaWithEverySchemaStatement(): void $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/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')); + } +} From 37b7d2bc933ed52a97bec0ddc94d5f27d9528ae3 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 26 May 2026 17:46:58 +0800 Subject: [PATCH 8/8] =?UTF-8?q?feat(w6):=20Pro=20=E2=80=94=20license=20man?= =?UTF-8?q?agement=20(Polar)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Pro entitlement layer that gates every Pro feature. License repository: - LicenseStatus: 5-state constant (inactive/active/expired/invalid/pending). - LicenseCache: typed snapshot of Polar's last response (entitled, verified_at, last_failure_at, consecutive_failures, plan_name, renews_at) — single source of truth, stored as JSON in botcat_license_cache. - LicenseRepository: owns the three options (botcat_license_key / _status / _cache); no other class reads them. Polar integration: - PolarClient: wp_remote_post wrapper for /v1/license-keys/validate and /v1/license-keys/deactivate. Classifies 401/403/404 as invalid, 5xx as upstream, network failure as a typed result. - PolarValidationResult: ok flag, entitled flag, plan/renews_at, and error_code/_message for failures. Status evaluator + gate (the contract Pro features consult): - LicenseStatusEvaluator: pure function — verified_at within 7 days → ACTIVE, beyond 7 days → EXPIRED. Both network outages and revoked entitlements flow into the same EXPIRED path. - LicenseGate::is_pro_active(): the ONE helper every Pro feature uses. Plugin hooks LicenseGate::filter_is_pro into the existing botcat_is_pro filter so Edition::is_pro() now reflects license state automatically. Pro classes still call $edition->is_pro(); the gate fans the truth in. Daily revalidation: - LicenseRevalidationCron: pulls Polar with the stored key, refreshes cache on entitled=true, bumps consecutive_failures otherwise. Transitions status to EXPIRED once the cache crosses the 7-day grace threshold even though daily polling continues. - Plugin schedules `botcat_license_revalidate` once per day on Pro builds. Polar webhook: - PolarWebhookEndpoint: REST POST /botcat/v1/polar-webhook with HMAC-SHA256 signature verification against botcat_polar_webhook_secret. subscription.cancelled, .refunded, and license_key.revoked all instantly mark the cache expired. Forged signature → 403 + no state change. UI: - LicensePage: status card + activation / deactivation forms. - LicenseActivationHandler: admin-post handlers that call PolarClient, update the repository, and redirect back with a status banner. Auto-update channel: - UpdateChannel: pre_set_site_transient_update_plugins + plugins_api hooks, fetches a custom manifest with the license key so lapsed sites stop seeing the Pro update. Free builds skip this and let WordPress.org handle updates. Tests: +10 (260 total, 570 assertions). The HTTP-bound classes (PolarClient, PolarWebhookEndpoint, UpdateChannel) and admin UI are manual acceptance; the gate, evaluator, and repository have full unit coverage. All six capability weeks (W1-W6) are now feature-complete: plugin-foundation / line-channel / subscriber-management → push-notification-core → message-template / push-rules → flex-message / tag-segmentation → link-shortener / analytics-dashboard → license-management. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Foundation/Plugin.php | 37 +++++ src/License/LicenseActivationHandler.php | 100 +++++++++++++ src/License/LicenseCache.php | 58 ++++++++ src/License/LicenseGate.php | 47 +++++++ src/License/LicensePage.php | 130 +++++++++++++++++ src/License/LicenseRepository.php | 63 +++++++++ src/License/LicenseRevalidationCron.php | 91 ++++++++++++ src/License/LicenseStatus.php | 28 ++++ src/License/LicenseStatusEvaluator.php | 42 ++++++ src/License/PolarClient.php | 123 ++++++++++++++++ src/License/PolarValidationResult.php | 32 +++++ src/License/PolarWebhookEndpoint.php | 87 ++++++++++++ src/License/UpdateChannel.php | 131 ++++++++++++++++++ tests/Unit/License/LicenseGateTest.php | 64 +++++++++ .../License/LicenseStatusEvaluatorTest.php | 99 +++++++++++++ 15 files changed, 1132 insertions(+) create mode 100644 src/License/LicenseActivationHandler.php create mode 100644 src/License/LicenseCache.php create mode 100644 src/License/LicenseGate.php create mode 100644 src/License/LicensePage.php create mode 100644 src/License/LicenseRepository.php create mode 100644 src/License/LicenseRevalidationCron.php create mode 100644 src/License/LicenseStatus.php create mode 100644 src/License/LicenseStatusEvaluator.php create mode 100644 src/License/PolarClient.php create mode 100644 src/License/PolarValidationResult.php create mode 100644 src/License/PolarWebhookEndpoint.php create mode 100644 src/License/UpdateChannel.php create mode 100644 tests/Unit/License/LicenseGateTest.php create mode 100644 tests/Unit/License/LicenseStatusEvaluatorTest.php diff --git a/src/Foundation/Plugin.php b/src/Foundation/Plugin.php index 2851791..e292dd9 100644 --- a/src/Foundation/Plugin.php +++ b/src/Foundation/Plugin.php @@ -49,6 +49,15 @@ use BotCat\Tags\TagCommandParser; use BotCat\Tags\TagRepository; use BotCat\Tags\TagsPage; +use BotCat\License\LicenseActivationHandler; +use BotCat\License\LicenseGate; +use BotCat\License\LicensePage; +use BotCat\License\LicenseRepository; +use BotCat\License\LicenseRevalidationCron; +use BotCat\License\LicenseStatusEvaluator; +use BotCat\License\PolarClient; +use BotCat\License\PolarWebhookEndpoint; +use BotCat\License\UpdateChannel; use BotCat\Shortener\ClickLogRepository; use BotCat\Shortener\ClickRedirectHandler; use BotCat\Shortener\LinkRewriter; @@ -113,6 +122,13 @@ public function register_hooks(): void { $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(); @@ -200,6 +216,27 @@ function () use ( $opt_out, $eligible ): void { 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' ) ); 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 ''; + $this->row( __( 'Status', 'bot-cat' ), esc_html( ucfirst( $status ) ) ); + + if ( $cache->plan_name !== null ) { + $this->row( __( 'Plan', 'bot-cat' ), esc_html( $cache->plan_name ) ); + } + if ( $cache->renews_at !== null ) { + $this->row( __( 'Renews at', 'bot-cat' ), esc_html( $cache->renews_at ) ); + } + if ( $cache->verified_at > 0 ) { + $this->row( + __( 'Last verified', 'bot-cat' ), + esc_html( + sprintf( + /* translators: %d: number of days. */ + _n( '%d day ago', '%d days ago', max( 1, $days ), 'bot-cat' ), + $days + ) + ) + ); + } + 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( + '

%2$s

', + 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/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)); + } +}