emdash-plugin-ai-comment-moderation 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # emdash-plugin-ai-comment-moderation
2
+
3
+ An EmDash plugin for comment moderation using Cloudflare Workers AI.
4
+
5
+ ## Features
6
+
7
+ - **Spam & Toxicity Detection**: Analyzes comments to identify spam and toxic content.
8
+ - **KV-Driven Configuration**: Settings are stored in EmDash's Key-Value store. No environment variables required.
9
+ - **Fail-safe Fallback**: Replicates EmDash's built-in 4-step moderation logic if the Cloudflare AI is unavailable or unconfigured.
10
+
11
+ ## Installation
12
+
13
+ Add the plugin to your `astro-blog` (or other EmDash-powered project):
14
+
15
+ ```json
16
+ "dependencies": {
17
+ "emdash-plugin-ai-comment-moderation": "file:../emdash-plugin-ai-comment-moderation"
18
+ }
19
+ ```
20
+
21
+ In your `astro.config.mjs`:
22
+
23
+ ```javascript
24
+ import { emdash } from "emdash/astro";
25
+ import { aiCommentModerationPlugin } from "emdash-plugin-ai-comment-moderation";
26
+
27
+ export default defineConfig({
28
+ // ...
29
+ integrations: [
30
+ emdash({
31
+ plugins: [aiCommentModerationPlugin()],
32
+ // ...
33
+ })
34
+ ]
35
+ });
36
+ ```
37
+
38
+ ## Configuration
39
+
40
+ Seed or set the following keys in your plugin's KV store:
41
+
42
+ - `settings:accountId`: Your Cloudflare Account ID.
43
+ - `settings:apiToken`: Your Cloudflare API Token (with access to Workers AI).
44
+ - `settings:model`: (Optional) The Cloudflare Workers AI model to run. Defaults to `@cf/meta/llama-3.1-8b-instruct` (seeded during installation).
45
+
46
+ ## Moderation Logic
47
+
48
+ 1. **Empty check**: Empty comments are automatically held as `pending`.
49
+ 2. **AI Moderation**:
50
+ - Queries Cloudflare Workers AI using the configured model.
51
+ - Parses the JSON response for `{ spam: boolean, toxic: boolean }`.
52
+ - Decisions:
53
+ - `spam=true`: Marked as `spam`.
54
+ - `toxic=true` (and not spam): Marked as `pending`.
55
+ - Otherwise: Marked as `approved`.
56
+ - The raw decision (e.g. `AI: spam=false, toxic=false`) is saved in the moderation `reason`.
57
+ 3. **Fallback**: If AI credentials are not configured, the API fails, or parsing fails, the plugin falls back to EmDash's default 4-step logic:
58
+ - Auto-approve if `commentsAutoApproveUsers` is enabled and user is authenticated.
59
+ - Auto-approve if collection moderation is set to `"none"`.
60
+ - Auto-approve if collection moderation is `"first_time"` and commenter is returning (has previous approved comments).
61
+ - Otherwise, holds as `pending`.
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "emdash-plugin-ai-comment-moderation",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.ts",
7
+ "./sandbox": "./src/sandbox-entry.ts"
8
+ },
9
+ "files": [
10
+ "src/",
11
+ "README.md"
12
+ ],
13
+ "scripts": {
14
+ "test": "vitest run"
15
+ },
16
+ "peerDependencies": {
17
+ "emdash": "^0.9.0"
18
+ },
19
+ "devDependencies": {
20
+ "emdash": "^0.9.0",
21
+ "typescript": "^5.5.3",
22
+ "vitest": "^4.1.7"
23
+ }
24
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { PluginDescriptor } from "emdash";
2
+
3
+ export function aiCommentModerationPlugin(): PluginDescriptor {
4
+ return {
5
+ id: "ai-comment-moderation",
6
+ version: "1.0.0",
7
+ format: "standard",
8
+ entrypoint: "emdash-plugin-ai-comment-moderation/sandbox",
9
+ capabilities: ["network:request", "users:read"],
10
+ allowedHosts: ["api.cloudflare.com"]
11
+ };
12
+ }
@@ -0,0 +1,178 @@
1
+ import {
2
+ definePlugin,
3
+ type CommentModerateEvent,
4
+ type ModerationDecision,
5
+ type PluginContext
6
+ } from "emdash";
7
+
8
+ const DEFAULT_MODEL = "@cf/meta/llama-3.1-8b-instruct";
9
+ const MAX_BODY_LENGTH = 10_000;
10
+
11
+ type ModerationOutput = {
12
+ spam: boolean;
13
+ toxic: boolean;
14
+ };
15
+
16
+ type CloudflareAIResponse = {
17
+ success?: boolean;
18
+ result?: {
19
+ response?: string;
20
+ };
21
+ };
22
+
23
+ export function parseModerationResponse(raw: string): ModerationOutput | null {
24
+ try {
25
+ const parsed = JSON.parse(raw) as Partial<ModerationOutput>;
26
+ if (typeof parsed.spam === "boolean" && typeof parsed.toxic === "boolean") {
27
+ return { spam: parsed.spam, toxic: parsed.toxic };
28
+ }
29
+ } catch {
30
+ const fencedJson = raw.match(/\{[\s\S]*?\}/);
31
+ if (fencedJson) {
32
+ try {
33
+ const parsed = JSON.parse(fencedJson[0]) as Partial<ModerationOutput>;
34
+ if (typeof parsed.spam === "boolean" && typeof parsed.toxic === "boolean") {
35
+ return { spam: parsed.spam, toxic: parsed.toxic };
36
+ }
37
+ } catch {
38
+ // ignore parsing failure
39
+ }
40
+ }
41
+ }
42
+ return null;
43
+ }
44
+
45
+ export function fallbackModeration(event: CommentModerateEvent): ModerationDecision {
46
+ const { comment, collectionSettings, priorApprovedCount } = event;
47
+
48
+ // 1. Auto-approve authenticated CMS users if configured
49
+ if (collectionSettings.commentsAutoApproveUsers && comment.authorUserId) {
50
+ return { status: "approved", reason: "Authenticated CMS user" };
51
+ }
52
+
53
+ // 2. If moderation is "none" → approved
54
+ if (collectionSettings.commentsModeration === "none") {
55
+ return { status: "approved", reason: "Moderation disabled" };
56
+ }
57
+
58
+ // 3. If moderation is "first_time" and returning commenter → approved
59
+ if (collectionSettings.commentsModeration === "first_time" && priorApprovedCount > 0) {
60
+ return { status: "approved", reason: "Returning commenter" };
61
+ }
62
+
63
+ // 4. Otherwise → pending
64
+ return { status: "pending", reason: "Held for review" };
65
+ }
66
+
67
+ async function moderateWithAI(
68
+ body: string,
69
+ ctx: PluginContext,
70
+ event: CommentModerateEvent
71
+ ): Promise<ModerationDecision> {
72
+ const accountId = await ctx.kv.get<string>("settings:accountId");
73
+ const apiToken = await ctx.kv.get<string>("settings:apiToken");
74
+ const model = (await ctx.kv.get<string>("settings:model")) || DEFAULT_MODEL;
75
+
76
+ if (!accountId || !apiToken) {
77
+ ctx.log.warn("Cloudflare AI accountId or apiToken not configured. Falling back to default moderator.");
78
+ return fallbackModeration(event);
79
+ }
80
+
81
+ if (!ctx.http) {
82
+ ctx.log.error("HTTP access capability not available in plugin context. Falling back to default moderator.");
83
+ return fallbackModeration(event);
84
+ }
85
+
86
+ try {
87
+ const truncatedBody = body.length > MAX_BODY_LENGTH ? body.substring(0, MAX_BODY_LENGTH) : body;
88
+
89
+ const response = await ctx.http.fetch(
90
+ `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${model}`,
91
+ {
92
+ method: "POST",
93
+ headers: {
94
+ Authorization: `Bearer ${apiToken}`,
95
+ "Content-Type": "application/json"
96
+ },
97
+ body: JSON.stringify({
98
+ messages: [
99
+ {
100
+ role: "system",
101
+ content:
102
+ 'You are a moderation bot. Analyze the comment and decide if it is spam or toxic. Return strictly JSON: {"spam": boolean, "toxic": boolean}.'
103
+ },
104
+ {
105
+ role: "user",
106
+ content: truncatedBody
107
+ }
108
+ ]
109
+ })
110
+ }
111
+ );
112
+
113
+ if (!response.ok) {
114
+ ctx.log.warn(`AI moderation request failed with status ${response.status}. Falling back to default moderator.`);
115
+ return fallbackModeration(event);
116
+ }
117
+
118
+ const payload = (await response.json()) as CloudflareAIResponse;
119
+ const rawOutput = payload?.result?.response;
120
+ if (!rawOutput) {
121
+ ctx.log.warn("Empty response from Cloudflare AI. Falling back to default moderator.");
122
+ return fallbackModeration(event);
123
+ }
124
+
125
+ const aiResult = parseModerationResponse(rawOutput);
126
+ if (!aiResult) {
127
+ ctx.log.warn("Failed to parse Cloudflare AI moderation response. Falling back to default moderator.");
128
+ return fallbackModeration(event);
129
+ }
130
+
131
+ const reason = `AI: spam=${aiResult.spam}, toxic=${aiResult.toxic}`;
132
+ if (aiResult.spam) {
133
+ return { status: "spam", reason };
134
+ }
135
+ if (aiResult.toxic) {
136
+ return { status: "pending", reason };
137
+ }
138
+ return { status: "approved", reason };
139
+ } catch (error) {
140
+ ctx.log.error("AI moderation request encountered an error. Falling back to default moderator.", {
141
+ error: error instanceof Error ? error.message : String(error)
142
+ });
143
+ return fallbackModeration(event);
144
+ }
145
+ }
146
+
147
+ export default definePlugin({
148
+ hooks: {
149
+ "plugin:install": {
150
+ handler: async (_event: Record<string, never>, ctx: PluginContext) => {
151
+ const existingModel = await ctx.kv.get<string>("settings:model");
152
+ if (!existingModel) {
153
+ await ctx.kv.set("settings:model", DEFAULT_MODEL);
154
+ }
155
+ }
156
+ },
157
+ "comment:moderate": {
158
+ exclusive: true,
159
+ errorPolicy: "abort",
160
+ timeout: 15000,
161
+ handler: async (event: CommentModerateEvent, ctx: PluginContext): Promise<ModerationDecision> => {
162
+ const body = event.comment.body?.trim();
163
+
164
+ if (!body) {
165
+ event.metadata.ai_moderation = { error: "Empty comment" };
166
+ return { status: "pending", reason: "AI: Empty comment" };
167
+ }
168
+
169
+ const decision = await moderateWithAI(body, ctx, event);
170
+ event.metadata.ai_moderation = {
171
+ decision: decision.status,
172
+ reason: decision.reason
173
+ };
174
+ return decision;
175
+ }
176
+ }
177
+ }
178
+ });