Skip to content

Commit 6a6fb79

Browse files
authored
Fix a couple of long-standing bugs in job board validation logic (#429)
* Fix job post parsing when tags + description are on the same line * Fix a source of random unhelpful messages in private threads * Fix busted validation rules * Apparently fix edited post detection * Remove posts that are edited out of compliance with formatting rules * Simplify thread/channel detection logic This caused a heccin big bug on first merge unforunately. It was too complex, too difficult to understand, too easy to introduce weirdness. I tested this pretty comprehensively, testing message send in channel/thread/private thread, as well as editing messages in all those contexts. * Add emoji tests, tune the sensitivity down
1 parent d740d2c commit 6a6fb79

File tree

9 files changed

+131
-54
lines changed

9 files changed

+131
-54
lines changed

src/features/jobs-moderation.ts

Lines changed: 31 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -106,75 +106,73 @@ const jobModeration = async (bot: Client) => {
106106
await deleteAgedPosts();
107107

108108
bot.on("messageCreate", async (message) => {
109-
const { channel } = message;
110-
if (
111-
message.author.bot ||
112-
message.channelId !== CHANNELS.jobBoard ||
113-
(channel.isThread() && channel.parentId !== CHANNELS.jobBoard) ||
114-
// Don't treat newly fetched old messages as new posts
115-
differenceInHours(new Date(), message.createdAt) >= 1
116-
) {
109+
// Bail if it's a bot or staff message
110+
if (message.author.bot || isStaff(message.member)) {
117111
return;
118112
}
113+
const { channel } = message;
119114
// If this is an existing enforcement thread, process the through a "REPL"
120115
// that lets people test messages against the rules
121116
if (
122117
channel.type === ChannelType.PrivateThread &&
123-
channel.ownerId === bot.user?.id &&
124-
channel.parentId === CHANNELS.jobBoard
118+
channel.parentId === CHANNELS.jobBoard &&
119+
channel.ownerId === bot.user?.id
125120
) {
126-
validationRepl(message);
121+
await validationRepl(message);
127122
return;
128123
}
129-
// If this is a staff member, bail early
130-
if (channel.type !== ChannelType.GuildText || isStaff(message.member)) {
124+
// Bail if this isn't #job-board
125+
if (
126+
channel.type !== ChannelType.GuildText ||
127+
message.channelId !== CHANNELS.jobBoard
128+
) {
131129
return;
132130
}
131+
133132
const posts = parseContent(message.content);
134133
const errors = validate(posts, message);
135134
console.log(
136135
`[DEBUG] validating new job post from @${
137136
message.author.username
138-
}, errors: [${JSON.stringify(errors)}]`,
137+
}, errors: ${JSON.stringify(errors)}`,
139138
);
140139
if (errors) {
141140
await handleErrors(channel, message, errors);
142141
}
143142
});
144143

145-
bot.on("messageUpdate", async (_, newMessage) => {
146-
const { channel } = newMessage;
147-
if (newMessage.author?.bot) {
148-
return;
149-
}
150-
if (channel.type === ChannelType.PrivateThread) {
151-
validationRepl(await newMessage.fetch());
152-
return;
153-
}
144+
bot.on("messageUpdate", async (_, message) => {
145+
const { channel } = message;
154146
if (
155-
newMessage.channelId !== CHANNELS.jobBoard ||
147+
message.author?.bot ||
148+
message.channelId !== CHANNELS.jobBoard ||
156149
channel.type !== ChannelType.GuildText ||
157-
isStaff(newMessage.member)
150+
isStaff(message.member)
158151
) {
159152
return;
160153
}
161-
const message = await newMessage.fetch();
162-
const posts = parseContent(message.content);
163-
// Don't validate hiring posts
164-
if (posts.every((p) => p.tags.includes(PostType.hiring))) {
165-
return;
154+
if (message.partial) {
155+
message = await message.fetch();
166156
}
157+
const posts = parseContent(message.content);
167158
// You can't post too frequently when editing a message, so filter those out
168159
const errors = validate(posts, message).filter(
169160
(e) => e.type !== POST_FAILURE_REASONS.tooFrequent,
170161
);
171162

172163
if (errors) {
164+
const isRecentEdit =
165+
differenceInMinutes(new Date(), message.createdAt) < REPOST_THRESHOLD;
166+
errors.unshift({
167+
type: POST_FAILURE_REASONS.circumventedRules,
168+
recentEdit: isRecentEdit,
169+
});
170+
if (isRecentEdit) {
171+
removeSpecificJob(message);
172+
}
173+
await handleErrors(channel, message, errors);
173174
if (posts.some((p) => p.tags.includes(PostType.forHire))) {
174175
reportUser({ reason: ReportReasons.jobCircumvent, message });
175-
// await newMessage.delete();
176-
} else {
177-
await handleErrors(channel, message, errors);
178176
}
179177
}
180178
});

src/features/jobs-moderation/job-mod-helpers.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,12 @@ import {
2929
PostFailures,
3030
PostType,
3131
PostFailureLinkRequired,
32+
CircumventedRules,
3233
} from "../../types/jobs-moderation";
3334

34-
export class RuleViolation extends Error {
35-
reasons: POST_FAILURE_REASONS[];
36-
constructor(reasons: POST_FAILURE_REASONS[]) {
37-
super("Job Mod Rule violation");
38-
this.reasons = reasons;
39-
}
40-
}
41-
35+
export const failedCircumventedRules = (
36+
e: PostFailures,
37+
): e is CircumventedRules => e.type === POST_FAILURE_REASONS.circumventedRules;
4238
export const failedMissingType = (
4339
e: PostFailures,
4440
): e is PostFailureMissingType => e.type === POST_FAILURE_REASONS.missingType;
@@ -268,7 +264,7 @@ export const removeSpecificJob = (message: Message) => {
268264
const index = jobBoardMessageCache.hiring.findIndex(
269265
(m) => m.message.id === message.id,
270266
);
271-
if (index) {
267+
if (index !== -1) {
272268
jobBoardMessageCache.hiring.splice(index);
273269
} else
274270
jobBoardMessageCache.forHire.splice(

src/features/jobs-moderation/parse-content.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,28 @@ many long lines of text`,
108108
expect(parsed[0]).toMatchObject({ tags: ["hiring"], description: "" });
109109
});
110110

111+
it("correctly pulls description off tags line", () => {
112+
let parsed = parseContent(`[hiring]Lorem ipsum dolor sit amet`);
113+
expect(parsed[0]).toMatchObject({
114+
tags: ["hiring"],
115+
description: "Lorem ipsum dolor sit amet",
116+
});
117+
118+
parsed = parseContent(`[hiring][remote][visa]Lorem ipsum dolor sit amet`);
119+
expect(parsed[0]).toMatchObject({
120+
tags: ["hiring", "remote", "visa"],
121+
description: "Lorem ipsum dolor sit amet",
122+
});
123+
124+
parsed = parseContent(
125+
`[hiring] [remote] [visa] Lorem ipsum dolor sit amet`,
126+
);
127+
expect(parsed[0]).toMatchObject({
128+
tags: ["hiring", "remote", "visa"],
129+
description: "Lorem ipsum dolor sit amet",
130+
});
131+
});
132+
111133
// Disable this, not relevant right now. Also broken as of May '23
112134
it.skip("parses contact", () => {
113135
const makePost = (contact: string) => `|

src/features/jobs-moderation/parse-content.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,31 @@ export const parseTags = (tags: string) => {
2525
.filter((tag) => tag !== "");
2626
};
2727

28-
export const parseContent = (inputString: string): Post[] => {
28+
const splitTagsFromDescription = (
29+
inputString: string,
30+
): { heading: string; body: string[] } => {
2931
const [tagsLine, ...lines] = inputString.trim().split("\n");
32+
33+
if (tagsLine.includes("[")) {
34+
const cleanedTags = tagsLine.replace(/\]\w+\[/, "][");
35+
const match = cleanedTags.match(/(.*)\](.*)/);
36+
const trailingText = match?.[2] || "";
37+
lines.unshift(trailingText.trim());
38+
return { heading: match?.[1] || "", body: lines };
39+
}
40+
return { heading: tagsLine, body: lines };
41+
};
42+
43+
export const parseContent = (inputString: string): Post[] => {
44+
const { heading, body } = splitTagsFromDescription(inputString);
45+
// TODO: Replace above .split() with some more logic around detecting tags
46+
// If |, treat the complete line as tags
47+
// if [], check for trailing text with no wrapper and add it to the description
48+
3049
return [
3150
{
32-
tags: parseTags(tagsLine),
33-
description: lines.reduce((description, line) => {
51+
tags: parseTags(heading),
52+
description: body.reduce((description, line) => {
3453
if (line === "") {
3554
return description;
3655
}

src/features/jobs-moderation/validate.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,45 @@
11
import { describe, expect, it } from "vitest";
22
import { PostType, POST_FAILURE_REASONS } from "../../types/jobs-moderation";
3-
import { links } from "./validate";
3+
import { links, formatting } from "./validate";
44

55
const makePost = (type: PostType, description: string) => [
66
{ tags: [type], description },
77
];
88

9+
describe("emoji", () => {
10+
it("isn't too crazy about emoji", () => {
11+
const noFailure = [
12+
"for some role and stuff\nDM me to apply ✨",
13+
"for some role and stuff\nDM me to apply ✨",
14+
"👉 stuff: some more details afterwards and whatever shenanigans\n👉 stuff: some more details afterwards and whatever shenanigans\n👉 stuff: some more details afterwards and whatever shenanigans\n👉 stuff: some more details afterwards and whatever shenanigans\n👉 stuff: some more details afterwards and whatever shenanigans\n",
15+
];
16+
for (const content of noFailure) {
17+
expect(
18+
formatting(
19+
makePost(PostType.forHire, content),
20+
// @ts-expect-error testing override
21+
{ content: `[forhire]\n${content}` },
22+
),
23+
).not.toContainEqual({ type: POST_FAILURE_REASONS.tooManyEmojis });
24+
}
25+
});
26+
it("stops obnoxious emoji usage", () => {
27+
const noFailure = [
28+
"for ✨ some role and stuff\nDM ✨ me ✨ to ✨ apply ✨",
29+
"for some role and stuff\nDM me to apply ✨✨✨✨✨✨",
30+
"👉 stuff: some more ✨✨ details afterwards and whatever shenanigans\n👉 stuff: some more ✨✨ details afterwards and whatever shenanigans\n👉 stuff: some more ✨✨ details afterwards and whatever shenanigans\n👉 stuff: some more ✨✨ details afterwards and whatever shenanigans\n👉 stuff: some more ✨✨ details afterwards and whatever shenanigans\n",
31+
];
32+
for (const content of noFailure) {
33+
expect(
34+
formatting(
35+
makePost(PostType.forHire, content),
36+
// @ts-expect-error testing override
37+
{ content: `[forhire]\n${content}` },
38+
),
39+
).toContainEqual({ type: POST_FAILURE_REASONS.tooManyEmojis });
40+
}
41+
});
42+
});
943
describe("links", () => {
1044
it("requires links", () => {
1145
expect(

src/features/jobs-moderation/validate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export const formatting: JobPostValidator = (posts, message) => {
5353
posts.forEach((post) => {
5454
// If > 1 in 150 chars is an emoji
5555
const emojiCount = extractEmoji(post.description).length;
56-
if (emojiCount / post.description.length > 1 / 150) {
56+
if (emojiCount / post.description.length > 1 / 30) {
5757
errors.push({ type: POST_FAILURE_REASONS.tooManyEmojis });
5858
}
5959
const lineCount = countLines(post.description.trim());

src/features/jobs-moderation/validation-messages.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import {
2+
CircumventedRules,
23
POST_FAILURE_REASONS,
34
PostFailures,
45
PostFailureTooFrequent,
56
PostFailureTooLong,
67
PostFailureTooManyLines,
78
} from "../../types/jobs-moderation";
89
import {
10+
failedCircumventedRules,
911
failedMissingType,
1012
failedReplyOrMention,
1113
failedTooManyLines,
@@ -18,6 +20,8 @@ import {
1820
} from "./job-mod-helpers";
1921

2022
const ValidationMessages = {
23+
[POST_FAILURE_REASONS.circumventedRules]: (e: CircumventedRules) =>
24+
`Your message was removed after you edited it so that it no longer complies with our formatting rules. ${e.recentEdit ? "Please re-post." : ""}`,
2125
[POST_FAILURE_REASONS.missingType]:
2226
"Your post does not include our required `[HIRING]` or `[FOR HIRE]` tag. Make sure the first line of your post includes `[HIRING]` if you’re looking to pay someone for their work, and `[FOR HIRE]` if you’re offering your services.",
2327
[POST_FAILURE_REASONS.inconsistentType]:
@@ -33,10 +37,13 @@ const ValidationMessages = {
3337
[POST_FAILURE_REASONS.tooFrequent]: (e: PostFailureTooFrequent) =>
3438
`You’re posting too frequently. You last posted ${e.lastSent} days ago, please wait at least 7 days.`,
3539
[POST_FAILURE_REASONS.replyOrMention]:
36-
"Messages in this channel may not be replies or include @-mentions of users, to ensure the channel isn’t being used to discuss postings.",
40+
"Messages in this channel may not be replies or include @-mentions of users due to a history of posters incorrectly attempting to 'apply' by replying within a thread or reply.",
3741
};
3842

3943
export const getValidationMessage = (reason: PostFailures): string => {
44+
if (failedCircumventedRules(reason)) {
45+
return ValidationMessages[reason.type](reason);
46+
}
4047
if (failedMissingType(reason)) {
4148
return ValidationMessages[reason.type];
4249
}

src/index.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,7 @@ export const bot = new discord.Client({
4848
IntentsBitField.Flags.DirectMessageReactions,
4949
IntentsBitField.Flags.MessageContent,
5050
],
51-
partials: [
52-
Partials.Channel,
53-
Partials.Message,
54-
Partials.Reaction,
55-
Partials.GuildMember,
56-
],
51+
partials: [Partials.Channel, Partials.Reaction, Partials.GuildMember],
5752
});
5853

5954
registerCommand(resetJobCacheCommand);

src/types/jobs-moderation.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,16 @@ export const enum POST_FAILURE_REASONS {
2828
tooManyGaps = "tooManyGaps",
2929
tooFrequent = "tooFrequent",
3030
replyOrMention = "replyOrMention",
31+
circumventedRules = "circumventedRules",
3132
// invalidContact = 'invalidContact',
3233
// unknownLocation = 'unknownLocation',
3334
// invalidPostType = 'invalidPostType',
3435
}
3536

37+
export interface CircumventedRules {
38+
type: POST_FAILURE_REASONS.circumventedRules;
39+
recentEdit: boolean;
40+
}
3641
export interface PostFailureMissingType {
3742
type: POST_FAILURE_REASONS.missingType;
3843
}
@@ -64,6 +69,7 @@ export interface PostFailureReplyOrMention {
6469
type: POST_FAILURE_REASONS.replyOrMention;
6570
}
6671
export type PostFailures =
72+
| CircumventedRules
6773
| PostFailureMissingType
6874
| PostFailureInconsistentType
6975
| PostFailureTooFrequent

0 commit comments

Comments
 (0)