diff --git a/src/core/client/admin/components/ModerateCard/MarkersContainer.spec.tsx b/src/core/client/admin/components/ModerateCard/MarkersContainer.spec.tsx
index 3df1c8d8c..5ce58dfac 100644
--- a/src/core/client/admin/components/ModerateCard/MarkersContainer.spec.tsx
+++ b/src/core/client/admin/components/ModerateCard/MarkersContainer.spec.tsx
@@ -28,6 +28,7 @@ it("renders all markers", () => {
COMMENT_DETECTED_SUSPECT_WORD: 1,
COMMENT_REPORTED_OFFENSIVE: 2,
COMMENT_REPORTED_SPAM: 3,
+ COMMENT_DETECTED_REPEAT_POST: 1,
},
},
},
@@ -71,6 +72,7 @@ it("renders some markers", () => {
COMMENT_DETECTED_SUSPECT_WORD: 0,
COMMENT_REPORTED_OFFENSIVE: 2,
COMMENT_REPORTED_SPAM: 0,
+ COMMENT_DETECTED_REPEAT_POST: 0,
},
},
},
diff --git a/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx b/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx
index 43fdc8c7b..4143b8c0a 100644
--- a/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx
+++ b/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx
@@ -32,7 +32,7 @@ const markers: Array<
c =>
(c.status === "PREMOD" && (
- Pre-Mod
+ Pre-mod
)) ||
null,
@@ -48,7 +48,7 @@ const markers: Array<
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_BANNED_WORD && (
- Banned Word
+ Banned word
)) ||
null,
@@ -57,7 +57,7 @@ const markers: Array<
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SUSPECT_WORD && (
- Suspect Word
+ Suspect word
)) ||
@@ -66,7 +66,7 @@ const markers: Array<
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SPAM && (
- Spam Detected
+ Spam detected
)) ||
null,
@@ -78,11 +78,19 @@ const markers: Array<
)) ||
null,
+ c =>
+ (c.revision &&
+ c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_REPEAT_POST && (
+
+ Repeat comment
+
+ )) ||
+ null,
c =>
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_RECENT_HISTORY && (
- Recent History
+ Recent history
)) ||
null,
@@ -162,6 +170,7 @@ const enhanced = withFragmentContainer({
COMMENT_DETECTED_SUSPECT_WORD
COMMENT_REPORTED_OFFENSIVE
COMMENT_REPORTED_SPAM
+ COMMENT_DETECTED_REPEAT_POST
}
}
}
diff --git a/src/core/client/admin/components/ModerateCard/__snapshots__/MarkersContainer.spec.tsx.snap b/src/core/client/admin/components/ModerateCard/__snapshots__/MarkersContainer.spec.tsx.snap
index 53ec9f5c7..7d600f664 100644
--- a/src/core/client/admin/components/ModerateCard/__snapshots__/MarkersContainer.spec.tsx.snap
+++ b/src/core/client/admin/components/ModerateCard/__snapshots__/MarkersContainer.spec.tsx.snap
@@ -16,6 +16,7 @@ exports[`renders all markers 1`] = `
"COMMENT_DETECTED_BANNED_WORD": 1,
"COMMENT_DETECTED_LINKS": 1,
"COMMENT_DETECTED_RECENT_HISTORY": 1,
+ "COMMENT_DETECTED_REPEAT_POST": 1,
"COMMENT_DETECTED_SPAM": 1,
"COMMENT_DETECTED_SUSPECT_WORD": 1,
"COMMENT_DETECTED_TOXIC": 1,
@@ -54,7 +55,7 @@ exports[`renders all markers 1`] = `
- Pre-Mod
+ Pre-mod
- Banned Word
+ Banned word
- Suspect Word
+ Suspect word
- Spam Detected
+ Spam detected
+
+
+ Repeat comment
+
+
- Recent History
+ Recent history
- Pre-Mod
+ Pre-mod
- Banned Word
+ Banned word
- Recent History
+ Recent history
({
COMMENT_DETECTED_SUSPECT_WORD: 0,
COMMENT_REPORTED_OFFENSIVE: 0,
COMMENT_REPORTED_SPAM: 0,
+ COMMENT_DETECTED_REPEAT_POST: 0,
},
},
},
diff --git a/src/core/client/admin/test/moderate/__snapshots__/regularQueue.spec.tsx.snap b/src/core/client/admin/test/moderate/__snapshots__/regularQueue.spec.tsx.snap
index e150ac3f1..6c7327d41 100644
--- a/src/core/client/admin/test/moderate/__snapshots__/regularQueue.spec.tsx.snap
+++ b/src/core/client/admin/test/moderate/__snapshots__/regularQueue.spec.tsx.snap
@@ -1550,7 +1550,7 @@ exports[`renders reported queue with comments and load more 1`] = `
- Pre-Mod
+ Pre-mod
= {
PERSISTED_QUERY_NOT_FOUND: "error-persistedQueryNotFound",
RAW_QUERY_NOT_AUTHORIZED: "error-rawQueryNotAuthorized",
USER_ALREADY_PREMOD: "error-userAlreadyPremod",
+ REPEAT_POST: "error-repeatPost",
};
diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql
index 464ca2493..554b2bbbc 100644
--- a/src/core/server/graph/tenant/schema/schema.graphql
+++ b/src/core/server/graph/tenant/schema/schema.graphql
@@ -146,6 +146,11 @@ enum COMMENT_FLAG_DETECTED_REASON {
COMMENT_DETECTED_PREMOD_USER is used when a Comment author has been tagged as requiring premoderation
"""
COMMENT_DETECTED_PREMOD_USER
+
+ """
+ COMMENT_DETECTED_REPEAT_POST is used when a Comment's text exactly matches a previous recent comment from the same author
+ """
+ COMMENT_DETECTED_REPEAT_POST
}
"""
@@ -163,6 +168,7 @@ enum COMMENT_FLAG_REASON {
COMMENT_DETECTED_SUSPECT_WORD
COMMENT_DETECTED_RECENT_HISTORY
COMMENT_DETECTED_PREMOD_USER
+ COMMENT_DETECTED_REPEAT_POST
}
"""
@@ -198,6 +204,7 @@ type FlagReasonActionCounts {
COMMENT_DETECTED_SUSPECT_WORD: Int!
COMMENT_DETECTED_RECENT_HISTORY: Int!
COMMENT_DETECTED_PREMOD_USER: Int!
+ COMMENT_DETECTED_REPEAT_POST: Int!
}
type Flag {
diff --git a/src/core/server/locales/en-US/errors.ftl b/src/core/server/locales/en-US/errors.ftl
index baee0b902..ae0e998ff 100644
--- a/src/core/server/locales/en-US/errors.ftl
+++ b/src/core/server/locales/en-US/errors.ftl
@@ -57,3 +57,4 @@ error-passwordIncorrect = Incorrect password. Please try again.
error-usernameAlreadyUpdated = You may only change your username once every { framework-timeago-time }.
error-persistedQueryNotFound = The persisted query with ID { $id } was not found.
error-rawQueryNotAuthorized = You are not authorized to execute this query.
+error-repeatPost = Are you sure? This comment is very similar to your previous comment.
diff --git a/src/core/server/models/action/__snapshots__/comment.spec.ts.snap b/src/core/server/models/action/__snapshots__/comment.spec.ts.snap
index 7dd9099da..231b48750 100644
--- a/src/core/server/models/action/__snapshots__/comment.spec.ts.snap
+++ b/src/core/server/models/action/__snapshots__/comment.spec.ts.snap
@@ -21,6 +21,7 @@ Object {
"COMMENT_DETECTED_LINKS": 0,
"COMMENT_DETECTED_PREMOD_USER": 0,
"COMMENT_DETECTED_RECENT_HISTORY": 0,
+ "COMMENT_DETECTED_REPEAT_POST": 0,
"COMMENT_DETECTED_SPAM": 0,
"COMMENT_DETECTED_SUSPECT_WORD": 0,
"COMMENT_DETECTED_TOXIC": 0,
diff --git a/src/core/server/services/comments/comments.ts b/src/core/server/services/comments/comments.ts
index e1b212105..8e1abca98 100644
--- a/src/core/server/services/comments/comments.ts
+++ b/src/core/server/services/comments/comments.ts
@@ -49,7 +49,10 @@ import {
import { AugmentedRedis } from "coral-server/services/redis";
import { Request } from "coral-server/types/express";
-import { updateUserLastWroteCommentTimestamp } from "../users";
+import {
+ updateUserLastCommentID,
+ updateUserLastWroteCommentTimestamp,
+} from "../users";
import { addCommentActions, CreateAction } from "./actions";
import { calculateCounts, calculateCountsDiff } from "./moderation/counts";
import { PhaseResult, processForModeration } from "./pipeline";
@@ -186,6 +189,8 @@ export async function create(
now
);
+ await updateUserLastCommentID(redis, tenant, author, comment.id);
+
// Pull the revision out.
const revision = getLatestRevision(comment);
diff --git a/src/core/server/services/comments/pipeline/phases/index.ts b/src/core/server/services/comments/pipeline/phases/index.ts
index 9778e8f37..0895cb926 100644
--- a/src/core/server/services/comments/pipeline/phases/index.ts
+++ b/src/core/server/services/comments/pipeline/phases/index.ts
@@ -8,6 +8,7 @@ import { preModerate } from "./preModerate";
import { premodUser } from "./preModerateUser";
import { purify } from "./purify";
import { recentCommentHistory } from "./recentCommentHistory";
+import { repeatPost } from "./repeatPost";
import { spam } from "./spam";
import { staff } from "./staff";
import { storyClosed } from "./storyClosed";
@@ -25,6 +26,7 @@ export const moderationPhases: IntermediateModerationPhase[] = [
commentingDisabled,
linkify,
purify,
+ repeatPost,
wordList,
staff,
toxic,
diff --git a/src/core/server/services/comments/pipeline/phases/repeatPost.ts b/src/core/server/services/comments/pipeline/phases/repeatPost.ts
new file mode 100644
index 000000000..6bd39ed77
--- /dev/null
+++ b/src/core/server/services/comments/pipeline/phases/repeatPost.ts
@@ -0,0 +1,86 @@
+import { RepeatPostCommentError } from "coral-server/errors";
+import {
+ GQLCOMMENT_FLAG_REASON,
+ GQLCOMMENT_STATUS,
+} from "coral-server/graph/tenant/schema/__generated__/types";
+import logger from "coral-server/logger";
+import { ACTION_TYPE } from "coral-server/models/action/comment";
+import { getLatestRevision } from "coral-server/models/comment/helpers";
+import {
+ IntermediateModerationPhase,
+ IntermediatePhaseResult,
+} from "coral-server/services/comments/pipeline";
+import { retrieveUserLastComment } from "coral-server/services/users";
+
+export const repeatPost: IntermediateModerationPhase = async ({
+ story,
+ mongo,
+ tenant,
+ comment,
+ author,
+ req,
+ nudge,
+ redis,
+}): Promise => {
+ const log = logger.child(
+ {
+ tenantID: tenant.id,
+ },
+ true
+ );
+
+ if (!comment.body) {
+ return;
+ }
+
+ try {
+ log.trace("checking comment for repeat content");
+
+ const lastComment = await retrieveUserLastComment(
+ mongo,
+ redis,
+ tenant,
+ author
+ );
+
+ if (!lastComment) {
+ return;
+ }
+
+ const revision = getLatestRevision(lastComment);
+ const isRepeatComment = revision.body === comment.body;
+
+ if (isRepeatComment) {
+ log.trace({ isRepeatComment }, "comment contains repeat content");
+
+ // Throw an error if we're nudging instead of recording.
+ if (nudge) {
+ throw new RepeatPostCommentError();
+ }
+
+ return {
+ status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
+ actions: [
+ {
+ userID: null,
+ actionType: ACTION_TYPE.FLAG,
+ reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_REPEAT_POST,
+ },
+ ],
+ metadata: {},
+ };
+ }
+
+ log.trace({ isRepeatComment }, "comment is not repeated");
+ } catch (err) {
+ // Rethrow any RepeatPostError.
+ if (err instanceof RepeatPostCommentError) {
+ throw err;
+ }
+
+ log.error(
+ { err },
+ "could not determine if comment contained duplicate content"
+ );
+ }
+};
diff --git a/src/core/server/services/users/users.ts b/src/core/server/services/users/users.ts
index e48e76f4b..de1173db7 100644
--- a/src/core/server/services/users/users.ts
+++ b/src/core/server/services/users/users.ts
@@ -4,6 +4,7 @@ import { Db } from "mongodb";
import {
ALLOWED_USERNAME_CHANGE_FREQUENCY,
COMMENT_LIMIT_WINDOW_SECONDS,
+ COMMENT_REPEAT_POST_TIMESPAN,
DOWNLOAD_LIMIT_TIMEFRAME,
} from "coral-common/constants";
import { SCHEDULED_DELETION_TIMESPAN_DAYS } from "coral-common/constants";
@@ -31,6 +32,7 @@ import {
GQLUSER_ROLE,
} from "coral-server/graph/tenant/schema/__generated__/types";
import logger from "coral-server/logger";
+import { Comment, retrieveComment } from "coral-server/models/comment";
import { Tenant } from "coral-server/models/tenant";
import {
banUser,
@@ -1190,6 +1192,13 @@ function userLastWroteCommentTimestampKey(
return `${tenant.id}:lastCommentTimestamp:${user.id}`;
}
+function userLastCommentIDKey(
+ tenant: Pick,
+ user: Pick
+) {
+ return `${tenant.id}:lastCommentID:${user.id}`;
+}
+
/**
* retrieveUserLastWroteCommentTimestamp will return the timestamp (if set) that
* the user last wrote a comment on. This will return null if the comment was
@@ -1245,3 +1254,46 @@ export async function updateUserLastWroteCommentTimestamp(
throw new RateLimitExceeded("createComment", 1);
}
}
+
+/**
+ * updateUserLastCommentID will update the id of the users most recent comment.
+ *
+ * @param redis the Redis instance that Coral interacts with
+ * @param tenant the Tenant to operate on
+ * @param user the User that we're setting the limit for
+ * @param commentID the id of the comment
+ */
+export async function updateUserLastCommentID(
+ redis: AugmentedRedis,
+ tenant: Tenant,
+ user: User,
+ commentID: string
+) {
+ const key = userLastCommentIDKey(tenant, user);
+
+ await redis.set(key, commentID, "EX", COMMENT_REPEAT_POST_TIMESPAN);
+}
+
+/**
+ * retrieveUserLastComment will return the id (if set) of the comment that
+ * the user last wrote. This will return null if the user has not made a comment
+ * within the CURRENT_REPEAT_POST_TIMESPAN.
+ *
+ * @param mongo the db
+ * @param redis the Redis instance that Coral interacts with
+ * @param tenant the Tenant to operate on
+ * @param user the User that we're looking up the limit for
+ */
+export async function retrieveUserLastComment(
+ mongo: Db,
+ redis: AugmentedRedis,
+ tenant: Tenant,
+ user: User
+): Promise | null> {
+ const id: string | null = await redis.get(userLastCommentIDKey(tenant, user));
+ if (!id) {
+ return null;
+ }
+
+ return retrieveComment(mongo, tenant.id, id);
+}
diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl
index 0e1c2a945..5be68f0be 100644
--- a/src/locales/en-US/admin.ftl
+++ b/src/locales/en-US/admin.ftl
@@ -387,16 +387,17 @@ moderate-navigation-pending = Pending
moderate-navigation-unmoderated = unmoderated
moderate-navigation-rejected = rejected
-moderate-marker-preMod = Pre-Mod
+moderate-marker-preMod = Pre-mod
moderate-marker-link = Link
-moderate-marker-bannedWord = Banned Word
-moderate-marker-suspectWord = Suspect Word
+moderate-marker-bannedWord = Banned word
+moderate-marker-suspectWord = Suspect word
moderate-marker-spam = Spam
-moderate-marker-spamDetected = Spam Detected
+moderate-marker-spamDetected = Spam detected
moderate-marker-toxic = Toxic
-moderate-marker-recentHistory = Recent History
-moderate-marker-bodyCount = Body Count
+moderate-marker-recentHistory = Recent history
+moderate-marker-bodyCount = Body count
moderate-marker-offensive = Offensive
+moderate-marker-repeatPost = Repeat comment
moderate-markers-details = Details
moderate-flagDetails-offensive = Offensive