Skip to content

feat: add a public spam check API for third-party integrations - #795

Open
2ndkauboy wants to merge 4 commits into
v3from
feat/public-spam-check-api
Open

feat: add a public spam check API for third-party integrations#795
2ndkauboy wants to merge 4 commits into
v3from
feat/public-spam-check-api

Conversation

@2ndkauboy

Copy link
Copy Markdown
Member

Closes #507.

Adds a documented, stable entry point for plugins that want their own content checked, so AntispamBee\Handlers\Rules can stay internal.

The trigger was the first real-world integration, epiphyt/form-block on its antispam-bee branch. Reviewing it surfaced three things worth settling before writing documentation, because the docs freeze whatever we describe.

1. new Rules( $reaction_type ) was the only way in

Rules is an internal handler that Reaction::process() instantiates. Documenting it would have made its constructor permanent third-party API.

AntispamBee\Api\SpamCheck is now the entry point, and Handlers\Rules and Handlers\PostProcessors are marked @internal:

$result = \AntispamBee\Api\SpamCheck::check(
    [ 'author' => $name, 'body' => $message, 'email' => $email ],
    'my_plugin_form'
);

if ( $result->is_spam() ) {
    $reasons = $result->get_reasons();
}

CheckResult carries the verdict, the reason slugs and the payload. It is an object rather than a boolean mainly because of was_evaluated(): a result can report "no spam" simply because no rule was active, and that is the most common reason an integration appears to do nothing. has_active_rules() lets an integration warn an administrator before any submission arrives, and get_reason_texts() resolves stored slugs so integrators need a single import.

2. The payload contract was easy to get wrong

form-block hand-built the item and left host empty. RegexpSpam reads host separately from rawurl, so a good number of our own patterns could never match. Nobody should have to know that host is wp_parse_url( $url, PHP_URL_HOST ).

SpamCheck::check() normalizes: it derives host from url via DataHelper::parse_url() and defaults ip and useragent from the current request, so a form plugin passes only what it actually has. The new antispam_bee_api_payload filter is the escape hatch.

3. Nothing was checked until an administrator ticked a box

Settings::$defaults only covered comment, linkback and general, so every ControllableBase rule started inactive for a reaction type registered by another plugin — including rules the integration had explicitly opted in. Verified on a local install: form-block's integration worked, but only because its settings tab had been visited and BBCode ticked. rule_asb_regexp_active was still unset there, so RegexpSpam silently never ran.

Settings::get_defaults() applies a new antispam_bee_default_options filter, and get_options() fills in the defaults of reaction types absent from the stored options.

Defaults deliberately apply only to reaction types that were never saved. An unticked checkbox is removed from the stored options by Sanitize::sanitize_controllables(), so merging defaults underneath the stored state would re-enable a rule an administrator had deliberately disabled — for comment and linkback too. There is a unit test for exactly this.

Rules::apply() now logs when no rule is active, so the "nothing ran" case leaves a trace instead of silently reporting no spam.

Also: comment-only post-processors

SaveReason and SendEmail defer their work to comment_post. For an item that never becomes a comment the callback not only never runs, it stays attached for the rest of the request — so an unrelated comment inserted later would be stamped with that item's spam reasons, or trigger its notification email. Both now bail unless comment_post_ID is set, mirroring what UpdateSpamLog::process() already does.

The check is structural on purpose: comment_type is legitimately empty for a regular comment, and gating on reaction_type would only re-block what an integration explicitly opted in via antispam_bee_post_processor_supported_types.

Documentation

New docs/integrating-own-content.md covers the API, the payload attributes, registering a custom reaction type, and registering your own post-processor via antispam_bee_post_processors (already supported, previously undocumented). Two traps are called out explicitly:

  • Rules register during module initialization, which is skipped for Ajax requests, so a plugin submitting over Ajax must allow it via antispam_bee_disallow_ajax_calls and register that filter early. REST requests are unaffected, because wp_doing_ajax() is false for them.
  • Our own post-processors support comments and linkbacks by design: they write comment meta, build notifications from a stored comment, and feed a spam counter rendered next to the comment counts in the At a Glance widget. They should not be opted into a custom reaction type. The documentation says so rather than advertising them.

docs/adding-rules.md gains the registration step, the reserved asb- slug prefix and a note on the payload. docs/program-flow.md no longer leaves Handlers\Rules looking like the way in.

Testing

composer test:unit — 56 tests pass, 20 of them new. phpstan and phpcs clean.

Verified end to end against a local install with form-block ported to the new API, submitting real forms over HTTP:

Check Result
BBCode submission stored as spam, reason asb-bbcode
host derived from url spam.example.com
Reaction type with stored options only the ticked rule active; a new default does not re-enable a disabled rule
Same reaction type with its stored options removed all four opted-in rules active from defaults, nothing written to the database
Submission only RegexpSpam can catch, on defaults alone stored as spam, reason asb-regexp
spam_count throughout unchanged at 0 — no post-processor ran, the comment counter is untouched

Notes for review

  • SpamCheck::check() fails open, matching current behaviour for an empty rule set: it catches the ReflectionException that Rules::get()/apply() declare, logs, and returns a not-evaluated result rather than surfacing a Reflection error to integrators.
  • API_VERSION is there so integrations can adapt to a breaking change; everything outside AntispamBee\Api and the documented hooks is stated to be internal.
  • The counter stays comment-scoped. A per-reaction-type counter would need new user-facing strings, i18n and a migration for the flat spam_count, and nothing requires it yet.

@2ndkauboy 2ndkauboy added this to the 3.0.0-beta.2 milestone Aug 7, 2026
@2ndkauboy 2ndkauboy added the v3 This issue is for the new version (v3) of the plugin label Aug 7, 2026
@2ndkauboy 2ndkauboy modified the milestones: 3.0.0-beta.2, 3.0.0-beta.3 Aug 7, 2026
@Zodiac1978

Copy link
Copy Markdown
Member

Related #768

@MatzeKitt MatzeKitt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully tested with epiphyt/form-block#83 🎉

Plugins that handle their own content had no supported way to have their
submissions classified. The only entry point was `new Rules( $reaction_type )`,
an internal handler that `Reaction::process()` instantiates, so documenting it
would have frozen its constructor as third-party API.

`AntispamBee\Api\SpamCheck` is now that entry point:

* `check()` normalizes a loose item into the payload the rules expect. It
  derives `host` from `url` via `DataHelper::parse_url()` and defaults `ip` and
  `useragent` from the current request, so integrators no longer have to know
  that `RegexpSpam` reads `host` separately from `rawurl`. The
  `antispam_bee_api_payload` filter is the escape hatch.
* `CheckResult` carries the verdict, the reason slugs and the payload.
  `was_evaluated()` distinguishes "checked and clean" from "no rule was
  active", which is otherwise indistinguishable from a bare boolean and is the
  most common reason an integration appears to do nothing.
* `has_active_rules()` lets an integration warn an administrator before any
  submission arrives.
* `post_process()` runs the post-processors registered for a reaction type
  without the comment-specific behaviour of `Reaction::handle_spam()`.
* `get_reason_texts()` resolves stored slugs, so an integration needs a single
  import.

`Rules::apply()` now logs when no rule is active, so that case leaves a trace
instead of silently reporting no spam. `Handlers\Rules` and
`Handlers\PostProcessors` are marked `@internal`.
`Settings::$defaults` only covered `comment`, `linkback` and `general`, so every
`ControllableBase` rule started inactive for a reaction type registered by
another plugin. Nothing was checked until an administrator found the new
settings tab and enabled rules there, which is easy to miss because the
integration otherwise looks configured.

`Settings::get_defaults()` applies the new `antispam_bee_default_options` filter,
and `get_options()` fills in the defaults of reaction types that are absent from
the stored options.

Defaults deliberately apply only to reaction types that were never saved. An
unticked checkbox is removed from the stored options by
`Sanitize::sanitize_controllables()`, so merging defaults underneath the stored
state would re-enable a rule an administrator had deliberately disabled.
`SaveReason` and `SendEmail` defer their work to the `comment_post` action. For
an item that never becomes a comment the callback not only never runs, it stays
attached for the rest of the request, so an unrelated comment inserted later
would be stamped with that item's spam reasons or trigger its notification.

Both now bail unless `comment_post_ID` is set and record the failure in
`asb_post_processors_failed`, mirroring what `UpdateSpamLog::process()` already
does. The check is deliberately structural rather than based on the reaction
type: `comment_type` is legitimately empty for a regular comment, and gating on
`reaction_type` would only re-block what an integration explicitly opted in via
`antispam_bee_post_processor_supported_types`.

Only reachable today by opting these post-processors into a custom reaction
type, which the documentation advises against, but the guard is cheap.
Adds `docs/integrating-own-content.md`, covering `Api\SpamCheck`, the payload
attributes, registering a custom reaction type with
`antispam_bee_reaction_types`, `antispam_bee_rule_supported_types` and
`antispam_bee_default_options`, and how to register your own post-processor via
`antispam_bee_post_processors`.

Two things that are easy to get wrong are called out explicitly:

* Rules register themselves during module initialization, which is skipped for
  Ajax requests, so a plugin submitting over Ajax has to allow it via
  `antispam_bee_disallow_ajax_calls` and register the filter early. REST
  requests are unaffected, because `wp_doing_ajax()` is false for them.
* The post-processors that ship with Antispam Bee support comments and linkbacks
  by design: they write comment meta, build the notification from a stored
  comment, and feed a spam counter that is displayed next to the comment counts
  in the At a Glance widget. They should not be opted into a custom reaction
  type.

`docs/adding-rules.md` gains the registration step, the reserved `asb-` slug
prefix and a note on the payload; `docs/program-flow.md` now points at the API
instead of leaving `Handlers\Rules` looking like the way in.
@2ndkauboy
2ndkauboy force-pushed the feat/public-spam-check-api branch from ccdca40 to acd7dc4 Compare August 21, 2026 20:36
@github-actions

Copy link
Copy Markdown

🔍 WordPress Plugin Check Report

❌ Status: Failed

📊 Report

🎯 Total Issues ❌ Errors ⚠️ Warnings
1 1 0

❌ Errors (1)

📁 readme.txt (1 error)
📍 Line 🔖 Check 💬 Message
0 outdated_tested_upto_header Tested up to: 7.0 < 7.1. The "Tested up to" value in your plugin is not set to the current version of WordPress. This means your plugin will not show up in searches, as we require plugins to be compatible and documented as tested up to the most recent version of WordPress.

🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

@2ndkauboy 2ndkauboy modified the milestones: 3.0.0-beta.3, 3.0.0-RC.1 Aug 23, 2026
@2ndkauboy 2ndkauboy modified the milestones: 3.0.0-RC.1, 3.0.0-beta.4 Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v3 This issue is for the new version (v3) of the plugin

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants