Si vous avez déjà ouvert l’onglet “Commentaires” et vu 200 messages à valider dont 180 sont du spam, vous connaissez le vrai coût de la modération : du temps, de l’attention, et parfois des erreurs (un bon commentaire supprimé, un lien douteux validé).

Le besoin / Le cas d’usage

WordPress 6.9.4 sait déjà faire beaucoup : liste noire, modération manuelle, règles “tenir un commentaire en attente si…”, et des plugins antispam. Le problème, c’est la zone grise : commentaires “humains” mais toxiques, auto-promo subtile, attaques déguisées, ou au contraire commentaires légitimes mais avec un lien qui déclenche vos règles.

L’IA est utile précisément là : classifier un commentaire selon vos critères éditoriaux, pas seulement selon des motifs techniques (IP, répétitions, mots interdits). Dans mon expérience, c’est particulièrement efficace sur :

  • Blogs à fort trafic (actualité, sport, crypto, parenting) où la charge de modération explose.
  • Sites multi-auteurs où chacun modère “à sa façon” et où vous voulez une cohérence.
  • Sites avec communauté (tutos, dev, photo) où vous voulez laisser passer les critiques, mais pas les insultes.
  • Sites bilingues : l’IA gère mieux le mélange FR/EN que des règles statiques.

À la fin, vous saurez implémenter un mini-système de modération IA qui :

  • intercepte un commentaire au moment de l’insertion,
  • envoie un résumé minimal à une API (ici : OpenAI, mais la structure est transposable),
  • décide : approuver, mettre en attente, ou marquer comme spam,
  • ajoute une note interne (raison + score) visible dans l’admin,
  • met en cache la décision pour éviter de payer deux fois si le même contenu revient.

Résumé rapide

  • On utilise un hook (un point d’accroche dans WordPress) sur preprocess_comment et wp_insert_comment pour analyser et annoter.
  • On appelle l’API IA via wp_remote_post(), pas via un SDK, pour rester simple et compatible.
  • La clé API est stockée dans wp-config.php (jamais en dur dans un plugin).
  • On met en place un cache Transients pour réduire les coûts et les latences.
  • On applique des garde-fous : timeout, fallback, sanitation (sanitize_text_field(), wp_kses_post()), et limitation de taille.
  • On ne remplace pas les plugins antispam : on les complète, surtout pour la modération “qualitative”.

Quand utiliser l’IA pour ça

Utilisez l’IA si vous avez un besoin de classification sémantique : “est-ce agressif ?”, “est-ce de la pub déguisée ?”, “est-ce hors sujet ?”. Les règles WordPress et les filtres classiques sont bons pour des patterns, moins bons pour l’intention.

Concrètement, l’IA vaut le coup quand :

  • vous recevez au moins quelques dizaines de commentaires par jour,
  • vous avez des critères de modération nuancés (tolérer un lien vers GitHub, refuser un lien d’affiliation, etc.),
  • vous voulez réduire le temps de tri plutôt que viser le zéro spam absolu,
  • vous acceptez d’envoyer un extrait du commentaire à un service externe (point RGPD à traiter plus bas).

Quand ne PAS utiliser l’IA

Ne payez pas une API IA pour un problème que WordPress règle déjà nativement ou avec un plugin spécialisé.

  • Si votre problème est du spam “bête” (bots, liens répétitifs, charabia) : un antispam et des règles suffisent.
  • Si vous avez très peu de commentaires : la modération manuelle est plus simple et plus fiable.
  • Si vous êtes sur un site très sensible (santé, juridique, données personnelles) : envoyer du contenu à un tiers peut être bloquant.
  • Si vous cherchez une “vérité” : l’IA se trompe. Elle doit aider à trier, pas décider d’un bannissement définitif sans contrôle.

Dans ces cas, une solution classique est souvent meilleure : réglages WordPress (Discussion), listes noires, ou un workflow éditorial (mettre tout en attente + modérateurs).

Prérequis

Version ciblée : WordPress 6.9.4 (avril 2026) et PHP 8.1+.

Ce qu’est une API : une interface qui permet à votre site d’envoyer une requête HTTP à un service externe (ici, un modèle IA) et de recevoir une réponse (souvent du JSON). Dans WordPress, on fait ça proprement avec l’API HTTP : wp_remote_post() et wp_remote_retrieve_body(). Référence : HTTP API (developer.wordpress.org).

Clé API : où la mettre (wp-config.php)

Vous avez besoin d’une clé API d’un fournisseur IA. Je montre OpenAI dans le code, parce que c’est simple à illustrer, mais la structure est la même pour Anthropic, Mistral ou Google.

Ne mettez jamais la clé dans le code du plugin (risque : fuite via Git, backup, ou export). Mettez-la dans wp-config.php, idéalement via une variable d’environnement si vous savez le faire.

Dans wp-config.php, ajoutez au-dessus de la ligne “That’s all, stop editing!” :

define( 'BPCAB_OPENAI_API_KEY', 'VOTRE_CLE_ICI' );

Avertissement coûts : chaque commentaire analysé = une requête payante (sauf cache). Si vous avez un pic de spam, la facture peut monter. On mettra un timeout strict et un cache.

Où coller le code (recommandation débutant)

Évitez functions.php pour ce type de logique : si votre thème change, vous perdez la modération. Préférez :

  • mu-plugin (recommandé) : fichier dans /wp-content/mu-plugins/, chargé automatiquement.
  • ou un plugin custom classique dans /wp-content/plugins/.

Je vais vous donner le code prêt à mettre dans un mu-plugin. Référence mu-plugins : Must-Use Plugins.

Extensions PHP

WordPress gère HTTP via cURL ou streams. Sur la plupart des hébergements, c’est déjà OK. Si wp_remote_post() échoue, c’est souvent un souci SSL/cURL côté serveur.

Référence PHP (cURL) : PHP cURL (php.net).

Architecture de la solution

Flux (schéma textuel) :

Visiteur envoie un commentaire → WordPress prépare les données → filtre preprocess_comment → (option) appel IA via wp_remote_post() → décision (approve/hold/spam) → insertion en base → action wp_insert_comment → ajout de métadonnées (raison IA, score) → affichage dans l’admin

Pourquoi deux hooks ?

Action : un hook qui “fait quelque chose” (effet de bord). Filtre : un hook qui “modifie une valeur” et la renvoie.

  • preprocess_comment est un filtre : parfait pour définir le statut (comment_approved) avant insertion.
  • wp_insert_comment est une action : parfait pour enregistrer des métadonnées après coup (raison, score, modèle utilisé).

Référence hooks : Hooks API.

Ce qu’on envoie réellement à l’IA

Envoyer tout le commentaire tel quel est tentant, mais je préfère minimiser :

  • le texte du commentaire (tronqué),
  • le nom (optionnel),
  • l’URL (optionnelle),
  • le contexte : titre de l’article, extrait du contenu (optionnel).

Moins vous envoyez, mieux c’est pour la confidentialité et les coûts. Et ça suffit dans 90% des cas.

Le code complet — étape par étape

Objectif : un mu-plugin qui classe automatiquement les commentaires. On va :

  • définir une fonction d’appel API robuste,
  • mettre en cache selon un hash du commentaire,
  • implémenter une décision simple et compréhensible,
  • journaliser proprement en cas d’erreur (sans casser la soumission).

Étape 1 — créer le mu-plugin

Créez le dossier si nécessaire : wp-content/mu-plugins/.

Puis créez un fichier : wp-content/mu-plugins/bpcab-ai-comment-moderation.php.

Étape 2 — en-tête et garde-fous

<?php
/**
 * Plugin Name: BPCAB — Modération IA des commentaires (mu-plugin)
 * Description: Modère automatiquement les commentaires via une API IA (WordPress 6.9.4+, PHP 8.1+).
 * Author: Votre nom
 * Version: 1.0.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * Petite aide pour écrire des logs sans casser le site.
 * Activez WP_DEBUG_LOG pour écrire dans wp-content/debug.log
 * https://developer.wordpress.org/advanced-administration/debug/debug-wordpress/
 */
function bpcab_log( $message, $context = array() ) {
	if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
		$line = '[BPCAB AI] ' . $message;
		if ( ! empty( $context ) ) {
			$line .= ' ' . wp_json_encode( $context );
		}
		error_log( $line );
	}
}

Étape 3 — expliquer la décision attendue (format JSON)

Le piège classique avec l’IA : demander “spam ou pas ?” et obtenir un roman. Pour une intégration WordPress fiable, je force une réponse JSON stricte avec 3 champs :

  • verdict : approve, hold, spam
  • confidence : nombre 0 à 1
  • reason : texte court (pour l’admin)

Étape 4 — construire un prompt minimal et stable

/**
 * Construit le message envoyé au modèle.
 * On limite volontairement la taille pour réduire coûts + risques.
 */
function bpcab_build_moderation_prompt( array $payload ) {
	$comment   = isset( $payload['comment'] ) ? (string) $payload['comment'] : '';
	$author    = isset( $payload['author'] ) ? (string) $payload['author'] : '';
	$authorurl = isset( $payload['author_url'] ) ? (string) $payload['author_url'] : '';
	$posttitle = isset( $payload['post_title'] ) ? (string) $payload['post_title'] : '';

	// On tronque pour éviter d'envoyer un pavé (et payer pour rien).
	$comment = mb_substr( $comment, 0, 1200 );

	$system = "Vous êtes un système de modération de commentaires pour un blog WordPress en français.n"
		. "Objectif : détecter spam, autopromo, insultes, harcèlement, contenu dangereux.n"
		. "Vous devez répondre UNIQUEMENT en JSON valide, sans texte autour.n"
		. "Schéma strict : {"verdict":"approve|hold|spam","confidence":0..1,"reason":"..."}n"
		. "Règles :n"
		. "- 'spam' si promo, SEO, liens suspects, message générique.n"
		. "- 'hold' si doute (ironie, agressivité légère, lien non clair).n"
		. "- 'approve' si contribution pertinente.n"
		. "- 'reason' doit être court (max 140 caractères).n";

	$user = "Titre de l'article : " . $posttitle . "n"
		. "Auteur : " . $author . "n"
		. "URL auteur : " . $authorurl . "n"
		. "Commentaire : " . $comment . "n";

	return array(
		'system' => $system,
		'user'   => $user,
	);
}

Étape 5 — appel API via wp_remote_post() (avec timeout et erreurs)

On utilise wp_remote_post() pour rester dans les standards WordPress. Référence : wp_remote_post().

/**
 * Appelle l'API OpenAI (Responses API) via HTTP.
 * Note : on n'utilise pas de SDK pour garder un exemple simple.
 */
function bpcab_openai_moderate( array $payload ) {
	if ( ! defined( 'BPCAB_OPENAI_API_KEY' ) || ! BPCAB_OPENAI_API_KEY ) {
		return new WP_Error( 'bpcab_no_api_key', 'Clé API manquante (BPCAB_OPENAI_API_KEY).' );
	}

	$prompt = bpcab_build_moderation_prompt( $payload );

	// Cache : si on revoit exactement le même commentaire, on évite un second appel payant.
	$cache_key = 'bpcab_ai_mod_' . md5( wp_json_encode( $payload ) );
	$cached    = get_transient( $cache_key );
	if ( is_array( $cached ) && isset( $cached['verdict'] ) ) {
		$cached['cached'] = true;
		return $cached;
	}

	$endpoint = 'https://api.openai.com/v1/responses';

	// Corps de requête (format Responses API).
	// Si l'API évolue, gardez l'idée : demander un JSON strict et court.
	$body = array(
		'model' => 'gpt-4.1-mini',
		'input' => array(
			array(
				'role'    => 'system',
				'content' => array(
					array(
						'type' => 'text',
						'text' => $prompt['system'],
					),
				),
			),
			array(
				'role'    => 'user',
				'content' => array(
					array(
						'type' => 'text',
						'text' => $prompt['user'],
					),
				),
			),
		),
		// On force un format JSON (quand supporté).
		'text' => array(
			'format' => array(
				'type' => 'json_object',
			),
		),
		// Limite de sortie : on veut une décision, pas un essai.
		'max_output_tokens' => 220,
	);

	$args = array(
		'headers' => array(
			'Authorization' => 'Bearer ' . BPCAB_OPENAI_API_KEY,
			'Content-Type'  => 'application/json',
		),
		'body'        => wp_json_encode( $body ),
		'timeout'     => 8, // Court : ne bloquez pas l'envoi du commentaire trop longtemps.
		'redirection' => 0,
	);

	$response = wp_remote_post( $endpoint, $args );

	if ( is_wp_error( $response ) ) {
		return $response;
	}

	$code = (int) wp_remote_retrieve_response_code( $response );
	$raw  = (string) wp_remote_retrieve_body( $response );

	if ( $code < 200 || $code >= 300 ) {
		return new WP_Error(
			'bpcab_api_http_error',
			'Erreur HTTP API IA: ' . $code,
			array( 'body' => $raw )
		);
	}

	$data = json_decode( $raw, true );
	if ( ! is_array( $data ) ) {
		return new WP_Error( 'bpcab_api_bad_json', 'Réponse API non JSON.', array( 'body' => $raw ) );
	}

	// Extraction : selon les réponses, le texte final est dans output_text.
	// On reste défensif : si absent, on tente d'autres champs.
	$json_text = '';
	if ( isset( $data['output_text'] ) && is_string( $data['output_text'] ) ) {
		$json_text = $data['output_text'];
	} elseif ( isset( $data['output'][0]['content'][0]['text'] ) ) {
		$json_text = (string) $data['output'][0]['content'][0]['text'];
	}

	$json_text = trim( $json_text );
	if ( $json_text === '' ) {
		return new WP_Error( 'bpcab_api_empty', 'Réponse IA vide.', array( 'body' => $raw ) );
	}

	$decision = json_decode( $json_text, true );
	if ( ! is_array( $decision ) ) {
		return new WP_Error(
			'bpcab_decision_not_json',
			'La décision IA n’est pas un JSON valide.',
			array( 'decision_raw' => $json_text )
		);
	}

	// Normalisation + sanitation.
	$verdict    = isset( $decision['verdict'] ) ? sanitize_key( (string) $decision['verdict'] ) : 'hold';
	$confidence = isset( $decision['confidence'] ) ? (float) $decision['confidence'] : 0.0;
	$reason     = isset( $decision['reason'] ) ? sanitize_text_field( (string) $decision['reason'] ) : 'Aucune raison fournie.';

	if ( ! in_array( $verdict, array( 'approve', 'hold', 'spam' ), true ) ) {
		$verdict = 'hold';
	}
	if ( $confidence < 0 ) {
		$confidence = 0.0;
	} elseif ( $confidence > 1 ) {
		$confidence = 1.0;
	}

	$result = array(
		'verdict'    => $verdict,
		'confidence' => $confidence,
		'reason'     => mb_substr( $reason, 0, 140 ),
		'model'      => 'gpt-4.1-mini',
		'cached'     => false,
	);

	// Cache 7 jours : ajustez selon votre trafic.
	set_transient( $cache_key, $result, 7 * DAY_IN_SECONDS );

	return $result;
}

Étape 6 — brancher la modération sur preprocess_comment

preprocess_comment reçoit les données du commentaire avant insertion. On peut définir comment_approved à :

  • 1 approuvé
  • 0 en attente
  • 'spam' spam

Dans la vraie vie, je recommande de rester conservateur : si l’IA est incertaine ou en erreur, on met en attente plutôt que spam.

/**
 * Filtre de modération : décide du statut avant insertion.
 */
add_filter( 'preprocess_comment', 'bpcab_ai_preprocess_comment', 20, 1 );

function bpcab_ai_preprocess_comment( $commentdata ) {
	// Ne modérez pas si l'admin est en train de répondre (optionnel).
	if ( is_admin() && current_user_can( 'moderate_comments' ) ) {
		return $commentdata;
	}

	// Évitez de traiter les pingbacks/trackbacks.
	if ( isset( $commentdata['comment_type'] ) && $commentdata['comment_type'] !== '' ) {
		return $commentdata;
	}

	// Payload minimal.
	$post_id = isset( $commentdata['comment_post_ID'] ) ? (int) $commentdata['comment_post_ID'] : 0;
	$post    = $post_id ? get_post( $post_id ) : null;

	$payload = array(
		'comment'    => (string) ( $commentdata['comment_content'] ?? '' ),
		'author'     => (string) ( $commentdata['comment_author'] ?? '' ),
		'author_url' => (string) ( $commentdata['comment_author_url'] ?? '' ),
		'post_title' => $post ? (string) get_the_title( $post ) : '',
	);

	// Si commentaire vide (ou quasi), laissez WordPress gérer.
	if ( trim( $payload['comment'] ) === '' ) {
		return $commentdata;
	}

	$decision = bpcab_openai_moderate( $payload );

	if ( is_wp_error( $decision ) ) {
		// En cas d'erreur API, on ne bloque pas : on met en attente.
		bpcab_log( 'Erreur IA, commentaire mis en attente.', array(
			'error' => $decision->get_error_message(),
			'code'  => $decision->get_error_code(),
		) );

		$commentdata['comment_approved'] = 0;
		// On stocke des infos temporaires pour l'étape suivante (wp_insert_comment).
		$commentdata['bpcab_ai_note'] = 'IA indisponible : ' . $decision->get_error_code();
		return $commentdata;
	}

	// Mapping verdict -> statut WP.
	if ( $decision['verdict'] === 'approve' && $decision['confidence'] >= 0.70 ) {
		$commentdata['comment_approved'] = 1;
	} elseif ( $decision['verdict'] === 'spam' && $decision['confidence'] >= 0.70 ) {
		$commentdata['comment_approved'] = 'spam';
	} else {
		$commentdata['comment_approved'] = 0;
	}

	// On passe une note (non stockée automatiquement) pour l'action wp_insert_comment.
	$commentdata['bpcab_ai_note'] = sprintf(
		'%s (%.2f) — %s%s',
		$decision['verdict'],
		$decision['confidence'],
		$decision['reason'],
		$decision['cached'] ? ' [cache]' : ''
	);

	return $commentdata;
}

Étape 7 — enregistrer la raison IA en meta du commentaire

WordPress permet des métadonnées sur les commentaires (comme les post meta). On va enregistrer :

  • la note lisible,
  • le verdict brut,
  • la confiance,
  • le modèle.

Référence : add_comment_meta().

add_action( 'wp_insert_comment', 'bpcab_ai_store_comment_meta', 10, 2 );

function bpcab_ai_store_comment_meta( $comment_id, $comment ) {
	// On récupère la note passée par preprocess_comment.
	// Elle n'est pas dans $comment : on la récupère via un filtre temporaire… sauf qu'ici on ne l'a plus.
	// Solution simple : recalculer un mini payload et relire le cache.
	// Dans la pratique, ça marche bien grâce au transient.

	$post = get_post( (int) $comment->comment_post_ID );

	$payload = array(
		'comment'    => (string) $comment->comment_content,
		'author'     => (string) $comment->comment_author,
		'author_url' => (string) $comment->comment_author_url,
		'post_title' => $post ? (string) get_the_title( $post ) : '',
	);

	$cache_key = 'bpcab_ai_mod_' . md5( wp_json_encode( $payload ) );
	$decision  = get_transient( $cache_key );

	// Si pas en cache (rare), on ne rappelle pas l'API ici (sinon double facturation).
	if ( ! is_array( $decision ) ) {
		add_comment_meta( $comment_id, 'bpcab_ai_note', 'Pas de décision en cache (évite double appel).', true );
		return;
	}

	add_comment_meta( $comment_id, 'bpcab_ai_verdict', sanitize_key( (string) $decision['verdict'] ), true );
	add_comment_meta( $comment_id, 'bpcab_ai_confidence', (string) (float) $decision['confidence'], true );
	add_comment_meta( $comment_id, 'bpcab_ai_model', sanitize_text_field( (string) $decision['model'] ), true );

	$note = sprintf(
		'%s (%.2f) — %s%s',
		$decision['verdict'],
		$decision['confidence'],
		sanitize_text_field( (string) $decision['reason'] ),
		! empty( $decision['cached'] ) ? ' [cache]' : ''
	);
	add_comment_meta( $comment_id, 'bpcab_ai_note', $note, true );
}

Étape 8 — afficher la note IA dans l’admin (colonne)

Ça évite le “spam/attente” sans explication. On ajoute une colonne dans la liste des commentaires.

add_filter( 'manage_edit-comments_columns', 'bpcab_ai_add_comment_column' );
function bpcab_ai_add_comment_column( $columns ) {
	$columns['bpcab_ai'] = 'IA';
	return $columns;
}

add_action( 'manage_comments_custom_column', 'bpcab_ai_render_comment_column', 10, 2 );
function bpcab_ai_render_comment_column( $column, $comment_id ) {
	if ( $column !== 'bpcab_ai' ) {
		return;
	}

	$note = get_comment_meta( $comment_id, 'bpcab_ai_note', true );
	if ( ! $note ) {
		echo '—';
		return;
	}

	// On affiche en texte simple (admin), pas de HTML venant d'une API.
	echo esc_html( $note );
}

Le code assemblé complet

Copiez-collez ce fichier tel quel dans wp-content/mu-plugins/bpcab-ai-comment-moderation.php. Avant : sauvegarde, et testez sur un site de staging si possible.

<?php
/**
 * Plugin Name: BPCAB — Modération IA des commentaires (mu-plugin)
 * Description: Modère automatiquement les commentaires via une API IA (WordPress 6.9.4+, PHP 8.1+).
 * Version: 1.0.0
 */

defined( 'ABSPATH' ) || exit;

function bpcab_log( $message, $context = array() ) {
	if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
		$line = '[BPCAB AI] ' . $message;
		if ( ! empty( $context ) ) {
			$line .= ' ' . wp_json_encode( $context );
		}
		error_log( $line );
	}
}

function bpcab_build_moderation_prompt( array $payload ) {
	$comment   = isset( $payload['comment'] ) ? (string) $payload['comment'] : '';
	$author    = isset( $payload['author'] ) ? (string) $payload['author'] : '';
	$authorurl = isset( $payload['author_url'] ) ? (string) $payload['author_url'] : '';
	$posttitle = isset( $payload['post_title'] ) ? (string) $payload['post_title'] : '';

	$comment = mb_substr( $comment, 0, 1200 );

	$system = "Vous êtes un système de modération de commentaires pour un blog WordPress en français.n"
		. "Objectif : détecter spam, autopromo, insultes, harcèlement, contenu dangereux.n"
		. "Vous devez répondre UNIQUEMENT en JSON valide, sans texte autour.n"
		. "Schéma strict : {"verdict":"approve|hold|spam","confidence":0..1,"reason":"..."}n"
		. "Règles :n"
		. "- 'spam' si promo, SEO, liens suspects, message générique.n"
		. "- 'hold' si doute (ironie, agressivité légère, lien non clair).n"
		. "- 'approve' si contribution pertinente.n"
		. "- 'reason' doit être court (max 140 caractères).n";

	$user = "Titre de l'article : " . $posttitle . "n"
		. "Auteur : " . $author . "n"
		. "URL auteur : " . $authorurl . "n"
		. "Commentaire : " . $comment . "n";

	return array(
		'system' => $system,
		'user'   => $user,
	);
}

function bpcab_openai_moderate( array $payload ) {
	if ( ! defined( 'BPCAB_OPENAI_API_KEY' ) || ! BPCAB_OPENAI_API_KEY ) {
		return new WP_Error( 'bpcab_no_api_key', 'Clé API manquante (BPCAB_OPENAI_API_KEY).' );
	}

	$prompt = bpcab_build_moderation_prompt( $payload );

	$cache_key = 'bpcab_ai_mod_' . md5( wp_json_encode( $payload ) );
	$cached    = get_transient( $cache_key );
	if ( is_array( $cached ) && isset( $cached['verdict'] ) ) {
		$cached['cached'] = true;
		return $cached;
	}

	$endpoint = 'https://api.openai.com/v1/responses';

	$body = array(
		'model' => 'gpt-4.1-mini',
		'input' => array(
			array(
				'role'    => 'system',
				'content' => array(
					array(
						'type' => 'text',
						'text' => $prompt['system'],
					),
				),
			),
			array(
				'role'    => 'user',
				'content' => array(
					array(
						'type' => 'text',
						'text' => $prompt['user'],
					),
				),
			),
		),
		'text' => array(
			'format' => array(
				'type' => 'json_object',
			),
		),
		'max_output_tokens' => 220,
	);

	$args = array(
		'headers' => array(
			'Authorization' => 'Bearer ' . BPCAB_OPENAI_API_KEY,
			'Content-Type'  => 'application/json',
		),
		'body'        => wp_json_encode( $body ),
		'timeout'     => 8,
		'redirection' => 0,
	);

	$response = wp_remote_post( $endpoint, $args );

	if ( is_wp_error( $response ) ) {
		return $response;
	}

	$code = (int) wp_remote_retrieve_response_code( $response );
	$raw  = (string) wp_remote_retrieve_body( $response );

	if ( $code < 200 || $code >= 300 ) {
		return new WP_Error(
			'bpcab_api_http_error',
			'Erreur HTTP API IA: ' . $code,
			array( 'body' => $raw )
		);
	}

	$data = json_decode( $raw, true );
	if ( ! is_array( $data ) ) {
		return new WP_Error( 'bpcab_api_bad_json', 'Réponse API non JSON.', array( 'body' => $raw ) );
	}

	$json_text = '';
	if ( isset( $data['output_text'] ) && is_string( $data['output_text'] ) ) {
		$json_text = $data['output_text'];
	} elseif ( isset( $data['output'][0]['content'][0]['text'] ) ) {
		$json_text = (string) $data['output'][0]['content'][0]['text'];
	}

	$json_text = trim( $json_text );
	if ( $json_text === '' ) {
		return new WP_Error( 'bpcab_api_empty', 'Réponse IA vide.', array( 'body' => $raw ) );
	}

	$decision = json_decode( $json_text, true );
	if ( ! is_array( $decision ) ) {
		return new WP_Error(
			'bpcab_decision_not_json',
			'La décision IA n’est pas un JSON valide.',
			array( 'decision_raw' => $json_text )
		);
	}

	$verdict    = isset( $decision['verdict'] ) ? sanitize_key( (string) $decision['verdict'] ) : 'hold';
	$confidence = isset( $decision['confidence'] ) ? (float) $decision['confidence'] : 0.0;
	$reason     = isset( $decision['reason'] ) ? sanitize_text_field( (string) $decision['reason'] ) : 'Aucune raison fournie.';

	if ( ! in_array( $verdict, array( 'approve', 'hold', 'spam' ), true ) ) {
		$verdict = 'hold';
	}
	if ( $confidence < 0 ) {
		$confidence = 0.0;
	} elseif ( $confidence > 1 ) {
		$confidence = 1.0;
	}

	$result = array(
		'verdict'    => $verdict,
		'confidence' => $confidence,
		'reason'     => mb_substr( $reason, 0, 140 ),
		'model'      => 'gpt-4.1-mini',
		'cached'     => false,
	);

	set_transient( $cache_key, $result, 7 * DAY_IN_SECONDS );

	return $result;
}

add_filter( 'preprocess_comment', 'bpcab_ai_preprocess_comment', 20, 1 );
function bpcab_ai_preprocess_comment( $commentdata ) {
	if ( is_admin() && current_user_can( 'moderate_comments' ) ) {
		return $commentdata;
	}

	if ( isset( $commentdata['comment_type'] ) && $commentdata['comment_type'] !== '' ) {
		return $commentdata;
	}

	$post_id = isset( $commentdata['comment_post_ID'] ) ? (int) $commentdata['comment_post_ID'] : 0;
	$post    = $post_id ? get_post( $post_id ) : null;

	$payload = array(
		'comment'    => (string) ( $commentdata['comment_content'] ?? '' ),
		'author'     => (string) ( $commentdata['comment_author'] ?? '' ),
		'author_url' => (string) ( $commentdata['comment_author_url'] ?? '' ),
		'post_title' => $post ? (string) get_the_title( $post ) : '',
	);

	if ( trim( $payload['comment'] ) === '' ) {
		return $commentdata;
	}

	$decision = bpcab_openai_moderate( $payload );

	if ( is_wp_error( $decision ) ) {
		bpcab_log( 'Erreur IA, commentaire mis en attente.', array(
			'error' => $decision->get_error_message(),
			'code'  => $decision->get_error_code(),
		) );

		$commentdata['comment_approved'] = 0;
		return $commentdata;
	}

	if ( $decision['verdict'] === 'approve' && $decision['confidence'] >= 0.70 ) {
		$commentdata['comment_approved'] = 1;
	} elseif ( $decision['verdict'] === 'spam' && $decision['confidence'] >= 0.70 ) {
		$commentdata['comment_approved'] = 'spam';
	} else {
		$commentdata['comment_approved'] = 0;
	}

	return $commentdata;
}

add_action( 'wp_insert_comment', 'bpcab_ai_store_comment_meta', 10, 2 );
function bpcab_ai_store_comment_meta( $comment_id, $comment ) {
	$post = get_post( (int) $comment->comment_post_ID );

	$payload = array(
		'comment'    => (string) $comment->comment_content,
		'author'     => (string) $comment->comment_author,
		'author_url' => (string) $comment->comment_author_url,
		'post_title' => $post ? (string) get_the_title( $post ) : '',
	);

	$cache_key = 'bpcab_ai_mod_' . md5( wp_json_encode( $payload ) );
	$decision  = get_transient( $cache_key );

	if ( ! is_array( $decision ) ) {
		add_comment_meta( $comment_id, 'bpcab_ai_note', 'Pas de décision en cache (évite double appel).', true );
		return;
	}

	add_comment_meta( $comment_id, 'bpcab_ai_verdict', sanitize_key( (string) $decision['verdict'] ), true );
	add_comment_meta( $comment_id, 'bpcab_ai_confidence', (string) (float) $decision['confidence'], true );
	add_comment_meta( $comment_id, 'bpcab_ai_model', sanitize_text_field( (string) $decision['model'] ), true );

	$note = sprintf(
		'%s (%.2f) — %s%s',
		$decision['verdict'],
		$decision['confidence'],
		sanitize_text_field( (string) $decision['reason'] ),
		! empty( $decision['cached'] ) ? ' [cache]' : ''
	);
	add_comment_meta( $comment_id, 'bpcab_ai_note', $note, true );
}

add_filter( 'manage_edit-comments_columns', 'bpcab_ai_add_comment_column' );
function bpcab_ai_add_comment_column( $columns ) {
	$columns['bpcab_ai'] = 'IA';
	return $columns;
}

add_action( 'manage_comments_custom_column', 'bpcab_ai_render_comment_column', 10, 2 );
function bpcab_ai_render_comment_column( $column, $comment_id ) {
	if ( $column !== 'bpcab_ai' ) {
		return;
	}

	$note = get_comment_meta( $comment_id, 'bpcab_ai_note', true );
	if ( ! $note ) {
		echo '—';
		return;
	}

	echo esc_html( $note );
}

Explication du code

1) Pourquoi un mu-plugin

J’ai souvent vu des snippets de modération collés dans functions.php puis “perdus” lors d’un changement de thème (Divi → Avada, ou refonte). Le mu-plugin évite ça : il est chargé avant les plugins classiques et ne dépend pas du thème.

2) Pourquoi preprocess_comment (filtre) plutôt qu’un cron

Le filtre agit au moment exact où WordPress traite le commentaire. C’est là que vous pouvez décider du statut. Un cron analyserait après coup, donc le commentaire pourrait apparaître brièvement, ou déclencher des notifications avant d’être rétrogradé.

3) Pourquoi un cache transient

Le spam “humain” est souvent réutilisé. Un transient sur un hash du payload évite :

  • de payer deux fois,
  • de subir deux fois la latence,
  • de doubler la charge sur votre serveur.

Référence Transients : Transients API.

4) Pourquoi un timeout court

Si l’API met 25 secondes, votre formulaire de commentaire “mouline”, et l’utilisateur re-clique. Résultat : doublons, frustration, et parfois 2 requêtes IA. 8 secondes est un bon compromis sur la plupart des hébergements.

5) Pourquoi “hold” en cas d’erreur

Mettre “spam” sur erreur API est une mauvaise idée : vous punissez des vrais visiteurs à cause d’un incident réseau. Mettre en attente est plus sûr, et vous gardez la main.

Coûts API et optimisation

Les prix changent selon les fournisseurs et les modèles. Je préfère raisonner en ordre de grandeur : une classification courte (entrée ~1 000–2 000 caractères, sortie ~200 tokens max) coûte généralement quelques dixièmes de centime à quelques centimes par requête selon le modèle.

Estimation simple (à ajuster)

  • 50 commentaires/jour analysés → ~1 500/mois
  • si 0,002 € / requête (modèle “mini”) → ~3 € / mois
  • si 0,01 € / requête (modèle plus costaud) → ~15 € / mois

Le vrai piège, c’est le spam massif : 10 000 tentatives en 48h peuvent vous coûter plus qu’un mois normal. D’où :

  • timeout court,
  • cache,
  • et idéalement un rate limiting (voir section sécurité).

Optimisations concrètes

  • Tronquez le commentaire (déjà fait à 1200 chars).
  • Réduisez max_output_tokens (déjà fait à 220).
  • Modèle plus petit pour la pré-modération, et escalade vers un modèle plus gros uniquement si “hold”.
  • Ne modérez pas les utilisateurs de confiance (connectés, rôle “contributeur”, etc.).

Variantes et cas d’usage avancés

Variante 1 — “approuver automatiquement” les auteurs connus

Si vous avez des habitués, vous pouvez bypass l’IA pour eux. Exemple : si un e-mail a déjà un commentaire approuvé.

function bpcab_is_trusted_commenter_email( $email ) {
	$email = sanitize_email( (string) $email );
	if ( ! $email ) {
		return false;
	}

	global $wpdb;
	// On cherche un commentaire approuvé existant avec cet email.
	$count = (int) $wpdb->get_var( $wpdb->prepare(
		"SELECT COUNT(*) FROM {$wpdb->comments} WHERE comment_author_email = %s AND comment_approved = '1'",
		$email
	) );

	return $count > 0;
}

Et dans bpcab_ai_preprocess_comment(), avant l’appel IA :

// Exemple : ne pas appeler l'IA pour un habitué.
if ( ! empty( $commentdata['comment_author_email'] ) && bpcab_is_trusted_commenter_email( $commentdata['comment_author_email'] ) ) {
	$commentdata['comment_approved'] = 1;
	return $commentdata;
}

Variante 2 — escalade : “mini modèle” puis “modèle meilleur” si doute

Approche que j’utilise souvent : modèle “mini” pour classer vite, et si verdict = hold, on refait une analyse avec un modèle plus robuste. Ça réduit les coûts sur le volume.

Concrètement : ajoutez un paramètre $model à bpcab_openai_moderate() et appelez une seconde fois uniquement si nécessaire. Gardez un cache séparé par modèle pour éviter des collisions.

Variante 3 — compatibilité Divi 5 / Elementor / Avada

Bonne nouvelle : la modération se fait côté serveur, au niveau WordPress. Que vos formulaires viennent de :

  • le formulaire natif de commentaires,
  • un module Divi 5,
  • un widget Elementor,
  • un élément Avada/Fusion Builder,

… le commentaire finit dans le même pipeline WordPress, donc vos hooks fonctionnent pareil.

Le seul cas où ça change : certains builders utilisent un système “AJAX custom” qui poste vers une route interne. Même là, WordPress appelle généralement les mêmes fonctions d’insertion de commentaire. Si vous constatez que vos hooks ne se déclenchent pas, c’est souvent parce que le builder n’utilise pas le système natif (rare) ou parce qu’un plugin de “reviews” remplace les commentaires par un CPT.

Sécurité et bonnes pratiques

Ne jamais exposer la clé API côté client

Évitez absolument une modération en JavaScript côté navigateur. Sinon, votre clé API se retrouve dans le code source (ou dans l’onglet Réseau). Ici, tout passe par PHP et wp_remote_post().

Sanitizer tout ce qui vient de l’IA

Même si l’IA est “de confiance”, traitez sa sortie comme non fiable :

  • sanitize_text_field() pour la raison,
  • sanitize_key() pour le verdict,
  • et si vous affichiez du HTML (je ne le recommande pas ici), utilisez wp_kses_post().

Référence : Data Sanitization (developer.wordpress.org).

Rate limiting (anti-facture surprise)

Sur des sites attaqués, j’ai vu des formulaires commentaire utilisés comme “pompe à API”. Ajoutez une limite simple par IP (ou par empreinte) : par exemple, max 10 analyses/heure/IP. Exemple minimal :

function bpcab_rate_limit_allow( $ip, $limit = 10, $window = 3600 ) {
	$ip = preg_replace( '/[^0-9a-fA-F:.]/', '', (string) $ip );
	if ( $ip === '' ) {
		return true; // Si pas d'IP, on ne bloque pas ici.
	}

	$key   = 'bpcab_rl_' . md5( $ip );
	$count = (int) get_transient( $key );

	if ( $count >= $limit ) {
		return false;
	}

	set_transient( $key, $count + 1, $window );
	return true;
}

À intégrer avant l’appel IA, avec $_SERVER['REMOTE_ADDR'] (sanitisée). Si la limite est atteinte, mettez en attente sans appel IA.

RGPD : vous envoyez des données à un tiers

Un commentaire peut contenir des données personnelles. Si vous envoyez son contenu à un fournisseur IA :

  • mettez-le dans votre registre de sous-traitants,
  • documentez la finalité (modération),
  • minimisez (tronquage + suppression éventuelle des e-mails),
  • vérifiez les options de conservation côté fournisseur.

Je ne suis pas juriste, mais techniquement, le minimum est de ne jamais envoyer l’e-mail de l’auteur à l’IA (on ne l’envoie pas dans ce tutoriel).

Comment tester et déboguer

1) Activez les logs WordPress

Dans wp-config.php (sur staging), activez :

define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );

Référence : Debug WordPress.

2) Testez avec 3 commentaires “types”

  1. Un commentaire normal, pertinent → doit être approuvé ou en attente selon votre seuil.
  2. Un commentaire obvious spam (“Great post! visit my site…”) → doit finir en spam.
  3. Un commentaire borderline (critique agressive, sarcasme) → doit finir en attente.

3) Vérifiez la colonne IA

Dans l’admin WordPress → Commentaires, vous devez voir la colonne “IA”. Si elle n’apparaît pas :

  • le fichier mu-plugin n’est pas au bon endroit,
  • ou il y a une erreur PHP (parenthèse/point-virgule manquant).

4) Vérifiez le cache

Soumettez deux fois le même commentaire (sur staging). La note doit afficher [cache] à partir de la seconde fois (si vous avez gardé ce suffixe).

Si ça ne marche pas

Voici un tableau de diagnostic basé sur ce que je vois le plus souvent en dépannage.

Symptôme Cause probable Vérification Solution
Le site affiche une erreur 500 dès que j’active le code Erreur PHP (point-virgule manquant, fichier encodé bizarrement) Consultez wp-content/debug.log ou les logs serveur Corrigez la ligne indiquée, vérifiez PHP 8.1+, éditeur en UTF-8 sans BOM
Les commentaires restent tous “en attente” Clé API absente/invalide, ou API inaccessible Activez WP_DEBUG_LOG, cherchez [BPCAB AI] Ajoutez BPCAB_OPENAI_API_KEY dans wp-config.php, vérifiez quota
Rien ne change, l’IA ne semble jamais appelée Code collé au mauvais endroit (functions.php du mauvais thème, mauvais dossier mu-plugins) Vérifiez /wp-content/mu-plugins/ et le nom du fichier Placez le fichier dans mu-plugins, pas dans uploads, ni dans un plugin “snippets” cassé
Timeout / formulaire qui mouline Timeout trop long, DNS/SSL sortant bloqué Logs + test de requête sortante depuis le serveur Réduisez timeout, demandez à l’hébergeur d’autoriser les appels HTTPS sortants
La colonne “IA” est vide Pas de décision en cache au moment de wp_insert_comment Regardez la meta du commentaire Augmentez la durée du transient, ou stockez une note via une approche plus avancée (session/objet global)
Des commentaires légitimes passent en spam Seuil trop bas, prompt trop agressif Comparez verdict + confidence en meta Montez le seuil (ex: 0.85), ou forcez “hold” sur les liens

Erreurs réalistes que je vois tout le temps

  • Copier le code dans le mauvais fichier : le mu-plugin doit être dans wp-content/mu-plugins/, pas dans wp-content/plugins/ (sauf si vous en faites un plugin normal).
  • Oublier une parenthèse : une seule suffit à casser tout le site. Travaillez sur staging.
  • Tester en production sans sauvegarde : évitez. Même un “petit snippet” peut provoquer un 500.
  • Confondre action et filtre : si vous utilisez une action au lieu de preprocess_comment, vous ne pourrez pas modifier comment_approved à temps.
  • Code d’un ancien tutoriel : j’en vois encore qui utilisent des endpoints obsolètes ou des formats non JSON. Gardez une extraction défensive et logguez.

Ressources

FAQ

Est-ce que ça remplace Akismet ou un antispam ?

Non. Je le vois comme un “deuxième étage” : antispam pour filtrer le gros, IA pour trier le qualitatif (toxique, autopromo subtile, hors sujet).

Est-ce que l’IA peut approuver automatiquement à 100% ?

Techniquement oui, mais je déconseille. Gardez un seuil de confiance élevé et mettez en attente en cas de doute.

Pourquoi mes commentaires sont tous en attente alors que la clé est bonne ?

Souvent : quota dépassé, endpoint bloqué par l’hébergeur, ou l’API renvoie un format inattendu. Activez WP_DEBUG_LOG et regardez debug.log.

Est-ce compatible avec Divi 5 / Elementor / Avada ?

Oui, tant que le builder utilise le système natif de commentaires WordPress. La modération se fait côté serveur, indépendamment du rendu du formulaire.

Je veux stocker la raison IA dans le commentaire visible publiquement, c’est une bonne idée ?

Non. Gardez ça interne. Exposer “spam détecté” peut aider les spammeurs à ajuster leurs messages.

Pourquoi la meta “Pas de décision en cache” apparaît parfois ?

Parce que le stockage de meta se fait après insertion, et on a choisi de ne pas refaire un appel IA à ce moment (pour éviter la double facturation). En pratique, avec le transient, ça reste rare.

Puis-je envoyer aussi l’email de l’auteur à l’IA pour mieux détecter le spam ?

Je l’évite. Vous augmentez le risque RGPD et ça apporte peu. Si vous avez besoin d’un signal, utilisez plutôt des règles locales (email déjà vu, domaine suspect) sans API.

Comment ajuster la sensibilité ?

Montez ou baissez le seuil 0.70. Exemple : 0.85 pour n’approuver que les cas très sûrs, et laisser plus de commentaires en attente.

Est-ce que ça ralentit l’envoi d’un commentaire ?

Oui : vous ajoutez une requête HTTP. D’où le timeout court, et le cache. Si la latence est un problème, passez en mode “hold par défaut” et analyse asynchrone via cron (plus complexe).

Que faire si le modèle renvoie du texte au lieu de JSON ?

Le code gère ce cas et met le commentaire en attente. Si ça arrive souvent, renforcez le prompt (déjà strict) et réduisez max_output_tokens.

Puis-je utiliser Anthropic ou Mistral à la place ?

Oui. Gardez la même architecture (wp_remote_post + JSON strict + cache + sanitation). Le seul changement est l’endpoint, l’auth, et le format exact du body/response.