Skip to content

Commit f1e2f6d

Browse files
authored
Merge pull request #492 from PretendoNetwork/feat/auto-mod
Add basic automod functionality
2 parents 7e0a19d + a0b4233 commit f1e2f6d

28 files changed

Lines changed: 1146 additions & 14 deletions

File tree

apps/juxtaposition-ui/src/assets/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
"none": "No Friend Requests"
100100
},
101101
"new_post": {
102+
"automod_error": "Your post contains text that is not allowed on Juxtaposition. For more information, please visit https://preten.do/juxt-rules",
102103
"new_post_short": "Post",
103104
"post_to": "Post to {{user}}",
104105
"swearing": "Post cannot contain explicit language.",
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Schema, model } from 'mongoose';
2+
import type { HydratedDocument } from 'mongoose';
3+
4+
export const automodAction = ['blocked', 'logged'] as const;
5+
export type AutomodAction = (typeof automodAction)[number];
6+
7+
export type AutomodLog = {
8+
rule_id: string;
9+
created_at: Date;
10+
author: number;
11+
action: AutomodAction;
12+
post_id: string | null;
13+
post_content_body: string | null;
14+
} & Document;
15+
16+
export type HydratedAutomodLogDocument = HydratedDocument<AutomodLog>;
17+
18+
export const automodLogSchema = new Schema<AutomodLog>({
19+
rule_id: {
20+
type: String,
21+
required: true
22+
},
23+
created_at: {
24+
type: Date,
25+
required: true
26+
},
27+
action: {
28+
type: String,
29+
enum: automodAction,
30+
required: true
31+
},
32+
author: {
33+
type: Number,
34+
required: true
35+
},
36+
post_id: {
37+
type: String,
38+
required: false
39+
},
40+
post_content_body: {
41+
type: String,
42+
required: false
43+
}
44+
});
45+
46+
export const AutomodLog = model('AutomodLog', automodLogSchema);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { Schema, model } from 'mongoose';
2+
import type { HydratedDocument } from 'mongoose';
3+
4+
export const automodRuleType = ['keyword'] as const;
5+
export type AutomodRuleType = (typeof automodRuleType)[number];
6+
7+
export const automodRuleMode = ['block', 'log'] as const;
8+
export type AutomodRuleMode = (typeof automodRuleMode)[number];
9+
10+
export type AutomodKeywordSettings = {
11+
keywords: string[];
12+
} & Document;
13+
14+
export type AutomodRule = {
15+
enabled: boolean;
16+
title: string;
17+
description: string | null;
18+
type: AutomodRuleType;
19+
mode: AutomodRuleMode;
20+
keyword_settings: {
21+
keywords: string[];
22+
};
23+
} & Document;
24+
25+
export type HydratedAutomodRuleDocument = HydratedDocument<AutomodRule>;
26+
27+
export const automodRuleKeywordSettingsSchema = new Schema<AutomodKeywordSettings>({
28+
keywords: {
29+
type: [String],
30+
required: true
31+
}
32+
});
33+
34+
export const automodRuleSchema = new Schema<AutomodRule>({
35+
enabled: {
36+
type: Boolean,
37+
required: true
38+
},
39+
title: {
40+
type: String,
41+
required: true
42+
},
43+
description: String,
44+
type: {
45+
type: String,
46+
enum: automodRuleType,
47+
required: true
48+
},
49+
mode: {
50+
type: String,
51+
enum: automodRuleMode,
52+
required: true
53+
},
54+
keyword_settings: {
55+
type: automodRuleKeywordSettingsSchema,
56+
required: false
57+
}
58+
});
59+
60+
export const AutomodRule = model('AutomodRule', automodRuleSchema);

apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const upload = multer({ storage: storage });
2727
export const adminRouter = express.Router();
2828

2929
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -- Too difficult to type
30-
const onOffSchema = () => z.enum(['on', 'off']).default('off').transform(v => v === 'on' ? 1 : 0);
30+
export const onOffSchema = () => z.enum(['on', 'off']).default('off').transform(v => v === 'on' ? 1 : 0);
3131

3232
adminRouter.get('/posts', async function (req, res) {
3333
if (!res.locals.moderator) {
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import express from 'express';
2+
import { z } from 'zod';
3+
import { parseReq } from '@/services/juxt-web/routes/routeUtils';
4+
import { WebAutomodLogListView } from '@/services/juxt-web/views/web/admin/automodLogListView';
5+
import { automodRuleMode, automodRuleType } from '@/models/automodRules';
6+
import { WebAutomodRuleListView } from '@/services/juxt-web/views/web/admin/automodRuleListView';
7+
import { WebAutomodRuleCreateView } from '@/services/juxt-web/views/web/admin/automodRuleCreateView';
8+
import { onOffSchema } from '@/services/juxt-web/routes/admin/admin';
9+
import type { AutomodRuleListViewProps } from '@/services/juxt-web/views/web/admin/automodRuleListView';
10+
import type { AutomodLogListViewProps } from '@/services/juxt-web/views/web/admin/automodLogListView';
11+
12+
export const adminAutomodRouter = express.Router();
13+
14+
adminAutomodRouter.get('/automod', async function (req, res) {
15+
if (!res.locals.moderator) {
16+
return res.redirect('/titles/show');
17+
}
18+
19+
const { query } = parseReq(req, {
20+
query: z.object({
21+
action: z.enum(['blocked', 'logged']).optional(),
22+
page: z.coerce.number().default(0)
23+
})
24+
});
25+
26+
const limit = 100;
27+
const offset = query.page * limit;
28+
const { data: logPage } = await req.api.admin.automodLogs.list({
29+
action: query.action,
30+
offset,
31+
limit
32+
});
33+
const hasNextPage = offset + limit < logPage.total;
34+
35+
const props: AutomodLogListViewProps = {
36+
items: logPage.items,
37+
total: logPage.total,
38+
page: query.page,
39+
hasNextPage
40+
};
41+
42+
return res.jsxForDirectory({
43+
web: <WebAutomodLogListView {...props} />
44+
});
45+
});
46+
47+
adminAutomodRouter.get('/automod/rules', async function (req, res) {
48+
if (!res.locals.moderator) {
49+
return res.redirect('/titles/show');
50+
}
51+
52+
const { query } = parseReq(req, {
53+
query: z.object({
54+
page: z.coerce.number().default(0)
55+
})
56+
});
57+
58+
const limit = 50;
59+
const offset = query.page * limit;
60+
const { data: rulePage } = await req.api.admin.automodRules.list({
61+
offset,
62+
limit
63+
});
64+
const hasNextPage = offset + limit < rulePage.total;
65+
66+
const props: AutomodRuleListViewProps = {
67+
items: rulePage.items,
68+
total: rulePage.total,
69+
page: query.page,
70+
hasNextPage,
71+
canEdit: res.locals.developer
72+
};
73+
74+
return res.jsxForDirectory({
75+
web: <WebAutomodRuleListView {...props} />
76+
});
77+
});
78+
79+
adminAutomodRouter.get('/automod/rules/create', async function (req, res) {
80+
if (!res.locals.developer) {
81+
return res.redirect('/titles/show');
82+
}
83+
84+
return res.jsxForDirectory({
85+
web: <WebAutomodRuleCreateView />
86+
});
87+
});
88+
89+
adminAutomodRouter.post('/automod/rules/create', async function (req, res) {
90+
const { body } = parseReq(req, {
91+
body: z.object({
92+
title: z.string().min(1),
93+
type: z.enum(automodRuleType),
94+
mode: z.enum(automodRuleMode)
95+
})
96+
});
97+
98+
await req.api.admin.automodRules.create({
99+
mode: body.mode,
100+
type: body.type,
101+
title: body.title
102+
});
103+
104+
res.redirect('/admin/automod/rules');
105+
});
106+
107+
adminAutomodRouter.post('/automod/rules/:id/update', async function (req, res) {
108+
const { body, params } = parseReq(req, {
109+
params: z.object({
110+
id: z.string()
111+
}),
112+
body: z.object({
113+
title: z.string().min(1),
114+
description: z.string(),
115+
type: z.enum(automodRuleType),
116+
mode: z.enum(automodRuleMode),
117+
enabled: onOffSchema(),
118+
keywordSettingsKeywords: z.string().default('')
119+
})
120+
});
121+
122+
const keywords = body.keywordSettingsKeywords.split('\n').map(v => v.trim()).filter(v => v.length > 0);
123+
124+
await req.api.admin.automodRules.update({
125+
id: params.id,
126+
mode: body.mode,
127+
type: body.type,
128+
title: body.title,
129+
description: body.description,
130+
enabled: !!body.enabled,
131+
settings: {
132+
keyword: body.type === 'keyword'
133+
? {
134+
keywords: keywords
135+
}
136+
: undefined
137+
}
138+
});
139+
140+
res.redirect('/admin/automod/rules');
141+
});
142+
143+
adminAutomodRouter.post('/automod/rules/:id/delete', async function (req, res) {
144+
const { params } = parseReq(req, {
145+
params: z.object({
146+
id: z.string()
147+
})
148+
});
149+
150+
await req.api.admin.automodRules.delete({
151+
id: params.id
152+
});
153+
154+
res.redirect('/admin/automod/rules');
155+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Router } from 'express';
2+
import { adminRouter } from '@/services/juxt-web/routes/admin/admin';
3+
import { adminAutomodRouter } from '@/services/juxt-web/routes/admin/adminAutomod';
4+
5+
export const baseAdminRouter = Router();
6+
7+
baseAdminRouter.use(adminRouter);
8+
baseAdminRouter.use(adminAutomodRouter);

apps/juxtaposition-ui/src/services/juxt-web/routes/console/communities.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type { CommunityViewProps } from '@/services/juxt-web/views/web/community
2525
import type { SubCommunityViewProps } from '@/services/juxt-web/views/portal/subCommunityView';
2626
import type { CommunityListViewProps, CommunityOverviewViewProps } from '@/services/juxt-web/views/web/communityListView';
2727
import type { Post } from '@/api/generated';
28+
import type { NewPostViewProps } from '@/services/juxt-web/views/web/newPostView';
2829

2930
const upload = multer({ dest: 'uploads/' });
3031
export const communitiesRouter = express.Router();
@@ -129,9 +130,12 @@ communitiesRouter.get('/:communityID/related', async function (req, res) {
129130
});
130131

131132
communitiesRouter.get('/:communityID/create', async function (req, res) {
132-
const { params, auth } = parseReq(req, {
133+
const { params, query, auth } = parseReq(req, {
133134
params: z.object({
134135
communityID: z.string()
136+
}),
137+
query: z.object({
138+
'error-text': z.string().optional()
135139
})
136140
});
137141

@@ -142,13 +146,14 @@ communitiesRouter.get('/:communityID/create', async function (req, res) {
142146

143147
const shotMode = getShotMode(community, auth().paramPackData);
144148

145-
const props = {
149+
const props: NewPostViewProps = {
146150
id: community.olive_community_id,
147151
name: community.name,
148152
url: `/posts/new`,
149153
show: 'post',
150154
shotMode,
151-
community
155+
community,
156+
errorText: query['error-text']
152157
};
153158
res.jsxForDirectory({
154159
ctr: <CtrNewPostPage {...props} />,

0 commit comments

Comments
 (0)