*/ private array $signals = array(); /** * Points at which one signal is conclusive on its own. * * Mirrors `SpamGuard::CERTAIN`; kept here so the floor below does not need the * guard, which would be a dependency in the wrong direction. */ private const CERTAIN = 100; /** * Whether a signal worth `SpamScore::CERTAIN` has ever fired. * * @var bool */ private bool $certain = false; /** * Records a signal. * * Adding the same reason twice sums it, so a repeatable signal such as "one * more link than expected" can be reported as it is found. * * @param string $reason Slug naming the signal. * @param int $points Points it contributes. Negative values clear suspicion. */ public function add( string $reason, int $points ): void { if ( '' === $reason || 0 === $points ) { return; } /* * A certain signal is remembered separately from the running total, because * the total alone can be talked out of it. The floor in `total()` looks for a * signal still worth CERTAIN — so a callback that subtracted from the *same* * reason (`add( 'honeypot', -100 )`) reduced that entry below the floor and * the honeypot was switched off after all. What was once certain stays * certain whatever is added to it afterwards. */ if ( $points >= self::CERTAIN ) { $this->certain = true; } $this->signals[ $reason ] = ( $this->signals[ $reason ] ?? 0 ) + $points; } /** * Returns the total. * * Negative adjustments are allowed and useful — a site that recognises a * logged-in customer has good reason to clear suspicion — but they cannot * cancel a signal that was certain on its own. A filled honeypot or a failed * captcha is not a judgement call to be outvoted, and without this floor any * plugin registering `formpilot_spam_score` could subtract enough to let every * submission through, whether it meant to or not. * * @return int The score, never below zero and never below a certain signal. */ public function total(): int { $floor = $this->certain ? self::CERTAIN : 0; return max( 0, $floor, array_sum( $this->signals ) ); } /** * Returns every signal that fired. * * @return array Points keyed by reason. */ public function signals(): array { return $this->signals; } /** * Determines whether the total has reached the threshold. * * @param int $threshold Points at which a submission is refused. * * @return bool True when the submission should be refused. */ public function is_spam( int $threshold ): bool { return $threshold > 0 && $this->total() >= $threshold; } /** * Returns the signals as one line, for a log. */ public function summary(): string { $parts = array(); foreach ( $this->signals as $reason => $points ) { $parts[] = $reason . '=' . $points; } return implode( ' ', $parts ); } }