Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,22 @@ This app checks the modqueue every five minutes. If there are any posts or comme

The app can be configured to remove posts or comments for deleted users or shadowbanned/suspended users - both options can be controlled independently.

You can also choose to lock comments and posts removed from the queue, and in the case of suspended or shadowbanned users who had queue items you can also choose to reply to removed items with a comment, e.g. if you wish to advise the user of the Reddit appeals process.

## Change History

### v1.2.0

* Add ability to remove modqueued items from recently banned users
* Add ability to lock removed items when removing from the modqueue
* Reduce the chances of incorrect removals during periods of Reddit infrastructure instability
* Update Devvit and dependencies
* Job execution is staggered on different subreddits to reduce spikes in activity on Devvit infrastructure

### v1.1.1

* Initial Release

## About this app

Modqueue Pruner is open source. You can find the source [here](https://github.com/fsvreddit/queue-pruner).
Modqueue Pruner is open source. [You can find the source here](https://github.com/fsvreddit/queue-pruner).
1,446 changes: 593 additions & 853 deletions package-lock.json

Large diffs are not rendered by default.

14 changes: 8 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,22 @@
"license": "BSD-3-Clause",
"type": "module",
"dependencies": {
"@devvit/protos": "^0.11.19",
"@devvit/public-api": "0.11.19",
"@types/lodash": "^4.17.20",
"cron-parser": "^5.3.0",
"@devvit/protos": "0.12.6",
"@devvit/public-api": "0.12.6",
"@types/pluralize": "^0.0.33",
"date-fns": "^4.1.0",
"lodash": "^4.17.21"
"devvit-helpers": "^0.8.5",
"lodash": "^4.17.21",
"pluralize": "^8.0.0"
},
"devDependencies": {
"@eslint/js": "^9.10.0",
"@stylistic/eslint-plugin": "^5.1.0",
"@types/lodash": "^4.17.20",
"@vitest/eslint-plugin": "^1.1.0",
"eslint": "^9.14.0",
"typescript": "5.8.3",
"typescript-eslint": "^8.4.0",
"vitest": "^3.0.5"
"vitest": "^4.0.16"
}
}
2 changes: 0 additions & 2 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,3 @@ export enum ScheduledJob {
PruneUsers = "pruneUsers",
RemoveUsers = "removeUsers",
}

export const CHECK_QUEUE_CRON = "0/5 * * * *";
10 changes: 7 additions & 3 deletions src/installActions.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { AppInstall, AppUpgrade } from "@devvit/protos";
import { TriggerContext } from "@devvit/public-api";
import { CHECK_QUEUE_CRON, ScheduledJob } from "./constants.js";
import { ScheduledJob } from "./constants.js";

export async function handleInstallOrUpgrade (event: AppInstall | AppUpgrade, context: TriggerContext) {
export async function handleInstallOrUpgrade (_: AppInstall | AppUpgrade, context: TriggerContext) {
const jobs = await context.scheduler.listJobs();
await Promise.all(jobs.map(job => context.scheduler.cancelJob(job.id)));

const randomMinute = Math.floor(Math.random() * 5);

await context.scheduler.runJob({
name: ScheduledJob.CheckQueue,
cron: CHECK_QUEUE_CRON,
cron: `${randomMinute}/5 * * * *`,
});

console.log("App installed or upgraded: scheduled jobs have been set up.");
}
110 changes: 90 additions & 20 deletions src/pruneQueue.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,45 @@
import { JobContext, JSONObject, ScheduledJobEvent } from "@devvit/public-api";
import { CronExpressionParser } from "cron-parser";
import { addSeconds } from "date-fns";
import { uniq } from "lodash";
import { CHECK_QUEUE_CRON, ScheduledJob } from "./constants.js";
import { ScheduledJob } from "./constants.js";
import { AppSetting } from "./settings.js";
import { isBanned } from "devvit-helpers";
import { isLinkId } from "@devvit/public-api/types/tid.js";
import pluralize from "pluralize";

const USER_QUEUE_KEY = "userQueue";
const REMOVE_QUEUE = "removeQueue";

async function getPostOrCommentById (itemId: string, context: JobContext) {
if (isLinkId(itemId)) {
return context.reddit.getPostById(itemId);
} else {
return context.reddit.getCommentById(itemId);
}
}

async function removeItems (itemIds: string[], lock: boolean, replyComment: string | undefined, context: JobContext) {
await Promise.all(itemIds.map(item => context.reddit.remove(item, false)));

if (lock) {
await Promise.all(itemIds.map(async (itemId) => {
const item = await getPostOrCommentById(itemId, context);
await item.lock();
}));
}

if (replyComment && replyComment.trim().length > 0) {
for (const itemId of itemIds) {
const newComment = await context.reddit.submitComment({
id: itemId,
text: replyComment,
});
await newComment.distinguish();
await newComment.lock();
}
}
}

export async function checkQueue (_: unknown, context: JobContext) {
const modQueue = await context.reddit.getModQueue({
subreddit: context.subredditName ?? await context.reddit.getCurrentSubredditName(),
Expand All @@ -26,12 +58,13 @@ export async function checkQueue (_: unknown, context: JobContext) {
// Remove items from deleted users
const itemsToRemove = modQueue.filter(item => item.authorName === "[deleted]");
if (itemsToRemove.length > 0) {
await Promise.all(itemsToRemove.map(item => context.reddit.remove(item.id, false)));
console.log(`Check step: Removed ${itemsToRemove.length} item(s) from the mod queue due to deleted users.`);
const shouldLock = settings[AppSetting.LockOnRemove] as boolean | undefined ?? false;
await removeItems(itemsToRemove.map(item => item.id), shouldLock, undefined, context);
console.log(`Check step: Removed ${itemsToRemove.length} ${pluralize("item", itemsToRemove.length)} from the mod queue due to deleted users.`);
}
}

if (!settings[AppSetting.RemoveShadowbanned]) {
if (!settings[AppSetting.RemoveShadowbanned] && !settings[AppSetting.RemoveBanned]) {
return;
}

Expand All @@ -49,16 +82,19 @@ export async function checkQueue (_: unknown, context: JobContext) {
}

await context.redis.zAdd(USER_QUEUE_KEY, ...newUsers.map(user => ({ member: user, score: Date.now() })));
console.log(`Check step: Added ${newUsers.length} new user(s) to the queue.`);
console.log(`Check step: Added ${newUsers.length} new ${pluralize("user", newUsers.length)} to the queue.`);

await context.scheduler.runJob({
name: ScheduledJob.PruneUsers,
runAt: addSeconds(new Date(), 5),
data: { runRemove: false },
data: {
firstRun: true,
runRemove: false,
},
});
}

export async function userIsActive (username: string, context: JobContext): Promise<boolean> {
async function userIsActive (username: string, context: JobContext): Promise<boolean> {
try {
const user = await context.reddit.getUserByUsername(username);
return user !== undefined;
Expand All @@ -69,15 +105,24 @@ export async function userIsActive (username: string, context: JobContext): Prom
}

export async function pruneUsers (event: ScheduledJobEvent<JSONObject | undefined>, context: JobContext) {
const runRecentlyKey = "pruneUsersRecentlyRun";
if (event.data?.firstRun && await context.redis.get(runRecentlyKey)) {
return;
}

let runRemove = event.data?.runRemove ?? false;

const runLimit = addSeconds(new Date(), 15);
const runLimit = addSeconds(new Date(), 10);
const queue = await context.redis.zRange(USER_QUEUE_KEY, 0, -1);

if (queue.length === 0) {
return;
}

await context.redis.set(runRecentlyKey, "", { expiration: addSeconds(new Date(), 30) });

const settings = await context.settings.getAll();

let processed = 0;
const processedUsers: string[] = [];

Expand All @@ -87,23 +132,36 @@ export async function pruneUsers (event: ScheduledJobEvent<JSONObject | undefine
break;
}

const isActive = await userIsActive(user.member, context);
processedUsers.push(user.member);
processed++;

if (!isActive) {
console.log(`Prune step: User ${user.member} is inactive, adding to remove queue.`);
await context.redis.zAdd(REMOVE_QUEUE, { member: user.member, score: Date.now() });
runRemove = true;
if (settings[AppSetting.RemoveShadowbanned]) {
const isActive = await userIsActive(user.member, context);

if (!isActive) {
console.log(`Prune step: User ${user.member} is shadowbanned/suspended, adding to remove queue.`);
await context.redis.zAdd(REMOVE_QUEUE, { member: user.member, score: Date.now() });
runRemove = true;
continue;
}
}

if (settings[AppSetting.RemoveBanned]) {
const subredditName = context.subredditName ?? await context.reddit.getCurrentSubredditName();
if (await isBanned(context.reddit, subredditName, user.member)) {
console.log(`Prune step: User ${user.member} is banned, adding to remove queue.`);
await context.redis.zAdd(REMOVE_QUEUE, { member: user.member, score: Date.now() });
runRemove = true;
continue;
}
}
}

await context.redis.zRem(USER_QUEUE_KEY, processedUsers);
console.log(`Prune step: Processed ${processed} user(s) in the prune job.`);
console.log(`Prune step: Processed ${processed} ${pluralize("user", processed)} in the prune job.`);

const nextRun = CronExpressionParser.parse(CHECK_QUEUE_CRON).next().toDate();
if (queue.length > 0 && nextRun > addSeconds(new Date(), 45)) {
console.log(`Prune step: There are still ${queue.length} user(s) left in the queue.`);
if (queue.length > 0) {
console.log(`Prune step: ${queue.length} ${pluralize("user", queue.length)} left in the queue.`);

await context.scheduler.runJob({
name: ScheduledJob.PruneUsers,
Expand All @@ -115,6 +173,7 @@ export async function pruneUsers (event: ScheduledJobEvent<JSONObject | undefine
name: ScheduledJob.RemoveUsers,
runAt: addSeconds(new Date(), 5),
});
await context.redis.del(runRecentlyKey);
}
}

Expand All @@ -125,6 +184,14 @@ export async function removeUsers (_: unknown, context: JobContext) {
return;
}

// Retrieve app user - this is a proxy for checking platform stability.
try {
await context.reddit.getUserByUsername(context.appName);
} catch {
console.error("Remove step: Platform appears to be unstable, aborting remove operation.");
return;
}

const modQueue = await context.reddit.getModQueue({
subreddit: context.subredditName ?? await context.reddit.getCurrentSubredditName(),
type: "all",
Expand All @@ -133,8 +200,11 @@ export async function removeUsers (_: unknown, context: JobContext) {

const itemsToRemove = modQueue.filter(item => removeQueue.some(user => user.member === item.authorName));
if (itemsToRemove.length > 0) {
await Promise.all(itemsToRemove.map(item => context.reddit.remove(item.id, false)));
console.log(`Remove step: Removed ${itemsToRemove.length} item(s) from the mod queue for shadowbanned or suspended users.`);
const settings = await context.settings.getAll();
const shouldLock = settings[AppSetting.LockOnRemove] as boolean | undefined ?? false;
const replyComment = settings[AppSetting.ReplyCommentForShadowbanned] as string | undefined;
await removeItems(itemsToRemove.map(item => item.id), shouldLock, replyComment, context);
console.log(`Remove step: Removed ${itemsToRemove.length} ${pluralize("item", itemsToRemove.length)} from the mod queue for shadowbanned or suspended users.`);
} else {
console.log("Remove step: No items found in the mod queue for users to remove.");
}
Expand Down
21 changes: 21 additions & 0 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { SettingsFormField } from "@devvit/public-api";
export enum AppSetting {
RemoveDeleted = "removeDeleted",
RemoveShadowbanned = "removeShadowbanned",
RemoveBanned = "removeBanned",
LockOnRemove = "lockOnRemove",
ReplyCommentForShadowbanned = "replyCommentForShadowbanned",
}

export const appSettings: SettingsFormField[] = [
Expand All @@ -18,4 +21,22 @@ export const appSettings: SettingsFormField[] = [
defaultValue: true,
label: "Remove modqueued content for suspended and shadowbanned users",
},
{
name: AppSetting.RemoveBanned,
type: "boolean",
defaultValue: false,
label: "Remove modqueued content for banned users",
},
{
name: AppSetting.LockOnRemove,
type: "boolean",
defaultValue: false,
label: "Lock posts or comments when removing from modqueue",
},
{
name: AppSetting.ReplyCommentForShadowbanned,
type: "paragraph",
label: "If a user is found to be shadowbanned or suspended, reply to their content with this comment on removal.",
helpText: "Leave blank to skip replying. Markdown is supported. You may wish to use this to alert users to the appeal process.",
},
];