Content moderation in WordPress (wordpress apis)

Submitted by Nicholas on Thu, 07/28/2022 - 14:14

Last time we solved a real world problem (creatively), where we moderated the content published on a Drupal site, as we learnt some of the basics of Drupal apis (even though there are ways one could achieve this by configuring the available modules like rules and views). In this blog we will achieve the same, but in WordPress, just like saying hello in another dialect.

But how do we do this in WordPress?
 

Same case

### The plan:

  • Implement WordPress add actions/events to listen to when posts and/or comments are published and check their contents against some words/phrases.
  • Create an admin settings form with a textfield, so it's easier to add/edit words or phrases to check for in the contents
  • Implement WordPress do action, to let other parts of the WP app, aware, that we have found content relating to the defined words/phrases to filter. Other plugins can then, act on this event, with it's argument(s) and any data related to that context(current session).

That doesn't sound like much, but it's because we will do some improvements in a later blog, where admins (content reviews roles) can access the contents that were unpublished because they had the words or phrases defined in the settings form above.

### Prerequisites:

  • A working installation of a WordPress website
  • A Code Editor

Let's name this plugin flair-antispam, because, you know how hard it is to name stuff! and we have a lot of that to do.

### Below is the plugin file/folder structure, also this plugins code can be found @gitbub
Plugin inside `wp-content\plugins\` folder.

flair antispam plugin file structure
flair antispam plugin file structure

Since the plugin file `flair-antispam.php` requires `admin\admin.php` file, lets start with the contents and the whys of that file, before moving to the plugin file itself (flair-antispam.php)

Before we get to the code, let's see what's required to be defined/implemented in this file:

  • Add an action: admin_menu hook, to register an extra submenu in the settings tab, of the admin dashboard/panel, it's also a requirement to, when registering a menu to include a menu slug (used in the uri) and a callback to serve the request, when this menu link is clicked. Some hooks that can be used inside the callback above to add admin links/menus include `add_menu_page`, `add_submenu_page`, `add_options_page`, etc depending on where you want you menu link to appear(parent).
  • Add an action: admin_init hook, to add a new section to the settings page (options group) and use the same section definitions to define form field settings as well as render them on this page (options page).

Below is the code that goes into this file.

<?php
 
// admin.php
if (!defined('ABSPATH')) {
	exit();
}
 
function add_flair_antispam_settings_page(): void
{
	add_options_page(
            __('Flair Antispam plugin', "flair-antispam"),
            __('Flair Antispam Config', "flair-antispam"),
		'manage_options',
		'flair-antispam-settings-page',
		'flair_antispam_admin_index',
            null);
}
 
 
function flair_antispam_settings_init(): void
{
    // Setup settings section
	add_settings_section(
		'flair_antispam_settings_section',
		'Flair Antispam Settings Page',
		'',
		'flair-antispam-settings-page'
	);
 
	// Register form fields
 
	// Phrases
	register_setting(
		'flair-antispam-settings-page',
		'flair_antispam_settings_phrases',
		array(
			'type' => 'string',
			'sanitize_callback' => 'sanitize_text_field',
			'default' => ''
		)
	);
 
	// Add settings fields
	add_settings_field(
		'flair_antispam_settings_phrases',
		__('Words/phrases to check for, comma separated', 'flair-antispam'),
		'flair_antispam_settings_phrases_callback',
		'flair-antispam-settings-page',
		'flair_antispam_settings_section'
	);
}
 
function flair_antispam_settings_phrases_callback(): void
{
	$options = get_option('flair_antispam_settings_phrases');
	?>
	<div class="flair-antispam-settings-phrases">
        <label for="flair_antispam_settings_phrases">
            Words or phrases to look for, comma separated, no spaces unless you want to include them too.
        </label>
        <br>
        <textarea
                rows="10"
                cols="55"
                name="flair_antispam_settings_phrases"
                id="flair_antispam_settings_phrases"
                placeholder="a bad word, another bad word, another one"
                class="regular-text"
        ><?php esc_html_e( $options, 'flair-antispam' ); ?></textarea>
        <div>
            Current pattern according to settings: '
            <?php
                $pattern = str_replace(',', '|', $options);
                $pattern = '/('.$pattern.')/i';
                esc_html_e( $pattern, 'flair-antispam' );
            ?> '
        </div>
    </div>
 
 
	<?php
}
function flair_antispam_admin_index(): void
{
	?>
	<div class="wrap">
		<form action="options.php" method="post">
			<?php
 
			// Security field
			settings_fields('flair-antispam-settings-page');
 
			// output settings section here
			do_settings_sections('flair-antispam-settings-page');
 
			// Save settings btn
 
			submit_button('Save words/phrases');
 
			?>
		</form>
        <div class="phrases-example-hints">
            <p>Example 1: wolf,moon,what will be converted to /(wolf|moon|what)/i</p>
            <p>Example 2: wolf, moon, what will be converted to /(wolf| moon| what)/i</p>
        </div>
	</div>
	<?php
}
 
add_action('admin_menu', 'add_flair_antispam_settings_page');
add_action('admin_init', 'flair_antispam_settings_init');
WP admin settings form.
admin\admin.php settings form definitions

 

### flair-antispam.php:
It's in this last file that we will:

  • Implement listeners(callbacks) for save_post and comment_post actions, to check for words/phrases from the input of the admin form we defined above.
  • Tell this plugin to load the implementations in the admin/admin.php file for admins only, as well as,
    add a plugins action link that goes to `admin.php?page=flair-antispam-settings-page` (the menu slug defined in add_options_page in admin/admin.php, which in turn renders the options form.
  • Define a database table, what will store the post/comment id and data related to that entry, so we can differentiate between drafts that were set so by the flair-antispam plugin implementations from other drafts.
  • Add a do_action everytime some matching content is found, so other plugins to act upon/react to that action 👀👀.

Below is the code that goes into this file.

<?php
 
/**
 * Plugin Name:       Flair Antispam
 * Description:       Filter and unpublish contents (posts/comments) based of defined words/phrases and provides a way to analyze the spam content.
 * Requires at least: 5.7
 * Tested up to: 6.0
 * Stable tag: 1.0.0
 * Requires PHP:      7.0
 * Version:           1.0.2
 * Author:            Nicholas Babu
 * Author URI:        https://profiles.wordpress.org/bahson/
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       flair-antispam
 *
 * @package           flair-antispam
 */
 
if (!defined('ABSPATH')) {
	exit();
}
 
if (!class_exists('Flair_AntiSpam')) {
	class Flair_AntiSpam {
 
		private $pattern;
 
		public function __construct() {
 
			add_action( 'save_post', array($this, 'is_spam_post'), 10, 3 );
			add_action( 'comment_post', array($this, 'is_spam_comment'), 10, 2 );
 
			register_activation_hook(__FILE__, array($this, 'create_tables'));
 
			register_deactivation_hook(__FILE__, array($this, 'drop_tables'));
 
			$this->flair_antispam_setup();
		}
 
		public function flair_antispam_setup() {
			if ( is_admin() ) {
				$plugin = plugin_basename(__FILE__);
				add_filter("plugin_action_links_$plugin", array($this, 'flair_antispam_settings_links'));
				require_once __DIR__ . '/admin/admin.php';
			}
 
			$phrases = get_option('flair_antispam_settings_phrases');
			$pattern = str_replace(',', '|', $phrases);
			$this->pattern = '/('.$pattern.')/i';
		}
 
		public function flair_antispam_settings_links($links) {
			$settings_link = '<a href="admin.php?page=flair-antispam-settings-page">Configuration</a>';
			$links[]       = $settings_link;
			return $links;
		}
 
		public function create_tables() {
			global $wpdb;
			$table = $wpdb->prefix . "flair_antispam";
			$charset = $wpdb->get_charset_collate();
 
			$msg_sql = "CREATE TABLE $table(
 		id mediumint NOT NULL AUTO_INCREMENT,
 		item_id varchar(200) NOT NULL,
 		item_type varchar(20) NOT NULL,
 		PRIMARY KEY (id)
 	    )$charset;";
 
			require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
			dbDelta($msg_sql);
		}
 
		public function drop_tables() {
			global $wpdb;
			$tables = array(
				$wpdb->prefix . "flair_antispam",
				'another_one'
			);
			foreach ($tables as $table) {
				$sql = "DROP TABLE IF EXISTS $table";
				$wpdb->query($sql);
			}
		}
 
		public function is_spam_post( $post_id, $post, $update ) {
			$title = $post->post_title;
			$content = $post->post_content;
			$status = $post->post_status;
			if ($status === 'publish' && ($content || $title)) {
				// unhook this function so it doesn't loop infinitely
				remove_action( 'save_post', array($this, 'is_spam_post'));
				global $wpdb;
				$table = $wpdb->prefix . "flair_antispam";
 
				if (!empty($this->does_match( $content )) || !empty($this->does_match( $title ))) {
					wp_update_post(array(
						'ID'    =>  $post_id,
						'post_status'   =>  'draft'
					));
 
					$this->create_entry( $post_id, 'post' );
 
					// Fires this event throughout the app.
					do_action('flair_antispam_post', $post);
 
				} else {
					$wpdb
						->delete( $table, array( 'item_id' => $post_id ), array( '%d' ) );
				}
 
				// re-hook this function
				add_action( 'save_post', array( $this, 'is_spam_post' ), 10, 3 );
			}
 
		}
 
		public function is_spam_comment( $comment_id, $comment_approved ) {
 
			if ( $comment_approved ) {
				$comment = get_comment( $comment_id );
				global $wpdb;
				$table = $wpdb->prefix . "flair_antispam";
 
				if (!empty($this->does_match($comment->comment_content))) {
					// unhook this function so it doesn't loop infinitely
					remove_action( 'save_post', array($this, 'is_spam_comment'));
					// If spam un-approve comment
					wp_set_comment_status( $comment_id, '0' );
 
					$this->create_entry( $comment_id, 'comment' );
 
					// re-hook this function
					add_action( 'comment_post', array( $this, 'is_spam_comment' ), 10, 2 );
 
					// Fires this event throughout the app.
					do_action('flair_antispam_post', $comment);
				} else {
					$wpdb
						->delete( $table, array( 'item_id' => $comment_id ), array( '%d' ));
				}
			}
 
		}
 
		private function create_entry($item_id, $item_type) {
			global $wpdb;
			$table = $wpdb->prefix . "flair_antispam";
			$where_array = array();
			$where_array[] = $wpdb->prepare( "item_type = %s", $item_type );
			$where_array[] = $wpdb->prepare( "item_id = %d", $item_id );
			$sql = "SELECT item_id FROM {$table}";
			$sql .= ' WHERE ' . join( ' AND ', $where_array);
			$saved = $wpdb->get_var($sql);
 
			if (!$saved) {
				$comment_data = array(
					'item_id' =>  $item_id,
					'item_type' => $item_type
				);
				$format = array( '%d', '%s' );
				// Write to table, so there's a separation from other Drafts according to other metrics.
				// This records a Draft as a result of the post/comment being spam.
				$wpdb->insert( $table, $comment_data, $format );
			}
		}
 
		public function does_match( $input ): array {
			preg_match_all( $this->pattern, $input, $matches );
			return $matches[0];
		}
	}
 
	$anti_spam = new Flair_AntiSpam();
}

### Recap/Testing:

From here, we can enable the plugin and either access our options form by clicking the 'Configuration' link below the plugin description or by through the Settings parent menu from the Admin panel/dashboard.

Just for demo, let's filter content with words; wolf,moon,@gmail: by entering the same text, comma separated into the form input and clicking save.

Try and create new posts, edit existing ones or comment on posts, be sure to include some of the phrases/words from the form input value.

For now, go to prefix_flair_antispam table and note the entries vs the item_id which is equal to the post/comment ID from WordPress core.
 

entries
Entries

 

 

### Building on/Conclusion:

We are also firing an event(broadcast 🗼🗼🗼), whenever 'spam' content is saved(posted), this event passes on,to any listener listening to it,one param, ie, the post/comment; according to our custom filters(words/phrases), so it's easier to build upon(extend) this functionality, to further fit your custom needs.
What one can do is implement actions (add_action) for when such events occur (are fired), and maybe work with that data and the context around it (any data related to this session). eg send a slack notification to the moderation team, call the police 
🚔🚔 on the user or put them in your own jail (suspension).

Suspension
Suspension

Also, the next step, would be to add menu links somewhere in the admin UI to display these contents for review and separate them with any other drafts.

### Resources