DEV Community

Paxmod
Paxmod

Posted on

Replacing Perspective API in Discourse and Coral Talk before Dec 31

Two of the most common places Perspective API hides are a Discourse forum running the official discourse-perspective-api plugin, and a Coral Talk comment section with the toxicity filter turned on. Perspective stops working after December 31, 2026, and neither of these will tell you loudly when it does.

Disclosure: we build Paxmod, a moderation API, and the code below calls it. The parts about how Discourse and Coral behave come from reading their source and apply whatever you switch to.

One thing up front: Paxmod has its own request and response format, and there's no Paxmod plugin for Discourse. So this isn't a "change the URL" job. It's a small amount of glue code, and below is all of it.

What each one does today

Discourse plugin (discourse/discourse-perspective-api, MIT licensed):

  • The Google host is a constant in lib/discourse_perspective.rb. There's no setting to change it.
  • On post_created and post_edited it queues a job that sends the post to Perspective, asking for TOXICITY (or SEVERE_TOXICITY if you picked the experimental model).
  • If the score is above perspective_flag_post_min_toxicity (default 0.9), it flags the post for moderators as the system user.
  • Optionally it checks the composer before posting and warns the user above 0.85, and it can backfill old posts every 10 minutes.

The part that matters for January: the plugin parses the response, and if there's no attributeScores in it (which is what an HTTP error body looks like), the score quietly becomes 0.0. Only connection-level failures raise an error. So if Perspective starts answering with an error, every post scores as clean and nothing gets flagged. If backfill is on, it also writes 0.0 into the stored scores.

Coral Talk:

  • Coral lets admins set a custom Perspective endpoint, key, model and threshold. It appends /comments:analyze and ?key= to whatever endpoint you give it.
  • It reads attributeScores[model].summaryScore.value and withholds the comment for review if the score is above the threshold.
  • If the call fails, Coral logs "could not determine comment toxicity" and the comment goes through unflagged.

That configurable endpoint only helps if the replacement speaks Perspective's exact format. Paxmod doesn't, so for Coral we use a different, better hook: external moderation phases.

The shared bit: one function that calls Paxmod

Both integrations below use this. Node 18+, no dependencies.

// paxmod.js
const PAXMOD_URL = "https://www.paxmod.com/api/v1/text";
const MAX_CHARS = 4900; // the API rejects anything over 5,000 characters

export async function moderate(text, { userId, contextId }) {
  if (!text || !text.trim()) return { flagged: false }; // the API rejects empty messages

  // Long forum posts get split, and the post is flagged if any part is.
  const chunks = [];
  for (let i = 0; i < text.length; i += MAX_CHARS) chunks.push(text.slice(i, i + MAX_CHARS));

  for (const chunk of chunks) {
    const res = await fetch(PAXMOD_URL, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.PAXMOD_API_KEY}`,
      },
      body: JSON.stringify({ message: chunk, user_id: String(userId), context_id: contextId }),
      signal: AbortSignal.timeout(3000),
    });
    const data = await res.json();

    // Check status first. Some error bodies also contain result: "not_flagged".
    if (!res.ok || data.status !== "success") {
      throw new Error(`Paxmod error ${res.status}: ${data?.error?.code ?? "unknown"}`);
    }
    if (data.result === "flagged") return { flagged: true, reason: data.reason };
  }
  return { flagged: false };
}
Enter fullscreen mode Exit fullscreen mode

Each chunk is a separate call, so a 12,000-character post costs three. If that bothers you, moderate the first chunk only.

Discourse, option A: a webhook (no fork)

This leaves Discourse untouched. Disable the Perspective plugin, then on the admin webhooks page (/admin/api/web_hooks) add a webhook that sends Post events as application/json to your service, with a secret. Discourse sends the post under a post key, including raw, user_id and topic_id, names the event in an X-Discourse-Event header (post_created, post_edited and so on), and signs the body in X-Discourse-Event-Signature.

You also need an API key to file the flag. On /admin/api/keys, either create a key for a single staff account, or create an "All users" key and pick the acting account with the Api-Username header, as below.

// server.js
import express from "express";
import crypto from "node:crypto";
import { moderate } from "./paxmod.js";

const app = express();
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));

function validSignature(rawBody, header, secret) {
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  return header && header.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}

app.post("/discourse", async (req, res) => {
  if (!validSignature(req.rawBody, req.get("X-Discourse-Event-Signature"), process.env.DISCOURSE_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }
  res.sendStatus(200); // answer Discourse fast, moderate after

  const event = req.get("X-Discourse-Event");
  const post = req.body.post;
  if (!post || !["post_created", "post_edited"].includes(event)) return;

  try {
    const verdict = await moderate(post.raw, { userId: post.user_id, contextId: `topic_${post.topic_id}` });
    if (!verdict.flagged) return;

    // Default flag type ids: 3 off_topic, 4 inappropriate, 7 notify_moderators, 8 spam.
    // notify_moderators requires a message.
    await fetch(`${process.env.DISCOURSE_URL}/post_actions.json`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Api-Key": process.env.DISCOURSE_API_KEY,
        "Api-Username": "system", // needed for an "All users" key, optional for a single-user key
      },
      body: JSON.stringify({ id: post.id, post_action_type_id: 7, message: `Paxmod: ${verdict.reason}` }),
    });
  } catch (err) {
    // Don't swallow this. A failed check should page someone, not look like a clean post.
    console.error("moderation failed for post", post.id, err);
  }
});
Enter fullscreen mode Exit fullscreen mode

If you'd rather treat it like a member's flag, use 4 (inappropriate) instead. That's one of the flag types that can feed Discourse's automatic hiding, while notify_moderators doesn't count toward it. Either way you lose the composer warning, but you gain an error you can actually see.

Discourse, option B: fork the plugin

If you'd rather keep the plugin's flow (composer check, backfill, settings), fork it. It's MIT, so keep the licence and copyright notice. The whole change is the score_comment method in lib/discourse_perspective.rb:

PAXMOD_URL = "https://www.paxmod.com/api/v1/text"

def self.score_comment(post)
  # The composer check passes a User object here, posts pass an id.
  uid = post&.user_id
  uid = uid.id if uid.respond_to?(:id)

  response = Excon.post(
    PAXMOD_URL,
    headers: {
      "Content-Type" => "application/json",
      # Reusing the existing setting to hold the Paxmod key. Rename it in your fork.
      "Authorization" => "Bearer #{SiteSetting.perspective_google_api_key}",
    },
    body: { message: post.raw.to_s[0, 4900], user_id: uid.to_s, context_id: "discourse" }.to_json,
    connect_timeout: 1,
    read_timeout: 3,
    write_timeout: 3,
  )

  data = (MultiJson.load(response.body) rescue {}) || {}
  if response.status != 200 || data["status"] != "success"
    # Raise instead of returning 0.0, so jobs retry and the failure shows in logs.
    raise NetworkError, "Paxmod returned #{response.status} #{data.dig("error", "code")}"
  end

  categories = data.dig("content_moderation", "categories") || {}
  top = categories.values.map { |c| c["score"].to_f }.max || 0.0
  { score: data["result"] == "flagged" ? 1.0 : top }
end
Enter fullscreen mode Exit fullscreen mode

Two things to know:

  • Flagged posts come back as 1.0, so the plugin's 0.9 threshold flags them. The real thresholds now live per category in the Paxmod console, so tune them there, not in Discourse.
  • This version opens a new connection per call and drops the old mutex and connection reuse. Fine for most forums. Add it back if you post a lot.

Coral: an external moderation phase

Coral can call your own HTTPS endpoint on every new or edited comment and let you decide what happens. Turn off the Perspective toxicity filter, then go to Configure, Moderation Phases in Coral's admin, add an external moderation phase with your service as the Callback URL, and copy its signing secret.

Raise the timeout. It defaults to 200 ms, and if your service doesn't answer in time Coral skips the phase and the comment carries on. Set it to about 3,500 ms (the field allows 100 to 10,000), a little above the 3-second timeout in moderate().

Coral signs the body with HMAC-SHA256 and sends X-Coral-Signature: sha256=<hex> (comma-separated if more than one secret is active).

app.post("/coral", async (req, res) => {
  const header = req.get("X-Coral-Signature") || "";
  const expected = "sha256=" + crypto.createHmac("sha256", process.env.CORAL_SIGNING_SECRET).update(req.rawBody).digest("hex");
  if (!header.split(",").includes(expected)) return res.sendStatus(401);

  const { comment, author, story } = req.body;
  try {
    const verdict = await moderate(comment.body, { userId: author.id, contextId: story.id });
    if (!verdict.flagged) return res.sendStatus(204); // no opinion, carry on

    // Same outcome as Coral's built-in toxicity filter: hold it and flag it.
    return res.json({
      status: "SYSTEM_WITHHELD",
      actions: [{ actionType: "FLAG", reason: "COMMENT_DETECTED_TOXIC" }],
    });
  } catch (err) {
    console.error("moderation failed", err);
    // Your call. PREMOD holds it for a human. Returning 204 lets it through.
    return res.json({ status: "PREMOD" });
  }
});
Enter fullscreen mode Exit fullscreen mode

Set Comment Body Format to Plain Text so comment.body has the HTML stripped. This is better than the Perspective filter in one way: when the moderation call fails, you decide whether comments wait for a human or go live. With the built-in filter, they just go live. (A Coral-side timeout still lets the comment through, which is why the timeout setting matters.)

Before you cut over

  1. Run both side by side while Perspective still answers. Log Perspective's score and Paxmod's decision for the same posts for a week or two, then set Paxmod's category thresholds from what you see.
  2. Check what your forum actually needs. Paxmod can also flag personal information like "what's your address" if you turn on that category, which is something Perspective never scored.
  3. Turn off backfill in the Discourse plugin before Jan 1 if you keep it, or it'll keep writing scores from failed calls.

Paxmod plans start at $29/month for 50,000 messages (Studio is $149/month for 500,000), with a 14-day trial that includes 10,000 messages. A card is needed for the trial and there's no free plan.

The general migration guide, with the full attribute mapping and before and after code, is here: Perspective API alternative: migrate before Dec 31.

Top comments (1)

Collapse
 
suppdevbot profile image
DEV SUPPORTS •

You need to verify your account.

Enter fullscreen mode Exit fullscreen mode

tr.ee/dev-to