diff --git a/backend/package-lock.json b/backend/package-lock.json index 482431a1..bd0b5f3f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1634,10 +1634,10 @@ "dev": true }, "eris": { - "version": "https://github.com/Dragory/eris/archive/custom.tar.gz", - "integrity": "sha512-6wb+mk7l/IDzqqki1IH0F8+U1dzGCbw7cHsg6dBVZ6emflHz+NnOND8XV3LPVnUQkw8ABIYzZhmYYXasURgmfg==", + "version": "github:abalabahaha/eris#54fc78d3a1f9f8ebe8b072c9c87c674c8453d016", + "from": "github:abalabahaha/eris#dev", "requires": { - "opusscript": "^0.0.7", + "opusscript": "^0.0.8", "tweetnacl": "^1.0.1", "ws": "^7.2.1" } @@ -2883,9 +2883,9 @@ } }, "opusscript": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.7.tgz", - "integrity": "sha512-DcBadTdYTUuH9zQtepsLjQn4Ll6rs3dmeFvN+SD0ThPnxRBRm/WC1zXWPg+wgAJimB784gdZvUMA57gDP7FdVg==", + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.8.tgz", + "integrity": "sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==", "optional": true }, "ora": { @@ -4696,9 +4696,9 @@ } }, "ws": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.0.tgz", - "integrity": "sha512-kyFwXuV/5ymf+IXhS6f0+eAFvydbaBW3zjpT6hUdAh/hbVjTIB5EHBGi0bPoCLSK2wcuz3BrEkB9LrYv1Nm4NQ==" + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.4.tgz", + "integrity": "sha512-Qm8k8ojNQIMx7S+Zp8u/uHOx7Qazv3Yv4q68MiWWWOJhiwG5W3x7iqmRtJo8xxrciZUY4vRxUTJCKuRnF28ZZw==" }, "xdg-basedir": { "version": "4.0.0", diff --git a/backend/package.json b/backend/package.json index 56e816d1..8ee06bdd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -31,7 +31,7 @@ "deep-diff": "^1.0.2", "dotenv": "^4.0.0", "emoji-regex": "^8.0.0", - "eris": "https://github.com/Dragory/eris/archive/custom.tar.gz", + "eris": "github:abalabahaha/eris#dev", "erlpack": "github:abalabahaha/erlpack", "escape-string-regexp": "^1.0.5", "express": "^4.17.0", diff --git a/backend/src/api/auth.ts b/backend/src/api/auth.ts index d66ae078..0015dcd4 100644 --- a/backend/src/api/auth.ts +++ b/backend/src/api/auth.ts @@ -39,7 +39,11 @@ function simpleDiscordAPIRequest(bearerToken, path): Promise { return; } - res.on("data", data => resolve(JSON.parse(data))); + let rawData = ""; + res.on("data", data => (rawData += data)); + res.on("end", () => { + resolve(JSON.parse(rawData)); + }); }, ); diff --git a/backend/src/data/DefaultLogMessages.json b/backend/src/data/DefaultLogMessages.json index 1ef3091b..0c6c010e 100644 --- a/backend/src/data/DefaultLogMessages.json +++ b/backend/src/data/DefaultLogMessages.json @@ -36,9 +36,9 @@ "MESSAGE_DELETE_BARE": "🗑 Message (`{messageId}`) deleted in {channelMention(channel)} (no more info available)", "MESSAGE_DELETE_AUTO": "🗑 Auto-deleted message (`{message.id}`) from {userMention(user)} in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}", - "VOICE_CHANNEL_JOIN": "🎙 🔵 {userMention(member)} joined **{channel.name}**", - "VOICE_CHANNEL_MOVE": "🎙 ↔ {userMention(member)} moved from **{oldChannel.name}** to **{newChannel.name}**", - "VOICE_CHANNEL_LEAVE": "🎙 🔴 {userMention(member)} left **{channel.name}**", + "VOICE_CHANNEL_JOIN": "🎙 🔵 {userMention(member)} joined {channelMention(channel)}", + "VOICE_CHANNEL_MOVE": "🎙 ↔ {userMention(member)} moved from {channelMention(oldChannel)} to {channelMention(newChannel)}", + "VOICE_CHANNEL_LEAVE": "🎙 🔴 {userMention(member)} left {channelMention(channel)}", "VOICE_CHANNEL_FORCE_MOVE": "\uD83C\uDF99 ✍ {userMention(member)} was moved from **{oldChannel.name}** to **{newChannel.name}** by {userMention(mod)}", "VOICE_CHANNEL_FORCE_DISCONNECT": "\uD83C\uDF99 🚫 {userMention(member)} was forcefully disconnected from **{oldChannel.name}** by {userMention(mod)}", diff --git a/backend/src/data/GuildCounters.ts b/backend/src/data/GuildCounters.ts index 3f05972e..d3c2389a 100644 --- a/backend/src/data/GuildCounters.ts +++ b/backend/src/data/GuildCounters.ts @@ -1,49 +1,26 @@ import { BaseGuildRepository } from "./BaseGuildRepository"; -import { getRepository, In, IsNull, LessThan, Not, Repository } from "typeorm"; +import { FindConditions, getRepository, In, IsNull, LessThan, Not, Repository } from "typeorm"; import { Counter } from "./entities/Counter"; import { CounterValue } from "./entities/CounterValue"; -import { CounterTrigger, TRIGGER_COMPARISON_OPS, TriggerComparisonOp } from "./entities/CounterTrigger"; +import { + CounterTrigger, + isValidCounterComparisonOp, + TRIGGER_COMPARISON_OPS, + TriggerComparisonOp, +} from "./entities/CounterTrigger"; import { CounterTriggerState } from "./entities/CounterTriggerState"; import moment from "moment-timezone"; import { DAYS, DBDateFormat, HOURS, MINUTES } from "../utils"; import { connection } from "./db"; - -const comparisonStringRegex = new RegExp(`^(${TRIGGER_COMPARISON_OPS.join("|")})([1-9]\\d*)$`); - -/** - * @return Parsed comparison op and value, or null if the comparison string was invalid - */ -export function parseCondition(str: string): [TriggerComparisonOp, number] | null { - const matches = str.match(comparisonStringRegex); - return matches ? [matches[1] as TriggerComparisonOp, parseInt(matches[2], 10)] : null; -} - -export function buildConditionString(comparisonOp: TriggerComparisonOp, comparisonValue: number): string { - return `${comparisonOp}${comparisonValue}`; -} - -function isValidComparisonOp(op: string): boolean { - return TRIGGER_COMPARISON_OPS.includes(op as any); -} - -const REVERSE_OPS: Record = { - "=": "!=", - "!=": "=", - ">": "<=", - "<": ">=", - ">=": "<", - "<=": ">", -}; - -function getReverseComparisonOp(op: TriggerComparisonOp): TriggerComparisonOp { - return REVERSE_OPS[op]; -} +import { Queue } from "../Queue"; const DELETE_UNUSED_COUNTERS_AFTER = 1 * DAYS; const DELETE_UNUSED_COUNTER_TRIGGERS_AFTER = 1 * DAYS; const MAX_COUNTER_VALUE = 2147483647; // 2^31-1, for MySQL INT +const decayQueue = new Queue(); + async function deleteCountersMarkedToBeDeleted(): Promise { await getRepository(Counter) .createQueryBuilder() @@ -92,6 +69,8 @@ export class GuildCounters extends BaseGuildRepository { // If the existing counter's properties match the ones we're looking for, return it. // Otherwise, delete the existing counter and re-create it with the proper properties. if (existing.per_channel === perChannel && existing.per_user === perUser) { + await this.counters.update({ id: existing.id }, { delete_at: null }); + return existing; } @@ -114,24 +93,23 @@ export class GuildCounters extends BaseGuildRepository { } async markUnusedCountersToBeDeleted(idsToKeep: number[]): Promise { - if (idsToKeep.length === 0) { - return; + const criteria: FindConditions = { + guild_id: this.guildId, + delete_at: IsNull(), + }; + + if (idsToKeep.length) { + criteria.id = Not(In(idsToKeep)); } const deleteAt = moment .utc() .add(DELETE_UNUSED_COUNTERS_AFTER, "ms") .format(DBDateFormat); - await this.counters.update( - { - guild_id: this.guildId, - id: Not(In(idsToKeep)), - delete_at: IsNull(), - }, - { - delete_at: deleteAt, - }, - ); + + await this.counters.update(criteria, { + delete_at: deleteAt, + }); } async deleteCountersMarkedToBeDeleted(): Promise { @@ -183,64 +161,88 @@ export class GuildCounters extends BaseGuildRepository { ); } - async decay(id: number, decayPeriodMs: number, decayAmount: number) { - const counter = (await this.counters.findOne({ - where: { - id, - }, - }))!; + decay(id: number, decayPeriodMs: number, decayAmount: number) { + return decayQueue.add(async () => { + const counter = (await this.counters.findOne({ + where: { + id, + }, + }))!; - const diffFromLastDecayMs = moment.utc().diff(moment.utc(counter.last_decay_at!), "ms"); - if (diffFromLastDecayMs < decayPeriodMs) { - return; - } + const diffFromLastDecayMs = moment.utc().diff(moment.utc(counter.last_decay_at!), "ms"); + if (diffFromLastDecayMs < decayPeriodMs) { + return; + } - const decayAmountToApply = Math.round((diffFromLastDecayMs / decayPeriodMs) * decayAmount); - if (decayAmountToApply === 0) { - return; - } + const decayAmountToApply = Math.round((diffFromLastDecayMs / decayPeriodMs) * decayAmount); + if (decayAmountToApply === 0) { + return; + } - // Calculate new last_decay_at based on the rounded decay amount we applied. This makes it so that over time, the decayed amount will stay accurate, even if we round some here. - const newLastDecayDate = moment - .utc(counter.last_decay_at) - .add((decayAmountToApply / decayAmount) * decayPeriodMs, "ms") - .format(DBDateFormat); + // Calculate new last_decay_at based on the rounded decay amount we applied. This makes it so that over time, the decayed amount will stay accurate, even if we round some here. + const newLastDecayDate = moment + .utc(counter.last_decay_at) + .add((decayAmountToApply / decayAmount) * decayPeriodMs, "ms") + .format(DBDateFormat); - const rawUpdate = - decayAmountToApply >= 0 - ? `GREATEST(value - ${decayAmountToApply}, 0)` - : `LEAST(value + ${Math.abs(decayAmountToApply)}, ${MAX_COUNTER_VALUE})`; + const rawUpdate = + decayAmountToApply >= 0 + ? `GREATEST(value - ${decayAmountToApply}, 0)` + : `LEAST(value + ${Math.abs(decayAmountToApply)}, ${MAX_COUNTER_VALUE})`; - await this.counterValues.update( - { - counter_id: id, - }, - { - value: () => rawUpdate, - }, - ); + // Using an UPDATE with ORDER BY in an attempt to avoid deadlocks from simultaneous decays + // Also see https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlocks-handling.html + await this.counterValues + .createQueryBuilder("CounterValue") + .where("counter_id = :id", { id }) + .orderBy("id") + .update({ + value: () => rawUpdate, + }) + .execute(); - await this.counters.update( - { - id, - }, - { - last_decay_at: newLastDecayDate, - }, - ); + await this.counters.update( + { + id, + }, + { + last_decay_at: newLastDecayDate, + }, + ); + }); } - async markAllTriggersTobeDeleted() { - const deleteAt = moment - .utc() - .add(DELETE_UNUSED_COUNTER_TRIGGERS_AFTER, "ms") - .format(DBDateFormat); - await this.counterTriggers.update( - {}, - { - delete_at: deleteAt, - }, - ); + async markUnusedTriggersToBeDeleted(triggerIdsToKeep: number[]) { + let triggersToMarkQuery = this.counterTriggers + .createQueryBuilder("counterTriggers") + .innerJoin(Counter, "counters", "counters.id = counterTriggers.counter_id") + .where("counters.guild_id = :guildId", { guildId: this.guildId }); + + // If there are no active triggers, we just mark all triggers from the guild to be deleted. + // Otherwise, we mark all but the active triggers in the guild. + if (triggerIdsToKeep.length) { + triggersToMarkQuery = triggersToMarkQuery.andWhere("counterTriggers.id NOT IN (:...triggerIds)", { + triggerIds: triggerIdsToKeep, + }); + } + + const triggersToMark = await triggersToMarkQuery.getMany(); + + if (triggersToMark.length) { + const deleteAt = moment + .utc() + .add(DELETE_UNUSED_COUNTER_TRIGGERS_AFTER, "ms") + .format(DBDateFormat); + + await this.counterTriggers.update( + { + id: In(triggersToMark.map(t => t.id)), + }, + { + delete_at: deleteAt, + }, + ); + } } async deleteTriggersMarkedToBeDeleted(): Promise { @@ -253,34 +255,53 @@ export class GuildCounters extends BaseGuildRepository { async initCounterTrigger( counterId: number, + triggerName: string, comparisonOp: TriggerComparisonOp, comparisonValue: number, + reverseComparisonOp: TriggerComparisonOp, + reverseComparisonValue: number, ): Promise { - if (!isValidComparisonOp(comparisonOp)) { + if (!isValidCounterComparisonOp(comparisonOp)) { throw new Error(`Invalid comparison op: ${comparisonOp}`); } + if (!isValidCounterComparisonOp(reverseComparisonOp)) { + throw new Error(`Invalid comparison op: ${reverseComparisonOp}`); + } + if (typeof comparisonValue !== "number") { throw new Error(`Invalid comparison value: ${comparisonValue}`); } + if (typeof reverseComparisonValue !== "number") { + throw new Error(`Invalid comparison value: ${reverseComparisonValue}`); + } + return connection.transaction(async entityManager => { const existing = await entityManager.findOne(CounterTrigger, { counter_id: counterId, - comparison_op: comparisonOp, - comparison_value: comparisonValue, + name: triggerName, }); if (existing) { // Since all existing triggers are marked as to-be-deleted before they are re-initialized, this needs to be reset - await entityManager.update(CounterTrigger, existing.id, { delete_at: null }); + await entityManager.update(CounterTrigger, existing.id, { + comparison_op: comparisonOp, + comparison_value: comparisonValue, + reverse_comparison_op: reverseComparisonOp, + reverse_comparison_value: reverseComparisonValue, + delete_at: null, + }); return existing; } const insertResult = await entityManager.insert(CounterTrigger, { counter_id: counterId, + name: triggerName, comparison_op: comparisonOp, comparison_value: comparisonValue, + reverse_comparison_op: reverseComparisonOp, + reverse_comparison_value: reverseComparisonValue, }); return (await entityManager.findOne(CounterTrigger, insertResult.identifiers[0].id))!; @@ -375,8 +396,8 @@ export class GuildCounters extends BaseGuildRepository { CounterTriggerState, matchingValues.map(row => ({ trigger_id: counterTrigger.id, - channelId: row.channel_id, - userId: row.user_id, + channel_id: row.channel_id, + user_id: row.user_id, })), ); } @@ -408,7 +429,6 @@ export class GuildCounters extends BaseGuildRepository { userId = userId || "0"; return connection.transaction(async entityManager => { - const reverseOp = getReverseComparisonOp(counterTrigger.comparison_op); const matchingValue = await entityManager .createQueryBuilder(CounterValue, "cv") .innerJoin( @@ -417,7 +437,9 @@ export class GuildCounters extends BaseGuildRepository { "triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id", { triggerId: counterTrigger.id }, ) - .where(`cv.value ${reverseOp} :value`, { value: counterTrigger.comparison_value }) + .where(`cv.value ${counterTrigger.reverse_comparison_op} :value`, { + value: counterTrigger.reverse_comparison_value, + }) .andWhere(`cv.counter_id = :counterId`, { counterId: counterTrigger.counter_id }) .andWhere(`cv.channel_id = :channelId AND cv.user_id = :userId`, { channelId, userId }) .getOne(); @@ -446,7 +468,6 @@ export class GuildCounters extends BaseGuildRepository { counterTrigger: CounterTrigger, ): Promise> { return connection.transaction(async entityManager => { - const reverseOp = getReverseComparisonOp(counterTrigger.comparison_op); const matchingValues: Array<{ id: string; triggerStateId: string; @@ -460,7 +481,9 @@ export class GuildCounters extends BaseGuildRepository { "triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id", { triggerId: counterTrigger.id }, ) - .where(`cv.value ${reverseOp} :value`, { value: counterTrigger.comparison_value }) + .where(`cv.value ${counterTrigger.reverse_comparison_op} :value`, { + value: counterTrigger.reverse_comparison_value, + }) .andWhere(`cv.counter_id = :counterId`, { counterId: counterTrigger.counter_id }) .select([ "cv.id AS id", diff --git a/backend/src/data/entities/CounterTrigger.ts b/backend/src/data/entities/CounterTrigger.ts index 7cf20700..71b6bf52 100644 --- a/backend/src/data/entities/CounterTrigger.ts +++ b/backend/src/data/entities/CounterTrigger.ts @@ -4,6 +4,37 @@ export const TRIGGER_COMPARISON_OPS = ["=", "!=", ">", "<", ">=", "<="] as const export type TriggerComparisonOp = typeof TRIGGER_COMPARISON_OPS[number]; +const REVERSE_OPS: Record = { + "=": "!=", + "!=": "=", + ">": "<=", + "<": ">=", + ">=": "<", + "<=": ">", +}; + +export function getReverseCounterComparisonOp(op: TriggerComparisonOp): TriggerComparisonOp { + return REVERSE_OPS[op]; +} + +const comparisonStringRegex = new RegExp(`^(${TRIGGER_COMPARISON_OPS.join("|")})([1-9]\\d*)$`); + +/** + * @return Parsed comparison op and value, or null if the comparison string was invalid + */ +export function parseCounterConditionString(str: string): [TriggerComparisonOp, number] | null { + const matches = str.match(comparisonStringRegex); + return matches ? [matches[1] as TriggerComparisonOp, parseInt(matches[2], 10)] : null; +} + +export function buildCounterConditionString(comparisonOp: TriggerComparisonOp, comparisonValue: number): string { + return `${comparisonOp}${comparisonValue}`; +} + +export function isValidCounterComparisonOp(op: string): boolean { + return TRIGGER_COMPARISON_OPS.includes(op as any); +} + @Entity("counter_triggers") export class CounterTrigger { @PrimaryGeneratedColumn() @@ -12,12 +43,21 @@ export class CounterTrigger { @Column() counter_id: number; + @Column() + name: string; + @Column({ type: "varchar" }) comparison_op: TriggerComparisonOp; @Column() comparison_value: number; + @Column({ type: "varchar" }) + reverse_comparison_op: TriggerComparisonOp; + + @Column() + reverse_comparison_value: number; + @Column({ type: "datetime", nullable: true }) delete_at: string | null; } diff --git a/backend/src/index.ts b/backend/src/index.ts index 5e1f82a3..a2bcfea4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -156,6 +156,13 @@ connect().then(async () => { restMode: true, compress: false, guildCreateTimeout: 0, + // Disable mentions by default + allowedMentions: { + everyone: false, + users: false, + roles: false, + repliedUser: false, + }, intents: [ // Privileged "guildMembers", diff --git a/backend/src/migrations/1556909512501-MigrateUsernamesToNewHistoryTable.ts b/backend/src/migrations/1556909512501-MigrateUsernamesToNewHistoryTable.ts index 691167f9..f15417ef 100644 --- a/backend/src/migrations/1556909512501-MigrateUsernamesToNewHistoryTable.ts +++ b/backend/src/migrations/1556909512501-MigrateUsernamesToNewHistoryTable.ts @@ -11,7 +11,7 @@ export class MigrateUsernamesToNewHistoryTable1556909512501 implements Migration await new Promise(async resolve => { const stream = await queryRunner.stream("SELECT CONCAT(user_id, '-', username) AS `key` FROM username_history"); - stream.on("result", row => { + stream.on("data", (row: any) => { migratedUsernames.add(row.key); }); stream.on("end", resolve); @@ -25,7 +25,7 @@ export class MigrateUsernamesToNewHistoryTable1556909512501 implements Migration const stream = await queryRunner.stream( `SELECT * FROM name_history WHERE type=1 ORDER BY timestamp ASC LIMIT ${BATCH_SIZE}`, ); - stream.on("result", row => { + stream.on("data", (row: any) => { const key = `${row.user_id}-${row.value}`; if (!migratedUsernames.has(key)) { diff --git a/backend/src/migrations/1617363975046-UpdateCounterTriggers.ts b/backend/src/migrations/1617363975046-UpdateCounterTriggers.ts new file mode 100644 index 00000000..2e1e8664 --- /dev/null +++ b/backend/src/migrations/1617363975046-UpdateCounterTriggers.ts @@ -0,0 +1,90 @@ +import { MigrationInterface, QueryRunner, TableColumn, TableIndex } from "typeorm"; +import { TableForeignKey } from "typeorm/index"; + +export class UpdateCounterTriggers1617363975046 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Since we're adding a non-nullable unique name column and existing triggers won't have that, clear the table first + await queryRunner.query("DELETE FROM counter_triggers"); + + await queryRunner.addColumns("counter_triggers", [ + new TableColumn({ + name: "name", + type: "varchar", + length: "255", + }), + + new TableColumn({ + name: "reverse_comparison_op", + type: "varchar", + length: "16", + }), + + new TableColumn({ + name: "reverse_comparison_value", + type: "int", + }), + ]); + + // Drop foreign key for counter_id -- needed to be able to drop the following unique index + await queryRunner.dropForeignKey("counter_triggers", "FK_6bb47849ec95c87e58c5d3e6ae1"); + + // Index for ["counter_id", "comparison_op", "comparison_value"] + await queryRunner.dropIndex("counter_triggers", "IDX_ddc8a6701f1234b926d35aebf3"); + + await queryRunner.createIndex( + "counter_triggers", + new TableIndex({ + columnNames: ["counter_id", "name"], + isUnique: true, + }), + ); + + // Recreate foreign key for counter_id + await queryRunner.createForeignKey( + "counter_triggers", + new TableForeignKey({ + columnNames: ["counter_id"], + referencedTableName: "counters", + referencedColumnNames: ["id"], + onDelete: "CASCADE", + onUpdate: "CASCADE", + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Since we're going back to unique comparison op and comparison value in this reverse-migration, + // clear table contents first so we don't run into any conflicts with triggers with different names but identical comparison op and comparison value + await queryRunner.query("DELETE FROM counter_triggers"); + + // Drop foreign key for counter_id -- needed to be able to drop the following unique index + await queryRunner.dropForeignKey("counter_triggers", "FK_6bb47849ec95c87e58c5d3e6ae1"); + + // Index for ["counter_id", "name"] + await queryRunner.dropIndex("counter_triggers", "IDX_2ec128e1d74bedd0288b60cdd1"); + + await queryRunner.createIndex( + "counter_triggers", + new TableIndex({ + columnNames: ["counter_id", "comparison_op", "comparison_value"], + isUnique: true, + }), + ); + + // Recreate foreign key for counter_id + await queryRunner.createForeignKey( + "counter_triggers", + new TableForeignKey({ + columnNames: ["counter_id"], + referencedTableName: "counters", + referencedColumnNames: ["id"], + onDelete: "CASCADE", + onUpdate: "CASCADE", + }), + ); + + await queryRunner.dropColumn("counter_triggers", "reverse_comparison_value"); + await queryRunner.dropColumn("counter_triggers", "reverse_comparison_op"); + await queryRunner.dropColumn("counter_triggers", "name"); + } +} diff --git a/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts b/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts index ed8aa590..f596cf0e 100644 --- a/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts +++ b/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts @@ -7,6 +7,7 @@ import { onMessageCreate } from "./util/onMessageCreate"; import { onMessageDelete } from "./util/onMessageDelete"; import { onMessageDeleteBulk } from "./util/onMessageDeleteBulk"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin"; +import { LogsPlugin } from "../Logs/LogsPlugin"; const defaultOptions: PluginOptions = { config: { @@ -23,7 +24,7 @@ export const AutoDeletePlugin = zeppelinGuildPlugin()("aut configurationGuide: "Maximum deletion delay is currently 5 minutes", }, - dependencies: [TimeAndDatePlugin], + dependencies: [TimeAndDatePlugin, LogsPlugin], configSchema: ConfigSchema, defaultOptions, diff --git a/backend/src/plugins/AutoDelete/util/deleteNextItem.ts b/backend/src/plugins/AutoDelete/util/deleteNextItem.ts index 5bd7a790..c5ff6d23 100644 --- a/backend/src/plugins/AutoDelete/util/deleteNextItem.ts +++ b/backend/src/plugins/AutoDelete/util/deleteNextItem.ts @@ -2,24 +2,58 @@ import { GuildPluginData } from "knub"; import { AutoDeletePluginType } from "../types"; import moment from "moment-timezone"; import { LogType } from "../../../data/LogType"; -import { stripObjectToScalars, resolveUser } from "../../../utils"; +import { resolveUser, stripObjectToScalars, verboseChannelMention } from "../../../utils"; import { logger } from "../../../logger"; import { scheduleNextDeletion } from "./scheduleNextDeletion"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin"; +import { hasDiscordPermissions } from "../../../utils/hasDiscordPermissions"; +import { Constants } from "eris"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export async function deleteNextItem(pluginData: GuildPluginData) { const [itemToDelete] = pluginData.state.deletionQueue.splice(0, 1); if (!itemToDelete) return; + scheduleNextDeletion(pluginData); + + const channel = pluginData.guild.channels.get(itemToDelete.message.channel_id); + if (!channel) { + // Channel was deleted, ignore + return; + } + + const logs = pluginData.getPlugin(LogsPlugin); + const perms = channel.permissionsOf(pluginData.client.user.id); + + if (!hasDiscordPermissions(perms, Constants.Permissions.readMessages | Constants.Permissions.readMessageHistory)) { + logs.log(LogType.BOT_ALERT, { + body: `Missing permissions to read messages or message history in auto-delete channel ${verboseChannelMention( + channel, + )}`, + }); + return; + } + + if (!hasDiscordPermissions(perms, Constants.Permissions.manageMessages)) { + logs.log(LogType.BOT_ALERT, { + body: `Missing permissions to delete messages in auto-delete channel ${verboseChannelMention(channel)}`, + }); + return; + } + const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin); pluginData.state.guildLogs.ignoreLog(LogType.MESSAGE_DELETE, itemToDelete.message.id); - pluginData.client.deleteMessage(itemToDelete.message.channel_id, itemToDelete.message.id).catch(logger.warn); + pluginData.client.deleteMessage(itemToDelete.message.channel_id, itemToDelete.message.id).catch(err => { + if (err.code === 10008) { + // "Unknown Message", probably already deleted by automod or another bot, ignore + return; + } - scheduleNextDeletion(pluginData); + logger.warn(err); + }); const user = await resolveUser(pluginData.client, itemToDelete.message.user_id); - const channel = pluginData.guild.channels.get(itemToDelete.message.channel_id); const messageDate = timeAndDate .inGuildTz(moment.utc(itemToDelete.message.data.timestamp, "x")) .format(timeAndDate.getDateFormat("pretty_datetime")); diff --git a/backend/src/plugins/Automod/AutomodPlugin.ts b/backend/src/plugins/Automod/AutomodPlugin.ts index 9cd380c2..2a6cf870 100644 --- a/backend/src/plugins/Automod/AutomodPlugin.ts +++ b/backend/src/plugins/Automod/AutomodPlugin.ts @@ -29,7 +29,6 @@ import { logger } from "../../logger"; import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners"; import { RunAutomodOnMemberUpdate } from "./events/RunAutomodOnMemberUpdate"; import { CountersPlugin } from "../Counters/CountersPlugin"; -import { parseCondition } from "../../data/GuildCounters"; import { runAutomodOnCounterTrigger } from "./events/runAutomodOnCounterTrigger"; import { runAutomodOnModAction } from "./events/runAutomodOnModAction"; import { registerEventListenersFromMap } from "../../utils/registerEventListenersFromMap"; @@ -114,15 +113,6 @@ const configPreprocessor: ConfigPreprocessorFn = options => { ]); } } - - if (triggerName === "counter") { - const parsedCondition = parseCondition(triggerObj[triggerName]!.condition); - if (parsedCondition == null) { - throw new StrictValidationError([ - `Invalid counter condition '${triggerObj[triggerName]!.condition}' in rule <${rule.name}>`, - ]); - } - } } } } @@ -229,23 +219,14 @@ export const AutomodPlugin = zeppelinGuildPlugin()("automod", async onAfterLoad(pluginData) { const countersPlugin = pluginData.getPlugin(CountersPlugin); - pluginData.state.onCounterTrigger = (name, condition, channelId, userId) => { - runAutomodOnCounterTrigger(pluginData, name, condition, channelId, userId, false); + pluginData.state.onCounterTrigger = (name, triggerName, channelId, userId) => { + runAutomodOnCounterTrigger(pluginData, name, triggerName, channelId, userId, false); }; - pluginData.state.onCounterReverseTrigger = (name, condition, channelId, userId) => { - runAutomodOnCounterTrigger(pluginData, name, condition, channelId, userId, true); + pluginData.state.onCounterReverseTrigger = (name, triggerName, channelId, userId) => { + runAutomodOnCounterTrigger(pluginData, name, triggerName, channelId, userId, true); }; - const config = pluginData.config.get(); - for (const rule of Object.values(config.rules)) { - for (const trigger of rule.triggers) { - if (trigger.counter) { - await countersPlugin.initCounterTrigger(trigger.counter.name, trigger.counter.condition); - } - } - } - countersPlugin.onCounterEvent("trigger", pluginData.state.onCounterTrigger); countersPlugin.onCounterEvent("reverseTrigger", pluginData.state.onCounterReverseTrigger); @@ -254,14 +235,20 @@ export const AutomodPlugin = zeppelinGuildPlugin()("automod", pluginData.state.modActionsListeners.set("note", (userId: string) => runAutomodOnModAction(pluginData, "note", userId), ); - pluginData.state.modActionsListeners.set("warn", (userId: string) => - runAutomodOnModAction(pluginData, "warn", userId), + pluginData.state.modActionsListeners.set( + "warn", + (userId: string, reason: string | undefined, isAutomodAction: boolean) => + runAutomodOnModAction(pluginData, "warn", userId, reason, isAutomodAction), ); - pluginData.state.modActionsListeners.set("kick", (userId: string) => - runAutomodOnModAction(pluginData, "kick", userId), + pluginData.state.modActionsListeners.set( + "kick", + (userId: string, reason: string | undefined, isAutomodAction: boolean) => + runAutomodOnModAction(pluginData, "kick", userId, reason, isAutomodAction), ); - pluginData.state.modActionsListeners.set("ban", (userId: string) => - runAutomodOnModAction(pluginData, "ban", userId), + pluginData.state.modActionsListeners.set( + "ban", + (userId: string, reason: string | undefined, isAutomodAction: boolean) => + runAutomodOnModAction(pluginData, "ban", userId, reason, isAutomodAction), ); pluginData.state.modActionsListeners.set("unban", (userId: string) => runAutomodOnModAction(pluginData, "unban", userId), @@ -270,7 +257,11 @@ export const AutomodPlugin = zeppelinGuildPlugin()("automod", const mutesEvents = pluginData.getPlugin(MutesPlugin).getEventEmitter(); pluginData.state.mutesListeners = new Map(); - pluginData.state.mutesListeners.set("mute", (userId: string) => runAutomodOnModAction(pluginData, "mute", userId)); + pluginData.state.mutesListeners.set( + "mute", + (userId: string, reason: string | undefined, isAutomodAction: boolean) => + runAutomodOnModAction(pluginData, "mute", userId, reason, isAutomodAction), + ); pluginData.state.mutesListeners.set("unmute", (userId: string) => runAutomodOnModAction(pluginData, "unmute", userId), ); diff --git a/backend/src/plugins/Automod/actions/addRoles.ts b/backend/src/plugins/Automod/actions/addRoles.ts index 4da915e7..976300a5 100644 --- a/backend/src/plugins/Automod/actions/addRoles.ts +++ b/backend/src/plugins/Automod/actions/addRoles.ts @@ -9,6 +9,7 @@ import { getMissingPermissions } from "../../../utils/getMissingPermissions"; import { canAssignRole } from "../../../utils/canAssignRole"; import { missingPermissionError } from "../../../utils/missingPermissionError"; import { ignoreRoleChange } from "../functions/ignoredRoleChanges"; +import { memberRolesLock } from "../../../utils/lockNameHelpers"; const p = Constants.Permissions; @@ -64,7 +65,7 @@ export const AddRolesAction = automodAction({ return; } - const memberRolesLock = await pluginData.locks.acquire(`member-roles-${member.id}`); + const memberRoleLock = await pluginData.locks.acquire(memberRolesLock(member)); const rolesArr = Array.from(memberRoles.values()); await member.edit({ @@ -72,7 +73,7 @@ export const AddRolesAction = automodAction({ }); member.roles = rolesArr; // Make sure we know of the new roles internally as well - memberRolesLock.unlock(); + memberRoleLock.unlock(); }), ); }, diff --git a/backend/src/plugins/Automod/actions/addToCounter.ts b/backend/src/plugins/Automod/actions/addToCounter.ts new file mode 100644 index 00000000..7606f91d --- /dev/null +++ b/backend/src/plugins/Automod/actions/addToCounter.ts @@ -0,0 +1,30 @@ +import * as t from "io-ts"; +import { automodAction } from "../helpers"; +import { CountersPlugin } from "../../Counters/CountersPlugin"; +import { LogType } from "../../../data/LogType"; + +export const AddToCounterAction = automodAction({ + configType: t.type({ + counter: t.string, + amount: t.number, + }), + + defaultConfig: {}, + + async apply({ pluginData, contexts, actionConfig, matchResult, ruleName }) { + const countersPlugin = pluginData.getPlugin(CountersPlugin); + if (!countersPlugin.counterExists(actionConfig.counter)) { + pluginData.state.logs.log(LogType.BOT_ALERT, { + body: `Unknown counter \`${actionConfig.counter}\` in \`add_to_counter\` action of Automod rule \`${ruleName}\``, + }); + return; + } + + countersPlugin.changeCounterValue( + actionConfig.counter, + contexts[0].message?.channel_id || null, + contexts[0].user?.id || null, + actionConfig.amount, + ); + }, +}); diff --git a/backend/src/plugins/Automod/actions/alert.ts b/backend/src/plugins/Automod/actions/alert.ts index f4a098c9..b22f8e3c 100644 --- a/backend/src/plugins/Automod/actions/alert.ts +++ b/backend/src/plugins/Automod/actions/alert.ts @@ -9,6 +9,7 @@ import { resolveMember, stripObjectToScalars, tNullable, + verboseChannelMention, } from "../../../utils"; import { resolveActionContactMethods } from "../functions/resolveActionContactMethods"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin"; @@ -68,7 +69,23 @@ export const AlertAction = automodAction({ throw err; } - await createChunkedMessage(channel, rendered); + try { + await createChunkedMessage(channel, rendered); + } catch (err) { + if (err.code === 50001) { + logs.log(LogType.BOT_ALERT, { + body: `Missing access to send alert to channel ${verboseChannelMention( + channel, + )} in automod rule **${ruleName}**`, + }); + } else { + logs.log(LogType.BOT_ALERT, { + body: `Error ${err.code || "UNKNOWN"} when sending alert to channel ${verboseChannelMention( + channel, + )} in automod rule **${ruleName}**`, + }); + } + } } else { logs.log(LogType.BOT_ALERT, { body: `Invalid channel id \`${actionConfig.channel}\` for alert action in automod rule **${ruleName}**`, diff --git a/backend/src/plugins/Automod/actions/availableActions.ts b/backend/src/plugins/Automod/actions/availableActions.ts index 21ebe945..7857db6f 100644 --- a/backend/src/plugins/Automod/actions/availableActions.ts +++ b/backend/src/plugins/Automod/actions/availableActions.ts @@ -12,7 +12,8 @@ import { AddRolesAction } from "./addRoles"; import { RemoveRolesAction } from "./removeRoles"; import { SetAntiraidLevelAction } from "./setAntiraidLevel"; import { ReplyAction } from "./reply"; -import { ChangeCounterAction } from "./changeCounter"; +import { AddToCounterAction } from "./addToCounter"; +import { SetCounterAction } from "./setCounter"; export const availableActions: Record> = { clean: CleanAction, @@ -27,7 +28,8 @@ export const availableActions: Record> = { remove_roles: RemoveRolesAction, set_antiraid_level: SetAntiraidLevelAction, reply: ReplyAction, - change_counter: ChangeCounterAction, + add_to_counter: AddToCounterAction, + set_counter: SetCounterAction, }; export const AvailableActions = t.type({ @@ -43,5 +45,6 @@ export const AvailableActions = t.type({ remove_roles: RemoveRolesAction.configType, set_antiraid_level: SetAntiraidLevelAction.configType, reply: ReplyAction.configType, - change_counter: ChangeCounterAction.configType, + add_to_counter: AddToCounterAction.configType, + set_counter: SetCounterAction.configType, }); diff --git a/backend/src/plugins/Automod/actions/ban.ts b/backend/src/plugins/Automod/actions/ban.ts index 0b53aff7..ab9e811a 100644 --- a/backend/src/plugins/Automod/actions/ban.ts +++ b/backend/src/plugins/Automod/actions/ban.ts @@ -4,6 +4,7 @@ import { LogType } from "../../../data/LogType"; import { asyncMap, nonNullish, resolveMember, tNullable, unique } from "../../../utils"; import { resolveActionContactMethods } from "../functions/resolveActionContactMethods"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin"; +import { CaseArgs } from "../../Cases/types"; export const BanAction = automodAction({ configType: t.type({ @@ -22,16 +23,22 @@ export const BanAction = automodAction({ const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined; const deleteMessageDays = actionConfig.deleteMessageDays || undefined; - const caseArgs = { + const caseArgs: Partial = { modId: pluginData.client.user.id, extraNotes: matchResult.fullSummary ? [matchResult.fullSummary] : [], + automatic: true, }; const userIdsToBan = unique(contexts.map(c => c.user?.id).filter(nonNullish)); const modActions = pluginData.getPlugin(ModActionsPlugin); for (const userId of userIdsToBan) { - await modActions.banUserId(userId, reason, { contactMethods, caseArgs, deleteMessageDays }); + await modActions.banUserId(userId, reason, { + contactMethods, + caseArgs, + deleteMessageDays, + isAutomodAction: true, + }); } }, }); diff --git a/backend/src/plugins/Automod/actions/changeCounter.ts b/backend/src/plugins/Automod/actions/changeCounter.ts deleted file mode 100644 index a3db8d31..00000000 --- a/backend/src/plugins/Automod/actions/changeCounter.ts +++ /dev/null @@ -1,27 +0,0 @@ -import * as t from "io-ts"; -import { automodAction } from "../helpers"; -import { CountersPlugin } from "../../Counters/CountersPlugin"; - -export const ChangeCounterAction = automodAction({ - configType: t.type({ - name: t.string, - change: t.string, - }), - - defaultConfig: {}, - - async apply({ pluginData, contexts, actionConfig, matchResult }) { - const change = parseInt(actionConfig.change, 10); - if (Number.isNaN(change)) { - throw new Error("Invalid change number"); - } - - const countersPlugin = pluginData.getPlugin(CountersPlugin); - countersPlugin.changeCounterValue( - actionConfig.name, - contexts[0].message?.channel_id || null, - contexts[0].user?.id || null, - change, - ); - }, -}); diff --git a/backend/src/plugins/Automod/actions/kick.ts b/backend/src/plugins/Automod/actions/kick.ts index 26f0dcc7..b11c44ae 100644 --- a/backend/src/plugins/Automod/actions/kick.ts +++ b/backend/src/plugins/Automod/actions/kick.ts @@ -4,6 +4,7 @@ import { LogType } from "../../../data/LogType"; import { asyncMap, nonNullish, resolveMember, tNullable, unique } from "../../../utils"; import { resolveActionContactMethods } from "../functions/resolveActionContactMethods"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin"; +import { CaseArgs } from "../../Cases/types"; export const KickAction = automodAction({ configType: t.type({ @@ -20,9 +21,10 @@ export const KickAction = automodAction({ const reason = actionConfig.reason || "Kicked automatically"; const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined; - const caseArgs = { + const caseArgs: Partial = { modId: pluginData.client.user.id, extraNotes: matchResult.fullSummary ? [matchResult.fullSummary] : [], + automatic: true, }; const userIdsToKick = unique(contexts.map(c => c.user?.id).filter(nonNullish)); @@ -31,7 +33,7 @@ export const KickAction = automodAction({ const modActions = pluginData.getPlugin(ModActionsPlugin); for (const member of membersToKick) { if (!member) continue; - await modActions.kickMember(member, reason, { contactMethods, caseArgs }); + await modActions.kickMember(member, reason, { contactMethods, caseArgs, isAutomodAction: true }); } }, }); diff --git a/backend/src/plugins/Automod/actions/mute.ts b/backend/src/plugins/Automod/actions/mute.ts index 6115a042..f09fbe34 100644 --- a/backend/src/plugins/Automod/actions/mute.ts +++ b/backend/src/plugins/Automod/actions/mute.ts @@ -15,6 +15,7 @@ import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin"; import { MutesPlugin } from "../../Mutes/MutesPlugin"; import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError"; import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { CaseArgs } from "../../Cases/types"; export const MuteAction = automodAction({ configType: t.type({ @@ -37,9 +38,10 @@ export const MuteAction = automodAction({ const rolesToRemove = actionConfig.remove_roles_on_mute; const rolesToRestore = actionConfig.restore_roles_on_mute; - const caseArgs = { + const caseArgs: Partial = { modId: pluginData.client.user.id, extraNotes: matchResult.fullSummary ? [matchResult.fullSummary] : [], + automatic: true, }; const userIdsToMute = unique(contexts.map(c => c.user?.id).filter(nonNullish)); @@ -47,7 +49,14 @@ export const MuteAction = automodAction({ const mutes = pluginData.getPlugin(MutesPlugin); for (const userId of userIdsToMute) { try { - await mutes.muteUser(userId, duration, reason, { contactMethods, caseArgs }, rolesToRemove, rolesToRestore); + await mutes.muteUser( + userId, + duration, + reason, + { contactMethods, caseArgs, isAutomodAction: true }, + rolesToRemove, + rolesToRestore, + ); } catch (e) { if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { diff --git a/backend/src/plugins/Automod/actions/removeRoles.ts b/backend/src/plugins/Automod/actions/removeRoles.ts index 6049cbf2..c6c74e49 100644 --- a/backend/src/plugins/Automod/actions/removeRoles.ts +++ b/backend/src/plugins/Automod/actions/removeRoles.ts @@ -10,6 +10,7 @@ import { missingPermissionError } from "../../../utils/missingPermissionError"; import { canAssignRole } from "../../../utils/canAssignRole"; import { Constants } from "eris"; import { ignoreRoleChange } from "../functions/ignoredRoleChanges"; +import { memberRolesLock } from "../../../utils/lockNameHelpers"; const p = Constants.Permissions; @@ -66,7 +67,7 @@ export const RemoveRolesAction = automodAction({ return; } - const memberRolesLock = await pluginData.locks.acquire(`member-roles-${member.id}`); + const memberRoleLock = await pluginData.locks.acquire(memberRolesLock(member)); const rolesArr = Array.from(memberRoles.values()); await member.edit({ @@ -74,7 +75,7 @@ export const RemoveRolesAction = automodAction({ }); member.roles = rolesArr; // Make sure we know of the new roles internally as well - memberRolesLock.unlock(); + memberRoleLock.unlock(); }), ); }, diff --git a/backend/src/plugins/Automod/actions/reply.ts b/backend/src/plugins/Automod/actions/reply.ts index 0a451d00..579ff3fa 100644 --- a/backend/src/plugins/Automod/actions/reply.ts +++ b/backend/src/plugins/Automod/actions/reply.ts @@ -9,10 +9,13 @@ import { tMessageContent, tNullable, unique, + verboseChannelMention, } from "../../../utils"; -import { TextChannel } from "eris"; +import { AdvancedMessageContent, Constants, MessageContent, TextChannel, User } from "eris"; import { AutomodContext } from "../types"; import { renderTemplate } from "../../../templateFormatter"; +import { hasDiscordPermissions } from "../../../utils/hasDiscordPermissions"; +import { LogType } from "../../../data/LogType"; export const ReplyAction = automodAction({ configType: t.union([ @@ -25,7 +28,7 @@ export const ReplyAction = automodAction({ defaultConfig: {}, - async apply({ pluginData, contexts, actionConfig }) { + async apply({ pluginData, contexts, actionConfig, ruleName }) { const contextsWithTextChannels = contexts .filter(c => c.message?.channel_id) .filter(c => pluginData.guild.channels.get(c.message!.channel_id) instanceof TextChannel); @@ -40,7 +43,7 @@ export const ReplyAction = automodAction({ }, new Map()); for (const [channelId, _contexts] of contextsByChannelId.entries()) { - const users = unique(Array.from(new Set(_contexts.map(c => c.user).filter(Boolean)))); + const users = unique(Array.from(new Set(_contexts.map(c => c.user).filter(Boolean)))) as User[]; const user = users[0]; const renderReplyText = async str => @@ -50,10 +53,37 @@ export const ReplyAction = automodAction({ const formatted = typeof actionConfig === "string" ? await renderReplyText(actionConfig) - : await renderRecursively(actionConfig.text, renderReplyText); + : ((await renderRecursively(actionConfig.text, renderReplyText)) as AdvancedMessageContent); if (formatted) { const channel = pluginData.guild.channels.get(channelId) as TextChannel; + + // Check for basic Send Messages and View Channel permissions + if ( + !hasDiscordPermissions( + channel.permissionsOf(pluginData.client.user.id), + Constants.Permissions.sendMessages | Constants.Permissions.readMessages, + ) + ) { + pluginData.state.logs.log(LogType.BOT_ALERT, { + body: `Missing permissions to reply in ${verboseChannelMention(channel)} in Automod rule \`${ruleName}\``, + }); + continue; + } + + // If the message is an embed, check for embed permissions + if ( + typeof formatted !== "string" && + !hasDiscordPermissions(channel.permissionsOf(pluginData.client.user.id), Constants.Permissions.embedLinks) + ) { + pluginData.state.logs.log(LogType.BOT_ALERT, { + body: `Missing permissions to reply **with an embed** in ${verboseChannelMention( + channel, + )} in Automod rule \`${ruleName}\``, + }); + continue; + } + const replyMsg = await channel.createMessage(formatted); if (typeof actionConfig === "object" && actionConfig.auto_delete) { diff --git a/backend/src/plugins/Automod/actions/setAntiraidLevel.ts b/backend/src/plugins/Automod/actions/setAntiraidLevel.ts index fca9632f..8d15a35f 100644 --- a/backend/src/plugins/Automod/actions/setAntiraidLevel.ts +++ b/backend/src/plugins/Automod/actions/setAntiraidLevel.ts @@ -1,12 +1,13 @@ import * as t from "io-ts"; import { automodAction } from "../helpers"; import { setAntiraidLevel } from "../functions/setAntiraidLevel"; +import { tNullable } from "../../../utils"; export const SetAntiraidLevelAction = automodAction({ - configType: t.string, + configType: tNullable(t.string), defaultConfig: "", async apply({ pluginData, contexts, actionConfig }) { - setAntiraidLevel(pluginData, actionConfig); + setAntiraidLevel(pluginData, actionConfig ?? null); }, }); diff --git a/backend/src/plugins/Automod/actions/setCounter.ts b/backend/src/plugins/Automod/actions/setCounter.ts new file mode 100644 index 00000000..eb63d80d --- /dev/null +++ b/backend/src/plugins/Automod/actions/setCounter.ts @@ -0,0 +1,30 @@ +import * as t from "io-ts"; +import { automodAction } from "../helpers"; +import { CountersPlugin } from "../../Counters/CountersPlugin"; +import { LogType } from "../../../data/LogType"; + +export const SetCounterAction = automodAction({ + configType: t.type({ + counter: t.string, + value: t.number, + }), + + defaultConfig: {}, + + async apply({ pluginData, contexts, actionConfig, matchResult, ruleName }) { + const countersPlugin = pluginData.getPlugin(CountersPlugin); + if (!countersPlugin.counterExists(actionConfig.counter)) { + pluginData.state.logs.log(LogType.BOT_ALERT, { + body: `Unknown counter \`${actionConfig.counter}\` in \`add_to_counter\` action of Automod rule \`${ruleName}\``, + }); + return; + } + + countersPlugin.setCounterValue( + actionConfig.counter, + contexts[0].message?.channel_id || null, + contexts[0].user?.id || null, + actionConfig.value, + ); + }, +}); diff --git a/backend/src/plugins/Automod/actions/warn.ts b/backend/src/plugins/Automod/actions/warn.ts index 31a898b6..70240080 100644 --- a/backend/src/plugins/Automod/actions/warn.ts +++ b/backend/src/plugins/Automod/actions/warn.ts @@ -4,6 +4,7 @@ import { LogType } from "../../../data/LogType"; import { asyncMap, nonNullish, resolveMember, tNullable, unique } from "../../../utils"; import { resolveActionContactMethods } from "../functions/resolveActionContactMethods"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin"; +import { CaseArgs } from "../../Cases/types"; export const WarnAction = automodAction({ configType: t.type({ @@ -20,9 +21,10 @@ export const WarnAction = automodAction({ const reason = actionConfig.reason || "Warned automatically"; const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined; - const caseArgs = { + const caseArgs: Partial = { modId: pluginData.client.user.id, extraNotes: matchResult.fullSummary ? [matchResult.fullSummary] : [], + automatic: true, }; const userIdsToWarn = unique(contexts.map(c => c.user?.id).filter(nonNullish)); @@ -31,7 +33,7 @@ export const WarnAction = automodAction({ const modActions = pluginData.getPlugin(ModActionsPlugin); for (const member of membersToWarn) { if (!member) continue; - await modActions.warnMember(member, reason, { contactMethods, caseArgs }); + await modActions.warnMember(member, reason, { contactMethods, caseArgs, isAutomodAction: true }); } }, }); diff --git a/backend/src/plugins/Automod/events/runAutomodOnAntiraidLevel.ts b/backend/src/plugins/Automod/events/runAutomodOnAntiraidLevel.ts new file mode 100644 index 00000000..f36e1067 --- /dev/null +++ b/backend/src/plugins/Automod/events/runAutomodOnAntiraidLevel.ts @@ -0,0 +1,22 @@ +import { GuildPluginData } from "knub"; +import { AutomodContext, AutomodPluginType } from "../types"; +import { runAutomod } from "../functions/runAutomod"; +import { User } from "eris"; + +export async function runAutomodOnAntiraidLevel( + pluginData: GuildPluginData, + level: string | null, + user?: User, +) { + const context: AutomodContext = { + timestamp: Date.now(), + antiraid: { + level, + }, + user, + }; + + pluginData.state.queue.add(async () => { + await runAutomod(pluginData, context); + }); +} diff --git a/backend/src/plugins/Automod/events/runAutomodOnCounterTrigger.ts b/backend/src/plugins/Automod/events/runAutomodOnCounterTrigger.ts index 50939d80..2b1b974d 100644 --- a/backend/src/plugins/Automod/events/runAutomodOnCounterTrigger.ts +++ b/backend/src/plugins/Automod/events/runAutomodOnCounterTrigger.ts @@ -2,30 +2,38 @@ import { GuildPluginData } from "knub"; import { AutomodContext, AutomodPluginType } from "../types"; import { runAutomod } from "../functions/runAutomod"; import { resolveMember, resolveUser, UnknownUser } from "../../../utils"; +import { CountersPlugin } from "../../Counters/CountersPlugin"; export async function runAutomodOnCounterTrigger( pluginData: GuildPluginData, counterName: string, - condition: string, + triggerName: string, channelId: string | null, userId: string | null, reverse: boolean, ) { const user = userId ? await resolveUser(pluginData.client, userId) : undefined; - const member = (userId && (await resolveMember(pluginData.client, pluginData.guild, userId))) || undefined; + const prettyCounterName = pluginData.getPlugin(CountersPlugin).getPrettyNameForCounter(counterName); + const prettyTriggerName = pluginData + .getPlugin(CountersPlugin) + .getPrettyNameForCounterTrigger(counterName, triggerName); + const context: AutomodContext = { timestamp: Date.now(), counterTrigger: { - name: counterName, - condition, + counter: counterName, + trigger: triggerName, + prettyCounter: prettyCounterName, + prettyTrigger: prettyTriggerName, channelId, userId, reverse, }, user: user instanceof UnknownUser ? undefined : user, member, + // TODO: Channel }; pluginData.state.queue.add(async () => { diff --git a/backend/src/plugins/Automod/events/runAutomodOnModAction.ts b/backend/src/plugins/Automod/events/runAutomodOnModAction.ts index 5831f1eb..59b2b650 100644 --- a/backend/src/plugins/Automod/events/runAutomodOnModAction.ts +++ b/backend/src/plugins/Automod/events/runAutomodOnModAction.ts @@ -9,6 +9,7 @@ export async function runAutomodOnModAction( modAction: ModActionType, userId: string, reason?: string, + isAutomodAction: boolean = false, ) { const user = await resolveUser(pluginData.client, userId); @@ -18,6 +19,7 @@ export async function runAutomodOnModAction( modAction: { type: modAction, reason, + isAutomodAction, }, }; diff --git a/backend/src/plugins/Automod/functions/checkAndUpdateCooldown.ts b/backend/src/plugins/Automod/functions/checkAndUpdateCooldown.ts index 4dfc1abe..887d18f0 100644 --- a/backend/src/plugins/Automod/functions/checkAndUpdateCooldown.ts +++ b/backend/src/plugins/Automod/functions/checkAndUpdateCooldown.ts @@ -8,7 +8,7 @@ export function checkAndUpdateCooldown( rule: TRule, context: AutomodContext, ) { - const cooldownKey = context.user?.id; + const cooldownKey = `${rule.name}-${context.user?.id}`; if (cooldownKey) { if (pluginData.state.cooldownManager.isOnCooldown(cooldownKey)) { diff --git a/backend/src/plugins/Automod/functions/runAutomod.ts b/backend/src/plugins/Automod/functions/runAutomod.ts index 3d3af219..3b433c88 100644 --- a/backend/src/plugins/Automod/functions/runAutomod.ts +++ b/backend/src/plugins/Automod/functions/runAutomod.ts @@ -24,10 +24,10 @@ export async function runAutomod(pluginData: GuildPluginData, for (const [ruleName, rule] of Object.entries(config.rules)) { if (rule.enabled === false) continue; - if (!rule.affects_bots && (!user || user.bot) && !context.counterTrigger) continue; + if (!rule.affects_bots && (!user || user.bot) && !context.counterTrigger && !context.antiraid) continue; if (rule.cooldown && checkAndUpdateCooldown(pluginData, rule, context)) { - return; + continue; } let matchResult: AutomodTriggerMatchResult | null | undefined; diff --git a/backend/src/plugins/Automod/functions/setAntiraidLevel.ts b/backend/src/plugins/Automod/functions/setAntiraidLevel.ts index 2d446bf8..eb72c3ec 100644 --- a/backend/src/plugins/Automod/functions/setAntiraidLevel.ts +++ b/backend/src/plugins/Automod/functions/setAntiraidLevel.ts @@ -4,6 +4,7 @@ import { AutomodPluginType } from "../types"; import { LogsPlugin } from "../../Logs/LogsPlugin"; import { LogType } from "../../../data/LogType"; import { stripObjectToScalars } from "../../../utils"; +import { runAutomodOnAntiraidLevel } from "../events/runAutomodOnAntiraidLevel"; export async function setAntiraidLevel( pluginData: GuildPluginData, @@ -13,6 +14,8 @@ export async function setAntiraidLevel( pluginData.state.cachedAntiraidLevel = newLevel; await pluginData.state.antiraidLevels.set(newLevel); + runAutomodOnAntiraidLevel(pluginData, newLevel, user); + const logs = pluginData.getPlugin(LogsPlugin); if (user) { diff --git a/backend/src/plugins/Automod/info.ts b/backend/src/plugins/Automod/info.ts index c2c87dbf..9fcdced0 100644 --- a/backend/src/plugins/Automod/info.ts +++ b/backend/src/plugins/Automod/info.ts @@ -64,9 +64,9 @@ export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = { reason: 'Auto-muted for spam' my_second_filter: triggers: - - message_spam: - amount: 5 - within: 10s + - emoji_spam: + amount: 2 + within: 5s actions: clean: true overrides: diff --git a/backend/src/plugins/Automod/triggers/antiraidLevel.ts b/backend/src/plugins/Automod/triggers/antiraidLevel.ts new file mode 100644 index 00000000..98398811 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/antiraidLevel.ts @@ -0,0 +1,33 @@ +import * as t from "io-ts"; +import { automodTrigger } from "../helpers"; +import { tNullable } from "../../../utils"; + +// tslint:disable-next-line +interface AntiraidLevelTriggerResult {} + +export const AntiraidLevelTrigger = automodTrigger()({ + configType: t.type({ + level: tNullable(t.string), + }), + + defaultConfig: {}, + + async match({ triggerConfig, context, pluginData }) { + if (!context.antiraid) { + return; + } + + if (context.antiraid.level !== triggerConfig.level) { + return; + } + + return { + extra: {}, + }; + }, + + renderMatchInformation({ matchResult, pluginData, contexts, triggerConfig }) { + const newLevel = contexts[0].antiraid!.level; + return newLevel ? `Antiraid level was set to ${newLevel}` : `Antiraid was turned off`; + }, +}); diff --git a/backend/src/plugins/Automod/triggers/anyMessage.ts b/backend/src/plugins/Automod/triggers/anyMessage.ts new file mode 100644 index 00000000..466f0460 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/anyMessage.ts @@ -0,0 +1,29 @@ +import * as t from "io-ts"; +import { automodTrigger } from "../helpers"; +import { verboseChannelMention } from "../../../utils"; + +// tslint:disable-next-line:no-empty-interface +interface AnyMessageResultType {} + +export const AnyMessageTrigger = automodTrigger()({ + configType: t.type({}), + + defaultConfig: {}, + + async match({ pluginData, context, triggerConfig: trigger }) { + if (!context.message) { + return; + } + + return { + extra: {}, + }; + }, + + renderMatchInformation({ pluginData, contexts, matchResult }) { + const channel = pluginData.guild.channels.get(contexts[0].message!.channel_id); + return `Matched message (\`${contexts[0].message!.id}\`) in ${ + channel ? verboseChannelMention(channel) : "Unknown Channel" + }`; + }, +}); diff --git a/backend/src/plugins/Automod/triggers/availableTriggers.ts b/backend/src/plugins/Automod/triggers/availableTriggers.ts index 175df6d7..cbb5b7d2 100644 --- a/backend/src/plugins/Automod/triggers/availableTriggers.ts +++ b/backend/src/plugins/Automod/triggers/availableTriggers.ts @@ -17,7 +17,7 @@ import { MemberJoinTrigger } from "./memberJoin"; import { RoleAddedTrigger } from "./roleAdded"; import { RoleRemovedTrigger } from "./roleRemoved"; import { StickerSpamTrigger } from "./stickerSpam"; -import { CounterTrigger } from "./counter"; +import { CounterTrigger } from "./counterTrigger"; import { NoteTrigger } from "./note"; import { WarnTrigger } from "./warn"; import { MuteTrigger } from "./mute"; @@ -25,8 +25,12 @@ import { UnmuteTrigger } from "./unmute"; import { KickTrigger } from "./kick"; import { BanTrigger } from "./ban"; import { UnbanTrigger } from "./unban"; +import { AnyMessageTrigger } from "./anyMessage"; +import { AntiraidLevelTrigger } from "./antiraidLevel"; export const availableTriggers: Record> = { + any_message: AnyMessageTrigger, + match_words: MatchWordsTrigger, match_regex: MatchRegexTrigger, match_invites: MatchInvitesTrigger, @@ -46,7 +50,7 @@ export const availableTriggers: Record member_join_spam: MemberJoinSpamTrigger, sticker_spam: StickerSpamTrigger, - counter: CounterTrigger, + counter_trigger: CounterTrigger, note: NoteTrigger, warn: WarnTrigger, @@ -55,9 +59,13 @@ export const availableTriggers: Record kick: KickTrigger, ban: BanTrigger, unban: UnbanTrigger, + + antiraid_level: AntiraidLevelTrigger, }; export const AvailableTriggers = t.type({ + any_message: AnyMessageTrigger.configType, + match_words: MatchWordsTrigger.configType, match_regex: MatchRegexTrigger.configType, match_invites: MatchInvitesTrigger.configType, @@ -77,7 +85,7 @@ export const AvailableTriggers = t.type({ member_join_spam: MemberJoinSpamTrigger.configType, sticker_spam: StickerSpamTrigger.configType, - counter: CounterTrigger.configType, + counter_trigger: CounterTrigger.configType, note: NoteTrigger.configType, warn: WarnTrigger.configType, @@ -86,4 +94,6 @@ export const AvailableTriggers = t.type({ kick: KickTrigger.configType, ban: BanTrigger.configType, unban: UnbanTrigger.configType, + + antiraid_level: AntiraidLevelTrigger.configType, }); diff --git a/backend/src/plugins/Automod/triggers/ban.ts b/backend/src/plugins/Automod/triggers/ban.ts index 64559e74..a7c16742 100644 --- a/backend/src/plugins/Automod/triggers/ban.ts +++ b/backend/src/plugins/Automod/triggers/ban.ts @@ -5,13 +5,25 @@ import { automodTrigger } from "../helpers"; interface BanTriggerResultType {} export const BanTrigger = automodTrigger()({ - configType: t.type({}), - defaultConfig: {}, + configType: t.type({ + manual: t.boolean, + automatic: t.boolean, + }), - async match({ context }) { + defaultConfig: { + manual: true, + automatic: true, + }, + + async match({ context, triggerConfig }) { if (context.modAction?.type !== "ban") { return; } + console.log(context); + // If automatic && automatic turned off -> return + if (context.modAction.isAutomodAction && !triggerConfig.automatic) return; + // If manual && manual turned off -> return + if (!context.modAction.isAutomodAction && !triggerConfig.manual) return; return { extra: {}, diff --git a/backend/src/plugins/Automod/triggers/counter.ts b/backend/src/plugins/Automod/triggers/counterTrigger.ts similarity index 67% rename from backend/src/plugins/Automod/triggers/counter.ts rename to backend/src/plugins/Automod/triggers/counterTrigger.ts index 6b3c3288..ddfda013 100644 --- a/backend/src/plugins/Automod/triggers/counter.ts +++ b/backend/src/plugins/Automod/triggers/counterTrigger.ts @@ -9,8 +9,8 @@ interface CounterTriggerResult {} export const CounterTrigger = automodTrigger()({ configType: t.type({ - name: t.string, - condition: t.string, + counter: t.string, + trigger: t.string, reverse: tNullable(t.boolean), }), @@ -21,11 +21,11 @@ export const CounterTrigger = automodTrigger()({ return; } - if (context.counterTrigger.name !== triggerConfig.name) { + if (context.counterTrigger.counter !== triggerConfig.counter) { return; } - if (context.counterTrigger.condition !== triggerConfig.condition) { + if (context.counterTrigger.trigger !== triggerConfig.trigger) { return; } @@ -40,7 +40,13 @@ export const CounterTrigger = automodTrigger()({ }, renderMatchInformation({ matchResult, pluginData, contexts, triggerConfig }) { - // TODO: Show user, channel, reverse - return `Matched counter \`${triggerConfig.name} ${triggerConfig.condition}\``; + let str = `Matched counter trigger \`${contexts[0].counterTrigger!.prettyCounter} / ${ + contexts[0].counterTrigger!.prettyTrigger + }\``; + if (contexts[0].counterTrigger!.reverse) { + str += " (reverse)"; + } + + return str; }, }); diff --git a/backend/src/plugins/Automod/triggers/kick.ts b/backend/src/plugins/Automod/triggers/kick.ts index 284a867d..116f1252 100644 --- a/backend/src/plugins/Automod/triggers/kick.ts +++ b/backend/src/plugins/Automod/triggers/kick.ts @@ -5,13 +5,24 @@ import { automodTrigger } from "../helpers"; interface KickTriggerResultType {} export const KickTrigger = automodTrigger()({ - configType: t.type({}), - defaultConfig: {}, + configType: t.type({ + manual: t.boolean, + automatic: t.boolean, + }), - async match({ context }) { + defaultConfig: { + manual: true, + automatic: true, + }, + + async match({ context, triggerConfig }) { if (context.modAction?.type !== "kick") { return; } + // If automatic && automatic turned off -> return + if (context.modAction.isAutomodAction && !triggerConfig.automatic) return; + // If manual && manual turned off -> return + if (!context.modAction.isAutomodAction && !triggerConfig.manual) return; return { extra: {}, diff --git a/backend/src/plugins/Automod/triggers/mute.ts b/backend/src/plugins/Automod/triggers/mute.ts index 94d14437..c5e2d2ba 100644 --- a/backend/src/plugins/Automod/triggers/mute.ts +++ b/backend/src/plugins/Automod/triggers/mute.ts @@ -5,13 +5,24 @@ import { automodTrigger } from "../helpers"; interface MuteTriggerResultType {} export const MuteTrigger = automodTrigger()({ - configType: t.type({}), - defaultConfig: {}, + configType: t.type({ + manual: t.boolean, + automatic: t.boolean, + }), - async match({ context }) { + defaultConfig: { + manual: true, + automatic: true, + }, + + async match({ context, triggerConfig }) { if (context.modAction?.type !== "mute") { return; } + // If automatic && automatic turned off -> return + if (context.modAction.isAutomodAction && !triggerConfig.automatic) return; + // If manual && manual turned off -> return + if (!context.modAction.isAutomodAction && !triggerConfig.manual) return; return { extra: {}, diff --git a/backend/src/plugins/Automod/triggers/warn.ts b/backend/src/plugins/Automod/triggers/warn.ts index 711f5cd7..545c4437 100644 --- a/backend/src/plugins/Automod/triggers/warn.ts +++ b/backend/src/plugins/Automod/triggers/warn.ts @@ -5,13 +5,24 @@ import { automodTrigger } from "../helpers"; interface WarnTriggerResultType {} export const WarnTrigger = automodTrigger()({ - configType: t.type({}), - defaultConfig: {}, + configType: t.type({ + manual: t.boolean, + automatic: t.boolean, + }), - async match({ context }) { + defaultConfig: { + manual: true, + automatic: true, + }, + + async match({ context, triggerConfig }) { if (context.modAction?.type !== "warn") { return; } + // If automatic && automatic turned off -> return + if (context.modAction.isAutomodAction && !triggerConfig.automatic) return; + // If manual && manual turned off -> return + if (!context.modAction.isAutomodAction && !triggerConfig.manual) return; return { extra: {}, diff --git a/backend/src/plugins/Automod/types.ts b/backend/src/plugins/Automod/types.ts index f6e273cb..cd7ebacf 100644 --- a/backend/src/plugins/Automod/types.ts +++ b/backend/src/plugins/Automod/types.ts @@ -103,8 +103,10 @@ export interface AutomodContext { actioned?: boolean; counterTrigger?: { - name: string; - condition: string; + counter: string; + trigger: string; + prettyCounter: string; + prettyTrigger: string; channelId: string | null; userId: string | null; reverse: boolean; @@ -120,6 +122,10 @@ export interface AutomodContext { modAction?: { type: ModActionType; reason?: string; + isAutomodAction: boolean; + }; + antiraid?: { + level: string | null; }; } diff --git a/backend/src/plugins/BotControl/BotControlPlugin.ts b/backend/src/plugins/BotControl/BotControlPlugin.ts index c36b09b8..0b3670d3 100644 --- a/backend/src/plugins/BotControl/BotControlPlugin.ts +++ b/backend/src/plugins/BotControl/BotControlPlugin.ts @@ -17,10 +17,12 @@ import { Configs } from "../../data/Configs"; import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments"; import { ListDashboardUsersCmd } from "./commands/ListDashboardUsersCmd"; import { ListDashboardPermsCmd } from "./commands/ListDashboardPermsCmd"; +import { EligibleCmd } from "./commands/EligibleCmd"; const defaultOptions = { config: { can_use: false, + can_eligible: false, update_cmd: null, }, }; @@ -41,6 +43,7 @@ export const BotControlPlugin = zeppelinGlobalPlugin()("bo RemoveDashboardUserCmd, ListDashboardUsersCmd, ListDashboardPermsCmd, + EligibleCmd, ], onLoad(pluginData) { diff --git a/backend/src/plugins/BotControl/commands/EligibleCmd.ts b/backend/src/plugins/BotControl/commands/EligibleCmd.ts new file mode 100644 index 00000000..922b423b --- /dev/null +++ b/backend/src/plugins/BotControl/commands/EligibleCmd.ts @@ -0,0 +1,59 @@ +import { botControlCmd } from "../types"; +import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { resolveInvite, verboseUserMention } from "../../../utils"; + +const REQUIRED_MEMBER_COUNT = 5000; + +export const EligibleCmd = botControlCmd({ + trigger: ["eligible", "is_eligible", "iseligible"], + permission: "can_eligible", + + signature: { + user: ct.resolvedUser(), + inviteCode: ct.string(), + }, + + async run({ pluginData, message: msg, args }) { + if ((await pluginData.state.apiPermissionAssignments.getByUserId(args.user.id)).length) { + sendSuccessMessage( + pluginData, + msg.channel, + `${verboseUserMention(args.user)} is an existing bot operator. They are eligible!`, + ); + return; + } + + const invite = await resolveInvite(pluginData.client, args.inviteCode, true); + if (!invite || !invite.guild) { + sendErrorMessage(pluginData, msg.channel, "Could not resolve server from invite"); + return; + } + + if (invite.guild.features.includes("PARTNERED")) { + sendSuccessMessage(pluginData, msg.channel, `Server is partnered. It is eligible!`); + return; + } + + if (invite.guild.features.includes("VERIFIED")) { + sendSuccessMessage(pluginData, msg.channel, `Server is verified. It is eligible!`); + return; + } + + const memberCount = invite.memberCount || 0; + if (memberCount >= REQUIRED_MEMBER_COUNT) { + sendSuccessMessage( + pluginData, + msg.channel, + `Server has ${memberCount} members, which is equal or higher than the required ${REQUIRED_MEMBER_COUNT}. It is eligible!`, + ); + return; + } + + sendErrorMessage( + pluginData, + msg.channel, + `Server **${invite.guild.name}** (\`${invite.guild.id}\`) is not eligible`, + ); + }, +}); diff --git a/backend/src/plugins/BotControl/types.ts b/backend/src/plugins/BotControl/types.ts index 8712a65f..dca9d24c 100644 --- a/backend/src/plugins/BotControl/types.ts +++ b/backend/src/plugins/BotControl/types.ts @@ -8,6 +8,7 @@ import { Configs } from "../../data/Configs"; export const ConfigSchema = t.type({ can_use: t.boolean, + can_eligible: t.boolean, update_cmd: tNullable(t.string), }); export type TConfigSchema = t.TypeOf; diff --git a/backend/src/plugins/Censor/util/onMessageCreate.ts b/backend/src/plugins/Censor/util/onMessageCreate.ts index 2042126d..fb9cfaeb 100644 --- a/backend/src/plugins/Censor/util/onMessageCreate.ts +++ b/backend/src/plugins/Censor/util/onMessageCreate.ts @@ -2,10 +2,11 @@ import { GuildPluginData } from "knub"; import { CensorPluginType } from "../types"; import { SavedMessage } from "../../../data/entities/SavedMessage"; import { applyFiltersToMsg } from "./applyFiltersToMsg"; +import { messageLock } from "../../../utils/lockNameHelpers"; export async function onMessageCreate(pluginData: GuildPluginData, savedMessage: SavedMessage) { if (savedMessage.is_bot) return; - const lock = await pluginData.locks.acquire(`message-${savedMessage.id}`); + const lock = await pluginData.locks.acquire(messageLock(savedMessage)); const wasDeleted = await applyFiltersToMsg(pluginData, savedMessage); diff --git a/backend/src/plugins/Censor/util/onMessageUpdate.ts b/backend/src/plugins/Censor/util/onMessageUpdate.ts index 4c279caa..7afd4c17 100644 --- a/backend/src/plugins/Censor/util/onMessageUpdate.ts +++ b/backend/src/plugins/Censor/util/onMessageUpdate.ts @@ -2,10 +2,11 @@ import { GuildPluginData } from "knub"; import { CensorPluginType } from "../types"; import { SavedMessage } from "../../../data/entities/SavedMessage"; import { applyFiltersToMsg } from "./applyFiltersToMsg"; +import { messageLock } from "../../../utils/lockNameHelpers"; export async function onMessageUpdate(pluginData: GuildPluginData, savedMessage: SavedMessage) { if (savedMessage.is_bot) return; - const lock = await pluginData.locks.acquire(`message-${savedMessage.id}`); + const lock = await pluginData.locks.acquire(messageLock(savedMessage)); const wasDeleted = await applyFiltersToMsg(pluginData, savedMessage); diff --git a/backend/src/plugins/Counters/CountersPlugin.ts b/backend/src/plugins/Counters/CountersPlugin.ts index 56af0f96..e9d4ddab 100644 --- a/backend/src/plugins/Counters/CountersPlugin.ts +++ b/backend/src/plugins/Counters/CountersPlugin.ts @@ -1,5 +1,5 @@ import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; -import { ConfigSchema, CountersPluginType } from "./types"; +import { ConfigSchema, CountersPluginType, TTrigger } from "./types"; import { GuildCounters } from "../../data/GuildCounters"; import { mapToPublicFn } from "../../pluginUtils"; import { changeCounterValue } from "./functions/changeCounterValue"; @@ -10,16 +10,24 @@ import { onCounterEvent } from "./functions/onCounterEvent"; import { offCounterEvent } from "./functions/offCounterEvent"; import { emitCounterEvent } from "./functions/emitCounterEvent"; import { ConfigPreprocessorFn } from "knub/dist/config/configTypes"; -import { initCounterTrigger } from "./functions/initCounterTrigger"; import { decayCounter } from "./functions/decayCounter"; -import { validateCondition } from "./functions/validateCondition"; import { StrictValidationError } from "../../validatorUtils"; import { PluginOptions } from "knub"; import { ViewCounterCmd } from "./commands/ViewCounterCmd"; import { AddCounterCmd } from "./commands/AddCounterCmd"; import { SetCounterCmd } from "./commands/SetCounterCmd"; +import { + buildCounterConditionString, + CounterTrigger, + getReverseCounterComparisonOp, + parseCounterConditionString, +} from "../../data/entities/CounterTrigger"; +import { getPrettyNameForCounter } from "./functions/getPrettyNameForCounter"; +import { getPrettyNameForCounterTrigger } from "./functions/getPrettyNameForCounterTrigger"; +import { counterExists } from "./functions/counterExists"; const MAX_COUNTERS = 5; +const MAX_TRIGGERS_PER_COUNTER = 5; const DECAY_APPLY_INTERVAL = 5 * MINUTES; const defaultOptions: PluginOptions = { @@ -45,14 +53,40 @@ const defaultOptions: PluginOptions = { }; const configPreprocessor: ConfigPreprocessorFn = options => { - for (const counter of Object.values(options.config?.counters || {})) { + for (const [counterName, counter] of Object.entries(options.config?.counters || {})) { + counter.name = counterName; counter.per_user = counter.per_user ?? false; counter.per_channel = counter.per_channel ?? false; counter.initial_value = counter.initial_value ?? 0; + counter.triggers = counter.triggers || []; + + if (Object.values(counter.triggers).length > MAX_TRIGGERS_PER_COUNTER) { + throw new StrictValidationError([`You can only have at most ${MAX_TRIGGERS_PER_COUNTER} triggers per counter`]); + } + + // Normalize triggers + for (const [triggerName, trigger] of Object.entries(counter.triggers)) { + const triggerObj: Partial = typeof trigger === "string" ? { condition: trigger } : trigger; + + triggerObj.name = triggerName; + const parsedCondition = parseCounterConditionString(triggerObj.condition || ""); + if (!parsedCondition) { + throw new StrictValidationError([ + `Invalid comparison in counter trigger ${counterName}/${triggerName}: "${triggerObj.condition}"`, + ]); + } + + triggerObj.condition = buildCounterConditionString(parsedCondition[0], parsedCondition[1]); + triggerObj.reverse_condition = + triggerObj.reverse_condition || + buildCounterConditionString(getReverseCounterComparisonOp(parsedCondition[0]), parsedCondition[1]); + + counter.triggers[triggerName] = triggerObj as TTrigger; + } } if (Object.values(options.config?.counters || {}).length > MAX_COUNTERS) { - throw new StrictValidationError([`You can only have at most ${MAX_COUNTERS} active counters`]); + throw new StrictValidationError([`You can only have at most ${MAX_COUNTERS} counters`]); } return options; @@ -69,21 +103,29 @@ const configPreprocessor: ConfigPreprocessorFn = options => * After being triggered, a trigger is "reset" if the counter value no longer matches the trigger (e.g. drops to 100 or below in the above example). After this, that trigger can be triggered again. */ export const CountersPlugin = zeppelinGuildPlugin()("counters", { + showInDocs: true, + info: { + prettyName: "Counters", + description: + "Keep track of per-user, per-channel, or global numbers and trigger specific actions based on this number", + configurationGuide: "See Counters setup guide", + }, + configSchema: ConfigSchema, defaultOptions, configPreprocessor, public: { + counterExists: mapToPublicFn(counterExists), + // Change a counter's value by a relative amount, e.g. +5 changeCounterValue: mapToPublicFn(changeCounterValue), + // Set a counter's value to an absolute value setCounterValue: mapToPublicFn(setCounterValue), - // Initialize a trigger. Once initialized, events will be fired when this trigger is triggered. - initCounterTrigger: mapToPublicFn(initCounterTrigger), - - // Validate a trigger's condition string - validateCondition: mapToPublicFn(validateCondition), + getPrettyNameForCounter: mapToPublicFn(getPrettyNameForCounter), + getPrettyNameForCounterTrigger: mapToPublicFn(getPrettyNameForCounterTrigger), onCounterEvent: mapToPublicFn(onCounterEvent), offCounterEvent: mapToPublicFn(offCounterEvent), @@ -99,22 +141,48 @@ export const CountersPlugin = zeppelinGuildPlugin()("counter async onLoad(pluginData) { pluginData.state.counters = new GuildCounters(pluginData.guild.id); pluginData.state.events = new EventEmitter(); + pluginData.state.counterTriggersByCounterId = new Map(); + + const activeTriggerIds: number[] = []; // Initialize and store the IDs of each of the counters internally pluginData.state.counterIds = {}; const config = pluginData.config.get(); - for (const [counterName, counter] of Object.entries(config.counters)) { + for (const counter of Object.values(config.counters)) { const dbCounter = await pluginData.state.counters.findOrCreateCounter( - counterName, + counter.name, counter.per_channel, counter.per_user, ); - pluginData.state.counterIds[counterName] = dbCounter.id; + pluginData.state.counterIds[counter.name] = dbCounter.id; + + const thisCounterTriggers: CounterTrigger[] = []; + pluginData.state.counterTriggersByCounterId.set(dbCounter.id, thisCounterTriggers); + + // Initialize triggers + for (const trigger of Object.values(counter.triggers)) { + const theTrigger = trigger as TTrigger; + const parsedCondition = parseCounterConditionString(theTrigger.condition)!; + const parsedReverseCondition = parseCounterConditionString(theTrigger.reverse_condition)!; + const counterTrigger = await pluginData.state.counters.initCounterTrigger( + dbCounter.id, + theTrigger.name, + parsedCondition[0], + parsedCondition[1], + parsedReverseCondition[0], + parsedReverseCondition[1], + ); + activeTriggerIds.push(counterTrigger.id); + thisCounterTriggers.push(counterTrigger); + } } // Mark old/unused counters to be deleted later await pluginData.state.counters.markUnusedCountersToBeDeleted([...Object.values(pluginData.state.counterIds)]); + // Mark old/unused triggers to be deleted later + await pluginData.state.counters.markUnusedTriggersToBeDeleted(activeTriggerIds); + // Start decay timers pluginData.state.decayTimers = []; for (const [counterName, counter] of Object.entries(config.counters)) { @@ -130,13 +198,6 @@ export const CountersPlugin = zeppelinGuildPlugin()("counter }, DECAY_APPLY_INTERVAL), ); } - - // Initially set the counter trigger map to just an empty map - // The actual triggers are added by other plugins via initCounterTrigger() - pluginData.state.counterTriggersByCounterId = new Map(); - - // Mark all triggers to be deleted later. This is cancelled/reset when a plugin adds the trigger again via initCounterTrigger(). - await pluginData.state.counters.markAllTriggersTobeDeleted(); }, onUnload(pluginData) { diff --git a/backend/src/plugins/Counters/commands/SetCounterCmd.ts b/backend/src/plugins/Counters/commands/SetCounterCmd.ts index edabe2e7..f74c2045 100644 --- a/backend/src/plugins/Counters/commands/SetCounterCmd.ts +++ b/backend/src/plugins/Counters/commands/SetCounterCmd.ts @@ -67,7 +67,7 @@ export const SetCounterCmd = guildCommand()({ let channel = args.channel; if (!channel && counter.per_channel) { - message.channel.createMessage(`Which channel's counter value would you like to add to?`); + message.channel.createMessage(`Which channel's counter value would you like to change?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { sendErrorMessage(pluginData, message.channel, "Cancelling"); @@ -85,7 +85,7 @@ export const SetCounterCmd = guildCommand()({ let user = args.user; if (!user && counter.per_user) { - message.channel.createMessage(`Which user's counter value would you like to add to?`); + message.channel.createMessage(`Which user's counter value would you like to change?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { sendErrorMessage(pluginData, message.channel, "Cancelling"); @@ -103,7 +103,7 @@ export const SetCounterCmd = guildCommand()({ let value = args.value; if (!value) { - message.channel.createMessage("How much would you like to add to the counter's value?"); + message.channel.createMessage("What would you like to set the counter's value to?"); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { sendErrorMessage(pluginData, message.channel, "Cancelling"); @@ -111,7 +111,7 @@ export const SetCounterCmd = guildCommand()({ } const potentialValue = parseInt(reply.content, 10); - if (!potentialValue) { + if (Number.isNaN(potentialValue)) { sendErrorMessage(pluginData, message.channel, "Not a number, cancelling"); return; } diff --git a/backend/src/plugins/Counters/functions/changeCounterValue.ts b/backend/src/plugins/Counters/functions/changeCounterValue.ts index be214eb7..d5da6825 100644 --- a/backend/src/plugins/Counters/functions/changeCounterValue.ts +++ b/backend/src/plugins/Counters/functions/changeCounterValue.ts @@ -1,4 +1,5 @@ import { GuildPluginData } from "knub"; +import { counterIdLock } from "../../../utils/lockNameHelpers"; import { CountersPluginType } from "../types"; import { checkCounterTrigger } from "./checkCounterTrigger"; import { checkReverseCounterTrigger } from "./checkReverseCounterTrigger"; @@ -28,7 +29,7 @@ export async function changeCounterValue( userId = counter.per_user ? userId : null; const counterId = pluginData.state.counterIds[counterName]; - const lock = await pluginData.locks.acquire(counterId.toString()); + const lock = await pluginData.locks.acquire(counterIdLock(counterId)); await pluginData.state.counters.changeCounterValue(counterId, channelId, userId, change); diff --git a/backend/src/plugins/Counters/functions/checkAllValuesForReverseTrigger.ts b/backend/src/plugins/Counters/functions/checkAllValuesForReverseTrigger.ts index 9336906e..19998225 100644 --- a/backend/src/plugins/Counters/functions/checkAllValuesForReverseTrigger.ts +++ b/backend/src/plugins/Counters/functions/checkAllValuesForReverseTrigger.ts @@ -1,6 +1,5 @@ import { GuildPluginData } from "knub"; import { CountersPluginType } from "../types"; -import { buildConditionString } from "../../../data/GuildCounters"; import { CounterTrigger } from "../../../data/entities/CounterTrigger"; import { emitCounterEvent } from "./emitCounterEvent"; @@ -11,13 +10,6 @@ export async function checkAllValuesForReverseTrigger( ) { const triggeredContexts = await pluginData.state.counters.checkAllValuesForReverseTrigger(counterTrigger); for (const context of triggeredContexts) { - emitCounterEvent( - pluginData, - "reverseTrigger", - counterName, - buildConditionString(counterTrigger.comparison_op, counterTrigger.comparison_value), - context.channelId, - context.userId, - ); + emitCounterEvent(pluginData, "reverseTrigger", counterName, counterTrigger.name, context.channelId, context.userId); } } diff --git a/backend/src/plugins/Counters/functions/checkAllValuesForTrigger.ts b/backend/src/plugins/Counters/functions/checkAllValuesForTrigger.ts index 277cff15..02b02e5f 100644 --- a/backend/src/plugins/Counters/functions/checkAllValuesForTrigger.ts +++ b/backend/src/plugins/Counters/functions/checkAllValuesForTrigger.ts @@ -1,6 +1,5 @@ import { GuildPluginData } from "knub"; import { CountersPluginType } from "../types"; -import { buildConditionString } from "../../../data/GuildCounters"; import { CounterTrigger } from "../../../data/entities/CounterTrigger"; import { emitCounterEvent } from "./emitCounterEvent"; @@ -11,13 +10,6 @@ export async function checkAllValuesForTrigger( ) { const triggeredContexts = await pluginData.state.counters.checkAllValuesForTrigger(counterTrigger); for (const context of triggeredContexts) { - emitCounterEvent( - pluginData, - "trigger", - counterName, - buildConditionString(counterTrigger.comparison_op, counterTrigger.comparison_value), - context.channelId, - context.userId, - ); + emitCounterEvent(pluginData, "trigger", counterName, counterTrigger.name, context.channelId, context.userId); } } diff --git a/backend/src/plugins/Counters/functions/checkCounterTrigger.ts b/backend/src/plugins/Counters/functions/checkCounterTrigger.ts index e45cf28d..5bdd1b05 100644 --- a/backend/src/plugins/Counters/functions/checkCounterTrigger.ts +++ b/backend/src/plugins/Counters/functions/checkCounterTrigger.ts @@ -1,6 +1,5 @@ import { GuildPluginData } from "knub"; import { CountersPluginType } from "../types"; -import { buildConditionString } from "../../../data/GuildCounters"; import { CounterTrigger } from "../../../data/entities/CounterTrigger"; import { emitCounterEvent } from "./emitCounterEvent"; @@ -13,13 +12,6 @@ export async function checkCounterTrigger( ) { const triggered = await pluginData.state.counters.checkForTrigger(counterTrigger, channelId, userId); if (triggered) { - await emitCounterEvent( - pluginData, - "trigger", - counterName, - buildConditionString(counterTrigger.comparison_op, counterTrigger.comparison_value), - channelId, - userId, - ); + await emitCounterEvent(pluginData, "trigger", counterName, counterTrigger.name, channelId, userId); } } diff --git a/backend/src/plugins/Counters/functions/checkReverseCounterTrigger.ts b/backend/src/plugins/Counters/functions/checkReverseCounterTrigger.ts index 5ed9b3d7..e601dd9d 100644 --- a/backend/src/plugins/Counters/functions/checkReverseCounterTrigger.ts +++ b/backend/src/plugins/Counters/functions/checkReverseCounterTrigger.ts @@ -1,6 +1,5 @@ import { GuildPluginData } from "knub"; import { CountersPluginType } from "../types"; -import { buildConditionString } from "../../../data/GuildCounters"; import { CounterTrigger } from "../../../data/entities/CounterTrigger"; import { emitCounterEvent } from "./emitCounterEvent"; @@ -13,13 +12,6 @@ export async function checkReverseCounterTrigger( ) { const triggered = await pluginData.state.counters.checkForReverseTrigger(counterTrigger, channelId, userId); if (triggered) { - await emitCounterEvent( - pluginData, - "reverseTrigger", - counterName, - buildConditionString(counterTrigger.comparison_op, counterTrigger.comparison_value), - channelId, - userId, - ); + await emitCounterEvent(pluginData, "reverseTrigger", counterName, counterTrigger.name, channelId, userId); } } diff --git a/backend/src/plugins/Counters/functions/counterExists.ts b/backend/src/plugins/Counters/functions/counterExists.ts new file mode 100644 index 00000000..2da10460 --- /dev/null +++ b/backend/src/plugins/Counters/functions/counterExists.ts @@ -0,0 +1,7 @@ +import { GuildPluginData } from "knub"; +import { CountersPluginType } from "../types"; + +export function counterExists(pluginData: GuildPluginData, counterName: string) { + const config = pluginData.config.get(); + return config.counters[counterName] != null; +} diff --git a/backend/src/plugins/Counters/functions/decayCounter.ts b/backend/src/plugins/Counters/functions/decayCounter.ts index 175cb158..7db4cef3 100644 --- a/backend/src/plugins/Counters/functions/decayCounter.ts +++ b/backend/src/plugins/Counters/functions/decayCounter.ts @@ -2,6 +2,7 @@ import { GuildPluginData } from "knub"; import { CountersPluginType } from "../types"; import { checkAllValuesForTrigger } from "./checkAllValuesForTrigger"; import { checkAllValuesForReverseTrigger } from "./checkAllValuesForReverseTrigger"; +import { counterIdLock } from "../../../utils/lockNameHelpers"; export async function decayCounter( pluginData: GuildPluginData, @@ -16,7 +17,7 @@ export async function decayCounter( } const counterId = pluginData.state.counterIds[counterName]; - const lock = await pluginData.locks.acquire(counterId.toString()); + const lock = await pluginData.locks.acquire(counterIdLock(counterId)); await pluginData.state.counters.decay(counterId, decayPeriodMS, decayAmount); diff --git a/backend/src/plugins/Counters/functions/getPrettyNameForCounter.ts b/backend/src/plugins/Counters/functions/getPrettyNameForCounter.ts new file mode 100644 index 00000000..766b4ef3 --- /dev/null +++ b/backend/src/plugins/Counters/functions/getPrettyNameForCounter.ts @@ -0,0 +1,8 @@ +import { CountersPluginType } from "../types"; +import { GuildPluginData } from "knub"; + +export function getPrettyNameForCounter(pluginData: GuildPluginData, counterName: string) { + const config = pluginData.config.get(); + const counter = config.counters[counterName]; + return counter ? counter.pretty_name || counter.name : "Unknown Counter"; +} diff --git a/backend/src/plugins/Counters/functions/getPrettyNameForCounterTrigger.ts b/backend/src/plugins/Counters/functions/getPrettyNameForCounterTrigger.ts new file mode 100644 index 00000000..d7a2b923 --- /dev/null +++ b/backend/src/plugins/Counters/functions/getPrettyNameForCounterTrigger.ts @@ -0,0 +1,17 @@ +import { CountersPluginType, TTrigger } from "../types"; +import { GuildPluginData } from "knub"; + +export function getPrettyNameForCounterTrigger( + pluginData: GuildPluginData, + counterName: string, + triggerName: string, +) { + const config = pluginData.config.get(); + const counter = config.counters[counterName]; + if (!counter) { + return "Unknown Counter Trigger"; + } + + const trigger = counter.triggers[triggerName] as TTrigger | undefined; + return trigger ? trigger.pretty_name || trigger.name : "Unknown Counter Trigger"; +} diff --git a/backend/src/plugins/Counters/functions/initCounterTrigger.ts b/backend/src/plugins/Counters/functions/initCounterTrigger.ts deleted file mode 100644 index afc5e9c9..00000000 --- a/backend/src/plugins/Counters/functions/initCounterTrigger.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { GuildPluginData } from "knub"; -import { CountersPluginType } from "../types"; -import { parseCondition } from "../../../data/GuildCounters"; - -/** - * Initialize a counter trigger. - * After a counter trigger has been initialized, it will be checked against whenever the counter's values change. - * If the trigger is triggered, an event is emitted. - */ -export async function initCounterTrigger( - pluginData: GuildPluginData, - counterName: string, - condition: string, -) { - const counterId = pluginData.state.counterIds[counterName]; - if (!counterId) { - throw new Error(`Unknown counter: ${counterName}`); - } - - const parsedComparison = parseCondition(condition); - if (!parsedComparison) { - throw new Error(`Invalid comparison string: ${condition}`); - } - - const [comparisonOp, comparisonValue] = parsedComparison; - const counterTrigger = await pluginData.state.counters.initCounterTrigger(counterId, comparisonOp, comparisonValue); - if (!pluginData.state.counterTriggersByCounterId.has(counterId)) { - pluginData.state.counterTriggersByCounterId.set(counterId, new Map()); - } - pluginData.state.counterTriggersByCounterId.get(counterId)!.set(counterTrigger.id, counterTrigger); -} diff --git a/backend/src/plugins/Counters/functions/setCounterValue.ts b/backend/src/plugins/Counters/functions/setCounterValue.ts index 2eefed8f..697c8503 100644 --- a/backend/src/plugins/Counters/functions/setCounterValue.ts +++ b/backend/src/plugins/Counters/functions/setCounterValue.ts @@ -1,4 +1,5 @@ import { GuildPluginData } from "knub"; +import { counterIdLock } from "../../../utils/lockNameHelpers"; import { CountersPluginType } from "../types"; import { checkCounterTrigger } from "./checkCounterTrigger"; import { checkReverseCounterTrigger } from "./checkReverseCounterTrigger"; @@ -25,7 +26,7 @@ export async function setCounterValue( } const counterId = pluginData.state.counterIds[counterName]; - const lock = await pluginData.locks.acquire(counterId.toString()); + const lock = await pluginData.locks.acquire(counterIdLock(counterId)); await pluginData.state.counters.setCounterValue(counterId, channelId, userId, value); diff --git a/backend/src/plugins/Counters/functions/validateCondition.ts b/backend/src/plugins/Counters/functions/validateCondition.ts deleted file mode 100644 index ca5e2e88..00000000 --- a/backend/src/plugins/Counters/functions/validateCondition.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { GuildPluginData } from "knub"; -import { CountersPluginType } from "../types"; -import { parseCondition } from "../../../data/GuildCounters"; - -export function validateCondition(pluginData: GuildPluginData, condition: string) { - const parsed = parseCondition(condition); - return parsed != null; -} diff --git a/backend/src/plugins/Counters/types.ts b/backend/src/plugins/Counters/types.ts index 3102e463..ec534452 100644 --- a/backend/src/plugins/Counters/types.ts +++ b/backend/src/plugins/Counters/types.ts @@ -6,11 +6,21 @@ import { EventEmitter } from "events"; import { CounterTrigger } from "../../data/entities/CounterTrigger"; import Timeout = NodeJS.Timeout; +export const Trigger = t.type({ + name: t.string, + pretty_name: tNullable(t.string), + condition: t.string, + reverse_condition: t.string, +}); +export type TTrigger = t.TypeOf; + export const Counter = t.type({ - name: tNullable(t.string), + name: t.string, + pretty_name: tNullable(t.string), per_channel: t.boolean, per_user: t.boolean, initial_value: t.number, + triggers: t.record(t.string, t.union([t.string, Trigger])), decay: tNullable( t.type({ amount: t.number, @@ -30,8 +40,8 @@ export const ConfigSchema = t.type({ export type TConfigSchema = t.TypeOf; export interface CounterEvents { - trigger: (name: string, condition: string, channelId: string | null, userId: string | null) => void; - reverseTrigger: (name: string, condition: string, channelId: string | null, userId: string | null) => void; + trigger: (counterName: string, triggerName: string, channelId: string | null, userId: string | null) => void; + reverseTrigger: (counterName: string, triggerName: string, channelId: string | null, userId: string | null) => void; } export interface CounterEventEmitter extends EventEmitter { @@ -46,6 +56,6 @@ export interface CountersPluginType extends BasePluginType { counterIds: Record; decayTimers: Timeout[]; events: CounterEventEmitter; - counterTriggersByCounterId: Map>; + counterTriggersByCounterId: Map; }; } diff --git a/backend/src/plugins/ModActions/commands/AddCaseCmd.ts b/backend/src/plugins/ModActions/commands/AddCaseCmd.ts index 7bd06bf8..3bed2911 100644 --- a/backend/src/plugins/ModActions/commands/AddCaseCmd.ts +++ b/backend/src/plugins/ModActions/commands/AddCaseCmd.ts @@ -45,7 +45,7 @@ export const AddCaseCmd = modActionsCmd({ let mod = msg.member; if (args.mod) { if (!hasPermission(pluginData, "can_act_as_other", { message: msg })) { - sendErrorMessage(pluginData, msg.channel, "No permission for -mod"); + sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); return; } diff --git a/backend/src/plugins/ModActions/commands/BanCmd.ts b/backend/src/plugins/ModActions/commands/BanCmd.ts index 4fe9561e..f42b426a 100644 --- a/backend/src/plugins/ModActions/commands/BanCmd.ts +++ b/backend/src/plugins/ModActions/commands/BanCmd.ts @@ -8,9 +8,10 @@ import { formatReasonWithAttachments } from "../functions/formatReasonWithAttach import { banUserId } from "../functions/banUserId"; import { getMemberLevel, waitForReaction } from "knub/dist/helpers"; import humanizeDuration from "humanize-duration"; -import { CasesPlugin } from "src/plugins/Cases/CasesPlugin"; -import { CaseTypes } from "src/data/CaseTypes"; -import { LogType } from "src/data/LogType"; +import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin"; +import { CaseTypes } from "../../../data/CaseTypes"; +import { LogType } from "../../../data/LogType"; +import { banLock } from "../../../utils/lockNameHelpers"; const opts = { mod: ct.member({ option: true }), @@ -54,7 +55,7 @@ export const BanCmd = modActionsCmd({ let mod = msg.member; if (args.mod) { if (!hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id })) { - sendErrorMessage(pluginData, msg.channel, "No permission for -mod"); + sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); return; } @@ -62,11 +63,11 @@ export const BanCmd = modActionsCmd({ } // acquire a lock because of the needed user-inputs below (if banned/not on server) - const lock = await pluginData.locks.acquire(`ban-${user.id}`); + const lock = await pluginData.locks.acquire(banLock(user)); let forceban = false; const existingTempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id); - const banned = await isBanned(pluginData, user.id); if (!memberToBan) { + const banned = await isBanned(pluginData, user.id); if (banned) { // Abort if trying to ban user indefinitely if they are already banned indefinitely if (!existingTempban && !time) { diff --git a/backend/src/plugins/ModActions/commands/CasesModCmd.ts b/backend/src/plugins/ModActions/commands/CasesModCmd.ts index 6d84fc77..f9f4667d 100644 --- a/backend/src/plugins/ModActions/commands/CasesModCmd.ts +++ b/backend/src/plugins/ModActions/commands/CasesModCmd.ts @@ -17,7 +17,7 @@ const opts = { const casesPerPage = 5; export const CasesModCmd = modActionsCmd({ - trigger: ["cases", "modlogs"], + trigger: ["cases", "modlogs", "infractions"], permission: "can_view", description: "Show the most recent 5 cases by the specified -mod", diff --git a/backend/src/plugins/ModActions/commands/ForcebanCmd.ts b/backend/src/plugins/ModActions/commands/ForcebanCmd.ts index 907237ef..afda940e 100644 --- a/backend/src/plugins/ModActions/commands/ForcebanCmd.ts +++ b/backend/src/plugins/ModActions/commands/ForcebanCmd.ts @@ -54,7 +54,7 @@ export const ForcebanCmd = modActionsCmd({ let mod = msg.member; if (args.mod) { if (!hasPermission(pluginData, "can_act_as_other", { message: msg })) { - sendErrorMessage(pluginData, msg.channel, "No permission for -mod"); + sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); return; } diff --git a/backend/src/plugins/ModActions/commands/MuteCmd.ts b/backend/src/plugins/ModActions/commands/MuteCmd.ts index ada23e2f..3aa96c38 100644 --- a/backend/src/plugins/ModActions/commands/MuteCmd.ts +++ b/backend/src/plugins/ModActions/commands/MuteCmd.ts @@ -6,7 +6,7 @@ import { formatReasonWithAttachments } from "../functions/formatReasonWithAttach import { CasesPlugin } from "../../Cases/CasesPlugin"; import { LogType } from "../../../data/LogType"; import { CaseTypes } from "../../../data/CaseTypes"; -import { errorMessage, resolveMember, resolveUser, stripObjectToScalars } from "../../../utils"; +import { errorMessage, noop, resolveMember, resolveUser, stripObjectToScalars } from "../../../utils"; import { isBanned } from "../functions/isBanned"; import { waitForReaction } from "knub/dist/helpers"; import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs"; @@ -59,15 +59,18 @@ export const MuteCmd = modActionsCmd({ msg.channel, `User is banned. Use \`${prefix}forcemute\` if you want to mute them anyway.`, ); + return; } else { - sendErrorMessage( - pluginData, - msg.channel, - `User is not on the server. Use \`${prefix}forcemute\` if you want to mute them anyway.`, - ); - } + // Ask the mod if we should upgrade to a forcemute as the user is not on the server + const notOnServerMsg = await msg.channel.createMessage("User not found on the server, forcemute instead?"); + const reply = await waitForReaction(pluginData.client, notOnServerMsg, ["✅", "❌"], msg.author.id); - return; + notOnServerMsg.delete().catch(noop); + if (!reply || reply.name === "❌") { + sendErrorMessage(pluginData, msg.channel, "User not on server, mute cancelled by moderator"); + return; + } + } } // Make sure we're allowed to mute this member diff --git a/backend/src/plugins/ModActions/commands/UnbanCmd.ts b/backend/src/plugins/ModActions/commands/UnbanCmd.ts index 80cac4f2..0d5abc1c 100644 --- a/backend/src/plugins/ModActions/commands/UnbanCmd.ts +++ b/backend/src/plugins/ModActions/commands/UnbanCmd.ts @@ -37,7 +37,7 @@ export const UnbanCmd = modActionsCmd({ let mod = msg.member; if (args.mod) { if (!hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id })) { - sendErrorMessage(pluginData, msg.channel, "No permission for -mod"); + sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); return; } diff --git a/backend/src/plugins/ModActions/commands/UnmuteCmd.ts b/backend/src/plugins/ModActions/commands/UnmuteCmd.ts index f6efdd39..c294517c 100644 --- a/backend/src/plugins/ModActions/commands/UnmuteCmd.ts +++ b/backend/src/plugins/ModActions/commands/UnmuteCmd.ts @@ -1,10 +1,11 @@ import { modActionsCmd } from "../types"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { canActOn, sendErrorMessage } from "../../../pluginUtils"; -import { resolveUser, resolveMember } from "../../../utils"; +import { resolveUser, resolveMember, noop } from "../../../utils"; import { MutesPlugin } from "../../../plugins/Mutes/MutesPlugin"; import { actualUnmuteCmd } from "../functions/actualUnmuteUserCmd"; import { isBanned } from "../functions/isBanned"; +import { waitForReaction } from "knub/dist/helpers"; const opts = { mod: ct.member({ option: true }), @@ -57,15 +58,18 @@ export const UnmuteCmd = modActionsCmd({ msg.channel, `User is banned. Use \`${prefix}forceunmute\` to unmute them anyway.`, ); + return; } else { - sendErrorMessage( - pluginData, - msg.channel, - `User is not on the server. Use \`${prefix}forceunmute\` to unmute them anyway.`, - ); - } + // Ask the mod if we should upgrade to a forceunmute as the user is not on the server + const notOnServerMsg = await msg.channel.createMessage("User not found on the server, forceunmute instead?"); + const reply = await waitForReaction(pluginData.client, notOnServerMsg, ["✅", "❌"], msg.author.id); - return; + notOnServerMsg.delete().catch(noop); + if (!reply || reply.name === "❌") { + sendErrorMessage(pluginData, msg.channel, "User not on server, unmute cancelled by moderator"); + return; + } + } } // Make sure we're allowed to unmute this member diff --git a/backend/src/plugins/ModActions/commands/UpdateCmd.ts b/backend/src/plugins/ModActions/commands/UpdateCmd.ts index e9d968d0..6c8d78ca 100644 --- a/backend/src/plugins/ModActions/commands/UpdateCmd.ts +++ b/backend/src/plugins/ModActions/commands/UpdateCmd.ts @@ -3,7 +3,7 @@ import { commandTypeHelpers as ct } from "../../../commandTypes"; import { updateCase } from "../functions/updateCase"; export const UpdateCmd = modActionsCmd({ - trigger: "update", + trigger: ["update", "reason"], permission: "can_note", description: "Update the specified case (or, if case number is omitted, your latest case) by adding more notes/details to it", diff --git a/backend/src/plugins/ModActions/commands/WarnCmd.ts b/backend/src/plugins/ModActions/commands/WarnCmd.ts index 8ac19baa..45526a0c 100644 --- a/backend/src/plugins/ModActions/commands/WarnCmd.ts +++ b/backend/src/plugins/ModActions/commands/WarnCmd.ts @@ -57,7 +57,7 @@ export const WarnCmd = modActionsCmd({ let mod = msg.member; if (args.mod) { if (!hasPermission(pluginData, "can_act_as_other", { message: msg })) { - msg.channel.createMessage(errorMessage("No permission for -mod")); + msg.channel.createMessage(errorMessage("You don't have permission to use -mod")); return; } @@ -112,7 +112,5 @@ export const WarnCmd = modActionsCmd({ msg.channel, `Warned **${memberToWarn.user.username}#${memberToWarn.user.discriminator}** (Case #${warnResult.case.case_number})${messageResultText}`, ); - - pluginData.state.events.emit("warn", user.id, reason); }, }); diff --git a/backend/src/plugins/ModActions/events/CreateKickCaseOnManualKickEvt.ts b/backend/src/plugins/ModActions/events/CreateKickCaseOnManualKickEvt.ts index dd353ac9..6663c497 100644 --- a/backend/src/plugins/ModActions/events/CreateKickCaseOnManualKickEvt.ts +++ b/backend/src/plugins/ModActions/events/CreateKickCaseOnManualKickEvt.ts @@ -61,6 +61,7 @@ export const CreateKickCaseOnManualKickEvt = modActionsEvt( user: stripObjectToScalars(member.user), mod: mod ? stripObjectToScalars(mod) : null, caseNumber: createdCase?.case_number ?? 0, + reason: kickAuditLogEntry.reason || "", }); pluginData.state.events.emit("kick", member.id, kickAuditLogEntry.reason || undefined); diff --git a/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts b/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts index 18ee2137..e467212c 100644 --- a/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts +++ b/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts @@ -52,7 +52,7 @@ export async function actualKickMemberCmd( let mod = msg.member; if (args.mod) { if (!hasPermission(pluginData.config.getForMessage(msg), "can_act_as_other")) { - sendErrorMessage(pluginData, msg.channel, "No permission for -mod"); + sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); return; } diff --git a/backend/src/plugins/ModActions/functions/actualMuteUserCmd.ts b/backend/src/plugins/ModActions/functions/actualMuteUserCmd.ts index 6d640ec6..19c13477 100644 --- a/backend/src/plugins/ModActions/functions/actualMuteUserCmd.ts +++ b/backend/src/plugins/ModActions/functions/actualMuteUserCmd.ts @@ -28,7 +28,7 @@ export async function actualMuteUserCmd( if (args.mod) { if (!hasPermission(pluginData, "can_act_as_other", { message: msg })) { - sendErrorMessage(pluginData, msg.channel, "No permission for -mod"); + sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); return; } diff --git a/backend/src/plugins/ModActions/functions/actualUnmuteUserCmd.ts b/backend/src/plugins/ModActions/functions/actualUnmuteUserCmd.ts index 0faee47c..7931aac1 100644 --- a/backend/src/plugins/ModActions/functions/actualUnmuteUserCmd.ts +++ b/backend/src/plugins/ModActions/functions/actualUnmuteUserCmd.ts @@ -19,7 +19,7 @@ export async function actualUnmuteCmd( if (args.mod) { if (!hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id })) { - sendErrorMessage(pluginData, msg.channel, "No permission for -mod"); + sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); return; } diff --git a/backend/src/plugins/ModActions/functions/banUserId.ts b/backend/src/plugins/ModActions/functions/banUserId.ts index 85d05de9..4620ec0f 100644 --- a/backend/src/plugins/ModActions/functions/banUserId.ts +++ b/backend/src/plugins/ModActions/functions/banUserId.ts @@ -127,7 +127,7 @@ export async function banUserId( banTime: banTime ? humanizeDuration(banTime) : null, }); - pluginData.state.events.emit("ban", user.id, reason); + pluginData.state.events.emit("ban", user.id, reason, banOptions.isAutomodAction); return { status: "success", diff --git a/backend/src/plugins/ModActions/functions/isBanned.ts b/backend/src/plugins/ModActions/functions/isBanned.ts index c809a30c..7bcd22d8 100644 --- a/backend/src/plugins/ModActions/functions/isBanned.ts +++ b/backend/src/plugins/ModActions/functions/isBanned.ts @@ -1,16 +1,44 @@ import { GuildPluginData } from "knub"; import { ModActionsPluginType } from "../types"; -import { isDiscordHTTPError } from "../../../utils"; +import { isDiscordHTTPError, isDiscordRESTError, SECONDS, sleep } from "../../../utils"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { LogType } from "../../../data/LogType"; +import { hasDiscordPermissions } from "../../../utils/hasDiscordPermissions"; +import { Constants } from "eris"; + +export async function isBanned( + pluginData: GuildPluginData, + userId: string, + timeout: number = 5 * SECONDS, +): Promise { + const botMember = pluginData.guild.members.get(pluginData.client.user.id); + if (botMember && !hasDiscordPermissions(botMember.permissions, Constants.Permissions.banMembers)) { + pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { + body: `Missing "Ban Members" permission to check for existing bans`, + }); + return false; + } -export async function isBanned(pluginData: GuildPluginData, userId: string): Promise { try { - const bans = await pluginData.guild.getBans(); - return bans.some(b => b.user.id === userId); + const potentialBan = await Promise.race([pluginData.guild.getBan(userId), sleep(timeout)]); + return potentialBan != null; } catch (e) { - if (isDiscordHTTPError(e) && e.code === 500) { + if (isDiscordRESTError(e) && e.code === 10026) { + // [10026]: Unknown Ban return false; } + if (isDiscordHTTPError(e) && e.code === 500) { + // Internal server error, ignore + return false; + } + + if (isDiscordRESTError(e) && e.code === 50013) { + pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { + body: `Missing "Ban Members" permission to check for existing bans`, + }); + } + throw e; } } diff --git a/backend/src/plugins/ModActions/functions/kickMember.ts b/backend/src/plugins/ModActions/functions/kickMember.ts index 9e297af8..b682018e 100644 --- a/backend/src/plugins/ModActions/functions/kickMember.ts +++ b/backend/src/plugins/ModActions/functions/kickMember.ts @@ -85,7 +85,7 @@ export async function kickMember( reason, }); - pluginData.state.events.emit("kick", member.id, reason); + pluginData.state.events.emit("kick", member.id, reason, kickOptions.isAutomodAction); return { status: "success", diff --git a/backend/src/plugins/ModActions/functions/warnMember.ts b/backend/src/plugins/ModActions/functions/warnMember.ts index 0c07c838..d61b183d 100644 --- a/backend/src/plugins/ModActions/functions/warnMember.ts +++ b/backend/src/plugins/ModActions/functions/warnMember.ts @@ -82,6 +82,8 @@ export async function warnMember( reason, }); + pluginData.state.events.emit("warn", member.id, reason, warnOptions.isAutomodAction); + return { status: "success", case: createdCase, diff --git a/backend/src/plugins/ModActions/types.ts b/backend/src/plugins/ModActions/types.ts index af4fd8d6..083e5642 100644 --- a/backend/src/plugins/ModActions/types.ts +++ b/backend/src/plugins/ModActions/types.ts @@ -48,9 +48,9 @@ export type TConfigSchema = t.TypeOf; export interface ModActionsEvents { note: (userId: string, reason?: string) => void; - warn: (userId: string, reason?: string) => void; - kick: (userId: string, reason?: string) => void; - ban: (userId: string, reason?: string) => void; + warn: (userId: string, reason?: string, isAutomodAction?: boolean) => void; + kick: (userId: string, reason?: string, isAutomodAction?: boolean) => void; + ban: (userId: string, reason?: string, isAutomodAction?: boolean) => void; unban: (userId: string, reason?: string) => void; // mute/unmute are in the Mutes plugin } @@ -126,17 +126,20 @@ export interface WarnOptions { caseArgs?: Partial | null; contactMethods?: UserNotificationMethod[] | null; retryPromptChannel?: TextChannel | null; + isAutomodAction?: boolean; } export interface KickOptions { caseArgs?: Partial; contactMethods?: UserNotificationMethod[]; + isAutomodAction?: boolean; } export interface BanOptions { caseArgs?: Partial; contactMethods?: UserNotificationMethod[]; deleteMessageDays?: number; + isAutomodAction?: boolean; } export type ModActionType = "note" | "warn" | "mute" | "unmute" | "kick" | "ban" | "unban"; diff --git a/backend/src/plugins/Mutes/MutesPlugin.ts b/backend/src/plugins/Mutes/MutesPlugin.ts index ac55acc6..36fb425e 100644 --- a/backend/src/plugins/Mutes/MutesPlugin.ts +++ b/backend/src/plugins/Mutes/MutesPlugin.ts @@ -25,6 +25,7 @@ const defaultOptions = { config: { mute_role: null, move_to_voice_channel: null, + kick_from_voice_channel: false, dm_on_mute: false, dm_on_update: false, diff --git a/backend/src/plugins/Mutes/events/ReapplyActiveMuteOnJoinEvt.ts b/backend/src/plugins/Mutes/events/ReapplyActiveMuteOnJoinEvt.ts index 890636a6..2e57e8da 100644 --- a/backend/src/plugins/Mutes/events/ReapplyActiveMuteOnJoinEvt.ts +++ b/backend/src/plugins/Mutes/events/ReapplyActiveMuteOnJoinEvt.ts @@ -1,6 +1,7 @@ import { mutesEvt } from "../types"; import { LogType } from "../../../data/LogType"; import { stripObjectToScalars } from "../../../utils"; +import { memberRolesLock } from "../../../utils/lockNameHelpers"; /** * Reapply active mutes on join @@ -11,9 +12,9 @@ export const ReapplyActiveMuteOnJoinEvt = mutesEvt("guildMemberAdd", async ({ pl const muteRole = pluginData.config.get().mute_role; if (muteRole) { - const memberRolesLock = await pluginData.locks.acquire(`member-roles-${member.id}`); + const memberRoleLock = await pluginData.locks.acquire(memberRolesLock(member)); await member.addRole(muteRole); - memberRolesLock.unlock(); + memberRoleLock.unlock(); } pluginData.state.serverLogs.log(LogType.MEMBER_MUTE_REJOIN, { diff --git a/backend/src/plugins/Mutes/functions/muteUser.ts b/backend/src/plugins/Mutes/functions/muteUser.ts index 2f8e4e28..622a5b1a 100644 --- a/backend/src/plugins/Mutes/functions/muteUser.ts +++ b/backend/src/plugins/Mutes/functions/muteUser.ts @@ -17,8 +17,8 @@ import { CasesPlugin } from "../../Cases/CasesPlugin"; import { CaseTypes } from "../../../data/CaseTypes"; import { LogType } from "../../../data/LogType"; import { Case } from "../../../data/entities/Case"; -import { sendErrorMessage } from "src/pluginUtils"; -import { LogsPlugin } from "src/plugins/Logs/LogsPlugin"; +import { LogsPlugin } from "../../../plugins/Logs/LogsPlugin"; +import { muteLock } from "../../../utils/lockNameHelpers"; export async function muteUser( pluginData: GuildPluginData, @@ -29,7 +29,7 @@ export async function muteUser( removeRolesOnMuteOverride: boolean | string[] | null = null, restoreRolesOnMuteOverride: boolean | string[] | null = null, ) { - const lock = await pluginData.locks.acquire(`mute-${userId}`); + const lock = await pluginData.locks.acquire(muteLock({ id: userId })); const muteRole = pluginData.config.get().mute_role; if (!muteRole) { @@ -120,11 +120,12 @@ export async function muteUser( } // If enabled, move the user to the mute voice channel (e.g. afk - just to apply the voice perms from the mute role) - const moveToVoiceChannelId = pluginData.config.get().move_to_voice_channel; - if (moveToVoiceChannelId) { + const cfg = pluginData.config.get(); + const moveToVoiceChannel = cfg.kick_from_voice_channel ? null : cfg.move_to_voice_channel; + if (moveToVoiceChannel || cfg.kick_from_voice_channel) { // TODO: Add back the voiceState check once we figure out how to get voice state for guild members that are loaded on-demand try { - await member.edit({ channelID: moveToVoiceChannelId }); + await member.edit({ channelID: moveToVoiceChannel }); } catch (e) {} // tslint:disable-line } } @@ -246,7 +247,7 @@ export async function muteUser( lock.unlock(); - pluginData.state.events.emit("mute", user.id, reason); + pluginData.state.events.emit("mute", user.id, reason, muteOptions.isAutomodAction); return { case: theCase, diff --git a/backend/src/plugins/Mutes/types.ts b/backend/src/plugins/Mutes/types.ts index 72ea52c2..ea180e90 100644 --- a/backend/src/plugins/Mutes/types.ts +++ b/backend/src/plugins/Mutes/types.ts @@ -15,6 +15,7 @@ import { EventEmitter } from "events"; export const ConfigSchema = t.type({ mute_role: tNullable(t.string), move_to_voice_channel: tNullable(t.string), + kick_from_voice_channel: t.boolean, dm_on_mute: t.boolean, dm_on_update: t.boolean, @@ -33,7 +34,7 @@ export const ConfigSchema = t.type({ export type TConfigSchema = t.TypeOf; export interface MutesEvents { - mute: (userId: string, reason?: string) => void; + mute: (userId: string, reason?: string, isAutomodAction?: boolean) => void; unmute: (userId: string, reason?: string) => void; } @@ -74,6 +75,7 @@ export type UnmuteResult = { export interface MuteOptions { caseArgs?: Partial; contactMethods?: UserNotificationMethod[]; + isAutomodAction?: boolean; } export const mutesCmd = guildCommand(); diff --git a/backend/src/plugins/Persist/events/LoadDataEvt.ts b/backend/src/plugins/Persist/events/LoadDataEvt.ts index e88cb333..bd356acb 100644 --- a/backend/src/plugins/Persist/events/LoadDataEvt.ts +++ b/backend/src/plugins/Persist/events/LoadDataEvt.ts @@ -7,6 +7,7 @@ import { getMissingPermissions } from "../../../utils/getMissingPermissions"; import { LogsPlugin } from "../../Logs/LogsPlugin"; import { missingPermissionError } from "../../../utils/missingPermissionError"; import { canAssignRole } from "../../../utils/canAssignRole"; +import { memberRolesLock } from "../../../utils/lockNameHelpers"; const p = Constants.Permissions; @@ -17,11 +18,11 @@ export const LoadDataEvt = persistEvt({ const member = meta.args.member; const pluginData = meta.pluginData; - const memberRolesLock = await pluginData.locks.acquire(`member-roles-${member.id}`); + const memberRoleLock = await pluginData.locks.acquire(memberRolesLock(member)); const persistedData = await pluginData.state.persistedData.find(member.id); if (!persistedData) { - memberRolesLock.unlock(); + memberRoleLock.unlock(); return; } @@ -79,6 +80,6 @@ export const LoadDataEvt = persistEvt({ }); } - memberRolesLock.unlock(); + memberRoleLock.unlock(); }, }); diff --git a/backend/src/plugins/ReactionRoles/util/addMemberPendingRoleChange.ts b/backend/src/plugins/ReactionRoles/util/addMemberPendingRoleChange.ts index 2b2dfd0d..00ae6108 100644 --- a/backend/src/plugins/ReactionRoles/util/addMemberPendingRoleChange.ts +++ b/backend/src/plugins/ReactionRoles/util/addMemberPendingRoleChange.ts @@ -2,6 +2,7 @@ import { GuildPluginData } from "knub"; import { ReactionRolesPluginType, RoleChangeMode, PendingMemberRoleChanges } from "../types"; import { resolveMember } from "../../../utils"; import { logger } from "../../../logger"; +import { memberRolesLock } from "../../../utils/lockNameHelpers"; const ROLE_CHANGE_BATCH_DEBOUNCE_TIME = 1500; @@ -18,7 +19,7 @@ export async function addMemberPendingRoleChange( applyFn: async () => { pluginData.state.pendingRoleChanges.delete(memberId); - const lock = await pluginData.locks.acquire(`member-roles-${memberId}`); + const lock = await pluginData.locks.acquire(memberRolesLock({ id: memberId })); const member = await resolveMember(pluginData.client, pluginData.guild, memberId); if (member) { diff --git a/backend/src/plugins/Reminders/commands/RemindCmd.ts b/backend/src/plugins/Reminders/commands/RemindCmd.ts index 67a2cfc1..ad9cd355 100644 --- a/backend/src/plugins/Reminders/commands/RemindCmd.ts +++ b/backend/src/plugins/Reminders/commands/RemindCmd.ts @@ -7,7 +7,7 @@ import { remindersCmd } from "../types"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin"; export const RemindCmd = remindersCmd({ - trigger: ["remind", "remindme"], + trigger: ["remind", "remindme", "reminder"], usage: "!remind 3h Remind me of this in 3 hours please", permission: "can_use", diff --git a/backend/src/plugins/Reminders/utils/postDueRemindersLoop.ts b/backend/src/plugins/Reminders/utils/postDueRemindersLoop.ts index cc774436..fac620cc 100644 --- a/backend/src/plugins/Reminders/utils/postDueRemindersLoop.ts +++ b/backend/src/plugins/Reminders/utils/postDueRemindersLoop.ts @@ -20,13 +20,21 @@ export async function postDueRemindersLoop(pluginData: GuildPluginData: ${reminder.body} \n\`Set at ${reminder.created_at} (${result} ago)\``, ), - ); + allowedMentions: { + users: [reminder.user_id], + }, + }); } else { - await channel.createMessage(disableLinkPreviews(`Reminder for <@!${reminder.user_id}>: ${reminder.body}`)); + await channel.createMessage({ + content: disableLinkPreviews(`Reminder for <@!${reminder.user_id}>: ${reminder.body}`), + allowedMentions: { + users: [reminder.user_id], + }, + }); } } catch (e) { // Probably random Discord internal server error or missing permissions or somesuch diff --git a/backend/src/plugins/SelfGrantableRoles/commands/RoleAddCmd.ts b/backend/src/plugins/SelfGrantableRoles/commands/RoleAddCmd.ts index 2c9a1d0f..8fe86f04 100644 --- a/backend/src/plugins/SelfGrantableRoles/commands/RoleAddCmd.ts +++ b/backend/src/plugins/SelfGrantableRoles/commands/RoleAddCmd.ts @@ -6,6 +6,7 @@ import { splitRoleNames } from "../util/splitRoleNames"; import { normalizeRoleNames } from "../util/normalizeRoleNames"; import { findMatchingRoles } from "../util/findMatchingRoles"; import { Role } from "eris"; +import { memberRolesLock } from "../../../utils/lockNameHelpers"; export const RoleAddCmd = selfGrantableRolesCmd({ trigger: ["role", "role add"], @@ -16,7 +17,7 @@ export const RoleAddCmd = selfGrantableRolesCmd({ }, async run({ message: msg, args, pluginData }) { - const lock = await pluginData.locks.acquire(`grantableRoles:${msg.author.id}`); + const lock = await pluginData.locks.acquire(memberRolesLock(msg.author)); const applyingEntries = getApplyingEntries(pluginData, msg); if (applyingEntries.length === 0) { diff --git a/backend/src/plugins/SelfGrantableRoles/commands/RoleRemoveCmd.ts b/backend/src/plugins/SelfGrantableRoles/commands/RoleRemoveCmd.ts index 7cabb3c4..c2011e05 100644 --- a/backend/src/plugins/SelfGrantableRoles/commands/RoleRemoveCmd.ts +++ b/backend/src/plugins/SelfGrantableRoles/commands/RoleRemoveCmd.ts @@ -5,6 +5,7 @@ import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; import { splitRoleNames } from "../util/splitRoleNames"; import { normalizeRoleNames } from "../util/normalizeRoleNames"; import { findMatchingRoles } from "../util/findMatchingRoles"; +import { memberRolesLock } from "../../../utils/lockNameHelpers"; export const RoleRemoveCmd = selfGrantableRolesCmd({ trigger: "role remove", @@ -15,7 +16,7 @@ export const RoleRemoveCmd = selfGrantableRolesCmd({ }, async run({ message: msg, args, pluginData }) { - const lock = await pluginData.locks.acquire(`grantableRoles:${msg.author.id}`); + const lock = await pluginData.locks.acquire(memberRolesLock(msg.author)); const applyingEntries = getApplyingEntries(pluginData, msg); if (applyingEntries.length === 0) { diff --git a/backend/src/plugins/Slowmode/util/onMessageCreate.ts b/backend/src/plugins/Slowmode/util/onMessageCreate.ts index 8e1b8564..4f0c3989 100644 --- a/backend/src/plugins/Slowmode/util/onMessageCreate.ts +++ b/backend/src/plugins/Slowmode/util/onMessageCreate.ts @@ -10,6 +10,7 @@ import { BOT_SLOWMODE_PERMISSIONS } from "../requiredPermissions"; import { LogsPlugin } from "../../Logs/LogsPlugin"; import { LogType } from "../../../data/LogType"; import { missingPermissionError } from "../../../utils/missingPermissionError"; +import { messageLock } from "../../../utils/lockNameHelpers"; export async function onMessageCreate(pluginData: GuildPluginData, msg: SavedMessage) { if (msg.is_bot) return; @@ -18,7 +19,7 @@ export async function onMessageCreate(pluginData: GuildPluginData()("starb stars_required: 5 ~~~ + ### Basic starboard with custom color + Any message on the server that gets 5 star reactions will be posted into the starboard channel (604342689038729226), with the given color (0x87CEEB). + + ~~~yml + starboard: + config: + boards: + basic: + channel_id: "604342689038729226" + stars_required: 5 + color: 0x87CEEB + ~~~ + ### Custom star emoji This is identical to the basic starboard above, but accepts two emoji: the regular star and a custom :mrvnSmile: emoji diff --git a/backend/src/plugins/Starboard/events/StarboardReactionAddEvt.ts b/backend/src/plugins/Starboard/events/StarboardReactionAddEvt.ts index b6142655..6c6cf93a 100644 --- a/backend/src/plugins/Starboard/events/StarboardReactionAddEvt.ts +++ b/backend/src/plugins/Starboard/events/StarboardReactionAddEvt.ts @@ -3,6 +3,7 @@ import { Message, TextChannel } from "eris"; import { UnknownUser, resolveMember, noop, resolveUser } from "../../../utils"; import { saveMessageToStarboard } from "../util/saveMessageToStarboard"; import { updateStarboardMessageStarCount } from "../util/updateStarboardMessageStarCount"; +import { allStarboardsLock } from "../../../utils/lockNameHelpers"; export const StarboardReactionAddEvt = starboardEvt({ event: "messageReactionAdd", @@ -36,7 +37,7 @@ export const StarboardReactionAddEvt = starboardEvt({ categoryId: (msg.channel as TextChannel).parentID, }); - const boardLock = await pluginData.locks.acquire(`starboards`); + const boardLock = await pluginData.locks.acquire(allStarboardsLock()); const applicableStarboards = Object.values(config.boards) .filter(board => board.enabled) diff --git a/backend/src/plugins/Starboard/events/StarboardReactionRemoveEvts.ts b/backend/src/plugins/Starboard/events/StarboardReactionRemoveEvts.ts index 07f85670..90160a4a 100644 --- a/backend/src/plugins/Starboard/events/StarboardReactionRemoveEvts.ts +++ b/backend/src/plugins/Starboard/events/StarboardReactionRemoveEvts.ts @@ -1,10 +1,11 @@ +import { allStarboardsLock } from "../../../utils/lockNameHelpers"; import { starboardEvt } from "../types"; export const StarboardReactionRemoveEvt = starboardEvt({ event: "messageReactionRemove", async listener(meta) { - const boardLock = await meta.pluginData.locks.acquire(`starboards`); + const boardLock = await meta.pluginData.locks.acquire(allStarboardsLock()); await meta.pluginData.state.starboardReactions.deleteStarboardReaction(meta.args.message.id, meta.args.member.id); boardLock.unlock(); }, @@ -14,7 +15,7 @@ export const StarboardReactionRemoveAllEvt = starboardEvt({ event: "messageReactionRemoveAll", async listener(meta) { - const boardLock = await meta.pluginData.locks.acquire(`starboards`); + const boardLock = await meta.pluginData.locks.acquire(allStarboardsLock()); await meta.pluginData.state.starboardReactions.deleteAllStarboardReactionsForMessageId(meta.args.message.id); boardLock.unlock(); }, diff --git a/backend/src/plugins/Starboard/types.ts b/backend/src/plugins/Starboard/types.ts index 8c94f415..1fbe3de5 100644 --- a/backend/src/plugins/Starboard/types.ts +++ b/backend/src/plugins/Starboard/types.ts @@ -12,6 +12,7 @@ const StarboardOpts = t.type({ copy_full_embed: tNullable(t.boolean), enabled: tNullable(t.boolean), show_star_count: t.boolean, + color: tNullable(t.number), }); export type TStarboardOpts = t.TypeOf; @@ -27,6 +28,7 @@ export const defaultStarboardOpts: Partial = { star_emoji: ["⭐"], enabled: true, show_star_count: true, + color: null, }; export interface StarboardPluginType extends BasePluginType { diff --git a/backend/src/plugins/Starboard/util/createStarboardEmbedFromMessage.ts b/backend/src/plugins/Starboard/util/createStarboardEmbedFromMessage.ts index d63b519c..9625556c 100644 --- a/backend/src/plugins/Starboard/util/createStarboardEmbedFromMessage.ts +++ b/backend/src/plugins/Starboard/util/createStarboardEmbedFromMessage.ts @@ -8,7 +8,11 @@ const videoAttachmentExtensions = ["mp4", "mkv", "mov"]; type StarboardEmbed = EmbedWith<"footer" | "author" | "fields" | "timestamp">; -export function createStarboardEmbedFromMessage(msg: Message, copyFullEmbed: boolean): StarboardEmbed { +export function createStarboardEmbedFromMessage( + msg: Message, + copyFullEmbed: boolean, + color?: number | null, +): StarboardEmbed { const embed: StarboardEmbed = { footer: { text: `#${(msg.channel as GuildChannel).name}`, @@ -20,6 +24,10 @@ export function createStarboardEmbedFromMessage(msg: Message, copyFullEmbed: boo timestamp: new Date(msg.timestamp).toISOString(), }; + if (color != null) { + embed.color = color; + } + if (msg.author.avatarURL) { embed.author.icon_url = msg.author.avatarURL; } diff --git a/backend/src/plugins/Starboard/util/saveMessageToStarboard.ts b/backend/src/plugins/Starboard/util/saveMessageToStarboard.ts index f30beffc..e7587abd 100644 --- a/backend/src/plugins/Starboard/util/saveMessageToStarboard.ts +++ b/backend/src/plugins/Starboard/util/saveMessageToStarboard.ts @@ -16,7 +16,7 @@ export async function saveMessageToStarboard( if (!channel) return; const starCount = (await pluginData.state.starboardReactions.getAllReactionsForMessageId(msg.id)).length; - const embed = createStarboardEmbedFromMessage(msg, Boolean(starboard.copy_full_embed)); + const embed = createStarboardEmbedFromMessage(msg, Boolean(starboard.copy_full_embed), starboard.color); embed.fields!.push(createStarboardPseudoFooterForMessage(starboard, msg, starboard.star_emoji![0], starCount)); const starboardMessage = await (channel as TextChannel).createMessage({ embed }); diff --git a/backend/src/plugins/Tags/TagsPlugin.ts b/backend/src/plugins/Tags/TagsPlugin.ts index e8bb8541..f6aa89e1 100644 --- a/backend/src/plugins/Tags/TagsPlugin.ts +++ b/backend/src/plugins/Tags/TagsPlugin.ts @@ -29,6 +29,7 @@ const defaultOptions: PluginOptions = { user_tag_cooldown: null, global_tag_cooldown: null, user_cooldown: null, + allow_mentions: false, global_cooldown: null, auto_delete_command: false, diff --git a/backend/src/plugins/Tags/types.ts b/backend/src/plugins/Tags/types.ts index 15b0ce5b..21324be0 100644 --- a/backend/src/plugins/Tags/types.ts +++ b/backend/src/plugins/Tags/types.ts @@ -15,6 +15,7 @@ export const TagCategory = t.type({ user_tag_cooldown: tNullable(t.union([t.string, t.number])), // Per user, per tag user_category_cooldown: tNullable(t.union([t.string, t.number])), // Per user, per tag category global_tag_cooldown: tNullable(t.union([t.string, t.number])), // Any user, per tag + allow_mentions: tNullable(t.boolean), // Per user, per category global_category_cooldown: tNullable(t.union([t.string, t.number])), // Any user, per category auto_delete_command: tNullable(t.boolean), // Any tag, per tag category @@ -31,6 +32,7 @@ export const ConfigSchema = t.type({ user_tag_cooldown: tNullable(t.union([t.string, t.number])), // Per user, per tag global_tag_cooldown: tNullable(t.union([t.string, t.number])), // Any user, per tag user_cooldown: tNullable(t.union([t.string, t.number])), // Per user + allow_mentions: t.boolean, // Per user global_cooldown: tNullable(t.union([t.string, t.number])), // Any tag use auto_delete_command: t.boolean, // Any tag diff --git a/backend/src/plugins/Tags/util/onMessageCreate.ts b/backend/src/plugins/Tags/util/onMessageCreate.ts index d7d8530b..c3e92a83 100644 --- a/backend/src/plugins/Tags/util/onMessageCreate.ts +++ b/backend/src/plugins/Tags/util/onMessageCreate.ts @@ -99,7 +99,11 @@ export async function onMessageCreate(pluginData: GuildPluginData = { config: { @@ -156,6 +157,21 @@ export const UtilityPlugin = zeppelinGuildPlugin()("utility", sendSuccessMessage(pluginData, activeReloads.get(guild.id)!, "Reloaded!"); activeReloads.delete(guild.id); } + + // FIXME: Temp fix for role change detection for specific servers, load all guild members in the background on bot start + const roleChangeDetectionFixServers = [ + "786212572285763605", + "653681924384096287", + "493351982887862283", + "513338222810497041", + "523043978178723840", + "718076393295970376", + "803251072877199400", + "750492934343753798", + ]; + if (roleChangeDetectionFixServers.includes(pluginData.guild.id)) { + refreshMembersIfNeeded(pluginData.guild); + } }, onUnload(pluginData) { diff --git a/backend/src/plugins/Utility/commands/AvatarCmd.ts b/backend/src/plugins/Utility/commands/AvatarCmd.ts index 9e413a3d..7ab1fac8 100644 --- a/backend/src/plugins/Utility/commands/AvatarCmd.ts +++ b/backend/src/plugins/Utility/commands/AvatarCmd.ts @@ -5,7 +5,7 @@ import { sendErrorMessage } from "../../../pluginUtils"; import { EmbedOptions } from "eris"; export const AvatarCmd = utilityCmd({ - trigger: "avatar", + trigger: ["avatar", "av"], description: "Retrieves a user's profile picture", permission: "can_avatar", diff --git a/backend/src/plugins/Utility/functions/getInviteInfoEmbed.ts b/backend/src/plugins/Utility/functions/getInviteInfoEmbed.ts index c25b3a06..84760b1f 100644 --- a/backend/src/plugins/Utility/functions/getInviteInfoEmbed.ts +++ b/backend/src/plugins/Utility/functions/getInviteInfoEmbed.ts @@ -9,6 +9,7 @@ import { EmbedWith, emptyEmbedValue, formatNumber, + inviteHasCounts, isGroupDMInvite, isGuildInvite, preEmbedPadding, @@ -50,13 +51,17 @@ export async function getInviteInfoEmbed( round: true, }); + const memberCount = inviteHasCounts(invite) ? invite.memberCount : 0; + + const presenceCount = inviteHasCounts(invite) ? invite.presenceCount : 0; + embed.fields.push({ name: preEmbedPadding + "Server information", value: trimLines(` Name: **${invite.guild.name}** ID: \`${invite.guild.id}\` Created: **${serverAge} ago** - Members: **${formatNumber(invite.memberCount)}** (${formatNumber(invite.presenceCount)} online) + Members: **${formatNumber(memberCount)}** (${formatNumber(presenceCount)} online) `), inline: true, }); diff --git a/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts b/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts index 3022a250..0a7564cc 100644 --- a/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts +++ b/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts @@ -4,6 +4,7 @@ import { embedPadding, EmbedWith, formatNumber, + inviteHasCounts, memoize, MINUTES, preEmbedPadding, @@ -81,11 +82,11 @@ export async function getServerInfoEmbed( }); // IMAGE LINKS - const iconUrl = `[URL](${(restGuild || guildPreview)!.iconURL})`; - const bannerUrl = restGuild?.bannerURL ?? "Unavailable"; + const iconUrl = `[Link](${(restGuild || guildPreview)!.iconURL})`; + const bannerUrl = restGuild?.bannerURL ? `[Link](${restGuild.bannerURL})` : "None"; const splashUrl = (restGuild || guildPreview)!.splashURL != null - ? `[URL](${(restGuild || guildPreview)!.splashURL?.replace("size=128", "size=2048")})` + ? `[Link](${(restGuild || guildPreview)!.splashURL?.replace("size=128", "size=2048")})` : "None"; embed.fields.push( @@ -120,7 +121,7 @@ export async function getServerInfoEmbed( if (onlineMemberCount == null && restGuild?.vanityURL) { // For servers with a vanity URL, we can also use the numbers from the invite for online count const invite = await resolveInvite(pluginData.client, restGuild.vanityURL!, true); - if (invite) { + if (invite && inviteHasCounts(invite)) { onlineMemberCount = invite.presenceCount; } } diff --git a/backend/src/plugins/Utility/search.ts b/backend/src/plugins/Utility/search.ts index c369e7ba..ec7a6604 100644 --- a/backend/src/plugins/Utility/search.ts +++ b/backend/src/plugins/Utility/search.ts @@ -1,4 +1,4 @@ -import { Member, Message, User } from "eris"; +import { Constants, Member, Message, User } from "eris"; import moment from "moment-timezone"; import escapeStringRegexp from "escape-string-regexp"; import { isFullMessage, MINUTES, multiSorter, noop, sorter, trimLines } from "../../utils"; @@ -10,10 +10,11 @@ import { banSearchSignature } from "./commands/BanSearchCmd"; import { UtilityPluginType } from "./types"; import { refreshMembersIfNeeded } from "./refreshMembers"; import { getUserInfoEmbed } from "./functions/getUserInfoEmbed"; -import { allowTimeout } from "../../RegExpRunner"; +import { allowTimeout, RegExpRunner } from "../../RegExpRunner"; import { inputPatternToRegExp, InvalidRegexError } from "../../validatorUtils"; import { asyncFilter } from "../../utils/async"; import Timeout = NodeJS.Timeout; +import { hasDiscordPermissions } from "../../utils/hasDiscordPermissions"; const SEARCH_RESULTS_PER_PAGE = 15; const SEARCH_ID_RESULTS_PER_PAGE = 50; @@ -29,6 +30,29 @@ class SearchError extends Error {} type MemberSearchParams = ArgsFromSignatureOrArray; type BanSearchParams = ArgsFromSignatureOrArray; +type RegexRunner = InstanceType["exec"]; +function getOptimizedRegExpRunner(pluginData: GuildPluginData, isSafeRegex: boolean): RegexRunner { + if (isSafeRegex) { + return async (regex: RegExp, str: string) => { + if (!regex.global) { + const singleMatch = regex.exec(str); + return singleMatch ? [singleMatch] : null; + } + + const matches: RegExpExecArray[] = []; + let match: RegExpExecArray | null; + // tslint:disable-next-line:no-conditional-assignment + while ((match = regex.exec(str)) != null) { + matches.push(match); + } + + return matches.length ? matches : null; + }; + } + + return pluginData.state.regexRunner.exec.bind(pluginData.state.regexRunner); +} + export async function displaySearch( pluginData: GuildPluginData, args: MemberSearchParams, @@ -270,59 +294,51 @@ async function performMemberSearch( } if (args.query) { + let isSafeRegex = true; let queryRegex: RegExp; if (args.regex) { const flags = args["case-sensitive"] ? "" : "i"; queryRegex = inputPatternToRegExp(args.query.trimStart()); queryRegex = new RegExp(queryRegex.source, flags); + isSafeRegex = false; } else { queryRegex = new RegExp(escapeStringRegexp(args.query.trimStart()), args["case-sensitive"] ? "" : "i"); } + const execRegExp = getOptimizedRegExpRunner(pluginData, isSafeRegex); + if (args["status-search"]) { matchingMembers = await asyncFilter(matchingMembers, async member => { if (member.game) { - if ( - member.game.name && - (await pluginData.state.regexRunner.exec(queryRegex, member.game.name).catch(allowTimeout)) - ) { + if (member.game.name && (await execRegExp(queryRegex, member.game.name).catch(allowTimeout))) { return true; } - if ( - member.game.state && - (await pluginData.state.regexRunner.exec(queryRegex, member.game.state).catch(allowTimeout)) - ) { + if (member.game.state && (await execRegExp(queryRegex, member.game.state).catch(allowTimeout))) { return true; } - if ( - member.game.details && - (await pluginData.state.regexRunner.exec(queryRegex, member.game.details).catch(allowTimeout)) - ) { + if (member.game.details && (await execRegExp(queryRegex, member.game.details).catch(allowTimeout))) { return true; } if (member.game.assets) { if ( member.game.assets.small_text && - (await pluginData.state.regexRunner.exec(queryRegex, member.game.assets.small_text).catch(allowTimeout)) + (await execRegExp(queryRegex, member.game.assets.small_text).catch(allowTimeout)) ) { return true; } if ( member.game.assets.large_text && - (await pluginData.state.regexRunner.exec(queryRegex, member.game.assets.large_text).catch(allowTimeout)) + (await execRegExp(queryRegex, member.game.assets.large_text).catch(allowTimeout)) ) { return true; } } - if ( - member.game.emoji && - (await pluginData.state.regexRunner.exec(queryRegex, member.game.emoji.name).catch(allowTimeout)) - ) { + if (member.game.emoji && (await execRegExp(queryRegex, member.game.emoji.name).catch(allowTimeout))) { return true; } } @@ -330,12 +346,12 @@ async function performMemberSearch( }); } else { matchingMembers = await asyncFilter(matchingMembers, async member => { - if (member.nick && (await pluginData.state.regexRunner.exec(queryRegex, member.nick).catch(allowTimeout))) { + if (member.nick && (await execRegExp(queryRegex, member.nick).catch(allowTimeout))) { return true; } const fullUsername = `${member.user.username}#${member.user.discriminator}`; - if (await pluginData.state.regexRunner.exec(queryRegex, fullUsername).catch(allowTimeout)) return true; + if (await execRegExp(queryRegex, fullUsername).catch(allowTimeout)) return true; return false; }); @@ -380,21 +396,29 @@ async function performBanSearch( page = 1, perPage = SEARCH_RESULTS_PER_PAGE, ): Promise<{ results: User[]; totalResults: number; page: number; lastPage: number; from: number; to: number }> { + const member = pluginData.guild.members.get(pluginData.client.user.id); + if (member && !hasDiscordPermissions(member.permissions, Constants.Permissions.banMembers)) { + throw new SearchError(`Unable to search bans: missing "Ban Members" permission`); + } + let matchingBans = (await pluginData.guild.getBans()).map(x => x.user); if (args.query) { + let isSafeRegex = true; let queryRegex: RegExp; if (args.regex) { const flags = args["case-sensitive"] ? "" : "i"; queryRegex = inputPatternToRegExp(args.query.trimStart()); queryRegex = new RegExp(queryRegex.source, flags); + isSafeRegex = false; } else { queryRegex = new RegExp(escapeStringRegexp(args.query.trimStart()), args["case-sensitive"] ? "" : "i"); } + const execRegExp = getOptimizedRegExpRunner(pluginData, isSafeRegex); matchingBans = await asyncFilter(matchingBans, async user => { const fullUsername = `${user.username}#${user.discriminator}`; - if (await pluginData.state.regexRunner.exec(queryRegex, fullUsername).catch(allowTimeout)) return true; + if (await execRegExp(queryRegex, fullUsername).catch(allowTimeout)) return true; return false; }); } diff --git a/backend/src/utils.ts b/backend/src/utils.ts index b78a29c7..059ddac9 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -12,7 +12,6 @@ import { GuildChannel, Invite, InvitePartialChannel, - InviteWithMetadata, Member, Message, MessageContent, @@ -21,7 +20,7 @@ import { TextChannel, User, } from "eris"; -import url from "url"; +import { URL } from "url"; import tlds from "tlds"; import emojiRegex from "emoji-regex"; import * as t from "io-ts"; @@ -181,11 +180,11 @@ export function nonNullish(v: V): v is NonNullable { return v != null; } -export type GuildInvite = Invite & { guild: Guild }; -export type GroupDMInvite = Invite & { channel: InvitePartialChannel; type: typeof Constants.ChannelTypes.GROUP_DM }; -export type WithInviteCounts = { - memberCount: number; - presenceCount: number; +export type InviteOpts = "withMetadata" | "withCount" | "withoutCount"; +export type GuildInvite = Invite & { guild: Guild }; +export type GroupDMInvite = Invite & { + channel: InvitePartialChannel; + type: typeof Constants.ChannelTypes.GROUP_DM; }; /** @@ -482,7 +481,7 @@ const plainLinkRegex = /((?!https?:\/\/)\S)+\.\S+/; // anything.anything, withou const urlRegex = new RegExp(`(${realLinkRegex.source}|${plainLinkRegex.source})`, "g"); const protocolRegex = /^[a-z]+:\/\//; -interface MatchedURL extends url.URL { +interface MatchedURL extends URL { input: string; } @@ -497,7 +496,7 @@ export function getUrlsInString(str: string, onlyUnique = false): MatchedURL[] { let matchUrl: MatchedURL; try { - matchUrl = new url.URL(withProtocol) as MatchedURL; + matchUrl = new URL(withProtocol) as MatchedURL; matchUrl.input = match; } catch (e) { return urls; @@ -521,9 +520,61 @@ export function parseInviteCodeInput(str: string): string { return getInviteCodesInString(str)[0]; } +export function isNotNull(value): value is Exclude { + return value != null; +} + +// discord.com/invite/ +// discordapp.com/invite/ +// discord.gg/invite/ +// discord.gg/ +const quickInviteDetection = /(?:discord.com|discordapp.com)\/invite\/([^\s\/#?]+)|discord.gg\/(?:\S+\/)?([^\s\/#?]+)/gi; + +const isInviteHostRegex = /(?:^|\.)(?:discord.gg|discord.com|discordapp.com)$/; +const longInvitePathRegex = /^\/invite\/([^\s\/]+)$/; + export function getInviteCodesInString(str: string): string[] { - const inviteCodeRegex = /(?:discord.gg|discordapp.com\/invite|discord.com\/invite)\/([a-z0-9\-]+)/gi; - return Array.from(str.matchAll(inviteCodeRegex)).map(m => m[1]); + const inviteCodes: string[] = []; + + // Clean up markdown + str = str.replace(/[|*_~]/g, ""); + + // Quick detection + const quickDetectionMatch = str.matchAll(quickInviteDetection); + if (quickDetectionMatch) { + inviteCodes.push(...[...quickDetectionMatch].map(m => m[1] || m[2])); + } + + // Deep detection via URL parsing + const linksInString = getUrlsInString(str, true); + const potentialInviteLinks = linksInString.filter(url => isInviteHostRegex.test(url.hostname)); + const withNormalizedPaths = potentialInviteLinks.map(url => { + url.pathname = url.pathname.replace(/\/{2,}/g, "/").replace(/\/+$/g, ""); + return url; + }); + + const codesFromInviteLinks = withNormalizedPaths + .map(url => { + // discord.gg/[anything/] + if (url.hostname === "discord.gg") { + const parts = url.pathname.split("/").filter(Boolean); + return parts[parts.length - 1]; + } + + // discord.com/invite/[/anything] + // discordapp.com/invite/[/anything] + const longInviteMatch = url.pathname.match(longInvitePathRegex); + if (longInviteMatch) { + return longInviteMatch[1]; + } + + return null; + }) + .filter(Boolean) as string[]; + + inviteCodes.push(...codesFromInviteLinks); + + return unique(inviteCodes); } export const unicodeEmojiRegex = emojiRegex(); @@ -1153,9 +1204,11 @@ export async function resolveRoleId(bot: Client, guildId: string, value: string) return null; } -const inviteCache = new SimpleCache>(10 * MINUTES, 200); +const inviteCache = new SimpleCache | null>>(10 * MINUTES, 200); -type ResolveInviteReturnType = Promise<(T extends true ? Invite & WithInviteCounts : Invite) | null>; +type ResolveInviteReturnType = Promise< + (T extends true ? Invite<"withCount" | "withMetadata"> : Invite<"withMetadata">) | null +>; export async function resolveInvite( client: Client, code: string, @@ -1215,7 +1268,11 @@ export function verboseUserName(user: User | UnknownUser): string { } export function verboseChannelMention(channel: GuildChannel): string { - return `<#${channel.id}> (**#${channel.name}**, \`${channel.id}\`)`; + const plainTextName = + channel.type === Constants.ChannelTypes.GUILD_VOICE || channel.type === Constants.ChannelTypes.GUILD_STAGE + ? channel.name + : `#${channel.name}`; + return `<#${channel.id}> (**${plainTextName}**, \`${channel.id}\`)`; } export function messageLink(message: Message): string; @@ -1330,14 +1387,18 @@ export function isFullMessage(msg: PossiblyUncachedMessage): msg is Message { return (msg as Message).createdAt != null; } -export function isGuildInvite(invite: Invite): invite is GuildInvite { +export function isGuildInvite(invite: Invite): invite is GuildInvite { return invite.guild != null; } -export function isGroupDMInvite(invite: Invite): invite is GroupDMInvite { +export function isGroupDMInvite(invite: Invite): invite is GroupDMInvite { return invite.guild == null && invite.channel?.type === Constants.ChannelTypes.GROUP_DM; } +export function inviteHasCounts(invite: Invite): invite is Invite<"withCount"> { + return invite.memberCount != null; +} + export function asyncMap(arr: T[], fn: (item: T) => Promise): Promise { return Promise.all(arr.map((item, index) => fn(item))); } diff --git a/backend/src/utils/hasDiscordPermissions.ts b/backend/src/utils/hasDiscordPermissions.ts index 6bc9c7aa..f4aefcab 100644 --- a/backend/src/utils/hasDiscordPermissions.ts +++ b/backend/src/utils/hasDiscordPermissions.ts @@ -12,5 +12,5 @@ export function hasDiscordPermissions(resolvedPermissions: Permission, requiredP return true; } - return Boolean(allowedPermissions & nRequiredPermissions); + return Boolean((allowedPermissions & nRequiredPermissions) === nRequiredPermissions); } diff --git a/backend/src/utils/lockNameHelpers.ts b/backend/src/utils/lockNameHelpers.ts new file mode 100644 index 00000000..4a16bd33 --- /dev/null +++ b/backend/src/utils/lockNameHelpers.ts @@ -0,0 +1,26 @@ +import { Member, Message, User } from "eris"; +import { SavedMessage } from "../data/entities/SavedMessage"; + +export function allStarboardsLock() { + return `starboards`; +} + +export function banLock(user: Member | User | { id: string }) { + return `ban-${user.id}`; +} + +export function counterIdLock(counterId: number | string) { + return `counter-${counterId}`; +} + +export function memberRolesLock(member: Member | User | { id: string }) { + return `member-roles-${member.id}`; +} + +export function messageLock(message: Message | SavedMessage | { id: string }) { + return `message-${message.id}`; +} + +export function muteLock(user: Member | User | { id: string }) { + return `mute-${user.id}`; +} diff --git a/backend/src/validatorUtils.ts b/backend/src/validatorUtils.ts index 1db917c0..5292ca96 100644 --- a/backend/src/validatorUtils.ts +++ b/backend/src/validatorUtils.ts @@ -27,7 +27,15 @@ export const TRegex = new t.Type( (s): s is RegExp => s instanceof RegExp, (from, to) => either.chain(t.string.validate(from, to), s => { - return t.success(inputPatternToRegExp(s)); + try { + return t.success(inputPatternToRegExp(s)); + } catch (err) { + if (err instanceof InvalidRegexError) { + return t.failure(s, [], err.message); + } + + throw err; + } }), s => `/${s.source}/${s.flags}`, ); diff --git a/dashboard/src/api.ts b/dashboard/src/api.ts index 0ca2145f..bc37d3b8 100644 --- a/dashboard/src/api.ts +++ b/dashboard/src/api.ts @@ -4,9 +4,9 @@ const apiUrl = process.env.API_URL; type QueryParamObject = { [key: string]: string | null }; export class ApiError extends Error { - protected body: object; - protected status: number; - protected res: Response; + public body: any; + public status: number; + public res: Response; constructor(message: string, body: object, status: number, res: Response) { super(message); diff --git a/dashboard/src/components/dashboard/GuildConfigEditor.vue b/dashboard/src/components/dashboard/GuildConfigEditor.vue index 69b35058..b69b57a7 100644 --- a/dashboard/src/components/dashboard/GuildConfigEditor.vue +++ b/dashboard/src/components/dashboard/GuildConfigEditor.vue @@ -8,8 +8,8 @@
{{ error }}
-
-

Config for {{ guild.name }}

+
+

Config for {{ guild.name }}

- diff --git a/dashboard/src/components/docs/DocsLayout.vue b/dashboard/src/components/docs/DocsLayout.vue index b508ef46..e8657d8a 100644 --- a/dashboard/src/components/docs/DocsLayout.vue +++ b/dashboard/src/components/docs/DocsLayout.vue @@ -115,6 +115,10 @@ to: '/docs/setup-guides/moderation', label: 'Moderation', }, + { + to: '/docs/setup-guides/counters', + label: 'Counters', + }, ], }, ]; diff --git a/dashboard/src/routes.ts b/dashboard/src/routes.ts index c38d1f7e..4ef8e996 100644 --- a/dashboard/src/routes.ts +++ b/dashboard/src/routes.ts @@ -53,6 +53,10 @@ export const router = new VueRouter({ path: "setup-guides/moderation", component: () => import("./components/docs/WorkInProgress.vue"), }, + { + path: "setup-guides/counters", + component: () => import("./components/docs/Counters.vue"), + }, { path: "plugins/:pluginName/:tab?", component: () => import("./components/docs/Plugin.vue"), diff --git a/dashboard/src/style/content.pcss b/dashboard/src/style/content.pcss index 3709ad7a..a4733b37 100644 --- a/dashboard/src/style/content.pcss +++ b/dashboard/src/style/content.pcss @@ -1,6 +1,6 @@ .main-content { & h1 { - @apply text-5xl; + @apply text-3xl; @apply font-semibold; @apply leading-none; @apply mb-4; @@ -55,6 +55,14 @@ } } +@screen lg { + .main-content { + & h1 { + @apply text-5xl; + } + } +} + @screen xl { .main-content { & a:not([class]),