Skip to content

Commit

Permalink
feat: add member locks
Browse files Browse the repository at this point in the history
  • Loading branch information
JPBM135 committed Sep 17, 2024
1 parent e398023 commit cab5360
Show file tree
Hide file tree
Showing 22 changed files with 209 additions and 9 deletions.
11 changes: 9 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@
"source.fixAll": "explicit",
"source.organizeImports": "never"
},
"eslint.workingDirectories": [{ "pattern": "./packages/*" }],
"eslint.workingDirectories": [
{
"pattern": "./packages/*"
},
{
"pattern": "./apps/*"
}
],
"unocss.root": "./packages/website",
"i18n-ally.localesPaths": "./packages/yuudachi/locales",
"i18n-ally.localesPaths": "./apps/yuudachi/locales",
"i18n-ally.enabledFrameworks": ["i18next"],
"i18n-ally.sourceLanguage": "en-US",
"i18n-ally.displayLanguage": "en-US",
Expand Down
6 changes: 4 additions & 2 deletions apps/yuudachi/locales/en-US/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"no_message_resolvable": "Provided value `{{-val}}` for argument `{{arg}}` is not a valid message link or id.",
"no_guild": "Could not find guild `{{guild_id}}`",
"no_channel": "Could not find channel `{{channel_id}}` in guild `{{- guild}}`",
"ignored_channel": "Resolving a message from an ignored channel is not allowed."
"ignored_channel": "Resolving a message from an ignored channel is not allowed.",
"member_lock_acquired": "This member is already being processed by another command."
},
"buttons": {
"cancel": "Cancel",
Expand Down Expand Up @@ -358,7 +359,8 @@
"invalid_attachment": "Invalid attachment, only images are allowed.",
"timed_out": "The report has timed out, please try again.",
"bot": "You cannot report bots.",
"no_attachment_forward": "This user has already been recently reported, you must specify an attachment to forward if it helps the context of the report."
"no_attachment_forward": "This user has already been recently reported, you must specify an attachment to forward if it helps the context of the report.",
"member_lock_acquired": "This user is already being processed by a moderator."
},
"warnings": "**Attention:** We are not Discord and we **cannot** moderate {{- trust_and_safety}} issues.\n**Creating false reports may lead to moderation actions.**",
"trust_and_safety_sub": "Trust & Safety",
Expand Down
4 changes: 4 additions & 0 deletions apps/yuudachi/src/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export const enum ThreatLevelColor {

export const OP_DELIMITER = "-";

export const LOCK_MAP_TOKEN = Symbol("LockMap");
export const MEMBER_LOCK_INITIAL_EXPIRE_SECONDS = 5;
export const MEMBER_LOCK_EXPIRE_SECONDS = 20;

export const CASE_REASON_MAX_LENGTH = 500;
export const CASE_REASON_MIN_LENGTH = 3;

Expand Down
5 changes: 5 additions & 0 deletions apps/yuudachi/src/commands/moderation/ban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { nanoid } from "nanoid";
import { inject, injectable } from "tsyringe";
import { CASE_REASON_MAX_LENGTH } from "../../Constants.js";
import { CaseAction, createCase } from "../../functions/cases/createCase.js";
import { extendMemberLock } from "../../functions/locks/index.js";
import { generateCasePayload } from "../../functions/logging/generateCasePayload.js";
import { upsertCaseLog } from "../../functions/logging/upsertCaseLog.js";
import { checkLogChannel } from "../../functions/settings/checkLogChannel.js";
Expand Down Expand Up @@ -126,6 +127,10 @@ export default class extends Command<typeof BanCommand> {
} else if (collectedInteraction?.customId === banKey) {
await collectedInteraction.deferUpdate();

if (args.user.member) {
await extendMemberLock(args.user.member);
}

await this.redis.setex(`guild:${collectedInteraction.guildId}:user:${args.user.user.id}:ban`, 15, "");
const case_ = await createCase(
collectedInteraction.guild,
Expand Down
2 changes: 2 additions & 0 deletions apps/yuudachi/src/commands/moderation/kick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { nanoid } from "nanoid";
import { inject, injectable } from "tsyringe";
import { CASE_REASON_MAX_LENGTH } from "../../Constants.js";
import { createCase, CaseAction } from "../../functions/cases/createCase.js";
import { extendMemberLock } from "../../functions/locks/index.js";
import { generateCasePayload } from "../../functions/logging/generateCasePayload.js";
import { upsertCaseLog } from "../../functions/logging/upsertCaseLog.js";
import { checkLogChannel } from "../../functions/settings/checkLogChannel.js";
Expand Down Expand Up @@ -118,6 +119,7 @@ export default class extends Command<typeof KickCommand> {
});
} else if (collectedInteraction?.customId === kickKey) {
await collectedInteraction.deferUpdate();
await extendMemberLock(args.user.member);

await this.redis.setex(`guild:${collectedInteraction.guildId}:user:${args.user.user.id}:kick`, 15, "");
const case_ = await createCase(
Expand Down
5 changes: 5 additions & 0 deletions apps/yuudachi/src/commands/moderation/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Redis } from "ioredis";
import { nanoid } from "nanoid";
import { inject, injectable } from "tsyringe";
import { REPORT_REASON_MAX_LENGTH, REPORT_REASON_MIN_LENGTH } from "../../Constants.js";
import { checkMemberLock } from "../../functions/locks/index.js";
import type { Report } from "../../functions/reports/createReport.js";
import { getPendingReportByTarget } from "../../functions/reports/getReport.js";
import { checkLogChannel } from "../../functions/settings/checkLogChannel.js";
Expand Down Expand Up @@ -248,6 +249,10 @@ export default class extends Command<
throw new Error(i18next.t("command.mod.report.common.errors.no_self", { lng: locale }));
}

if (await checkMemberLock(author.guild.id, target.id)) {
throw new Error(i18next.t("command.mod.report.common.errors.member_lock_acquired", { lng: locale }));
}

const userKey = `guild:${author.guild.id}:report:user:${target.id}`;
const latestReport = await getPendingReportByTarget(author.guild.id, target.id);
if (latestReport || (await this.redis.exists(userKey))) {
Expand Down
4 changes: 4 additions & 0 deletions apps/yuudachi/src/commands/moderation/softban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { nanoid } from "nanoid";
import { inject, injectable } from "tsyringe";
import { CASE_REASON_MAX_LENGTH } from "../../Constants.js";
import { CaseAction, createCase } from "../../functions/cases/createCase.js";
import { extendMemberLock } from "../../functions/locks/index.js";
import { generateCasePayload } from "../../functions/logging/generateCasePayload.js";
import { upsertCaseLog } from "../../functions/logging/upsertCaseLog.js";
import { checkLogChannel } from "../../functions/settings/checkLogChannel.js";
Expand Down Expand Up @@ -112,6 +113,9 @@ export default class extends Command<typeof SoftbanCommand> {
});
} else if (collectedInteraction?.customId === softbanKey) {
await collectedInteraction.deferUpdate();
if (isStillMember) {
await extendMemberLock(args.user.member!);
}

await this.redis.setex(`guild:${collectedInteraction.guildId}:user:${args.user.user.id}:ban`, 15, "");
await this.redis.setex(`guild:${collectedInteraction.guildId}:user:${args.user.user.id}:unban`, 15, "");
Expand Down
2 changes: 2 additions & 0 deletions apps/yuudachi/src/commands/moderation/sub/restrict/embed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { nanoid } from "nanoid";
import type { Sql } from "postgres";
import { CASE_REASON_MAX_LENGTH } from "../../../../Constants.js";
import { CaseAction, createCase } from "../../../../functions/cases/createCase.js";
import { extendMemberLock } from "../../../../functions/locks/index.js";
import { generateCasePayload } from "../../../../functions/logging/generateCasePayload.js";
import { upsertCaseLog } from "../../../../functions/logging/upsertCaseLog.js";
import type { RestrictCommand } from "../../../../interactions/index.js";
Expand Down Expand Up @@ -124,6 +125,7 @@ export async function embed(
});
} else if (collectedInteraction?.customId === roleKey) {
await collectedInteraction.deferUpdate();
await extendMemberLock(args.user.member);

const case_ = await createCase(
collectedInteraction.guild,
Expand Down
2 changes: 2 additions & 0 deletions apps/yuudachi/src/commands/moderation/sub/restrict/emoji.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { nanoid } from "nanoid";
import type { Sql } from "postgres";
import { CASE_REASON_MAX_LENGTH } from "../../../../Constants.js";
import { CaseAction, createCase } from "../../../../functions/cases/createCase.js";
import { extendMemberLock } from "../../../../functions/locks/index.js";
import { generateCasePayload } from "../../../../functions/logging/generateCasePayload.js";
import { upsertCaseLog } from "../../../../functions/logging/upsertCaseLog.js";
import type { RestrictCommand } from "../../../../interactions/index.js";
Expand Down Expand Up @@ -124,6 +125,7 @@ export async function emoji(
});
} else if (collectedInteraction?.customId === roleKey) {
await collectedInteraction.deferUpdate();
await extendMemberLock(args.user.member);

const case_ = await createCase(
collectedInteraction.guild,
Expand Down
2 changes: 2 additions & 0 deletions apps/yuudachi/src/commands/moderation/sub/restrict/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { nanoid } from "nanoid";
import type { Sql } from "postgres";
import { CASE_REASON_MAX_LENGTH } from "../../../../Constants.js";
import { CaseAction, createCase } from "../../../../functions/cases/createCase.js";
import { extendMemberLock } from "../../../../functions/locks/index.js";
import { generateCasePayload } from "../../../../functions/logging/generateCasePayload.js";
import { upsertCaseLog } from "../../../../functions/logging/upsertCaseLog.js";
import type { RestrictCommand } from "../../../../interactions/index.js";
Expand Down Expand Up @@ -124,6 +125,7 @@ export async function react(
});
} else if (collectedInteraction?.customId === roleKey) {
await collectedInteraction.deferUpdate();
await extendMemberLock(args.user.member);

const case_ = await createCase(
collectedInteraction.guild,
Expand Down
2 changes: 2 additions & 0 deletions apps/yuudachi/src/commands/moderation/timeout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { nanoid } from "nanoid";
import { inject, injectable } from "tsyringe";
import { CASE_REASON_MAX_LENGTH } from "../../Constants.js";
import { CaseAction, createCase } from "../../functions/cases/createCase.js";
import { extendMemberLock } from "../../functions/locks/index.js";
import { generateCasePayload } from "../../functions/logging/generateCasePayload.js";
import { upsertCaseLog } from "../../functions/logging/upsertCaseLog.js";
import { checkLogChannel } from "../../functions/settings/checkLogChannel.js";
Expand Down Expand Up @@ -132,6 +133,7 @@ export default class extends Command<typeof TimeoutCommand> {
});
} else if (collectedInteraction?.customId === timeoutKey) {
await collectedInteraction.deferUpdate();
await extendMemberLock(args.user.member);

await this.redis.setex(`guild:${collectedInteraction.guildId}:user:${args.user.user.id}:timeout`, 15, "");
const case_ = await createCase(
Expand Down
2 changes: 2 additions & 0 deletions apps/yuudachi/src/commands/moderation/warn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import i18next from "i18next";
import { nanoid } from "nanoid";
import { CASE_REASON_MAX_LENGTH } from "../../Constants.js";
import { CaseAction, createCase } from "../../functions/cases/createCase.js";
import { extendMemberLock } from "../../functions/locks/index.js";
import { generateCasePayload } from "../../functions/logging/generateCasePayload.js";
import { upsertCaseLog } from "../../functions/logging/upsertCaseLog.js";
import { checkLogChannel } from "../../functions/settings/checkLogChannel.js";
Expand Down Expand Up @@ -101,6 +102,7 @@ export default class extends Command<typeof WarnCommand> {
});
} else if (collectedInteraction?.customId === warnKey) {
await collectedInteraction.deferUpdate();
await extendMemberLock(args.user.member);

const case_ = await createCase(
collectedInteraction.guild,
Expand Down
17 changes: 12 additions & 5 deletions apps/yuudachi/src/events/interactionCreate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import { handleCaseAutocomplete } from "../functions/autocomplete/cases.js";
import { handleReasonAutocomplete } from "../functions/autocomplete/reasons.js";
import { handleReportAutocomplete } from "../functions/autocomplete/reports.js";
import { AutocompleteType, findAutocompleteType } from "../functions/autocomplete/validate.js";
import { acquireMemberLock, releaseMemberLock } from "../functions/locks/index.js";
import { getGuildSetting, SettingsKeys } from "../functions/settings/getGuildSetting.js";
import { findMemberInArgs } from "../util/findMember.js";

const commandCounter = new Counter({
name: "yuudachi_bot_v3_gateway_events_interaction_create_command_total",
Expand Down Expand Up @@ -108,11 +110,16 @@ export default class implements Event {
);
break;
} else {
await command.chatInput(
interaction,
transformApplicationInteraction(interaction.options.data),
effectiveLocale,
);
const args = transformApplicationInteraction(interaction.options.data);
const member = findMemberInArgs(args);

if (member) {
await acquireMemberLock(member, effectiveLocale);
}

await command
.chatInput(interaction, args, effectiveLocale)
.finally(() => member && void releaseMemberLock(member));
break;
}
}
Expand Down
26 changes: 26 additions & 0 deletions apps/yuudachi/src/functions/locks/acquireLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { container, kRedis } from "@yuudachi/framework";
import type { LocaleParam } from "@yuudachi/framework/types";
import type { GuildMember } from "discord.js";
import i18next from "i18next";
import type { Redis } from "ioredis";
import { LOCK_MAP_TOKEN, MEMBER_LOCK_INITIAL_EXPIRE_SECONDS } from "../../Constants.js";
import { memberToLockKey, createLockTimeout } from "./utils.js";

export async function acquireMemberLock(member: GuildMember, locale: LocaleParam): Promise<void> {
const redis = container.resolve<Redis>(kRedis);
const lockMap = container.resolve<Map<string, NodeJS.Timeout>>(LOCK_MAP_TOKEN);

const key = memberToLockKey(member);

const lock = await redis.set(key, member.user.createdTimestamp, "EX", MEMBER_LOCK_INITIAL_EXPIRE_SECONDS, "NX");

if (!lock) {
throw new Error(
i18next.t("command.common.errors.member_lock_acquired", {
lng: locale,
}),
);
}

lockMap.set(key, createLockTimeout(member, lockMap));
}
12 changes: 12 additions & 0 deletions apps/yuudachi/src/functions/locks/checkLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { container, kRedis } from "@yuudachi/framework";
import type { Redis } from "ioredis";

export async function checkMemberLock(guildId: string, memberId: string): Promise<boolean> {
const redis = container.resolve<Redis>(kRedis);

const key = `guild:${guildId}:member-lock:${memberId}`;

const lock = await redis.exists(key);

return Boolean(lock);
}
8 changes: 8 additions & 0 deletions apps/yuudachi/src/functions/locks/createLockMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { container } from "@yuudachi/framework";
import { LOCK_MAP_TOKEN } from "../../Constants.js";

export function createLockMap() {
container.register(LOCK_MAP_TOKEN, {
useValue: new Map(),
});
}
21 changes: 21 additions & 0 deletions apps/yuudachi/src/functions/locks/extendLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { container, kRedis } from "@yuudachi/framework";
import type { GuildMember } from "discord.js";
import type { Redis } from "ioredis";
import { LOCK_MAP_TOKEN, MEMBER_LOCK_EXPIRE_SECONDS } from "../../Constants.js";
import { memberToLockKey, createLockTimeout } from "./utils.js";

export async function extendMemberLock(member: GuildMember): Promise<void> {
const redis = container.resolve<Redis>(kRedis);
const lockMap = container.resolve<Map<string, NodeJS.Timeout>>(LOCK_MAP_TOKEN);

const key = memberToLockKey(member);

await redis.expire(key, MEMBER_LOCK_EXPIRE_SECONDS, "GT");

const lock = lockMap.get(key);

if (lock) {
clearTimeout(lock);
lockMap.set(key, createLockTimeout(member, lockMap));
}
}
6 changes: 6 additions & 0 deletions apps/yuudachi/src/functions/locks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export * from "./releaseLock.js";
export * from "./extendLock.js";
export * from "./acquireLock.js";
export * from "./checkLock.js";
export * from "./createLockMap.js";
export * from "./utils.js";
18 changes: 18 additions & 0 deletions apps/yuudachi/src/functions/locks/releaseLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { container, kRedis } from "@yuudachi/framework";
import type { GuildMember } from "discord.js";
import type { Redis } from "ioredis";
import { memberToLockKey } from "./utils.js";
import { LOCK_MAP_TOKEN } from "../../Constants.js";

export async function releaseMemberLock(member: GuildMember): Promise<void> {
const redis = container.resolve<Redis>(kRedis);
const lockMap = container.resolve<Map<string, NodeJS.Timeout>>(LOCK_MAP_TOKEN);

const key = memberToLockKey(member);

await redis.del(key);

const lock = lockMap.get(key);
clearTimeout(lock);
lockMap.delete(key);
}
34 changes: 34 additions & 0 deletions apps/yuudachi/src/functions/locks/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { setTimeout } from "node:timers";
import { logger } from "@yuudachi/framework";
import type { GuildMember } from "discord.js";
import { Counter } from "prom-client";

const expiredLocksCounter = new Counter({
name: "yuudachi_bot_v3_utils_expired_locks_total",
help: "Total number of unreleased keys that expired",
labelNames: ["memberId", "guildId"],
});

export function memberToLockKey(member: GuildMember): `guild:${string}:member-lock:${string}` {
return `guild:${member.guild.id}:member-lock:${member.id}`;
}

export function createLockTimeout(member: GuildMember, map: Map<string, NodeJS.Timeout>): NodeJS.Timeout {
const lockKey = `guild:${member.guild.id}:member-lock:${member.id}`;

return setTimeout(() => {
logger.warn({
msg: "Lock expired",
memberId: member.id,
guildId: member.guild.id,
lockKey,
});

expiredLocksCounter.inc({
memberId: member.id,
guildId: member.guild.id,
});

map.delete(lockKey);
}, 60_000);
}
2 changes: 2 additions & 0 deletions apps/yuudachi/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { scamDomainRequestHeaders } from "./functions/anti-scam/refreshScamDomai
import { api } from "./util/api.js";
import { createWebhooks } from "./util/webhooks.js";
import { WebSocketConnection } from "./websocket/WebSocketConnection.js";
import { createLockMap } from "./functions/locks/index.js";

await createPostgres();
await createRedis();
Expand All @@ -50,6 +51,7 @@ const client = createClient({

createCommands();
createWebhooks();
createLockMap();

register.setDefaultLabels({
app: "yuudachi-bot-v3",
Expand Down
Loading

0 comments on commit cab5360

Please sign in to comment.