diff --git a/cleantalk.php b/cleantalk.php
index 74d231ed5..fb92bea6e 100644
--- a/cleantalk.php
+++ b/cleantalk.php
@@ -11,6 +11,7 @@
Domain Path: /i18n
*/
+use Cleantalk\Antispam\ScriptsIntegration\CleantalkScriptsIntegrator;
use Cleantalk\Antispam\ProtectByShortcode;
use Cleantalk\ApbctWP\Activator;
use Cleantalk\ApbctWP\AdminNotices;
@@ -597,27 +598,8 @@ function apbct_write_js_errors($data)
// Public actions
if ( ! is_admin() && ! apbct_is_ajax() && ! apbct_is_customize_preview() ) {
- if (
- apbct_is_plugin_active('fluentformpro/fluentformpro.php') &&
- (
- apbct_is_in_uri('ff_landing=') ||
- (
- // Load scripts for logged in users if constant is defined
- apbct_is_user_logged_in() &&
- (defined('APBCT_FF_JS_SCRIPTS_LOAD') &&
- APBCT_FF_JS_SCRIPTS_LOAD == true)
- )
- )
- ) {
- add_action('wp_head', function () {
- echo '';
- echo '';
- }, 100);
- }
+ $sci = new CleantalkScriptsIntegrator();
+ $sci->run();
SFWUpdateHelper::processSFWOutdatedError($apbct);
diff --git a/lib/Cleantalk/Antispam/ScriptsIntegration/CleantalkScriptsIntegrator.php b/lib/Cleantalk/Antispam/ScriptsIntegration/CleantalkScriptsIntegrator.php
new file mode 100644
index 000000000..3b3b43fdd
--- /dev/null
+++ b/lib/Cleantalk/Antispam/ScriptsIntegration/CleantalkScriptsIntegrator.php
@@ -0,0 +1,99 @@
+getIntegrations();
+
+ $this->plugins_loaded = !empty($integrations)
+ ? $this->getPluginsToInline($integrations)
+ : [];
+
+ if (!empty($this->plugins_loaded)) {
+ foreach ($this->plugins_loaded as $_hook => $plugin) {
+ if ($plugin instanceof ScriptIntegrationPlugin) {
+ add_action($_hook, function () use ($plugin) {
+ $plugin->integrate();
+ }, 100);
+ }
+ }
+ }
+ }
+
+ /**
+ * Filters plugins that are eligible for inline script integration.
+ *
+ * A plugin is included only if:
+ * - It is active
+ * - It matches the current URI context
+ * - It passes additional runtime checks
+ *
+ * Each hook can only be assigned to one plugin (first match wins).
+ *
+ * @param ScriptIntegrationPlugin[] $integrations List of available plugin integrations.
+ * @return ScriptIntegrationPlugin[] Filtered plugins indexed by hook name.
+ */
+ public function getPluginsToInline($integrations)
+ {
+ $plugins_loaded = [];
+
+ foreach ($integrations as $plugin) {
+ if (
+ $plugin->is_plugin_active &&
+ $plugin->is_in_uri &&
+ $plugin->additional_checks_passed
+ ) {
+ if (!isset($plugins_loaded[$plugin->hook_name])) {
+ $plugins_loaded[$plugin->hook_name] = $plugin;
+ }
+ }
+ }
+
+ return $plugins_loaded;
+ }
+
+ /**
+ * Returns a list of all available plugin integrations.
+ *
+ * Each integration defines:
+ * - Activation rules
+ * - Context conditions (URI, environment, etc.)
+ * - Hook target for script injection
+ *
+ * @return ScriptIntegrationPlugin[]
+ */
+ public function getIntegrations()
+ {
+ try {
+ $integrations = [
+ new GiveWPScript(),
+ new FluentFormScript(),
+ ];
+ } catch (\Exception $e) {
+ $integrations = [];
+ }
+
+ return $integrations;
+ }
+}
diff --git a/lib/Cleantalk/Antispam/ScriptsIntegration/FluentFormScript.php b/lib/Cleantalk/Antispam/ScriptsIntegration/FluentFormScript.php
new file mode 100644
index 000000000..7c021302d
--- /dev/null
+++ b/lib/Cleantalk/Antispam/ScriptsIntegration/FluentFormScript.php
@@ -0,0 +1,29 @@
+';
+ echo '';
+ }
+
+ public function additionalChecks()
+ {
+ return $this->is_in_uri || (
+ function_exists('apbct_is_user_logged_in') &&
+ apbct_is_user_logged_in() &&
+ (defined('APBCT_FF_JS_SCRIPTS_LOAD') && APBCT_FF_JS_SCRIPTS_LOAD == true)
+ );
+ }
+}
diff --git a/lib/Cleantalk/Antispam/ScriptsIntegration/GiveWPScript.php b/lib/Cleantalk/Antispam/ScriptsIntegration/GiveWPScript.php
new file mode 100644
index 000000000..28c129ae0
--- /dev/null
+++ b/lib/Cleantalk/Antispam/ScriptsIntegration/GiveWPScript.php
@@ -0,0 +1,32 @@
+ true,
+ 'strategy' => 'async'
+ )
+ );
+ }
+ }
+}
diff --git a/lib/Cleantalk/Antispam/ScriptsIntegration/ScriptIntegrationPlugin.php b/lib/Cleantalk/Antispam/ScriptsIntegration/ScriptIntegrationPlugin.php
new file mode 100644
index 000000000..f5518b5c2
--- /dev/null
+++ b/lib/Cleantalk/Antispam/ScriptsIntegration/ScriptIntegrationPlugin.php
@@ -0,0 +1,77 @@
+plugin_file) ||
+ !is_string($this->plugin_file) ||
+ !isset($this->uri_chunk) ||
+ !is_string($this->uri_chunk) ||
+ !isset($this->hook_name) ||
+ !is_string($this->hook_name)
+ ) {
+ throw new \Exception('Plugin file, URI chunk and hook name must be set');
+ }
+
+ $this->is_plugin_active = $this->isPluginActive($this->plugin_file);
+ $this->is_in_uri = $this->isInUri($this->uri_chunk);
+ $this->additional_checks_passed = $this->additionalChecks();
+ }
+
+ /**
+ * Executes the plugin integration logic.
+ *
+ * This method must be implemented by each concrete integration class
+ * and is responsible for registering scripts, hooks, or other behaviors.
+ *
+ * @return void
+ */
+ abstract public function integrate();
+
+ /**
+ * Checks whether a given WordPress plugin is active.
+ *
+ * @param string $plugin_file Path to the plugin main file.
+ * @return bool True if the plugin is active, false otherwise.
+ */
+ public function isPluginActive($plugin_file)
+ {
+ return apbct_is_plugin_active($plugin_file);
+ }
+
+ /**
+ * Checks whether the current request URI contains a specific substring.
+ *
+ * @param string $uri_chunk URI fragment to search for in the current request URI.
+ * @return bool True if the URI fragment is found, false otherwise.
+ */
+ public function isInUri($uri_chunk)
+ {
+ return apbct_is_in_uri($uri_chunk);
+ }
+
+ /**
+ * Performs additional runtime checks required for plugin activation.
+ *
+ * This method can be overridden in child classes to implement
+ * custom validation logic.
+ *
+ * @return bool True if all additional checks pass, false otherwise.
+ */
+ public function additionalChecks()
+ {
+ return true;
+ }
+}
diff --git a/tests/Antispam/Integrations/TestScriptsIntegrator.php b/tests/Antispam/Integrations/TestScriptsIntegrator.php
new file mode 100644
index 000000000..5843b37f8
--- /dev/null
+++ b/tests/Antispam/Integrations/TestScriptsIntegrator.php
@@ -0,0 +1,208 @@
+getPluginsToInline($plugins);
+
+ $this->assertCount(1, $result);
+ $this->assertArrayHasKey('hook1', $result);
+ }
+
+ public function testGetPluginsToInlineSkipsInvalidPlugin()
+ {
+ $obj = new CleantalkScriptsIntegrator();
+
+ $plugins = [
+ new TestPlugin(false, true, true, 'hook1'),
+ ];
+
+ $result = $obj->getPluginsToInline($plugins);
+
+ $this->assertEmpty($result);
+ }
+
+ public function testFirstPluginWinsForSameHook()
+ {
+ $obj = new CleantalkScriptsIntegrator();
+
+ $plugins = [
+ new TestPlugin(true, true, true, 'same_hook'),
+ new TestPlugin(true, true, true, 'same_hook'),
+ ];
+
+ $result = $obj->getPluginsToInline($plugins);
+
+ $this->assertCount(1, $result);
+ }
+
+ public function testMultipleHooksLoaded()
+ {
+ $obj = new CleantalkScriptsIntegrator();
+
+ $plugins = [
+ new TestPlugin(true, true, true, 'hook1'),
+ new TestPlugin(true, true, true, 'hook2'),
+ ];
+
+ $result = $obj->getPluginsToInline($plugins);
+
+ $this->assertCount(2, $result);
+ $this->assertArrayHasKey('hook1', $result);
+ $this->assertArrayHasKey('hook2', $result);
+ }
+
+ /**
+ * run()
+ */
+ public function testRunRegistersHooks()
+ {
+ $obj = $this->getMockBuilder(CleantalkScriptsIntegrator::class)
+ ->onlyMethods(['getIntegrations'])
+ ->getMock();
+
+ $obj->method('getIntegrations')->willReturn([
+ new TestPlugin(true, true, true, 'hook1'),
+ ]);
+
+ $obj->run();
+
+ $this->assertTrue(has_action('hook1') !== false);
+ }
+
+ /**
+ * run()
+ */
+ public function testRunDoesNothingIfNoPlugins()
+ {
+ $obj = $this->getMockBuilder(CleantalkScriptsIntegrator::class)
+ ->onlyMethods(['getIntegrations'])
+ ->getMock();
+
+ $obj->method('getIntegrations')->willReturn([]);
+
+ $obj->run();
+
+ $this->assertEmpty($obj->plugins_loaded);
+ $this->assertFalse(has_action('hook1'));
+ }
+
+ /**
+ * run()
+ */
+ public function testRunResetsState()
+ {
+ $obj = new CleantalkScriptsIntegrator();
+
+ $obj->plugins_loaded = [
+ 'old_hook' => new TestPlugin()
+ ];
+
+ $mock = $this->getMockBuilder(CleantalkScriptsIntegrator::class)
+ ->onlyMethods(['getIntegrations'])
+ ->getMock();
+
+ $mock->plugins_loaded = $obj->plugins_loaded;
+
+ $mock->method('getIntegrations')->willReturn([]);
+
+ $mock->run();
+
+ $this->assertEmpty($mock->plugins_loaded);
+ }
+
+ /**
+ * run()
+ */
+ public function testRunActuallyExecutesIntegrate()
+ {
+ $plugin = new TestPlugin(true, true, true, 'hook1');
+
+ $obj = $this->getMockBuilder(CleantalkScriptsIntegrator::class)
+ ->onlyMethods(['getIntegrations'])
+ ->getMock();
+
+ $obj->method('getIntegrations')->willReturn([$plugin]);
+
+ $obj->run();
+
+ $callbacks = $this->get_registered_hooks('hook1');
+
+ $this->assertNotEmpty($callbacks);
+
+ $callback = null;
+
+ foreach ($callbacks as $priority => $group) {
+ foreach ($group as $cb) {
+ $callback = $cb['function'];
+ break 2;
+ }
+ }
+
+ $this->assertIsCallable($callback);
+
+ $callback();
+
+ $this->assertTrue($plugin->called);
+ }
+
+ /**
+ * helper: достать callbacks из WP
+ */
+ private function get_registered_hooks($hook)
+ {
+ global $wp_filter;
+ return isset($wp_filter[$hook]) ? $wp_filter[$hook]->callbacks : [];
+ }
+}
+
+
+/**
+ * Test plugin
+ */
+class TestPlugin extends ScriptIntegrationPlugin
+{
+ public $hook_name = 'test_hook';
+ public $plugin_file = 'test/plugin.php';
+ public $uri_chunk = 'test';
+
+ public $called = false;
+
+ public function __construct($active = true, $in_uri = true, $additional = true, $hook = 'test_hook')
+ {
+ $this->plugin_file = 'test/plugin.php';
+ $this->uri_chunk = 'test';
+ $this->hook_name = $hook;
+
+ $this->is_plugin_active = $active;
+ $this->is_in_uri = $in_uri;
+ $this->additional_checks_passed = $additional;
+ }
+
+ public function integrate()
+ {
+ $this->called = true;
+ }
+}