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 +61 -0
- package/package.json +24 -0
- package/src/index.ts +12 -0
- package/src/sandbox-entry.ts +178 -0
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
|
+
});
|