3
0
Fork 0
mirror of https://github.com/ZeppelinBot/Zeppelin.git synced 2025-03-15 05:41:51 +00:00

feat: add member cache; handle all role changes with RoleManagerPlugin; exit gracefully

This commit is contained in:
Dragory 2023-05-07 17:56:55 +03:00
parent fd60a09947
commit fa50110766
No known key found for this signature in database
GPG key ID: 5F387BA66DF8AAC1
48 changed files with 755 additions and 264 deletions

View file

@ -1,6 +1,5 @@
const fs = require("fs");
const path = require("path");
const pkgUp = require("pkg-up");
const { backendDir } = require("./dist/backend/src/paths");
const { env } = require("./dist/backend/src/env");

40
backend/src/Blocker.ts Normal file
View file

@ -0,0 +1,40 @@
export type Block = {
count: number;
unblock: () => void;
getPromise: () => Promise<void>;
};
export class Blocker {
#blocks: Map<string, Block> = new Map();
block(key: string): void {
if (!this.#blocks.has(key)) {
const promise = new Promise<void>((resolve) => {
this.#blocks.set(key, {
count: 0, // Incremented to 1 further below
unblock() {
this.count--;
if (this.count === 0) {
resolve();
}
},
getPromise: () => promise, // :d
});
});
}
this.#blocks.get(key)!.count++;
}
unblock(key: string): void {
if (this.#blocks.has(key)) {
this.#blocks.get(key)!.unblock();
}
}
async waitToBeUnblocked(key: string): Promise<void> {
if (!this.#blocks.has(key)) {
return;
}
await this.#blocks.get(key)!.getPromise();
}
}

View file

@ -1,3 +1,7 @@
// KEEP THIS AS FIRST IMPORT
// See comment in module for details
import "../threadsSignalFix";
import { connect } from "../data/db";
import { env } from "../env";
import { setIsAPI } from "../globals";

View file

@ -13,8 +13,8 @@
"MEMBER_SOFTBAN": "{timestamp} 🔨 {userMention(member)} was softbanned by {userMention(mod)}",
"MEMBER_JOIN": "{timestamp} 📥 {new} {userMention(member)} joined (created {account_age} ago)",
"MEMBER_LEAVE": "{timestamp} 📤 {userMention(member)} left the server",
"MEMBER_ROLE_ADD": "{timestamp} 🔑 {userMention(member)} received roles: **{roles}**",
"MEMBER_ROLE_REMOVE": "{timestamp} 🔑 {userMention(member)} lost roles: **{roles}**",
"MEMBER_ROLE_ADD": "{timestamp} 🔑 {userMention(mod)} added roles for {userMention(member)}: **{roles}**",
"MEMBER_ROLE_REMOVE": "{timestamp} 🔑 {userMention(mod)} removed roles from {userMention(member)}: **{roles}**",
"MEMBER_ROLE_CHANGES": "{timestamp} 🔑 {userMention(member)} had role changes: received **{addedRoles}**, lost **{removedRoles}**",
"MEMBER_NICK_CHANGE": "{timestamp} ✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**",
"MEMBER_USERNAME_CHANGE": "{timestamp} ✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**",

View file

@ -0,0 +1,101 @@
import moment from "moment-timezone";
import { getRepository, Repository } from "typeorm";
import { Blocker } from "../Blocker";
import { DBDateFormat, MINUTES } from "../utils";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { MemberCacheItem } from "./entities/MemberCacheItem";
const SAVE_PENDING_BLOCKER_KEY = "save-pending" as const;
const DELETION_DELAY = 5 * MINUTES;
type UpdateData = Pick<MemberCacheItem, "username" | "nickname" | "roles">;
export class GuildMemberCache extends BaseGuildRepository {
#memberCache: Repository<MemberCacheItem>;
#pendingUpdates: Map<string, Partial<MemberCacheItem>>;
#blocker: Blocker;
constructor(guildId: string) {
super(guildId);
this.#memberCache = getRepository(MemberCacheItem);
this.#pendingUpdates = new Map();
this.#blocker = new Blocker();
}
async savePendingUpdates(): Promise<void> {
await this.#blocker.waitToBeUnblocked(SAVE_PENDING_BLOCKER_KEY);
if (this.#pendingUpdates.size === 0) {
return;
}
this.#blocker.block(SAVE_PENDING_BLOCKER_KEY);
const entitiesToSave = Array.from(this.#pendingUpdates.values());
this.#pendingUpdates.clear();
await this.#memberCache.upsert(entitiesToSave, ["guild_id", "user_id"]).finally(() => {
this.#blocker.unblock(SAVE_PENDING_BLOCKER_KEY);
});
}
async getCachedMemberData(userId: string): Promise<MemberCacheItem | null> {
await this.#blocker.waitToBeUnblocked(SAVE_PENDING_BLOCKER_KEY);
const dbItem = await this.#memberCache.findOne({
where: {
guild_id: this.guildId,
user_id: userId,
},
});
const pendingItem = this.#pendingUpdates.get(userId);
if (!dbItem && !pendingItem) {
return null;
}
const item = new MemberCacheItem();
Object.assign(item, dbItem ?? {});
Object.assign(item, pendingItem ?? {});
return item;
}
async setCachedMemberData(userId: string, data: UpdateData): Promise<void> {
await this.#blocker.waitToBeUnblocked(SAVE_PENDING_BLOCKER_KEY);
if (!this.#pendingUpdates.has(userId)) {
const newItem = new MemberCacheItem();
newItem.guild_id = this.guildId;
newItem.user_id = userId;
this.#pendingUpdates.set(userId, newItem);
}
Object.assign(this.#pendingUpdates.get(userId)!, data);
this.#pendingUpdates.get(userId)!.last_seen = moment().format("YYYY-MM-DD");
}
async markMemberForDeletion(userId: string): Promise<void> {
await this.#memberCache.update(
{
guild_id: this.guildId,
user_id: userId,
},
{
delete_at: moment().add(DELETION_DELAY, "ms").format(DBDateFormat),
},
);
}
async unmarkMemberForDeletion(userId: string): Promise<void> {
await this.#memberCache.update(
{
guild_id: this.guildId,
user_id: userId,
},
{
delete_at: null,
},
);
}
}

View file

@ -2,11 +2,6 @@ import { getRepository, Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { PersistedData } from "./entities/PersistedData";
export interface IPartialPersistData {
roles?: string[];
nickname?: string;
}
export class GuildPersistedData extends BaseGuildRepository {
private persistedData: Repository<PersistedData>;
@ -24,11 +19,7 @@ export class GuildPersistedData extends BaseGuildRepository {
});
}
async set(userId: string, data: IPartialPersistData = {}) {
const finalData: any = {};
if (data.roles) finalData.roles = data.roles.join(",");
if (data.nickname) finalData.nickname = data.nickname;
async set(userId: string, data: Partial<PersistedData> = {}) {
const existing = await this.find(userId);
if (existing) {
await this.persistedData.update(
@ -36,11 +27,11 @@ export class GuildPersistedData extends BaseGuildRepository {
guild_id: this.guildId,
user_id: userId,
},
finalData,
data,
);
} else {
await this.persistedData.insert({
...finalData,
...data,
guild_id: this.guildId,
user_id: userId,
});

View file

@ -0,0 +1,29 @@
import moment from "moment-timezone";
import { getRepository, Repository } from "typeorm";
import { DAYS } from "../utils";
import { BaseRepository } from "./BaseRepository";
import { MemberCacheItem } from "./entities/MemberCacheItem";
const STALE_PERIOD = 90 * DAYS;
export class MemberCache extends BaseRepository {
#memberCache: Repository<MemberCacheItem>;
constructor() {
super();
this.#memberCache = getRepository(MemberCacheItem);
}
async deleteStaleData(): Promise<void> {
const cutoff = moment().subtract(STALE_PERIOD, "ms").format("YYYY-MM-DD");
await this.#memberCache.createQueryBuilder().where("last_seen < :cutoff", { cutoff }).delete().execute();
}
async deleteMarkedToBeDeletedEntries(): Promise<void> {
await this.#memberCache
.createQueryBuilder()
.where("delete_at IS NOT NULL AND delete_at <= NOW()")
.delete()
.execute();
}
}

View file

@ -0,0 +1,20 @@
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity("member_cache")
export class MemberCacheItem {
@PrimaryGeneratedColumn() id: number;
@Column() guild_id: string;
@Column() user_id: string;
@Column() username: string;
@Column({ type: String, nullable: true }) nickname: string | null;
@Column("simple-json") roles: string[];
@Column() last_seen: string;
@Column({ type: String, nullable: true }) delete_at: string | null;
}

View file

@ -14,5 +14,5 @@ export class PersistedData {
@Column() nickname: string;
@Column() is_voice_muted: number;
@Column({ type: "boolean" }) is_voice_muted: boolean;
}

View file

@ -0,0 +1,13 @@
// tslint:disable:no-console
import { HOURS, lazyMemoize } from "../../utils";
import { MemberCache } from "../MemberCache";
const LOOP_INTERVAL = 6 * HOURS;
const getMemberCacheRepository = lazyMemoize(() => new MemberCache());
export async function runExpiredMemberCacheDeletionLoop() {
console.log("[EXPIRED MEMBER CACHE DELETION LOOP] Deleting stale member cache entries");
await getMemberCacheRepository().deleteStaleData();
setTimeout(() => runExpiredMemberCacheDeletionLoop(), LOOP_INTERVAL);
}

View file

@ -0,0 +1,13 @@
// tslint:disable:no-console
import { lazyMemoize, MINUTES } from "../../utils";
import { MemberCache } from "../MemberCache";
const LOOP_INTERVAL = 5 * MINUTES;
const getMemberCacheRepository = lazyMemoize(() => new MemberCache());
export async function runMemberCacheDeletionLoop() {
console.log("[MEMBER CACHE DELETION LOOP] Deleting entries marked to be deleted");
await getMemberCacheRepository().deleteMarkedToBeDeletedEntries();
setTimeout(() => runMemberCacheDeletionLoop(), LOOP_INTERVAL);
}

View file

@ -1,3 +1,7 @@
// KEEP THIS AS FIRST IMPORT
// See comment in module for details
import "./threadsSignalFix";
import {
Client,
Events,
@ -12,31 +16,34 @@ import { EventEmitter } from "events";
import { Knub, PluginError, PluginLoadError, PluginNotLoadedError } from "knub";
import moment from "moment-timezone";
import { performance } from "perf_hooks";
import process from "process";
import { DiscordJSError } from "./DiscordJSError";
import { RecoverablePluginError } from "./RecoverablePluginError";
import { SimpleError } from "./SimpleError";
import { AllowedGuilds } from "./data/AllowedGuilds";
import { Configs } from "./data/Configs";
import { connect } from "./data/db";
import { GuildLogs } from "./data/GuildLogs";
import { LogType } from "./data/LogType";
import { hasPhishermanMasterAPIKey } from "./data/Phisherman";
import { connect } from "./data/db";
import { runExpiredArchiveDeletionLoop } from "./data/loops/expiredArchiveDeletionLoop";
import { runExpiredMemberCacheDeletionLoop } from "./data/loops/expiredMemberCacheDeletionLoop";
import { runExpiringMutesLoop } from "./data/loops/expiringMutesLoop";
import { runExpiringTempbansLoop } from "./data/loops/expiringTempbansLoop";
import { runExpiringVCAlertsLoop } from "./data/loops/expiringVCAlertsLoop";
import { runMemberCacheDeletionLoop } from "./data/loops/memberCacheDeletionLoop";
import { runPhishermanCacheCleanupLoop, runPhishermanReportingLoop } from "./data/loops/phishermanLoops";
import { runSavedMessageCleanupLoop } from "./data/loops/savedMessageCleanupLoop";
import { runUpcomingRemindersLoop } from "./data/loops/upcomingRemindersLoop";
import { runUpcomingScheduledPostsLoop } from "./data/loops/upcomingScheduledPostsLoop";
import { hasPhishermanMasterAPIKey } from "./data/Phisherman";
import { consumeQueryStats } from "./data/queryLogger";
import { DiscordJSError } from "./DiscordJSError";
import { env } from "./env";
import { logger } from "./logger";
import { baseGuildPlugins, globalPlugins, guildPlugins } from "./plugins/availablePlugins";
import { setProfiler } from "./profiler";
import { logRateLimit } from "./rateLimitStats";
import { RecoverablePluginError } from "./RecoverablePluginError";
import { SimpleError } from "./SimpleError";
import { startUptimeCounter } from "./uptime";
import { errorMessage, isDiscordAPIError, isDiscordHTTPError, MINUTES, SECONDS, sleep, successMessage } from "./utils";
import { MINUTES, SECONDS, errorMessage, isDiscordAPIError, isDiscordHTTPError, sleep, successMessage } from "./utils";
import { DecayingCounter } from "./utils/DecayingCounter";
import { enableProfiling } from "./utils/easyProfiler";
import { loadYamlSafely } from "./utils/loadYamlSafely";
@ -191,7 +198,7 @@ setInterval(() => {
}, 5 * 60 * 1000);
logger.info("Connecting to database");
connect().then(async () => {
connect().then(async (connection) => {
const client = new Client({
partials: [Partials.User, Partials.Channel, Partials.GuildMember, Partials.Message, Partials.Reaction],
@ -365,6 +372,11 @@ connect().then(async () => {
});
bot.on("loadingFinished", async () => {
setProfiler(bot.profiler);
if (process.env.PROFILING === "true") {
enableProfiling();
}
runExpiringMutesLoop();
await sleep(10 * SECONDS);
runExpiringTempbansLoop();
@ -378,6 +390,10 @@ connect().then(async () => {
runExpiredArchiveDeletionLoop();
await sleep(10 * SECONDS);
runSavedMessageCleanupLoop();
await sleep(10 * SECONDS);
runExpiredMemberCacheDeletionLoop();
await sleep(10 * SECONDS);
runMemberCacheDeletionLoop();
if (hasPhishermanMasterAPIKey()) {
await sleep(10 * SECONDS);
@ -387,11 +403,6 @@ connect().then(async () => {
}
});
setProfiler(bot.profiler);
if (process.env.PROFILING === "true") {
enableProfiling();
}
let lowestGlobalRemaining = Infinity;
setInterval(() => {
lowestGlobalRemaining = Math.min(lowestGlobalRemaining, (client as any).rest.globalRemaining);
@ -422,4 +433,28 @@ connect().then(async () => {
logger.info("Bot Initialized");
logger.info("Logging in...");
await client.login(env.BOT_TOKEN);
let stopping = false;
const cleanupAndStop = async (code) => {
if (stopping) {
return;
}
stopping = true;
logger.info("Cleaning up before exit...");
// Force exit after 10sec
setTimeout(() => process.exit(code), 10 * SECONDS);
await bot.stop();
await connection.close();
logger.info("Done! Exiting now.");
process.exit(code);
};
process.on("beforeExit", () => cleanupAndStop(0));
process.on("SIGINT", () => {
logger.info("Received SIGINT, exiting...");
cleanupAndStop(0);
});
process.on("SIGTERM", (code) => {
logger.info("Received SIGTERM, exiting...");
cleanupAndStop(0);
});
});

View file

@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner, Table } from "typeorm";
export class CreateMemberCacheTable1682788165866 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: "member_cache",
columns: [
{
name: "id",
type: "int",
isPrimary: true,
isGenerated: true,
generationStrategy: "increment",
},
{
name: "guild_id",
type: "bigint",
},
{
name: "user_id",
type: "bigint",
},
{
name: "username",
type: "varchar",
length: "255",
},
{
name: "nickname",
type: "varchar",
length: "255",
isNullable: true,
},
{
name: "roles",
type: "text",
},
{
name: "last_seen",
type: "date",
},
{
name: "delete_at",
type: "datetime",
isNullable: true,
default: null,
},
],
indices: [
{
columnNames: ["guild_id", "user_id"],
isUnique: true,
},
{
columnNames: ["last_seen"],
},
{
columnNames: ["delete_at"],
},
],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable("member_cache");
}
}

View file

@ -15,6 +15,7 @@ import { LogsPlugin } from "../Logs/LogsPlugin";
import { ModActionsPlugin } from "../ModActions/ModActionsPlugin";
import { MutesPlugin } from "../Mutes/MutesPlugin";
import { PhishermanPlugin } from "../Phisherman/PhishermanPlugin";
import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin";
import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
import { availableActions } from "./actions/availableActions";
import { AntiraidClearCmd } from "./commands/AntiraidClearCmd";
@ -196,6 +197,7 @@ export const AutomodPlugin = zeppelinGuildPlugin<AutomodPluginType>()({
CountersPlugin,
PhishermanPlugin,
InternalPosterPlugin,
RoleManagerPlugin,
],
defaultOptions,

View file

@ -3,9 +3,9 @@ import * as t from "io-ts";
import { nonNullish, unique } from "../../../utils";
import { canAssignRole } from "../../../utils/canAssignRole";
import { getMissingPermissions } from "../../../utils/getMissingPermissions";
import { memberRolesLock } from "../../../utils/lockNameHelpers";
import { missingPermissionError } from "../../../utils/missingPermissionError";
import { LogsPlugin } from "../../Logs/LogsPlugin";
import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin";
import { ignoreRoleChange } from "../functions/ignoredRoleChanges";
import { automodAction } from "../helpers";
@ -52,25 +52,14 @@ export const AddRolesAction = automodAction({
await Promise.all(
members.map(async (member) => {
const memberRoles = new Set(member.roles.cache.keys());
const currentMemberRoles = new Set(member.roles.cache.keys());
for (const roleId of rolesToAssign) {
memberRoles.add(roleId as Snowflake);
ignoreRoleChange(pluginData, member.id, roleId);
if (!currentMemberRoles.has(roleId)) {
pluginData.getPlugin(RoleManagerPlugin).addRole(member.id, roleId);
// TODO: Remove this and just ignore bot changes in general?
ignoreRoleChange(pluginData, member.id, roleId);
}
}
if (memberRoles.size === member.roles.cache.size) {
// No role changes
return;
}
const memberRoleLock = await pluginData.locks.acquire(memberRolesLock(member));
const rolesArr = Array.from(memberRoles.values());
await member.edit({
roles: rolesArr,
});
memberRoleLock.unlock();
}),
);
},

View file

@ -0,0 +1,53 @@
import * as t from "io-ts";
import { GuildMemberCache } from "../../data/GuildMemberCache";
import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils";
import { SECONDS } from "../../utils";
import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
import { cancelDeletionOnMemberJoin } from "./events/cancelDeletionOnMemberJoin";
import { removeMemberCacheOnMemberLeave } from "./events/removeMemberCacheOnMemberLeave";
import { updateMemberCacheOnMemberUpdate } from "./events/updateMemberCacheOnMemberUpdate";
import { updateMemberCacheOnMessage } from "./events/updateMemberCacheOnMessage";
import { updateMemberCacheOnRoleChange } from "./events/updateMemberCacheOnRoleChange";
import { updateMemberCacheOnVoiceStateUpdate } from "./events/updateMemberCacheOnVoiceStateUpdate";
import { getCachedMemberData } from "./functions/getCachedMemberData";
import { GuildMemberCachePluginType } from "./types";
const PENDING_SAVE_INTERVAL = 30 * SECONDS;
export const GuildMemberCachePlugin = zeppelinGuildPlugin<GuildMemberCachePluginType>()({
name: "guild_member_cache",
showInDocs: false,
configParser: makeIoTsConfigParser(t.type({})),
events: [
updateMemberCacheOnMemberUpdate,
updateMemberCacheOnMessage,
updateMemberCacheOnVoiceStateUpdate,
updateMemberCacheOnRoleChange,
removeMemberCacheOnMemberLeave,
cancelDeletionOnMemberJoin,
],
public: {
getCachedMemberData: mapToPublicFn(getCachedMemberData),
},
beforeLoad(pluginData) {
pluginData.state.memberCache = GuildMemberCache.getGuildInstance(pluginData.guild.id);
// This won't leak memory... too much #trust
pluginData.state.initialUpdatedMembers = new Set();
},
afterLoad(pluginData) {
pluginData.state.saveInterval = setInterval(
() => pluginData.state.memberCache.savePendingUpdates(),
PENDING_SAVE_INTERVAL,
);
},
async beforeUnload(pluginData) {
clearInterval(pluginData.state.saveInterval);
await pluginData.state.memberCache.savePendingUpdates();
},
});

View file

@ -0,0 +1,9 @@
import { guildPluginEventListener } from "knub";
import { GuildMemberCachePluginType } from "../types";
export const cancelDeletionOnMemberJoin = guildPluginEventListener<GuildMemberCachePluginType>()({
event: "guildMemberAdd",
async listener({ pluginData, args: { member } }) {
pluginData.state.memberCache.unmarkMemberForDeletion(member.id);
},
});

View file

@ -0,0 +1,12 @@
import { guildPluginEventListener } from "knub";
import { MINUTES } from "../../../utils";
import { GuildMemberCachePluginType } from "../types";
const DELETION_DELAY = 2 * MINUTES;
export const removeMemberCacheOnMemberLeave = guildPluginEventListener<GuildMemberCachePluginType>()({
event: "guildMemberRemove",
async listener({ pluginData, args: { member } }) {
pluginData.state.memberCache.markMemberForDeletion(member.id);
},
});

View file

@ -0,0 +1,15 @@
import { AuditLogEvent } from "discord.js";
import { guildPluginEventListener } from "knub";
import { updateMemberCacheForMember } from "../functions/updateMemberCacheForMember";
import { GuildMemberCachePluginType } from "../types";
export const updateMemberCacheOnMemberUpdate = guildPluginEventListener<GuildMemberCachePluginType>()({
event: "guildAuditLogEntryCreate",
async listener({ pluginData, args: { auditLogEntry } }) {
if (auditLogEntry.action !== AuditLogEvent.MemberUpdate) {
return;
}
updateMemberCacheForMember(pluginData, auditLogEntry.targetId!);
},
});

View file

@ -0,0 +1,16 @@
import { guildPluginEventListener } from "knub";
import { updateMemberCacheForMember } from "../functions/updateMemberCacheForMember";
import { GuildMemberCachePluginType } from "../types";
export const updateMemberCacheOnMessage = guildPluginEventListener<GuildMemberCachePluginType>()({
event: "messageCreate",
listener({ pluginData, args }) {
// Update each member once per guild load when we see a message from them
const key = `${pluginData.guild.id}-${args.message.author.id}`;
if (pluginData.state.initialUpdatedMembers.has(key)) {
return;
}
updateMemberCacheForMember(pluginData, args.message.author.id);
pluginData.state.initialUpdatedMembers.add(key);
},
});

View file

@ -0,0 +1,15 @@
import { AuditLogEvent } from "discord.js";
import { guildPluginEventListener } from "knub";
import { updateMemberCacheForMember } from "../functions/updateMemberCacheForMember";
import { GuildMemberCachePluginType } from "../types";
export const updateMemberCacheOnRoleChange = guildPluginEventListener<GuildMemberCachePluginType>()({
event: "guildAuditLogEntryCreate",
async listener({ pluginData, args: { auditLogEntry } }) {
if (auditLogEntry.action !== AuditLogEvent.MemberRoleUpdate) {
return;
}
updateMemberCacheForMember(pluginData, auditLogEntry.targetId!);
},
});

View file

@ -0,0 +1,20 @@
import { guildPluginEventListener } from "knub";
import { updateMemberCacheForMember } from "../functions/updateMemberCacheForMember";
import { GuildMemberCachePluginType } from "../types";
export const updateMemberCacheOnVoiceStateUpdate = guildPluginEventListener<GuildMemberCachePluginType>()({
event: "voiceStateUpdate",
listener({ pluginData, args }) {
const memberId = args.newState.member?.id;
if (!memberId) {
return;
}
// Update each member once per guild load when we see a message from them
const key = `${pluginData.guild.id}-${memberId}`;
if (pluginData.state.initialUpdatedMembers.has(key)) {
return;
}
updateMemberCacheForMember(pluginData, memberId);
pluginData.state.initialUpdatedMembers.add(key);
},
});

View file

@ -0,0 +1,10 @@
import { GuildPluginData } from "knub";
import { MemberCacheItem } from "../../../data/entities/MemberCacheItem";
import { GuildMemberCachePluginType } from "../types";
export function getCachedMemberData(
pluginData: GuildPluginData<GuildMemberCachePluginType>,
userId: string,
): Promise<MemberCacheItem | null> {
return pluginData.state.memberCache.getCachedMemberData(userId);
}

View file

@ -0,0 +1,17 @@
import { GuildPluginData } from "knub";
import { GuildMemberCachePluginType } from "../types";
export async function updateMemberCacheForMember(
pluginData: GuildPluginData<GuildMemberCachePluginType>,
userId: string,
) {
const upToDateMember = await pluginData.guild.members.fetch(userId);
const roles = Array.from(upToDateMember.roles.cache.keys())
// Filter out @everyone role
.filter((roleId) => roleId !== pluginData.guild.id);
pluginData.state.memberCache.setCachedMemberData(upToDateMember.id, {
username: upToDateMember.user.username,
nickname: upToDateMember.nickname,
roles,
});
}

View file

@ -0,0 +1,10 @@
import { BasePluginType } from "knub";
import { GuildMemberCache } from "../../data/GuildMemberCache";
export interface GuildMemberCachePluginType extends BasePluginType {
state: {
memberCache: GuildMemberCache;
saveInterval: NodeJS.Timeout;
initialUpdatedMembers: Set<string>;
};
}

View file

@ -8,7 +8,7 @@ import { LogType } from "../../data/LogType";
import { logger } from "../../logger";
import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils";
import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners";
import { createTypedTemplateSafeValueContainer, TypedTemplateSafeValueContainer } from "../../templateFormatter";
import { TypedTemplateSafeValueContainer, createTypedTemplateSafeValueContainer } from "../../templateFormatter";
import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin";
import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
import { LogsChannelCreateEvt, LogsChannelDeleteEvt, LogsChannelUpdateEvt } from "./events/LogsChannelModifyEvts";
@ -40,6 +40,7 @@ import { onMessageUpdate } from "./util/onMessageUpdate";
import { escapeCodeBlock } from "discord.js";
import { InternalPosterPlugin } from "../InternalPoster/InternalPosterPlugin";
import { LogsGuildMemberRoleChangeEvt } from "./events/LogsGuildMemberRoleChangeEvt";
import { logAutomodAction } from "./logFunctions/logAutomodAction";
import { logBotAlert } from "./logFunctions/logBotAlert";
import { logCaseCreate } from "./logFunctions/logCaseCreate";
@ -173,6 +174,7 @@ export const LogsPlugin = zeppelinGuildPlugin<LogsPluginType>()({
LogsStickerCreateEvt,
LogsStickerDeleteEvt,
LogsStickerUpdateEvt,
LogsGuildMemberRoleChangeEvt,
],
public: {

View file

@ -0,0 +1,58 @@
import { APIRole, AuditLogChange, AuditLogEvent } from "discord.js";
import { guildPluginEventListener } from "knub";
import { resolveRole } from "../../../utils";
import { logMemberRoleAdd } from "../logFunctions/logMemberRoleAdd";
import { logMemberRoleRemove } from "../logFunctions/logMemberRoleRemove";
import { LogsPluginType } from "../types";
type RoleAddChange = AuditLogChange & {
key: "$add";
new: Array<Pick<APIRole, "id" | "name">>;
};
function isRoleAddChange(change: AuditLogChange): change is RoleAddChange {
return change.key === "$add";
}
type RoleRemoveChange = AuditLogChange & {
key: "$remove";
new: Array<Pick<APIRole, "id" | "name">>;
};
function isRoleRemoveChange(change: AuditLogChange): change is RoleRemoveChange {
return change.key === "$remove";
}
export const LogsGuildMemberRoleChangeEvt = guildPluginEventListener<LogsPluginType>()({
event: "guildAuditLogEntryCreate",
async listener({ pluginData, args: { auditLogEntry } }) {
// Ignore the bot's own audit log events
if (auditLogEntry.executorId === pluginData.client.user?.id) {
return;
}
if (auditLogEntry.action !== AuditLogEvent.MemberRoleUpdate) {
return;
}
const member = await pluginData.guild.members.fetch(auditLogEntry.targetId!);
const mod = await pluginData.client.users.fetch(auditLogEntry.executorId!);
for (const change of auditLogEntry.changes) {
if (isRoleAddChange(change)) {
const addedRoles = change.new.map((r) => resolveRole(pluginData.guild, r.id));
logMemberRoleAdd(pluginData, {
member,
mod,
roles: addedRoles,
});
}
if (isRoleRemoveChange(change)) {
const removedRoles = change.new.map((r) => resolveRole(pluginData.guild, r.id));
logMemberRoleRemove(pluginData, {
member,
mod,
roles: removedRoles,
});
}
}
},
});

View file

@ -1,10 +1,4 @@
import diff from "lodash.difference";
import isEqual from "lodash.isequal";
import { LogType } from "../../../data/LogType";
import { logMemberNickChange } from "../logFunctions/logMemberNickChange";
import { logMemberRoleAdd } from "../logFunctions/logMemberRoleAdd";
import { logMemberRoleChanges } from "../logFunctions/logMemberRoleChanges";
import { logMemberRoleRemove } from "../logFunctions/logMemberRoleRemove";
import { logsEvt } from "../types";
export const LogsGuildMemberUpdateEvt = logsEvt({
@ -14,8 +8,6 @@ export const LogsGuildMemberUpdateEvt = logsEvt({
const pluginData = meta.pluginData;
const oldMember = meta.args.oldMember;
const member = meta.args.newMember;
const oldRoles = [...oldMember.roles.cache.keys()];
const currentRoles = [...member.roles.cache.keys()];
if (!oldMember || oldMember.partial) {
return;
@ -28,62 +20,5 @@ export const LogsGuildMemberUpdateEvt = logsEvt({
newNick: member.nickname != null ? member.nickname : "<none>",
});
}
if (!isEqual(oldRoles, currentRoles)) {
const addedRoles = diff(currentRoles, oldRoles);
const removedRoles = diff(oldRoles, currentRoles);
let skip = false;
if (
addedRoles.length &&
removedRoles.length &&
pluginData.state.guildLogs.isLogIgnored(LogType.MEMBER_ROLE_CHANGES, member.id)
) {
skip = true;
} else if (addedRoles.length && pluginData.state.guildLogs.isLogIgnored(LogType.MEMBER_ROLE_ADD, member.id)) {
skip = true;
} else if (
removedRoles.length &&
pluginData.state.guildLogs.isLogIgnored(LogType.MEMBER_ROLE_REMOVE, member.id)
) {
skip = true;
}
if (!skip) {
if (addedRoles.length && removedRoles.length) {
// Roles added *and* removed
logMemberRoleChanges(pluginData, {
member,
addedRoles: addedRoles.map(
(roleId) => pluginData.guild.roles.cache.get(roleId) ?? { id: roleId, name: `Unknown (${roleId})` },
),
removedRoles: removedRoles.map(
(roleId) => pluginData.guild.roles.cache.get(roleId) ?? { id: roleId, name: `Unknown (${roleId})` },
),
mod: null,
});
} else if (addedRoles.length) {
// Roles added
logMemberRoleAdd(pluginData, {
member,
roles: addedRoles.map(
(roleId) => pluginData.guild.roles.cache.get(roleId) ?? { id: roleId, name: `Unknown (${roleId})` },
),
mod: null,
});
} else if (removedRoles.length && !addedRoles.length) {
// Roles removed
logMemberRoleRemove(pluginData, {
member,
roles: removedRoles.map(
(roleId) => pluginData.guild.roles.cache.get(roleId) ?? { id: roleId, name: `Unknown (${roleId})` },
),
mod: null,
});
}
}
}
},
});
// TODO: Reimplement USERNAME_CHANGE

View file

@ -2,6 +2,7 @@ import { GuildMember, Role, User } from "discord.js";
import { GuildPluginData } from "knub";
import { LogType } from "../../../data/LogType";
import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter";
import { UnknownRole } from "../../../utils";
import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
import { LogsPluginType } from "../types";
import { log } from "../util/log";
@ -9,7 +10,7 @@ import { log } from "../util/log";
interface LogMemberRoleAddData {
mod: User | null;
member: GuildMember;
roles: Role[];
roles: Array<Role | UnknownRole>;
}
export function logMemberRoleAdd(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberRoleAddData) {

View file

@ -14,6 +14,9 @@ interface LogMemberRoleChangesData {
removedRoles: Role[];
}
/**
* @deprecated Use logMemberRoleAdd() and logMemberRoleRemove() instead
*/
export function logMemberRoleChanges(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberRoleChangesData) {
return log(
pluginData,

View file

@ -2,6 +2,7 @@ import { GuildMember, Role, User } from "discord.js";
import { GuildPluginData } from "knub";
import { LogType } from "../../../data/LogType";
import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter";
import { UnknownRole } from "../../../utils";
import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
import { LogsPluginType } from "../types";
import { log } from "../util/log";
@ -9,7 +10,7 @@ import { log } from "../util/log";
interface LogMemberRoleRemoveData {
mod: User | null;
member: GuildMember;
roles: Role[];
roles: Array<Role | UnknownRole>;
}
export function logMemberRoleRemove(pluginData: GuildPluginData<LogsPluginType>, data: LogMemberRoleRemoveData) {

View file

@ -1,8 +1,7 @@
import { Snowflake } from "discord.js";
import moment from "moment-timezone";
import { MuteTypes } from "../../../data/MuteTypes";
import { memberRolesLock } from "../../../utils/lockNameHelpers";
import { LogsPlugin } from "../../Logs/LogsPlugin";
import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin";
import { getTimeoutExpiryTime } from "../functions/getTimeoutExpiryTime";
import { mutesEvt } from "../types";
@ -18,15 +17,9 @@ export const ReapplyActiveMuteOnJoinEvt = mutesEvt({
}
if (mute.type === MuteTypes.Role) {
const muteRole = pluginData.config.get().mute_role;
if (muteRole) {
const memberRoleLock = await pluginData.locks.acquire(memberRolesLock(member));
try {
await member.roles.add(muteRole as Snowflake);
} finally {
memberRoleLock.unlock();
}
const muteRoleId = pluginData.config.get().mute_role;
if (muteRoleId) {
pluginData.getPlugin(RoleManagerPlugin).addPriorityRole(member.id, muteRoleId);
}
} else {
if (!member.isCommunicationDisabled()) {

View file

@ -1,11 +1,12 @@
import { GuildMember } from "discord.js";
import { GuildPluginData } from "knub";
import { MuteTypes } from "../../../data/MuteTypes";
import { Mute } from "../../../data/entities/Mute";
import { clearExpiringMute } from "../../../data/loops/expiringMutesLoop";
import { MuteTypes } from "../../../data/MuteTypes";
import { resolveMember, verboseUserMention } from "../../../utils";
import { memberRolesLock } from "../../../utils/lockNameHelpers";
import { LogsPlugin } from "../../Logs/LogsPlugin";
import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin";
import { MutesPluginType } from "../types";
export async function clearMute(
@ -23,15 +24,16 @@ export async function clearMute(
if (member) {
const lock = await pluginData.locks.acquire(memberRolesLock(member));
const roleManagerPlugin = pluginData.getPlugin(RoleManagerPlugin);
try {
const defaultMuteRole = pluginData.config.get().mute_role;
if (mute) {
const muteRole = mute.mute_role || pluginData.config.get().mute_role;
const muteRoleId = mute.mute_role || pluginData.config.get().mute_role;
if (mute.type === MuteTypes.Role) {
if (muteRole) {
await member.roles.remove(muteRole);
if (muteRoleId) {
roleManagerPlugin.removePriorityRole(member.id, muteRoleId);
}
} else {
await member.timeout(null);
@ -39,19 +41,18 @@ export async function clearMute(
if (mute.roles_to_restore) {
const guildRoles = pluginData.guild.roles.cache;
const newRoles = [...member.roles.cache.keys()].filter((roleId) => roleId !== muteRole);
for (const toRestore of mute?.roles_to_restore) {
if (guildRoles.has(toRestore) && toRestore !== muteRole && !newRoles.includes(toRestore)) {
newRoles.push(toRestore);
const newRoles = [...member.roles.cache.keys()].filter((roleId) => roleId !== muteRoleId);
for (const roleIdToRestore of mute?.roles_to_restore) {
if (guildRoles.has(roleIdToRestore) && roleIdToRestore !== muteRoleId) {
roleManagerPlugin.addRole(member.id, roleIdToRestore);
}
}
await member.roles.set(newRoles);
}
} else {
// Unmuting someone without an active mute -> remove timeouts and/or mute role
const muteRole = pluginData.config.get().mute_role;
if (muteRole && member.roles.cache.has(muteRole)) {
await member.roles.remove(muteRole);
roleManagerPlugin.removePriorityRole(member.id, muteRole);
}
if (member.isCommunicationDisabled()) {
await member.timeout(null);

View file

@ -1,26 +1,27 @@
import { Snowflake } from "discord.js";
import humanizeDuration from "humanize-duration";
import { GuildPluginData } from "knub";
import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
import { CaseTypes } from "../../../data/CaseTypes";
import { AddMuteParams } from "../../../data/GuildMutes";
import { MuteTypes } from "../../../data/MuteTypes";
import { Case } from "../../../data/entities/Case";
import { Mute } from "../../../data/entities/Mute";
import { AddMuteParams } from "../../../data/GuildMutes";
import { registerExpiringMute } from "../../../data/loops/expiringMutesLoop";
import { MuteTypes } from "../../../data/MuteTypes";
import { LogsPlugin } from "../../../plugins/Logs/LogsPlugin";
import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter";
import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
import {
UserNotificationMethod,
UserNotificationResult,
notifyUser,
resolveMember,
resolveUser,
ucfirst,
UserNotificationMethod,
UserNotificationResult,
} from "../../../utils";
import { muteLock } from "../../../utils/lockNameHelpers";
import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
import { CasesPlugin } from "../../Cases/CasesPlugin";
import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin";
import { MuteOptions, MutesPluginType } from "../types";
import { getDefaultMuteType } from "./getDefaultMuteType";
import { getTimeoutExpiryTime } from "./getTimeoutExpiryTime";
@ -91,38 +92,29 @@ export async function muteUser(
}
if (muteType === MuteTypes.Role) {
// Apply mute role if it's missing
if (!currentUserRoles.includes(muteRole!)) {
try {
await member.roles.add(muteRole!);
} catch (e) {
const actualMuteRole = pluginData.guild.roles.cache.get(muteRole!);
if (!actualMuteRole) {
lock.unlock();
logs.logBotAlert({
body: `Cannot mute users, specified mute role Id is invalid`,
});
throw new RecoverablePluginError(ERRORS.INVALID_MUTE_ROLE_ID);
}
// Verify the configured mute role is valid
const actualMuteRole = pluginData.guild.roles.cache.get(muteRole!);
if (!actualMuteRole) {
lock.unlock();
logs.logBotAlert({
body: `Cannot mute users, specified mute role Id is invalid`,
});
throw new RecoverablePluginError(ERRORS.INVALID_MUTE_ROLE_ID);
}
const zep = await resolveMember(pluginData.client, pluginData.guild, pluginData.client.user!.id);
const zepRoles = pluginData.guild.roles.cache.filter((x) => zep!.roles.cache.has(x.id));
// If we have roles and one of them is above the muted role, throw generic error
if (zepRoles.size >= 0 && zepRoles.some((zepRole) => zepRole.position > actualMuteRole.position)) {
lock.unlock();
logs.logBotAlert({
body: `Cannot mute user ${member.id}: ${e}`,
});
throw e;
} else {
// Otherwise, throw error that mute role is above zeps roles
lock.unlock();
logs.logBotAlert({
body: `Cannot mute users, specified mute role is above Zeppelin in the role hierarchy`,
});
throw new RecoverablePluginError(ERRORS.MUTE_ROLE_ABOVE_ZEP, pluginData.guild);
}
}
// Verify the mute role is not above Zep's roles
const zep = await pluginData.guild.members.fetchMe();
const zepRoles = pluginData.guild.roles.cache.filter((x) => zep.roles.cache.has(x.id));
if (zepRoles.size === 0 || !zepRoles.some((zepRole) => zepRole.position > actualMuteRole.position)) {
lock.unlock();
logs.logBotAlert({
body: `Cannot mute users, specified mute role is above Zeppelin in the role hierarchy`,
});
throw new RecoverablePluginError(ERRORS.MUTE_ROLE_ABOVE_ZEP, pluginData.guild);
}
if (!currentUserRoles.includes(muteRole!)) {
pluginData.getPlugin(RoleManagerPlugin).addPriorityRole(member.id, muteRole!);
}
} else {
await member.disableCommunicationUntil(timeoutUntil);

View file

@ -3,7 +3,9 @@ import { GuildLogs } from "../../data/GuildLogs";
import { GuildPersistedData } from "../../data/GuildPersistedData";
import { makeIoTsConfigParser } from "../../pluginUtils";
import { trimPluginDescription } from "../../utils";
import { GuildMemberCachePlugin } from "../GuildMemberCache/GuildMemberCachePlugin";
import { LogsPlugin } from "../Logs/LogsPlugin";
import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin";
import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
import { LoadDataEvt } from "./events/LoadDataEvt";
import { StoreDataEvt } from "./events/StoreDataEvt";
@ -29,7 +31,7 @@ export const PersistPlugin = zeppelinGuildPlugin<PersistPluginType>()({
configSchema: ConfigSchema,
},
dependencies: () => [LogsPlugin],
dependencies: () => [LogsPlugin, RoleManagerPlugin, GuildMemberCachePlugin],
configParser: makeIoTsConfigParser(ConfigSchema),
defaultOptions,

View file

@ -2,9 +2,9 @@ import { GuildMemberEditOptions, PermissionFlagsBits } from "discord.js";
import intersection from "lodash.intersection";
import { canAssignRole } from "../../../utils/canAssignRole";
import { getMissingPermissions } from "../../../utils/getMissingPermissions";
import { memberRolesLock } from "../../../utils/lockNameHelpers";
import { missingPermissionError } from "../../../utils/missingPermissionError";
import { LogsPlugin } from "../../Logs/LogsPlugin";
import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin";
import { persistEvt } from "../types";
const p = PermissionFlagsBits;
@ -16,13 +16,11 @@ export const LoadDataEvt = persistEvt({
const member = meta.args.member;
const pluginData = meta.pluginData;
const memberRoleLock = await pluginData.locks.acquire(memberRolesLock(member));
const persistedData = await pluginData.state.persistedData.find(member.id);
if (!persistedData) {
memberRoleLock.unlock();
return;
}
await pluginData.state.persistedData.clear(member.id);
const toRestore: GuildMemberEditOptions = {
reason: "Restored upon rejoin",
@ -59,29 +57,29 @@ export const LoadDataEvt = persistEvt({
const persistedRoles = config.persisted_roles;
if (persistedRoles.length) {
const roleManager = pluginData.getPlugin(RoleManagerPlugin);
const rolesToRestore = intersection(persistedRoles, persistedData.roles, guildRoles);
if (rolesToRestore.length) {
restoredData.push("roles");
toRestore.roles = Array.from(new Set([...rolesToRestore, ...member.roles.cache.keys()]));
for (const roleId of rolesToRestore) {
roleManager.addRole(member.id, roleId);
}
}
}
if (config.persist_nicknames && persistedData.nickname) {
restoredData.push("nickname");
toRestore.nick = persistedData.nickname;
await member.edit({
nick: persistedData.nickname,
});
}
if (restoredData.length) {
await member.edit(toRestore);
await pluginData.state.persistedData.clear(member.id);
pluginData.getPlugin(LogsPlugin).logMemberRestore({
member,
restoredData: restoredData.join(", "),
});
}
memberRoleLock.unlock();
},
});

View file

@ -1,34 +1,41 @@
import { GuildMember } from "discord.js";
import intersection from "lodash.intersection";
import { IPartialPersistData } from "../../../data/GuildPersistedData";
import { PersistedData } from "../../../data/entities/PersistedData";
import { GuildMemberCachePlugin } from "../../GuildMemberCache/GuildMemberCachePlugin";
import { persistEvt } from "../types";
export const StoreDataEvt = persistEvt({
event: "guildMemberRemove",
async listener(meta) {
const member = meta.args.member as GuildMember;
const pluginData = meta.pluginData;
let persist = false;
const persistData: IPartialPersistData = {};
async listener({ pluginData, args: { member } }) {
const config = await pluginData.config.getForUser(member.user);
const persistData: Partial<PersistedData> = {};
const persistedRoles = config.persisted_roles;
if (persistedRoles.length && member.roles) {
const rolesToPersist = intersection(persistedRoles, [...member.roles.cache.keys()]);
if (member.partial) {
// Djs hasn't cached member data => use db cache
const data = await pluginData.getPlugin(GuildMemberCachePlugin).getCachedMemberData(member.id);
if (!data) {
return;
}
const rolesToPersist = config.persisted_roles.filter((roleId) => data.roles.includes(roleId));
if (rolesToPersist.length) {
persist = true;
persistData.roles = rolesToPersist;
}
if (config.persist_nicknames && data.nickname) {
persistData.nickname = data.nickname;
}
} else {
// Djs has cached member data => use that
const memberRoles = Array.from(member.roles.cache.keys());
const rolesToPersist = config.persisted_roles.filter((roleId) => memberRoles.includes(roleId));
if (rolesToPersist.length) {
persistData.roles = rolesToPersist;
}
if (config.persist_nicknames && member.nickname) {
persistData.nickname = member.nickname as any;
}
}
if (config.persist_nicknames && member.nickname) {
persist = true;
persistData.nickname = member.nickname;
}
if (persist) {
if (Object.keys(persistData).length) {
pluginData.state.persistedData.set(member.id, persistData);
}
},

View file

@ -1,10 +1,13 @@
import { GuildMember } from "discord.js";
import { guildPluginEventListener } from "knub";
import { SECONDS } from "../../../utils";
import { parseCustomId } from "../../../utils/parseCustomId";
import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin";
import { getAllRolesInButtons } from "../functions/getAllRolesInButtons";
import { RoleButtonsPluginType, TRoleButtonOption } from "../types";
const ROLE_BUTTON_CD = 5 * SECONDS;
export const onButtonInteraction = guildPluginEventListener<RoleButtonsPluginType>()({
event: "interactionCreate",
async listener({ pluginData, args }) {
@ -33,6 +36,16 @@ export const onButtonInteraction = guildPluginEventListener<RoleButtonsPluginTyp
return;
}
const cdIdentifier = `${args.interaction.user.id}-${optionIndex}`;
if (pluginData.cooldowns.isOnCooldown(cdIdentifier)) {
args.interaction.reply({
ephemeral: true,
content: "Please wait before clicking the button again",
});
return;
}
pluginData.cooldowns.setCooldown(cdIdentifier, ROLE_BUTTON_CD);
const member = args.interaction.member as GuildMember;
const role = pluginData.guild.roles.cache.get(option.role_id);
const roleName = role?.name || option.role_id;

View file

@ -1,10 +1,9 @@
import { GuildPluginData } from "knub";
import { RoleQueueItem } from "../../../data/entities/RoleQueueItem";
import { logger } from "../../../logger";
import { LogsPlugin } from "../../Logs/LogsPlugin";
import { RoleManagerPluginType } from "../types";
const ROLE_ASSIGNMENTS_PER_BATCH = 20;
const ROLE_ASSIGNMENTS_PER_BATCH = 10;
export async function runRoleAssignmentLoop(pluginData: GuildPluginData<RoleManagerPluginType>) {
if (pluginData.state.roleAssignmentLoopRunning || pluginData.state.abortRoleAssignmentLoop) {
@ -30,40 +29,25 @@ export async function runRoleAssignmentLoop(pluginData: GuildPluginData<RoleMana
return;
}
// Remove assignments that cancel each other out (e.g. from spam-clicking a role button)
const validAssignments = new Map<string, RoleQueueItem>();
for (const assignment of nextAssignments) {
const key = `${assignment.should_add ? 1 : 0}|${assignment.user_id}|${assignment.role_id}`;
const oppositeKey = `${assignment.should_add ? 0 : 1}|${assignment.user_id}|${assignment.role_id}`;
if (validAssignments.has(oppositeKey)) {
validAssignments.delete(oppositeKey);
continue;
const member = await pluginData.guild.members.fetch(assignment.user_id).catch(() => null);
if (!member) {
return;
}
validAssignments.set(key, assignment);
}
// Apply batch in parallel
await Promise.all(
Array.from(validAssignments.values()).map(async (assignment) => {
const member = await pluginData.guild.members.fetch(assignment.user_id).catch(() => null);
if (!member) {
return;
}
const operation = assignment.should_add
? member.roles.add(assignment.role_id)
: member.roles.remove(assignment.role_id);
const operation = assignment.should_add
? member.roles.add(assignment.role_id)
: member.roles.remove(assignment.role_id);
await operation.catch((err) => {
logger.warn(err);
pluginData.getPlugin(LogsPlugin).logBotAlert({
body: `Could not ${assignment.should_add ? "assign" : "remove"} role <@&${assignment.role_id}> (\`${
assignment.role_id
}\`) ${assignment.should_add ? "to" : "from"} <@!${assignment.user_id}> (\`${assignment.user_id}\`)`,
});
await operation.catch((err) => {
logger.warn(err);
pluginData.getPlugin(LogsPlugin).logBotAlert({
body: `Could not ${assignment.should_add ? "assign" : "remove"} role <@&${assignment.role_id}> (\`${
assignment.role_id
}\`) ${assignment.should_add ? "to" : "from"} <@!${assignment.user_id}> (\`${assignment.user_id}\`)`,
});
}),
);
});
}
})());
}
}

View file

@ -3,6 +3,7 @@ import { GuildLogs } from "../../data/GuildLogs";
import { makeIoTsConfigParser } from "../../pluginUtils";
import { trimPluginDescription } from "../../utils";
import { LogsPlugin } from "../Logs/LogsPlugin";
import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin";
import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
import { AddRoleCmd } from "./commands/AddRoleCmd";
import { MassAddRoleCmd } from "./commands/MassAddRoleCmd";
@ -43,7 +44,7 @@ export const RolesPlugin = zeppelinGuildPlugin<RolesPluginType>()({
configSchema: ConfigSchema,
},
dependencies: () => [LogsPlugin],
dependencies: () => [LogsPlugin, RoleManagerPlugin],
configParser: makeIoTsConfigParser(ConfigSchema),
defaultOptions,

View file

@ -1,9 +1,9 @@
import { GuildChannel } from "discord.js";
import { commandTypeHelpers as ct } from "../../../commandTypes";
import { LogType } from "../../../data/LogType";
import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
import { resolveRoleId, verboseUserMention } from "../../../utils";
import { LogsPlugin } from "../../Logs/LogsPlugin";
import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin";
import { rolesCmd } from "../types";
export const AddRoleCmd = rolesCmd({
@ -49,9 +49,7 @@ export const AddRoleCmd = rolesCmd({
return;
}
pluginData.state.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, args.member.id);
await args.member.roles.add(roleId);
pluginData.getPlugin(RoleManagerPlugin).addRole(args.member.id, roleId);
pluginData.getPlugin(LogsPlugin).logMemberRoleAdd({
mod: msg.author,

View file

@ -1,10 +1,10 @@
import { GuildMember } from "discord.js";
import { commandTypeHelpers as ct } from "../../../commandTypes";
import { LogType } from "../../../data/LogType";
import { logger } from "../../../logger";
import { canActOn, sendErrorMessage } from "../../../pluginUtils";
import { resolveMember, resolveRoleId, successMessage } from "../../../utils";
import { LogsPlugin } from "../../Logs/LogsPlugin";
import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin";
import { rolesCmd } from "../types";
export const MassAddRoleCmd = rolesCmd({
@ -72,8 +72,7 @@ export const MassAddRoleCmd = rolesCmd({
for (const member of membersWithoutTheRole) {
try {
pluginData.state.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, member.id);
await member.roles.add(roleId);
pluginData.getPlugin(RoleManagerPlugin).addRole(member.id, roleId);
pluginData.getPlugin(LogsPlugin).logMemberRoleAdd({
member,
roles: [role],

View file

@ -1,10 +1,9 @@
import { GuildMember } from "discord.js";
import { commandTypeHelpers as ct } from "../../../commandTypes";
import { LogType } from "../../../data/LogType";
import { logger } from "../../../logger";
import { canActOn, sendErrorMessage } from "../../../pluginUtils";
import { resolveMember, resolveRoleId, successMessage } from "../../../utils";
import { LogsPlugin } from "../../Logs/LogsPlugin";
import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin";
import { rolesCmd } from "../types";
export const MassRemoveRoleCmd = rolesCmd({
@ -71,19 +70,13 @@ export const MassRemoveRoleCmd = rolesCmd({
);
for (const member of membersWithTheRole) {
try {
pluginData.state.logs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, member.id);
await member.roles.remove(roleId);
pluginData.getPlugin(LogsPlugin).logMemberRoleRemove({
member,
roles: [role],
mod: msg.author,
});
assigned++;
} catch (e) {
logger.warn(`Error when removing role via !massremoverole: ${e.message}`);
failed.push(member.id);
}
pluginData.getPlugin(RoleManagerPlugin).removeRole(member.id, roleId);
pluginData.getPlugin(LogsPlugin).logMemberRoleRemove({
member,
roles: [role],
mod: msg.author,
});
assigned++;
}
let resultMessage = `Removed role **${role.name}** from ${assigned} ${assigned === 1 ? "member" : "members"}!`;

View file

@ -1,9 +1,9 @@
import { GuildChannel } from "discord.js";
import { commandTypeHelpers as ct } from "../../../commandTypes";
import { LogType } from "../../../data/LogType";
import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
import { resolveRoleId, verboseUserMention } from "../../../utils";
import { LogsPlugin } from "../../Logs/LogsPlugin";
import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin";
import { rolesCmd } from "../types";
export const RemoveRoleCmd = rolesCmd({
@ -49,10 +49,7 @@ export const RemoveRoleCmd = rolesCmd({
return;
}
pluginData.state.logs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, args.member.id);
await args.member.roles.remove(roleId);
pluginData.getPlugin(RoleManagerPlugin).removeRole(args.member.id, roleId);
pluginData.getPlugin(LogsPlugin).logMemberRoleRemove({
mod: msg.author,
member: args.member,

View file

@ -1,6 +1,6 @@
import { AutoDeletePlugin } from "./AutoDelete/AutoDeletePlugin";
import { AutomodPlugin } from "./Automod/AutomodPlugin";
import { AutoReactionsPlugin } from "./AutoReactions/AutoReactionsPlugin";
import { AutomodPlugin } from "./Automod/AutomodPlugin";
import { BotControlPlugin } from "./BotControl/BotControlPlugin";
import { CasesPlugin } from "./Cases/CasesPlugin";
import { CensorPlugin } from "./Censor/CensorPlugin";
@ -12,6 +12,7 @@ import { CustomEventsPlugin } from "./CustomEvents/CustomEventsPlugin";
import { GuildAccessMonitorPlugin } from "./GuildAccessMonitor/GuildAccessMonitorPlugin";
import { GuildConfigReloaderPlugin } from "./GuildConfigReloader/GuildConfigReloaderPlugin";
import { GuildInfoSaverPlugin } from "./GuildInfoSaver/GuildInfoSaverPlugin";
import { GuildMemberCachePlugin } from "./GuildMemberCache/GuildMemberCachePlugin";
import { InternalPosterPlugin } from "./InternalPoster/InternalPosterPlugin";
import { LocateUserPlugin } from "./LocateUser/LocateUserPlugin";
import { LogsPlugin } from "./Logs/LogsPlugin";
@ -53,6 +54,7 @@ export const guildPlugins: Array<ZeppelinGuildPluginBlueprint<any>> = [
PostPlugin,
ReactionRolesPlugin,
MessageSaverPlugin,
GuildMemberCachePlugin,
ModActionsPlugin,
NameHistoryPlugin,
RemindersPlugin,
@ -91,6 +93,7 @@ export const baseGuildPlugins: Array<ZeppelinGuildPluginBlueprint<any>> = [
GuildInfoSaverPlugin,
MessageSaverPlugin,
NameHistoryPlugin,
GuildMemberCachePlugin,
CasesPlugin,
MutesPlugin,
TimeAndDatePlugin,

View file

@ -0,0 +1,10 @@
/**
* Hack for wiping out the threads signal handlers
* See: https://github.com/andywer/threads.js/issues/388
* Make sure:
* - This is imported before any real imports from "threads"
* - This is imported as early as possible to avoid removing our own signal handlers
*/
import "threads";
process.removeAllListeners("SIGINT");
process.removeAllListeners("SIGTERM");

View file

@ -20,6 +20,7 @@ import {
MessageMentionOptions,
PartialChannelData,
PartialMessage,
RoleResolvable,
Snowflake,
Sticker,
TextBasedChannel,
@ -680,7 +681,7 @@ export function parseInviteCodeInput(str: string): string {
return getInviteCodesInString(str)[0];
}
export function isNotNull(value): value is Exclude<typeof value, null> {
export function isNotNull<T>(value: T): value is Exclude<T, null | undefined> {
return value != null;
}
@ -1367,6 +1368,22 @@ export async function resolveRoleId(bot: Client, guildId: string, value: string)
return null;
}
export class UnknownRole {
public id: string;
public name: string;
constructor(props = {}) {
for (const key in props) {
this[key] = props[key];
}
}
}
export function resolveRole(guild: Guild, roleResolvable: RoleResolvable) {
const roleId = guild.roles.resolveId(roleResolvable);
return guild.roles.resolve(roleId) ?? new UnknownRole({ id: roleId, name: roleId });
}
const inviteCache = new SimpleCache<Promise<Invite | null>>(10 * MINUTES, 200);
type ResolveInviteReturnType<T extends boolean> = Promise<Invite | null>;

View file

@ -1,5 +1,6 @@
import { Pool, spawn, Worker } from "threads";
import { env } from "../env";
import "../threadsSignalFix";
import { MINUTES } from "../utils";
const pool = Pool(() => spawn(new Worker("./cryptWorker"), { timeout: 10 * MINUTES }), 8);