3
0
Fork 0
mirror of https://github.com/ZeppelinBot/Zeppelin.git synced 2025-05-20 16:25:03 +00:00

Merge branch 'master' into fr_cleanWithUpdate

This commit is contained in:
Miikka 2021-04-28 22:14:42 +03:00 committed by GitHub
commit 6c273a1193
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
115 changed files with 1697 additions and 513 deletions

View file

@ -1634,10 +1634,10 @@
"dev": true "dev": true
}, },
"eris": { "eris": {
"version": "https://github.com/Dragory/eris/archive/custom.tar.gz", "version": "github:abalabahaha/eris#54fc78d3a1f9f8ebe8b072c9c87c674c8453d016",
"integrity": "sha512-6wb+mk7l/IDzqqki1IH0F8+U1dzGCbw7cHsg6dBVZ6emflHz+NnOND8XV3LPVnUQkw8ABIYzZhmYYXasURgmfg==", "from": "github:abalabahaha/eris#dev",
"requires": { "requires": {
"opusscript": "^0.0.7", "opusscript": "^0.0.8",
"tweetnacl": "^1.0.1", "tweetnacl": "^1.0.1",
"ws": "^7.2.1" "ws": "^7.2.1"
} }
@ -2883,9 +2883,9 @@
} }
}, },
"opusscript": { "opusscript": {
"version": "0.0.7", "version": "0.0.8",
"resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.7.tgz", "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.8.tgz",
"integrity": "sha512-DcBadTdYTUuH9zQtepsLjQn4Ll6rs3dmeFvN+SD0ThPnxRBRm/WC1zXWPg+wgAJimB784gdZvUMA57gDP7FdVg==", "integrity": "sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==",
"optional": true "optional": true
}, },
"ora": { "ora": {
@ -4696,9 +4696,9 @@
} }
}, },
"ws": { "ws": {
"version": "7.4.0", "version": "7.4.4",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.4.tgz",
"integrity": "sha512-kyFwXuV/5ymf+IXhS6f0+eAFvydbaBW3zjpT6hUdAh/hbVjTIB5EHBGi0bPoCLSK2wcuz3BrEkB9LrYv1Nm4NQ==" "integrity": "sha512-Qm8k8ojNQIMx7S+Zp8u/uHOx7Qazv3Yv4q68MiWWWOJhiwG5W3x7iqmRtJo8xxrciZUY4vRxUTJCKuRnF28ZZw=="
}, },
"xdg-basedir": { "xdg-basedir": {
"version": "4.0.0", "version": "4.0.0",

View file

@ -31,7 +31,7 @@
"deep-diff": "^1.0.2", "deep-diff": "^1.0.2",
"dotenv": "^4.0.0", "dotenv": "^4.0.0",
"emoji-regex": "^8.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", "erlpack": "github:abalabahaha/erlpack",
"escape-string-regexp": "^1.0.5", "escape-string-regexp": "^1.0.5",
"express": "^4.17.0", "express": "^4.17.0",

View file

@ -39,7 +39,11 @@ function simpleDiscordAPIRequest(bearerToken, path): Promise<any> {
return; return;
} }
res.on("data", data => resolve(JSON.parse(data))); let rawData = "";
res.on("data", data => (rawData += data));
res.on("end", () => {
resolve(JSON.parse(rawData));
});
}, },
); );

View file

@ -36,9 +36,9 @@
"MESSAGE_DELETE_BARE": "🗑 Message (`{messageId}`) deleted in {channelMention(channel)} (no more info available)", "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)}", "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_JOIN": "🎙 🔵 {userMention(member)} joined {channelMention(channel)}",
"VOICE_CHANNEL_MOVE": "🎙 ↔ {userMention(member)} moved from **{oldChannel.name}** to **{newChannel.name}**", "VOICE_CHANNEL_MOVE": "🎙 ↔ {userMention(member)} moved from {channelMention(oldChannel)} to {channelMention(newChannel)}",
"VOICE_CHANNEL_LEAVE": "🎙 🔴 {userMention(member)} left **{channel.name}**", "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_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)}", "VOICE_CHANNEL_FORCE_DISCONNECT": "\uD83C\uDF99 🚫 {userMention(member)} was forcefully disconnected from **{oldChannel.name}** by {userMention(mod)}",

View file

@ -1,49 +1,26 @@
import { BaseGuildRepository } from "./BaseGuildRepository"; 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 { Counter } from "./entities/Counter";
import { CounterValue } from "./entities/CounterValue"; 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 { CounterTriggerState } from "./entities/CounterTriggerState";
import moment from "moment-timezone"; import moment from "moment-timezone";
import { DAYS, DBDateFormat, HOURS, MINUTES } from "../utils"; import { DAYS, DBDateFormat, HOURS, MINUTES } from "../utils";
import { connection } from "./db"; import { connection } from "./db";
import { Queue } from "../Queue";
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<TriggerComparisonOp, TriggerComparisonOp> = {
"=": "!=",
"!=": "=",
">": "<=",
"<": ">=",
">=": "<",
"<=": ">",
};
function getReverseComparisonOp(op: TriggerComparisonOp): TriggerComparisonOp {
return REVERSE_OPS[op];
}
const DELETE_UNUSED_COUNTERS_AFTER = 1 * DAYS; const DELETE_UNUSED_COUNTERS_AFTER = 1 * DAYS;
const DELETE_UNUSED_COUNTER_TRIGGERS_AFTER = 1 * DAYS; const DELETE_UNUSED_COUNTER_TRIGGERS_AFTER = 1 * DAYS;
const MAX_COUNTER_VALUE = 2147483647; // 2^31-1, for MySQL INT const MAX_COUNTER_VALUE = 2147483647; // 2^31-1, for MySQL INT
const decayQueue = new Queue();
async function deleteCountersMarkedToBeDeleted(): Promise<void> { async function deleteCountersMarkedToBeDeleted(): Promise<void> {
await getRepository(Counter) await getRepository(Counter)
.createQueryBuilder() .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. // 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. // Otherwise, delete the existing counter and re-create it with the proper properties.
if (existing.per_channel === perChannel && existing.per_user === perUser) { if (existing.per_channel === perChannel && existing.per_user === perUser) {
await this.counters.update({ id: existing.id }, { delete_at: null });
return existing; return existing;
} }
@ -114,24 +93,23 @@ export class GuildCounters extends BaseGuildRepository {
} }
async markUnusedCountersToBeDeleted(idsToKeep: number[]): Promise<void> { async markUnusedCountersToBeDeleted(idsToKeep: number[]): Promise<void> {
if (idsToKeep.length === 0) { const criteria: FindConditions<Counter> = {
return; guild_id: this.guildId,
delete_at: IsNull(),
};
if (idsToKeep.length) {
criteria.id = Not(In(idsToKeep));
} }
const deleteAt = moment const deleteAt = moment
.utc() .utc()
.add(DELETE_UNUSED_COUNTERS_AFTER, "ms") .add(DELETE_UNUSED_COUNTERS_AFTER, "ms")
.format(DBDateFormat); .format(DBDateFormat);
await this.counters.update(
{ await this.counters.update(criteria, {
guild_id: this.guildId, delete_at: deleteAt,
id: Not(In(idsToKeep)), });
delete_at: IsNull(),
},
{
delete_at: deleteAt,
},
);
} }
async deleteCountersMarkedToBeDeleted(): Promise<void> { async deleteCountersMarkedToBeDeleted(): Promise<void> {
@ -183,64 +161,88 @@ export class GuildCounters extends BaseGuildRepository {
); );
} }
async decay(id: number, decayPeriodMs: number, decayAmount: number) { decay(id: number, decayPeriodMs: number, decayAmount: number) {
const counter = (await this.counters.findOne({ return decayQueue.add(async () => {
where: { const counter = (await this.counters.findOne({
id, where: {
}, id,
}))!; },
}))!;
const diffFromLastDecayMs = moment.utc().diff(moment.utc(counter.last_decay_at!), "ms"); const diffFromLastDecayMs = moment.utc().diff(moment.utc(counter.last_decay_at!), "ms");
if (diffFromLastDecayMs < decayPeriodMs) { if (diffFromLastDecayMs < decayPeriodMs) {
return; return;
} }
const decayAmountToApply = Math.round((diffFromLastDecayMs / decayPeriodMs) * decayAmount); const decayAmountToApply = Math.round((diffFromLastDecayMs / decayPeriodMs) * decayAmount);
if (decayAmountToApply === 0) { if (decayAmountToApply === 0) {
return; 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. // 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 const newLastDecayDate = moment
.utc(counter.last_decay_at) .utc(counter.last_decay_at)
.add((decayAmountToApply / decayAmount) * decayPeriodMs, "ms") .add((decayAmountToApply / decayAmount) * decayPeriodMs, "ms")
.format(DBDateFormat); .format(DBDateFormat);
const rawUpdate = const rawUpdate =
decayAmountToApply >= 0 decayAmountToApply >= 0
? `GREATEST(value - ${decayAmountToApply}, 0)` ? `GREATEST(value - ${decayAmountToApply}, 0)`
: `LEAST(value + ${Math.abs(decayAmountToApply)}, ${MAX_COUNTER_VALUE})`; : `LEAST(value + ${Math.abs(decayAmountToApply)}, ${MAX_COUNTER_VALUE})`;
await this.counterValues.update( // 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
counter_id: id, await this.counterValues
}, .createQueryBuilder("CounterValue")
{ .where("counter_id = :id", { id })
value: () => rawUpdate, .orderBy("id")
}, .update({
); value: () => rawUpdate,
})
.execute();
await this.counters.update( await this.counters.update(
{ {
id, id,
}, },
{ {
last_decay_at: newLastDecayDate, last_decay_at: newLastDecayDate,
}, },
); );
});
} }
async markAllTriggersTobeDeleted() { async markUnusedTriggersToBeDeleted(triggerIdsToKeep: number[]) {
const deleteAt = moment let triggersToMarkQuery = this.counterTriggers
.utc() .createQueryBuilder("counterTriggers")
.add(DELETE_UNUSED_COUNTER_TRIGGERS_AFTER, "ms") .innerJoin(Counter, "counters", "counters.id = counterTriggers.counter_id")
.format(DBDateFormat); .where("counters.guild_id = :guildId", { guildId: this.guildId });
await this.counterTriggers.update(
{}, // 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.
delete_at: deleteAt, 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<void> { async deleteTriggersMarkedToBeDeleted(): Promise<void> {
@ -253,34 +255,53 @@ export class GuildCounters extends BaseGuildRepository {
async initCounterTrigger( async initCounterTrigger(
counterId: number, counterId: number,
triggerName: string,
comparisonOp: TriggerComparisonOp, comparisonOp: TriggerComparisonOp,
comparisonValue: number, comparisonValue: number,
reverseComparisonOp: TriggerComparisonOp,
reverseComparisonValue: number,
): Promise<CounterTrigger> { ): Promise<CounterTrigger> {
if (!isValidComparisonOp(comparisonOp)) { if (!isValidCounterComparisonOp(comparisonOp)) {
throw new Error(`Invalid comparison op: ${comparisonOp}`); throw new Error(`Invalid comparison op: ${comparisonOp}`);
} }
if (!isValidCounterComparisonOp(reverseComparisonOp)) {
throw new Error(`Invalid comparison op: ${reverseComparisonOp}`);
}
if (typeof comparisonValue !== "number") { if (typeof comparisonValue !== "number") {
throw new Error(`Invalid comparison value: ${comparisonValue}`); throw new Error(`Invalid comparison value: ${comparisonValue}`);
} }
if (typeof reverseComparisonValue !== "number") {
throw new Error(`Invalid comparison value: ${reverseComparisonValue}`);
}
return connection.transaction(async entityManager => { return connection.transaction(async entityManager => {
const existing = await entityManager.findOne(CounterTrigger, { const existing = await entityManager.findOne(CounterTrigger, {
counter_id: counterId, counter_id: counterId,
comparison_op: comparisonOp, name: triggerName,
comparison_value: comparisonValue,
}); });
if (existing) { if (existing) {
// Since all existing triggers are marked as to-be-deleted before they are re-initialized, this needs to be reset // 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; return existing;
} }
const insertResult = await entityManager.insert(CounterTrigger, { const insertResult = await entityManager.insert(CounterTrigger, {
counter_id: counterId, counter_id: counterId,
name: triggerName,
comparison_op: comparisonOp, comparison_op: comparisonOp,
comparison_value: comparisonValue, comparison_value: comparisonValue,
reverse_comparison_op: reverseComparisonOp,
reverse_comparison_value: reverseComparisonValue,
}); });
return (await entityManager.findOne(CounterTrigger, insertResult.identifiers[0].id))!; return (await entityManager.findOne(CounterTrigger, insertResult.identifiers[0].id))!;
@ -375,8 +396,8 @@ export class GuildCounters extends BaseGuildRepository {
CounterTriggerState, CounterTriggerState,
matchingValues.map(row => ({ matchingValues.map(row => ({
trigger_id: counterTrigger.id, trigger_id: counterTrigger.id,
channelId: row.channel_id, channel_id: row.channel_id,
userId: row.user_id, user_id: row.user_id,
})), })),
); );
} }
@ -408,7 +429,6 @@ export class GuildCounters extends BaseGuildRepository {
userId = userId || "0"; userId = userId || "0";
return connection.transaction(async entityManager => { return connection.transaction(async entityManager => {
const reverseOp = getReverseComparisonOp(counterTrigger.comparison_op);
const matchingValue = await entityManager const matchingValue = await entityManager
.createQueryBuilder(CounterValue, "cv") .createQueryBuilder(CounterValue, "cv")
.innerJoin( .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", "triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id",
{ triggerId: counterTrigger.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.counter_id = :counterId`, { counterId: counterTrigger.counter_id })
.andWhere(`cv.channel_id = :channelId AND cv.user_id = :userId`, { channelId, userId }) .andWhere(`cv.channel_id = :channelId AND cv.user_id = :userId`, { channelId, userId })
.getOne(); .getOne();
@ -446,7 +468,6 @@ export class GuildCounters extends BaseGuildRepository {
counterTrigger: CounterTrigger, counterTrigger: CounterTrigger,
): Promise<Array<{ channelId: string; userId: string }>> { ): Promise<Array<{ channelId: string; userId: string }>> {
return connection.transaction(async entityManager => { return connection.transaction(async entityManager => {
const reverseOp = getReverseComparisonOp(counterTrigger.comparison_op);
const matchingValues: Array<{ const matchingValues: Array<{
id: string; id: string;
triggerStateId: 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", "triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id",
{ triggerId: counterTrigger.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.counter_id = :counterId`, { counterId: counterTrigger.counter_id })
.select([ .select([
"cv.id AS id", "cv.id AS id",

View file

@ -4,6 +4,37 @@ export const TRIGGER_COMPARISON_OPS = ["=", "!=", ">", "<", ">=", "<="] as const
export type TriggerComparisonOp = typeof TRIGGER_COMPARISON_OPS[number]; export type TriggerComparisonOp = typeof TRIGGER_COMPARISON_OPS[number];
const REVERSE_OPS: Record<TriggerComparisonOp, TriggerComparisonOp> = {
"=": "!=",
"!=": "=",
">": "<=",
"<": ">=",
">=": "<",
"<=": ">",
};
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") @Entity("counter_triggers")
export class CounterTrigger { export class CounterTrigger {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@ -12,12 +43,21 @@ export class CounterTrigger {
@Column() @Column()
counter_id: number; counter_id: number;
@Column()
name: string;
@Column({ type: "varchar" }) @Column({ type: "varchar" })
comparison_op: TriggerComparisonOp; comparison_op: TriggerComparisonOp;
@Column() @Column()
comparison_value: number; comparison_value: number;
@Column({ type: "varchar" })
reverse_comparison_op: TriggerComparisonOp;
@Column()
reverse_comparison_value: number;
@Column({ type: "datetime", nullable: true }) @Column({ type: "datetime", nullable: true })
delete_at: string | null; delete_at: string | null;
} }

View file

@ -156,6 +156,13 @@ connect().then(async () => {
restMode: true, restMode: true,
compress: false, compress: false,
guildCreateTimeout: 0, guildCreateTimeout: 0,
// Disable mentions by default
allowedMentions: {
everyone: false,
users: false,
roles: false,
repliedUser: false,
},
intents: [ intents: [
// Privileged // Privileged
"guildMembers", "guildMembers",

View file

@ -11,7 +11,7 @@ export class MigrateUsernamesToNewHistoryTable1556909512501 implements Migration
await new Promise(async resolve => { await new Promise(async resolve => {
const stream = await queryRunner.stream("SELECT CONCAT(user_id, '-', username) AS `key` FROM username_history"); 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); migratedUsernames.add(row.key);
}); });
stream.on("end", resolve); stream.on("end", resolve);
@ -25,7 +25,7 @@ export class MigrateUsernamesToNewHistoryTable1556909512501 implements Migration
const stream = await queryRunner.stream( const stream = await queryRunner.stream(
`SELECT * FROM name_history WHERE type=1 ORDER BY timestamp ASC LIMIT ${BATCH_SIZE}`, `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}`; const key = `${row.user_id}-${row.value}`;
if (!migratedUsernames.has(key)) { if (!migratedUsernames.has(key)) {

View file

@ -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<void> {
// 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<void> {
// 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");
}
}

View file

@ -7,6 +7,7 @@ import { onMessageCreate } from "./util/onMessageCreate";
import { onMessageDelete } from "./util/onMessageDelete"; import { onMessageDelete } from "./util/onMessageDelete";
import { onMessageDeleteBulk } from "./util/onMessageDeleteBulk"; import { onMessageDeleteBulk } from "./util/onMessageDeleteBulk";
import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin";
import { LogsPlugin } from "../Logs/LogsPlugin";
const defaultOptions: PluginOptions<AutoDeletePluginType> = { const defaultOptions: PluginOptions<AutoDeletePluginType> = {
config: { config: {
@ -23,7 +24,7 @@ export const AutoDeletePlugin = zeppelinGuildPlugin<AutoDeletePluginType>()("aut
configurationGuide: "Maximum deletion delay is currently 5 minutes", configurationGuide: "Maximum deletion delay is currently 5 minutes",
}, },
dependencies: [TimeAndDatePlugin], dependencies: [TimeAndDatePlugin, LogsPlugin],
configSchema: ConfigSchema, configSchema: ConfigSchema,
defaultOptions, defaultOptions,

View file

@ -2,24 +2,58 @@ import { GuildPluginData } from "knub";
import { AutoDeletePluginType } from "../types"; import { AutoDeletePluginType } from "../types";
import moment from "moment-timezone"; import moment from "moment-timezone";
import { LogType } from "../../../data/LogType"; import { LogType } from "../../../data/LogType";
import { stripObjectToScalars, resolveUser } from "../../../utils"; import { resolveUser, stripObjectToScalars, verboseChannelMention } from "../../../utils";
import { logger } from "../../../logger"; import { logger } from "../../../logger";
import { scheduleNextDeletion } from "./scheduleNextDeletion"; import { scheduleNextDeletion } from "./scheduleNextDeletion";
import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin"; 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<AutoDeletePluginType>) { export async function deleteNextItem(pluginData: GuildPluginData<AutoDeletePluginType>) {
const [itemToDelete] = pluginData.state.deletionQueue.splice(0, 1); const [itemToDelete] = pluginData.state.deletionQueue.splice(0, 1);
if (!itemToDelete) return; 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); const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);
pluginData.state.guildLogs.ignoreLog(LogType.MESSAGE_DELETE, itemToDelete.message.id); 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 user = await resolveUser(pluginData.client, itemToDelete.message.user_id);
const channel = pluginData.guild.channels.get(itemToDelete.message.channel_id);
const messageDate = timeAndDate const messageDate = timeAndDate
.inGuildTz(moment.utc(itemToDelete.message.data.timestamp, "x")) .inGuildTz(moment.utc(itemToDelete.message.data.timestamp, "x"))
.format(timeAndDate.getDateFormat("pretty_datetime")); .format(timeAndDate.getDateFormat("pretty_datetime"));

View file

@ -29,7 +29,6 @@ import { logger } from "../../logger";
import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners"; import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners";
import { RunAutomodOnMemberUpdate } from "./events/RunAutomodOnMemberUpdate"; import { RunAutomodOnMemberUpdate } from "./events/RunAutomodOnMemberUpdate";
import { CountersPlugin } from "../Counters/CountersPlugin"; import { CountersPlugin } from "../Counters/CountersPlugin";
import { parseCondition } from "../../data/GuildCounters";
import { runAutomodOnCounterTrigger } from "./events/runAutomodOnCounterTrigger"; import { runAutomodOnCounterTrigger } from "./events/runAutomodOnCounterTrigger";
import { runAutomodOnModAction } from "./events/runAutomodOnModAction"; import { runAutomodOnModAction } from "./events/runAutomodOnModAction";
import { registerEventListenersFromMap } from "../../utils/registerEventListenersFromMap"; import { registerEventListenersFromMap } from "../../utils/registerEventListenersFromMap";
@ -114,15 +113,6 @@ const configPreprocessor: ConfigPreprocessorFn<AutomodPluginType> = 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<AutomodPluginType>()("automod",
async onAfterLoad(pluginData) { async onAfterLoad(pluginData) {
const countersPlugin = pluginData.getPlugin(CountersPlugin); const countersPlugin = pluginData.getPlugin(CountersPlugin);
pluginData.state.onCounterTrigger = (name, condition, channelId, userId) => { pluginData.state.onCounterTrigger = (name, triggerName, channelId, userId) => {
runAutomodOnCounterTrigger(pluginData, name, condition, channelId, userId, false); runAutomodOnCounterTrigger(pluginData, name, triggerName, channelId, userId, false);
}; };
pluginData.state.onCounterReverseTrigger = (name, condition, channelId, userId) => { pluginData.state.onCounterReverseTrigger = (name, triggerName, channelId, userId) => {
runAutomodOnCounterTrigger(pluginData, name, condition, channelId, userId, true); 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("trigger", pluginData.state.onCounterTrigger);
countersPlugin.onCounterEvent("reverseTrigger", pluginData.state.onCounterReverseTrigger); countersPlugin.onCounterEvent("reverseTrigger", pluginData.state.onCounterReverseTrigger);
@ -254,14 +235,20 @@ export const AutomodPlugin = zeppelinGuildPlugin<AutomodPluginType>()("automod",
pluginData.state.modActionsListeners.set("note", (userId: string) => pluginData.state.modActionsListeners.set("note", (userId: string) =>
runAutomodOnModAction(pluginData, "note", userId), runAutomodOnModAction(pluginData, "note", userId),
); );
pluginData.state.modActionsListeners.set("warn", (userId: string) => pluginData.state.modActionsListeners.set(
runAutomodOnModAction(pluginData, "warn", userId), "warn",
(userId: string, reason: string | undefined, isAutomodAction: boolean) =>
runAutomodOnModAction(pluginData, "warn", userId, reason, isAutomodAction),
); );
pluginData.state.modActionsListeners.set("kick", (userId: string) => pluginData.state.modActionsListeners.set(
runAutomodOnModAction(pluginData, "kick", userId), "kick",
(userId: string, reason: string | undefined, isAutomodAction: boolean) =>
runAutomodOnModAction(pluginData, "kick", userId, reason, isAutomodAction),
); );
pluginData.state.modActionsListeners.set("ban", (userId: string) => pluginData.state.modActionsListeners.set(
runAutomodOnModAction(pluginData, "ban", userId), "ban",
(userId: string, reason: string | undefined, isAutomodAction: boolean) =>
runAutomodOnModAction(pluginData, "ban", userId, reason, isAutomodAction),
); );
pluginData.state.modActionsListeners.set("unban", (userId: string) => pluginData.state.modActionsListeners.set("unban", (userId: string) =>
runAutomodOnModAction(pluginData, "unban", userId), runAutomodOnModAction(pluginData, "unban", userId),
@ -270,7 +257,11 @@ export const AutomodPlugin = zeppelinGuildPlugin<AutomodPluginType>()("automod",
const mutesEvents = pluginData.getPlugin(MutesPlugin).getEventEmitter(); const mutesEvents = pluginData.getPlugin(MutesPlugin).getEventEmitter();
pluginData.state.mutesListeners = new Map(); 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) => pluginData.state.mutesListeners.set("unmute", (userId: string) =>
runAutomodOnModAction(pluginData, "unmute", userId), runAutomodOnModAction(pluginData, "unmute", userId),
); );

View file

@ -9,6 +9,7 @@ import { getMissingPermissions } from "../../../utils/getMissingPermissions";
import { canAssignRole } from "../../../utils/canAssignRole"; import { canAssignRole } from "../../../utils/canAssignRole";
import { missingPermissionError } from "../../../utils/missingPermissionError"; import { missingPermissionError } from "../../../utils/missingPermissionError";
import { ignoreRoleChange } from "../functions/ignoredRoleChanges"; import { ignoreRoleChange } from "../functions/ignoredRoleChanges";
import { memberRolesLock } from "../../../utils/lockNameHelpers";
const p = Constants.Permissions; const p = Constants.Permissions;
@ -64,7 +65,7 @@ export const AddRolesAction = automodAction({
return; 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()); const rolesArr = Array.from(memberRoles.values());
await member.edit({ 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 member.roles = rolesArr; // Make sure we know of the new roles internally as well
memberRolesLock.unlock(); memberRoleLock.unlock();
}), }),
); );
}, },

View file

@ -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,
);
},
});

View file

@ -9,6 +9,7 @@ import {
resolveMember, resolveMember,
stripObjectToScalars, stripObjectToScalars,
tNullable, tNullable,
verboseChannelMention,
} from "../../../utils"; } from "../../../utils";
import { resolveActionContactMethods } from "../functions/resolveActionContactMethods"; import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
@ -68,7 +69,23 @@ export const AlertAction = automodAction({
throw err; 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 { } else {
logs.log(LogType.BOT_ALERT, { logs.log(LogType.BOT_ALERT, {
body: `Invalid channel id \`${actionConfig.channel}\` for alert action in automod rule **${ruleName}**`, body: `Invalid channel id \`${actionConfig.channel}\` for alert action in automod rule **${ruleName}**`,

View file

@ -12,7 +12,8 @@ import { AddRolesAction } from "./addRoles";
import { RemoveRolesAction } from "./removeRoles"; import { RemoveRolesAction } from "./removeRoles";
import { SetAntiraidLevelAction } from "./setAntiraidLevel"; import { SetAntiraidLevelAction } from "./setAntiraidLevel";
import { ReplyAction } from "./reply"; import { ReplyAction } from "./reply";
import { ChangeCounterAction } from "./changeCounter"; import { AddToCounterAction } from "./addToCounter";
import { SetCounterAction } from "./setCounter";
export const availableActions: Record<string, AutomodActionBlueprint<any>> = { export const availableActions: Record<string, AutomodActionBlueprint<any>> = {
clean: CleanAction, clean: CleanAction,
@ -27,7 +28,8 @@ export const availableActions: Record<string, AutomodActionBlueprint<any>> = {
remove_roles: RemoveRolesAction, remove_roles: RemoveRolesAction,
set_antiraid_level: SetAntiraidLevelAction, set_antiraid_level: SetAntiraidLevelAction,
reply: ReplyAction, reply: ReplyAction,
change_counter: ChangeCounterAction, add_to_counter: AddToCounterAction,
set_counter: SetCounterAction,
}; };
export const AvailableActions = t.type({ export const AvailableActions = t.type({
@ -43,5 +45,6 @@ export const AvailableActions = t.type({
remove_roles: RemoveRolesAction.configType, remove_roles: RemoveRolesAction.configType,
set_antiraid_level: SetAntiraidLevelAction.configType, set_antiraid_level: SetAntiraidLevelAction.configType,
reply: ReplyAction.configType, reply: ReplyAction.configType,
change_counter: ChangeCounterAction.configType, add_to_counter: AddToCounterAction.configType,
set_counter: SetCounterAction.configType,
}); });

View file

@ -4,6 +4,7 @@ import { LogType } from "../../../data/LogType";
import { asyncMap, nonNullish, resolveMember, tNullable, unique } from "../../../utils"; import { asyncMap, nonNullish, resolveMember, tNullable, unique } from "../../../utils";
import { resolveActionContactMethods } from "../functions/resolveActionContactMethods"; import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
import { CaseArgs } from "../../Cases/types";
export const BanAction = automodAction({ export const BanAction = automodAction({
configType: t.type({ configType: t.type({
@ -22,16 +23,22 @@ export const BanAction = automodAction({
const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined; const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined;
const deleteMessageDays = actionConfig.deleteMessageDays || undefined; const deleteMessageDays = actionConfig.deleteMessageDays || undefined;
const caseArgs = { const caseArgs: Partial<CaseArgs> = {
modId: pluginData.client.user.id, modId: pluginData.client.user.id,
extraNotes: matchResult.fullSummary ? [matchResult.fullSummary] : [], extraNotes: matchResult.fullSummary ? [matchResult.fullSummary] : [],
automatic: true,
}; };
const userIdsToBan = unique(contexts.map(c => c.user?.id).filter(nonNullish)); const userIdsToBan = unique(contexts.map(c => c.user?.id).filter(nonNullish));
const modActions = pluginData.getPlugin(ModActionsPlugin); const modActions = pluginData.getPlugin(ModActionsPlugin);
for (const userId of userIdsToBan) { for (const userId of userIdsToBan) {
await modActions.banUserId(userId, reason, { contactMethods, caseArgs, deleteMessageDays }); await modActions.banUserId(userId, reason, {
contactMethods,
caseArgs,
deleteMessageDays,
isAutomodAction: true,
});
} }
}, },
}); });

View file

@ -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,
);
},
});

View file

@ -4,6 +4,7 @@ import { LogType } from "../../../data/LogType";
import { asyncMap, nonNullish, resolveMember, tNullable, unique } from "../../../utils"; import { asyncMap, nonNullish, resolveMember, tNullable, unique } from "../../../utils";
import { resolveActionContactMethods } from "../functions/resolveActionContactMethods"; import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
import { CaseArgs } from "../../Cases/types";
export const KickAction = automodAction({ export const KickAction = automodAction({
configType: t.type({ configType: t.type({
@ -20,9 +21,10 @@ export const KickAction = automodAction({
const reason = actionConfig.reason || "Kicked automatically"; const reason = actionConfig.reason || "Kicked automatically";
const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined; const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined;
const caseArgs = { const caseArgs: Partial<CaseArgs> = {
modId: pluginData.client.user.id, modId: pluginData.client.user.id,
extraNotes: matchResult.fullSummary ? [matchResult.fullSummary] : [], extraNotes: matchResult.fullSummary ? [matchResult.fullSummary] : [],
automatic: true,
}; };
const userIdsToKick = unique(contexts.map(c => c.user?.id).filter(nonNullish)); const userIdsToKick = unique(contexts.map(c => c.user?.id).filter(nonNullish));
@ -31,7 +33,7 @@ export const KickAction = automodAction({
const modActions = pluginData.getPlugin(ModActionsPlugin); const modActions = pluginData.getPlugin(ModActionsPlugin);
for (const member of membersToKick) { for (const member of membersToKick) {
if (!member) continue; if (!member) continue;
await modActions.kickMember(member, reason, { contactMethods, caseArgs }); await modActions.kickMember(member, reason, { contactMethods, caseArgs, isAutomodAction: true });
} }
}, },
}); });

View file

@ -15,6 +15,7 @@ import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
import { MutesPlugin } from "../../Mutes/MutesPlugin"; import { MutesPlugin } from "../../Mutes/MutesPlugin";
import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError"; import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
import { LogsPlugin } from "../../Logs/LogsPlugin"; import { LogsPlugin } from "../../Logs/LogsPlugin";
import { CaseArgs } from "../../Cases/types";
export const MuteAction = automodAction({ export const MuteAction = automodAction({
configType: t.type({ configType: t.type({
@ -37,9 +38,10 @@ export const MuteAction = automodAction({
const rolesToRemove = actionConfig.remove_roles_on_mute; const rolesToRemove = actionConfig.remove_roles_on_mute;
const rolesToRestore = actionConfig.restore_roles_on_mute; const rolesToRestore = actionConfig.restore_roles_on_mute;
const caseArgs = { const caseArgs: Partial<CaseArgs> = {
modId: pluginData.client.user.id, modId: pluginData.client.user.id,
extraNotes: matchResult.fullSummary ? [matchResult.fullSummary] : [], extraNotes: matchResult.fullSummary ? [matchResult.fullSummary] : [],
automatic: true,
}; };
const userIdsToMute = unique(contexts.map(c => c.user?.id).filter(nonNullish)); const userIdsToMute = unique(contexts.map(c => c.user?.id).filter(nonNullish));
@ -47,7 +49,14 @@ export const MuteAction = automodAction({
const mutes = pluginData.getPlugin(MutesPlugin); const mutes = pluginData.getPlugin(MutesPlugin);
for (const userId of userIdsToMute) { for (const userId of userIdsToMute) {
try { 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) { } catch (e) {
if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) {
pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, {

View file

@ -10,6 +10,7 @@ import { missingPermissionError } from "../../../utils/missingPermissionError";
import { canAssignRole } from "../../../utils/canAssignRole"; import { canAssignRole } from "../../../utils/canAssignRole";
import { Constants } from "eris"; import { Constants } from "eris";
import { ignoreRoleChange } from "../functions/ignoredRoleChanges"; import { ignoreRoleChange } from "../functions/ignoredRoleChanges";
import { memberRolesLock } from "../../../utils/lockNameHelpers";
const p = Constants.Permissions; const p = Constants.Permissions;
@ -66,7 +67,7 @@ export const RemoveRolesAction = automodAction({
return; 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()); const rolesArr = Array.from(memberRoles.values());
await member.edit({ 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 member.roles = rolesArr; // Make sure we know of the new roles internally as well
memberRolesLock.unlock(); memberRoleLock.unlock();
}), }),
); );
}, },

View file

@ -9,10 +9,13 @@ import {
tMessageContent, tMessageContent,
tNullable, tNullable,
unique, unique,
verboseChannelMention,
} from "../../../utils"; } from "../../../utils";
import { TextChannel } from "eris"; import { AdvancedMessageContent, Constants, MessageContent, TextChannel, User } from "eris";
import { AutomodContext } from "../types"; import { AutomodContext } from "../types";
import { renderTemplate } from "../../../templateFormatter"; import { renderTemplate } from "../../../templateFormatter";
import { hasDiscordPermissions } from "../../../utils/hasDiscordPermissions";
import { LogType } from "../../../data/LogType";
export const ReplyAction = automodAction({ export const ReplyAction = automodAction({
configType: t.union([ configType: t.union([
@ -25,7 +28,7 @@ export const ReplyAction = automodAction({
defaultConfig: {}, defaultConfig: {},
async apply({ pluginData, contexts, actionConfig }) { async apply({ pluginData, contexts, actionConfig, ruleName }) {
const contextsWithTextChannels = contexts const contextsWithTextChannels = contexts
.filter(c => c.message?.channel_id) .filter(c => c.message?.channel_id)
.filter(c => pluginData.guild.channels.get(c.message!.channel_id) instanceof TextChannel); .filter(c => pluginData.guild.channels.get(c.message!.channel_id) instanceof TextChannel);
@ -40,7 +43,7 @@ export const ReplyAction = automodAction({
}, new Map()); }, new Map());
for (const [channelId, _contexts] of contextsByChannelId.entries()) { 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 user = users[0];
const renderReplyText = async str => const renderReplyText = async str =>
@ -50,10 +53,37 @@ export const ReplyAction = automodAction({
const formatted = const formatted =
typeof actionConfig === "string" typeof actionConfig === "string"
? await renderReplyText(actionConfig) ? await renderReplyText(actionConfig)
: await renderRecursively(actionConfig.text, renderReplyText); : ((await renderRecursively(actionConfig.text, renderReplyText)) as AdvancedMessageContent);
if (formatted) { if (formatted) {
const channel = pluginData.guild.channels.get(channelId) as TextChannel; 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); const replyMsg = await channel.createMessage(formatted);
if (typeof actionConfig === "object" && actionConfig.auto_delete) { if (typeof actionConfig === "object" && actionConfig.auto_delete) {

View file

@ -1,12 +1,13 @@
import * as t from "io-ts"; import * as t from "io-ts";
import { automodAction } from "../helpers"; import { automodAction } from "../helpers";
import { setAntiraidLevel } from "../functions/setAntiraidLevel"; import { setAntiraidLevel } from "../functions/setAntiraidLevel";
import { tNullable } from "../../../utils";
export const SetAntiraidLevelAction = automodAction({ export const SetAntiraidLevelAction = automodAction({
configType: t.string, configType: tNullable(t.string),
defaultConfig: "", defaultConfig: "",
async apply({ pluginData, contexts, actionConfig }) { async apply({ pluginData, contexts, actionConfig }) {
setAntiraidLevel(pluginData, actionConfig); setAntiraidLevel(pluginData, actionConfig ?? null);
}, },
}); });

View file

@ -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,
);
},
});

View file

@ -4,6 +4,7 @@ import { LogType } from "../../../data/LogType";
import { asyncMap, nonNullish, resolveMember, tNullable, unique } from "../../../utils"; import { asyncMap, nonNullish, resolveMember, tNullable, unique } from "../../../utils";
import { resolveActionContactMethods } from "../functions/resolveActionContactMethods"; import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
import { CaseArgs } from "../../Cases/types";
export const WarnAction = automodAction({ export const WarnAction = automodAction({
configType: t.type({ configType: t.type({
@ -20,9 +21,10 @@ export const WarnAction = automodAction({
const reason = actionConfig.reason || "Warned automatically"; const reason = actionConfig.reason || "Warned automatically";
const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined; const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined;
const caseArgs = { const caseArgs: Partial<CaseArgs> = {
modId: pluginData.client.user.id, modId: pluginData.client.user.id,
extraNotes: matchResult.fullSummary ? [matchResult.fullSummary] : [], extraNotes: matchResult.fullSummary ? [matchResult.fullSummary] : [],
automatic: true,
}; };
const userIdsToWarn = unique(contexts.map(c => c.user?.id).filter(nonNullish)); const userIdsToWarn = unique(contexts.map(c => c.user?.id).filter(nonNullish));
@ -31,7 +33,7 @@ export const WarnAction = automodAction({
const modActions = pluginData.getPlugin(ModActionsPlugin); const modActions = pluginData.getPlugin(ModActionsPlugin);
for (const member of membersToWarn) { for (const member of membersToWarn) {
if (!member) continue; if (!member) continue;
await modActions.warnMember(member, reason, { contactMethods, caseArgs }); await modActions.warnMember(member, reason, { contactMethods, caseArgs, isAutomodAction: true });
} }
}, },
}); });

View file

@ -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<AutomodPluginType>,
level: string | null,
user?: User,
) {
const context: AutomodContext = {
timestamp: Date.now(),
antiraid: {
level,
},
user,
};
pluginData.state.queue.add(async () => {
await runAutomod(pluginData, context);
});
}

View file

@ -2,30 +2,38 @@ import { GuildPluginData } from "knub";
import { AutomodContext, AutomodPluginType } from "../types"; import { AutomodContext, AutomodPluginType } from "../types";
import { runAutomod } from "../functions/runAutomod"; import { runAutomod } from "../functions/runAutomod";
import { resolveMember, resolveUser, UnknownUser } from "../../../utils"; import { resolveMember, resolveUser, UnknownUser } from "../../../utils";
import { CountersPlugin } from "../../Counters/CountersPlugin";
export async function runAutomodOnCounterTrigger( export async function runAutomodOnCounterTrigger(
pluginData: GuildPluginData<AutomodPluginType>, pluginData: GuildPluginData<AutomodPluginType>,
counterName: string, counterName: string,
condition: string, triggerName: string,
channelId: string | null, channelId: string | null,
userId: string | null, userId: string | null,
reverse: boolean, reverse: boolean,
) { ) {
const user = userId ? await resolveUser(pluginData.client, userId) : undefined; const user = userId ? await resolveUser(pluginData.client, userId) : undefined;
const member = (userId && (await resolveMember(pluginData.client, pluginData.guild, 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 = { const context: AutomodContext = {
timestamp: Date.now(), timestamp: Date.now(),
counterTrigger: { counterTrigger: {
name: counterName, counter: counterName,
condition, trigger: triggerName,
prettyCounter: prettyCounterName,
prettyTrigger: prettyTriggerName,
channelId, channelId,
userId, userId,
reverse, reverse,
}, },
user: user instanceof UnknownUser ? undefined : user, user: user instanceof UnknownUser ? undefined : user,
member, member,
// TODO: Channel
}; };
pluginData.state.queue.add(async () => { pluginData.state.queue.add(async () => {

View file

@ -9,6 +9,7 @@ export async function runAutomodOnModAction(
modAction: ModActionType, modAction: ModActionType,
userId: string, userId: string,
reason?: string, reason?: string,
isAutomodAction: boolean = false,
) { ) {
const user = await resolveUser(pluginData.client, userId); const user = await resolveUser(pluginData.client, userId);
@ -18,6 +19,7 @@ export async function runAutomodOnModAction(
modAction: { modAction: {
type: modAction, type: modAction,
reason, reason,
isAutomodAction,
}, },
}; };

View file

@ -8,7 +8,7 @@ export function checkAndUpdateCooldown(
rule: TRule, rule: TRule,
context: AutomodContext, context: AutomodContext,
) { ) {
const cooldownKey = context.user?.id; const cooldownKey = `${rule.name}-${context.user?.id}`;
if (cooldownKey) { if (cooldownKey) {
if (pluginData.state.cooldownManager.isOnCooldown(cooldownKey)) { if (pluginData.state.cooldownManager.isOnCooldown(cooldownKey)) {

View file

@ -24,10 +24,10 @@ export async function runAutomod(pluginData: GuildPluginData<AutomodPluginType>,
for (const [ruleName, rule] of Object.entries(config.rules)) { for (const [ruleName, rule] of Object.entries(config.rules)) {
if (rule.enabled === false) continue; 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)) { if (rule.cooldown && checkAndUpdateCooldown(pluginData, rule, context)) {
return; continue;
} }
let matchResult: AutomodTriggerMatchResult<any> | null | undefined; let matchResult: AutomodTriggerMatchResult<any> | null | undefined;

View file

@ -4,6 +4,7 @@ import { AutomodPluginType } from "../types";
import { LogsPlugin } from "../../Logs/LogsPlugin"; import { LogsPlugin } from "../../Logs/LogsPlugin";
import { LogType } from "../../../data/LogType"; import { LogType } from "../../../data/LogType";
import { stripObjectToScalars } from "../../../utils"; import { stripObjectToScalars } from "../../../utils";
import { runAutomodOnAntiraidLevel } from "../events/runAutomodOnAntiraidLevel";
export async function setAntiraidLevel( export async function setAntiraidLevel(
pluginData: GuildPluginData<AutomodPluginType>, pluginData: GuildPluginData<AutomodPluginType>,
@ -13,6 +14,8 @@ export async function setAntiraidLevel(
pluginData.state.cachedAntiraidLevel = newLevel; pluginData.state.cachedAntiraidLevel = newLevel;
await pluginData.state.antiraidLevels.set(newLevel); await pluginData.state.antiraidLevels.set(newLevel);
runAutomodOnAntiraidLevel(pluginData, newLevel, user);
const logs = pluginData.getPlugin(LogsPlugin); const logs = pluginData.getPlugin(LogsPlugin);
if (user) { if (user) {

View file

@ -64,9 +64,9 @@ export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = {
reason: 'Auto-muted for spam' reason: 'Auto-muted for spam'
my_second_filter: my_second_filter:
triggers: triggers:
- message_spam: - emoji_spam:
amount: 5 amount: 2
within: 10s within: 5s
actions: actions:
clean: true clean: true
overrides: overrides:

View file

@ -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<AntiraidLevelTriggerResult>()({
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`;
},
});

View file

@ -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<AnyMessageResultType>()({
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"
}`;
},
});

View file

@ -17,7 +17,7 @@ import { MemberJoinTrigger } from "./memberJoin";
import { RoleAddedTrigger } from "./roleAdded"; import { RoleAddedTrigger } from "./roleAdded";
import { RoleRemovedTrigger } from "./roleRemoved"; import { RoleRemovedTrigger } from "./roleRemoved";
import { StickerSpamTrigger } from "./stickerSpam"; import { StickerSpamTrigger } from "./stickerSpam";
import { CounterTrigger } from "./counter"; import { CounterTrigger } from "./counterTrigger";
import { NoteTrigger } from "./note"; import { NoteTrigger } from "./note";
import { WarnTrigger } from "./warn"; import { WarnTrigger } from "./warn";
import { MuteTrigger } from "./mute"; import { MuteTrigger } from "./mute";
@ -25,8 +25,12 @@ import { UnmuteTrigger } from "./unmute";
import { KickTrigger } from "./kick"; import { KickTrigger } from "./kick";
import { BanTrigger } from "./ban"; import { BanTrigger } from "./ban";
import { UnbanTrigger } from "./unban"; import { UnbanTrigger } from "./unban";
import { AnyMessageTrigger } from "./anyMessage";
import { AntiraidLevelTrigger } from "./antiraidLevel";
export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>> = { export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>> = {
any_message: AnyMessageTrigger,
match_words: MatchWordsTrigger, match_words: MatchWordsTrigger,
match_regex: MatchRegexTrigger, match_regex: MatchRegexTrigger,
match_invites: MatchInvitesTrigger, match_invites: MatchInvitesTrigger,
@ -46,7 +50,7 @@ export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>
member_join_spam: MemberJoinSpamTrigger, member_join_spam: MemberJoinSpamTrigger,
sticker_spam: StickerSpamTrigger, sticker_spam: StickerSpamTrigger,
counter: CounterTrigger, counter_trigger: CounterTrigger,
note: NoteTrigger, note: NoteTrigger,
warn: WarnTrigger, warn: WarnTrigger,
@ -55,9 +59,13 @@ export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>
kick: KickTrigger, kick: KickTrigger,
ban: BanTrigger, ban: BanTrigger,
unban: UnbanTrigger, unban: UnbanTrigger,
antiraid_level: AntiraidLevelTrigger,
}; };
export const AvailableTriggers = t.type({ export const AvailableTriggers = t.type({
any_message: AnyMessageTrigger.configType,
match_words: MatchWordsTrigger.configType, match_words: MatchWordsTrigger.configType,
match_regex: MatchRegexTrigger.configType, match_regex: MatchRegexTrigger.configType,
match_invites: MatchInvitesTrigger.configType, match_invites: MatchInvitesTrigger.configType,
@ -77,7 +85,7 @@ export const AvailableTriggers = t.type({
member_join_spam: MemberJoinSpamTrigger.configType, member_join_spam: MemberJoinSpamTrigger.configType,
sticker_spam: StickerSpamTrigger.configType, sticker_spam: StickerSpamTrigger.configType,
counter: CounterTrigger.configType, counter_trigger: CounterTrigger.configType,
note: NoteTrigger.configType, note: NoteTrigger.configType,
warn: WarnTrigger.configType, warn: WarnTrigger.configType,
@ -86,4 +94,6 @@ export const AvailableTriggers = t.type({
kick: KickTrigger.configType, kick: KickTrigger.configType,
ban: BanTrigger.configType, ban: BanTrigger.configType,
unban: UnbanTrigger.configType, unban: UnbanTrigger.configType,
antiraid_level: AntiraidLevelTrigger.configType,
}); });

View file

@ -5,13 +5,25 @@ import { automodTrigger } from "../helpers";
interface BanTriggerResultType {} interface BanTriggerResultType {}
export const BanTrigger = automodTrigger<BanTriggerResultType>()({ export const BanTrigger = automodTrigger<BanTriggerResultType>()({
configType: t.type({}), configType: t.type({
defaultConfig: {}, manual: t.boolean,
automatic: t.boolean,
}),
async match({ context }) { defaultConfig: {
manual: true,
automatic: true,
},
async match({ context, triggerConfig }) {
if (context.modAction?.type !== "ban") { if (context.modAction?.type !== "ban") {
return; 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 { return {
extra: {}, extra: {},

View file

@ -9,8 +9,8 @@ interface CounterTriggerResult {}
export const CounterTrigger = automodTrigger<CounterTriggerResult>()({ export const CounterTrigger = automodTrigger<CounterTriggerResult>()({
configType: t.type({ configType: t.type({
name: t.string, counter: t.string,
condition: t.string, trigger: t.string,
reverse: tNullable(t.boolean), reverse: tNullable(t.boolean),
}), }),
@ -21,11 +21,11 @@ export const CounterTrigger = automodTrigger<CounterTriggerResult>()({
return; return;
} }
if (context.counterTrigger.name !== triggerConfig.name) { if (context.counterTrigger.counter !== triggerConfig.counter) {
return; return;
} }
if (context.counterTrigger.condition !== triggerConfig.condition) { if (context.counterTrigger.trigger !== triggerConfig.trigger) {
return; return;
} }
@ -40,7 +40,13 @@ export const CounterTrigger = automodTrigger<CounterTriggerResult>()({
}, },
renderMatchInformation({ matchResult, pluginData, contexts, triggerConfig }) { renderMatchInformation({ matchResult, pluginData, contexts, triggerConfig }) {
// TODO: Show user, channel, reverse let str = `Matched counter trigger \`${contexts[0].counterTrigger!.prettyCounter} / ${
return `Matched counter \`${triggerConfig.name} ${triggerConfig.condition}\``; contexts[0].counterTrigger!.prettyTrigger
}\``;
if (contexts[0].counterTrigger!.reverse) {
str += " (reverse)";
}
return str;
}, },
}); });

View file

@ -5,13 +5,24 @@ import { automodTrigger } from "../helpers";
interface KickTriggerResultType {} interface KickTriggerResultType {}
export const KickTrigger = automodTrigger<KickTriggerResultType>()({ export const KickTrigger = automodTrigger<KickTriggerResultType>()({
configType: t.type({}), configType: t.type({
defaultConfig: {}, manual: t.boolean,
automatic: t.boolean,
}),
async match({ context }) { defaultConfig: {
manual: true,
automatic: true,
},
async match({ context, triggerConfig }) {
if (context.modAction?.type !== "kick") { if (context.modAction?.type !== "kick") {
return; 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 { return {
extra: {}, extra: {},

View file

@ -5,13 +5,24 @@ import { automodTrigger } from "../helpers";
interface MuteTriggerResultType {} interface MuteTriggerResultType {}
export const MuteTrigger = automodTrigger<MuteTriggerResultType>()({ export const MuteTrigger = automodTrigger<MuteTriggerResultType>()({
configType: t.type({}), configType: t.type({
defaultConfig: {}, manual: t.boolean,
automatic: t.boolean,
}),
async match({ context }) { defaultConfig: {
manual: true,
automatic: true,
},
async match({ context, triggerConfig }) {
if (context.modAction?.type !== "mute") { if (context.modAction?.type !== "mute") {
return; 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 { return {
extra: {}, extra: {},

View file

@ -5,13 +5,24 @@ import { automodTrigger } from "../helpers";
interface WarnTriggerResultType {} interface WarnTriggerResultType {}
export const WarnTrigger = automodTrigger<WarnTriggerResultType>()({ export const WarnTrigger = automodTrigger<WarnTriggerResultType>()({
configType: t.type({}), configType: t.type({
defaultConfig: {}, manual: t.boolean,
automatic: t.boolean,
}),
async match({ context }) { defaultConfig: {
manual: true,
automatic: true,
},
async match({ context, triggerConfig }) {
if (context.modAction?.type !== "warn") { if (context.modAction?.type !== "warn") {
return; 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 { return {
extra: {}, extra: {},

View file

@ -103,8 +103,10 @@ export interface AutomodContext {
actioned?: boolean; actioned?: boolean;
counterTrigger?: { counterTrigger?: {
name: string; counter: string;
condition: string; trigger: string;
prettyCounter: string;
prettyTrigger: string;
channelId: string | null; channelId: string | null;
userId: string | null; userId: string | null;
reverse: boolean; reverse: boolean;
@ -120,6 +122,10 @@ export interface AutomodContext {
modAction?: { modAction?: {
type: ModActionType; type: ModActionType;
reason?: string; reason?: string;
isAutomodAction: boolean;
};
antiraid?: {
level: string | null;
}; };
} }

View file

@ -17,10 +17,12 @@ import { Configs } from "../../data/Configs";
import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments"; import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments";
import { ListDashboardUsersCmd } from "./commands/ListDashboardUsersCmd"; import { ListDashboardUsersCmd } from "./commands/ListDashboardUsersCmd";
import { ListDashboardPermsCmd } from "./commands/ListDashboardPermsCmd"; import { ListDashboardPermsCmd } from "./commands/ListDashboardPermsCmd";
import { EligibleCmd } from "./commands/EligibleCmd";
const defaultOptions = { const defaultOptions = {
config: { config: {
can_use: false, can_use: false,
can_eligible: false,
update_cmd: null, update_cmd: null,
}, },
}; };
@ -41,6 +43,7 @@ export const BotControlPlugin = zeppelinGlobalPlugin<BotControlPluginType>()("bo
RemoveDashboardUserCmd, RemoveDashboardUserCmd,
ListDashboardUsersCmd, ListDashboardUsersCmd,
ListDashboardPermsCmd, ListDashboardPermsCmd,
EligibleCmd,
], ],
onLoad(pluginData) { onLoad(pluginData) {

View file

@ -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`,
);
},
});

View file

@ -8,6 +8,7 @@ import { Configs } from "../../data/Configs";
export const ConfigSchema = t.type({ export const ConfigSchema = t.type({
can_use: t.boolean, can_use: t.boolean,
can_eligible: t.boolean,
update_cmd: tNullable(t.string), update_cmd: tNullable(t.string),
}); });
export type TConfigSchema = t.TypeOf<typeof ConfigSchema>; export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;

View file

@ -2,10 +2,11 @@ import { GuildPluginData } from "knub";
import { CensorPluginType } from "../types"; import { CensorPluginType } from "../types";
import { SavedMessage } from "../../../data/entities/SavedMessage"; import { SavedMessage } from "../../../data/entities/SavedMessage";
import { applyFiltersToMsg } from "./applyFiltersToMsg"; import { applyFiltersToMsg } from "./applyFiltersToMsg";
import { messageLock } from "../../../utils/lockNameHelpers";
export async function onMessageCreate(pluginData: GuildPluginData<CensorPluginType>, savedMessage: SavedMessage) { export async function onMessageCreate(pluginData: GuildPluginData<CensorPluginType>, savedMessage: SavedMessage) {
if (savedMessage.is_bot) return; 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); const wasDeleted = await applyFiltersToMsg(pluginData, savedMessage);

View file

@ -2,10 +2,11 @@ import { GuildPluginData } from "knub";
import { CensorPluginType } from "../types"; import { CensorPluginType } from "../types";
import { SavedMessage } from "../../../data/entities/SavedMessage"; import { SavedMessage } from "../../../data/entities/SavedMessage";
import { applyFiltersToMsg } from "./applyFiltersToMsg"; import { applyFiltersToMsg } from "./applyFiltersToMsg";
import { messageLock } from "../../../utils/lockNameHelpers";
export async function onMessageUpdate(pluginData: GuildPluginData<CensorPluginType>, savedMessage: SavedMessage) { export async function onMessageUpdate(pluginData: GuildPluginData<CensorPluginType>, savedMessage: SavedMessage) {
if (savedMessage.is_bot) return; 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); const wasDeleted = await applyFiltersToMsg(pluginData, savedMessage);

View file

@ -1,5 +1,5 @@
import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
import { ConfigSchema, CountersPluginType } from "./types"; import { ConfigSchema, CountersPluginType, TTrigger } from "./types";
import { GuildCounters } from "../../data/GuildCounters"; import { GuildCounters } from "../../data/GuildCounters";
import { mapToPublicFn } from "../../pluginUtils"; import { mapToPublicFn } from "../../pluginUtils";
import { changeCounterValue } from "./functions/changeCounterValue"; import { changeCounterValue } from "./functions/changeCounterValue";
@ -10,16 +10,24 @@ import { onCounterEvent } from "./functions/onCounterEvent";
import { offCounterEvent } from "./functions/offCounterEvent"; import { offCounterEvent } from "./functions/offCounterEvent";
import { emitCounterEvent } from "./functions/emitCounterEvent"; import { emitCounterEvent } from "./functions/emitCounterEvent";
import { ConfigPreprocessorFn } from "knub/dist/config/configTypes"; import { ConfigPreprocessorFn } from "knub/dist/config/configTypes";
import { initCounterTrigger } from "./functions/initCounterTrigger";
import { decayCounter } from "./functions/decayCounter"; import { decayCounter } from "./functions/decayCounter";
import { validateCondition } from "./functions/validateCondition";
import { StrictValidationError } from "../../validatorUtils"; import { StrictValidationError } from "../../validatorUtils";
import { PluginOptions } from "knub"; import { PluginOptions } from "knub";
import { ViewCounterCmd } from "./commands/ViewCounterCmd"; import { ViewCounterCmd } from "./commands/ViewCounterCmd";
import { AddCounterCmd } from "./commands/AddCounterCmd"; import { AddCounterCmd } from "./commands/AddCounterCmd";
import { SetCounterCmd } from "./commands/SetCounterCmd"; 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_COUNTERS = 5;
const MAX_TRIGGERS_PER_COUNTER = 5;
const DECAY_APPLY_INTERVAL = 5 * MINUTES; const DECAY_APPLY_INTERVAL = 5 * MINUTES;
const defaultOptions: PluginOptions<CountersPluginType> = { const defaultOptions: PluginOptions<CountersPluginType> = {
@ -45,14 +53,40 @@ const defaultOptions: PluginOptions<CountersPluginType> = {
}; };
const configPreprocessor: ConfigPreprocessorFn<CountersPluginType> = options => { const configPreprocessor: ConfigPreprocessorFn<CountersPluginType> = 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_user = counter.per_user ?? false;
counter.per_channel = counter.per_channel ?? false; counter.per_channel = counter.per_channel ?? false;
counter.initial_value = counter.initial_value ?? 0; 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<TTrigger> = 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) { 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; return options;
@ -69,21 +103,29 @@ const configPreprocessor: ConfigPreprocessorFn<CountersPluginType> = 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. * 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<CountersPluginType>()("counters", { export const CountersPlugin = zeppelinGuildPlugin<CountersPluginType>()("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 <a href='/docs/setup-guides/counters'>Counters setup guide</a>",
},
configSchema: ConfigSchema, configSchema: ConfigSchema,
defaultOptions, defaultOptions,
configPreprocessor, configPreprocessor,
public: { public: {
counterExists: mapToPublicFn(counterExists),
// Change a counter's value by a relative amount, e.g. +5 // Change a counter's value by a relative amount, e.g. +5
changeCounterValue: mapToPublicFn(changeCounterValue), changeCounterValue: mapToPublicFn(changeCounterValue),
// Set a counter's value to an absolute value // Set a counter's value to an absolute value
setCounterValue: mapToPublicFn(setCounterValue), setCounterValue: mapToPublicFn(setCounterValue),
// Initialize a trigger. Once initialized, events will be fired when this trigger is triggered. getPrettyNameForCounter: mapToPublicFn(getPrettyNameForCounter),
initCounterTrigger: mapToPublicFn(initCounterTrigger), getPrettyNameForCounterTrigger: mapToPublicFn(getPrettyNameForCounterTrigger),
// Validate a trigger's condition string
validateCondition: mapToPublicFn(validateCondition),
onCounterEvent: mapToPublicFn(onCounterEvent), onCounterEvent: mapToPublicFn(onCounterEvent),
offCounterEvent: mapToPublicFn(offCounterEvent), offCounterEvent: mapToPublicFn(offCounterEvent),
@ -99,22 +141,48 @@ export const CountersPlugin = zeppelinGuildPlugin<CountersPluginType>()("counter
async onLoad(pluginData) { async onLoad(pluginData) {
pluginData.state.counters = new GuildCounters(pluginData.guild.id); pluginData.state.counters = new GuildCounters(pluginData.guild.id);
pluginData.state.events = new EventEmitter(); pluginData.state.events = new EventEmitter();
pluginData.state.counterTriggersByCounterId = new Map();
const activeTriggerIds: number[] = [];
// Initialize and store the IDs of each of the counters internally // Initialize and store the IDs of each of the counters internally
pluginData.state.counterIds = {}; pluginData.state.counterIds = {};
const config = pluginData.config.get(); 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( const dbCounter = await pluginData.state.counters.findOrCreateCounter(
counterName, counter.name,
counter.per_channel, counter.per_channel,
counter.per_user, 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 // Mark old/unused counters to be deleted later
await pluginData.state.counters.markUnusedCountersToBeDeleted([...Object.values(pluginData.state.counterIds)]); 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 // Start decay timers
pluginData.state.decayTimers = []; pluginData.state.decayTimers = [];
for (const [counterName, counter] of Object.entries(config.counters)) { for (const [counterName, counter] of Object.entries(config.counters)) {
@ -130,13 +198,6 @@ export const CountersPlugin = zeppelinGuildPlugin<CountersPluginType>()("counter
}, DECAY_APPLY_INTERVAL), }, 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) { onUnload(pluginData) {

View file

@ -67,7 +67,7 @@ export const SetCounterCmd = guildCommand<CountersPluginType>()({
let channel = args.channel; let channel = args.channel;
if (!channel && counter.per_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); const reply = await waitForReply(pluginData.client, message.channel, message.author.id);
if (!reply || !reply.content) { if (!reply || !reply.content) {
sendErrorMessage(pluginData, message.channel, "Cancelling"); sendErrorMessage(pluginData, message.channel, "Cancelling");
@ -85,7 +85,7 @@ export const SetCounterCmd = guildCommand<CountersPluginType>()({
let user = args.user; let user = args.user;
if (!user && counter.per_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); const reply = await waitForReply(pluginData.client, message.channel, message.author.id);
if (!reply || !reply.content) { if (!reply || !reply.content) {
sendErrorMessage(pluginData, message.channel, "Cancelling"); sendErrorMessage(pluginData, message.channel, "Cancelling");
@ -103,7 +103,7 @@ export const SetCounterCmd = guildCommand<CountersPluginType>()({
let value = args.value; let value = args.value;
if (!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); const reply = await waitForReply(pluginData.client, message.channel, message.author.id);
if (!reply || !reply.content) { if (!reply || !reply.content) {
sendErrorMessage(pluginData, message.channel, "Cancelling"); sendErrorMessage(pluginData, message.channel, "Cancelling");
@ -111,7 +111,7 @@ export const SetCounterCmd = guildCommand<CountersPluginType>()({
} }
const potentialValue = parseInt(reply.content, 10); const potentialValue = parseInt(reply.content, 10);
if (!potentialValue) { if (Number.isNaN(potentialValue)) {
sendErrorMessage(pluginData, message.channel, "Not a number, cancelling"); sendErrorMessage(pluginData, message.channel, "Not a number, cancelling");
return; return;
} }

View file

@ -1,4 +1,5 @@
import { GuildPluginData } from "knub"; import { GuildPluginData } from "knub";
import { counterIdLock } from "../../../utils/lockNameHelpers";
import { CountersPluginType } from "../types"; import { CountersPluginType } from "../types";
import { checkCounterTrigger } from "./checkCounterTrigger"; import { checkCounterTrigger } from "./checkCounterTrigger";
import { checkReverseCounterTrigger } from "./checkReverseCounterTrigger"; import { checkReverseCounterTrigger } from "./checkReverseCounterTrigger";
@ -28,7 +29,7 @@ export async function changeCounterValue(
userId = counter.per_user ? userId : null; userId = counter.per_user ? userId : null;
const counterId = pluginData.state.counterIds[counterName]; 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); await pluginData.state.counters.changeCounterValue(counterId, channelId, userId, change);

View file

@ -1,6 +1,5 @@
import { GuildPluginData } from "knub"; import { GuildPluginData } from "knub";
import { CountersPluginType } from "../types"; import { CountersPluginType } from "../types";
import { buildConditionString } from "../../../data/GuildCounters";
import { CounterTrigger } from "../../../data/entities/CounterTrigger"; import { CounterTrigger } from "../../../data/entities/CounterTrigger";
import { emitCounterEvent } from "./emitCounterEvent"; import { emitCounterEvent } from "./emitCounterEvent";
@ -11,13 +10,6 @@ export async function checkAllValuesForReverseTrigger(
) { ) {
const triggeredContexts = await pluginData.state.counters.checkAllValuesForReverseTrigger(counterTrigger); const triggeredContexts = await pluginData.state.counters.checkAllValuesForReverseTrigger(counterTrigger);
for (const context of triggeredContexts) { for (const context of triggeredContexts) {
emitCounterEvent( emitCounterEvent(pluginData, "reverseTrigger", counterName, counterTrigger.name, context.channelId, context.userId);
pluginData,
"reverseTrigger",
counterName,
buildConditionString(counterTrigger.comparison_op, counterTrigger.comparison_value),
context.channelId,
context.userId,
);
} }
} }

View file

@ -1,6 +1,5 @@
import { GuildPluginData } from "knub"; import { GuildPluginData } from "knub";
import { CountersPluginType } from "../types"; import { CountersPluginType } from "../types";
import { buildConditionString } from "../../../data/GuildCounters";
import { CounterTrigger } from "../../../data/entities/CounterTrigger"; import { CounterTrigger } from "../../../data/entities/CounterTrigger";
import { emitCounterEvent } from "./emitCounterEvent"; import { emitCounterEvent } from "./emitCounterEvent";
@ -11,13 +10,6 @@ export async function checkAllValuesForTrigger(
) { ) {
const triggeredContexts = await pluginData.state.counters.checkAllValuesForTrigger(counterTrigger); const triggeredContexts = await pluginData.state.counters.checkAllValuesForTrigger(counterTrigger);
for (const context of triggeredContexts) { for (const context of triggeredContexts) {
emitCounterEvent( emitCounterEvent(pluginData, "trigger", counterName, counterTrigger.name, context.channelId, context.userId);
pluginData,
"trigger",
counterName,
buildConditionString(counterTrigger.comparison_op, counterTrigger.comparison_value),
context.channelId,
context.userId,
);
} }
} }

View file

@ -1,6 +1,5 @@
import { GuildPluginData } from "knub"; import { GuildPluginData } from "knub";
import { CountersPluginType } from "../types"; import { CountersPluginType } from "../types";
import { buildConditionString } from "../../../data/GuildCounters";
import { CounterTrigger } from "../../../data/entities/CounterTrigger"; import { CounterTrigger } from "../../../data/entities/CounterTrigger";
import { emitCounterEvent } from "./emitCounterEvent"; import { emitCounterEvent } from "./emitCounterEvent";
@ -13,13 +12,6 @@ export async function checkCounterTrigger(
) { ) {
const triggered = await pluginData.state.counters.checkForTrigger(counterTrigger, channelId, userId); const triggered = await pluginData.state.counters.checkForTrigger(counterTrigger, channelId, userId);
if (triggered) { if (triggered) {
await emitCounterEvent( await emitCounterEvent(pluginData, "trigger", counterName, counterTrigger.name, channelId, userId);
pluginData,
"trigger",
counterName,
buildConditionString(counterTrigger.comparison_op, counterTrigger.comparison_value),
channelId,
userId,
);
} }
} }

View file

@ -1,6 +1,5 @@
import { GuildPluginData } from "knub"; import { GuildPluginData } from "knub";
import { CountersPluginType } from "../types"; import { CountersPluginType } from "../types";
import { buildConditionString } from "../../../data/GuildCounters";
import { CounterTrigger } from "../../../data/entities/CounterTrigger"; import { CounterTrigger } from "../../../data/entities/CounterTrigger";
import { emitCounterEvent } from "./emitCounterEvent"; import { emitCounterEvent } from "./emitCounterEvent";
@ -13,13 +12,6 @@ export async function checkReverseCounterTrigger(
) { ) {
const triggered = await pluginData.state.counters.checkForReverseTrigger(counterTrigger, channelId, userId); const triggered = await pluginData.state.counters.checkForReverseTrigger(counterTrigger, channelId, userId);
if (triggered) { if (triggered) {
await emitCounterEvent( await emitCounterEvent(pluginData, "reverseTrigger", counterName, counterTrigger.name, channelId, userId);
pluginData,
"reverseTrigger",
counterName,
buildConditionString(counterTrigger.comparison_op, counterTrigger.comparison_value),
channelId,
userId,
);
} }
} }

View file

@ -0,0 +1,7 @@
import { GuildPluginData } from "knub";
import { CountersPluginType } from "../types";
export function counterExists(pluginData: GuildPluginData<CountersPluginType>, counterName: string) {
const config = pluginData.config.get();
return config.counters[counterName] != null;
}

View file

@ -2,6 +2,7 @@ import { GuildPluginData } from "knub";
import { CountersPluginType } from "../types"; import { CountersPluginType } from "../types";
import { checkAllValuesForTrigger } from "./checkAllValuesForTrigger"; import { checkAllValuesForTrigger } from "./checkAllValuesForTrigger";
import { checkAllValuesForReverseTrigger } from "./checkAllValuesForReverseTrigger"; import { checkAllValuesForReverseTrigger } from "./checkAllValuesForReverseTrigger";
import { counterIdLock } from "../../../utils/lockNameHelpers";
export async function decayCounter( export async function decayCounter(
pluginData: GuildPluginData<CountersPluginType>, pluginData: GuildPluginData<CountersPluginType>,
@ -16,7 +17,7 @@ export async function decayCounter(
} }
const counterId = pluginData.state.counterIds[counterName]; 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); await pluginData.state.counters.decay(counterId, decayPeriodMS, decayAmount);

View file

@ -0,0 +1,8 @@
import { CountersPluginType } from "../types";
import { GuildPluginData } from "knub";
export function getPrettyNameForCounter(pluginData: GuildPluginData<CountersPluginType>, counterName: string) {
const config = pluginData.config.get();
const counter = config.counters[counterName];
return counter ? counter.pretty_name || counter.name : "Unknown Counter";
}

View file

@ -0,0 +1,17 @@
import { CountersPluginType, TTrigger } from "../types";
import { GuildPluginData } from "knub";
export function getPrettyNameForCounterTrigger(
pluginData: GuildPluginData<CountersPluginType>,
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";
}

View file

@ -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<CountersPluginType>,
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);
}

View file

@ -1,4 +1,5 @@
import { GuildPluginData } from "knub"; import { GuildPluginData } from "knub";
import { counterIdLock } from "../../../utils/lockNameHelpers";
import { CountersPluginType } from "../types"; import { CountersPluginType } from "../types";
import { checkCounterTrigger } from "./checkCounterTrigger"; import { checkCounterTrigger } from "./checkCounterTrigger";
import { checkReverseCounterTrigger } from "./checkReverseCounterTrigger"; import { checkReverseCounterTrigger } from "./checkReverseCounterTrigger";
@ -25,7 +26,7 @@ export async function setCounterValue(
} }
const counterId = pluginData.state.counterIds[counterName]; 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); await pluginData.state.counters.setCounterValue(counterId, channelId, userId, value);

View file

@ -1,8 +0,0 @@
import { GuildPluginData } from "knub";
import { CountersPluginType } from "../types";
import { parseCondition } from "../../../data/GuildCounters";
export function validateCondition(pluginData: GuildPluginData<CountersPluginType>, condition: string) {
const parsed = parseCondition(condition);
return parsed != null;
}

View file

@ -6,11 +6,21 @@ import { EventEmitter } from "events";
import { CounterTrigger } from "../../data/entities/CounterTrigger"; import { CounterTrigger } from "../../data/entities/CounterTrigger";
import Timeout = NodeJS.Timeout; 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<typeof Trigger>;
export const Counter = t.type({ export const Counter = t.type({
name: tNullable(t.string), name: t.string,
pretty_name: tNullable(t.string),
per_channel: t.boolean, per_channel: t.boolean,
per_user: t.boolean, per_user: t.boolean,
initial_value: t.number, initial_value: t.number,
triggers: t.record(t.string, t.union([t.string, Trigger])),
decay: tNullable( decay: tNullable(
t.type({ t.type({
amount: t.number, amount: t.number,
@ -30,8 +40,8 @@ export const ConfigSchema = t.type({
export type TConfigSchema = t.TypeOf<typeof ConfigSchema>; export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export interface CounterEvents { export interface CounterEvents {
trigger: (name: string, condition: string, channelId: string | null, userId: string | null) => void; trigger: (counterName: string, triggerName: string, channelId: string | null, userId: string | null) => void;
reverseTrigger: (name: string, condition: 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 { export interface CounterEventEmitter extends EventEmitter {
@ -46,6 +56,6 @@ export interface CountersPluginType extends BasePluginType {
counterIds: Record<string, number>; counterIds: Record<string, number>;
decayTimers: Timeout[]; decayTimers: Timeout[];
events: CounterEventEmitter; events: CounterEventEmitter;
counterTriggersByCounterId: Map<number, Map<number, CounterTrigger>>; counterTriggersByCounterId: Map<number, CounterTrigger[]>;
}; };
} }

View file

@ -45,7 +45,7 @@ export const AddCaseCmd = modActionsCmd({
let mod = msg.member; let mod = msg.member;
if (args.mod) { if (args.mod) {
if (!hasPermission(pluginData, "can_act_as_other", { message: msg })) { 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; return;
} }

View file

@ -8,9 +8,10 @@ import { formatReasonWithAttachments } from "../functions/formatReasonWithAttach
import { banUserId } from "../functions/banUserId"; import { banUserId } from "../functions/banUserId";
import { getMemberLevel, waitForReaction } from "knub/dist/helpers"; import { getMemberLevel, waitForReaction } from "knub/dist/helpers";
import humanizeDuration from "humanize-duration"; import humanizeDuration from "humanize-duration";
import { CasesPlugin } from "src/plugins/Cases/CasesPlugin"; import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin";
import { CaseTypes } from "src/data/CaseTypes"; import { CaseTypes } from "../../../data/CaseTypes";
import { LogType } from "src/data/LogType"; import { LogType } from "../../../data/LogType";
import { banLock } from "../../../utils/lockNameHelpers";
const opts = { const opts = {
mod: ct.member({ option: true }), mod: ct.member({ option: true }),
@ -54,7 +55,7 @@ export const BanCmd = modActionsCmd({
let mod = msg.member; let mod = msg.member;
if (args.mod) { if (args.mod) {
if (!hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id })) { 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; return;
} }
@ -62,11 +63,11 @@ export const BanCmd = modActionsCmd({
} }
// acquire a lock because of the needed user-inputs below (if banned/not on server) // 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; let forceban = false;
const existingTempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id); const existingTempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id);
const banned = await isBanned(pluginData, user.id);
if (!memberToBan) { if (!memberToBan) {
const banned = await isBanned(pluginData, user.id);
if (banned) { if (banned) {
// Abort if trying to ban user indefinitely if they are already banned indefinitely // Abort if trying to ban user indefinitely if they are already banned indefinitely
if (!existingTempban && !time) { if (!existingTempban && !time) {

View file

@ -17,7 +17,7 @@ const opts = {
const casesPerPage = 5; const casesPerPage = 5;
export const CasesModCmd = modActionsCmd({ export const CasesModCmd = modActionsCmd({
trigger: ["cases", "modlogs"], trigger: ["cases", "modlogs", "infractions"],
permission: "can_view", permission: "can_view",
description: "Show the most recent 5 cases by the specified -mod", description: "Show the most recent 5 cases by the specified -mod",

View file

@ -54,7 +54,7 @@ export const ForcebanCmd = modActionsCmd({
let mod = msg.member; let mod = msg.member;
if (args.mod) { if (args.mod) {
if (!hasPermission(pluginData, "can_act_as_other", { message: msg })) { 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; return;
} }

View file

@ -6,7 +6,7 @@ import { formatReasonWithAttachments } from "../functions/formatReasonWithAttach
import { CasesPlugin } from "../../Cases/CasesPlugin"; import { CasesPlugin } from "../../Cases/CasesPlugin";
import { LogType } from "../../../data/LogType"; import { LogType } from "../../../data/LogType";
import { CaseTypes } from "../../../data/CaseTypes"; 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 { isBanned } from "../functions/isBanned";
import { waitForReaction } from "knub/dist/helpers"; import { waitForReaction } from "knub/dist/helpers";
import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs"; import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs";
@ -59,15 +59,18 @@ export const MuteCmd = modActionsCmd({
msg.channel, msg.channel,
`User is banned. Use \`${prefix}forcemute\` if you want to mute them anyway.`, `User is banned. Use \`${prefix}forcemute\` if you want to mute them anyway.`,
); );
return;
} else { } else {
sendErrorMessage( // Ask the mod if we should upgrade to a forcemute as the user is not on the server
pluginData, const notOnServerMsg = await msg.channel.createMessage("User not found on the server, forcemute instead?");
msg.channel, const reply = await waitForReaction(pluginData.client, notOnServerMsg, ["✅", "❌"], msg.author.id);
`User is not on the server. Use \`${prefix}forcemute\` if you want to mute them anyway.`,
);
}
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 // Make sure we're allowed to mute this member

View file

@ -37,7 +37,7 @@ export const UnbanCmd = modActionsCmd({
let mod = msg.member; let mod = msg.member;
if (args.mod) { if (args.mod) {
if (!hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id })) { 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; return;
} }

View file

@ -1,10 +1,11 @@
import { modActionsCmd } from "../types"; import { modActionsCmd } from "../types";
import { commandTypeHelpers as ct } from "../../../commandTypes"; import { commandTypeHelpers as ct } from "../../../commandTypes";
import { canActOn, sendErrorMessage } from "../../../pluginUtils"; import { canActOn, sendErrorMessage } from "../../../pluginUtils";
import { resolveUser, resolveMember } from "../../../utils"; import { resolveUser, resolveMember, noop } from "../../../utils";
import { MutesPlugin } from "../../../plugins/Mutes/MutesPlugin"; import { MutesPlugin } from "../../../plugins/Mutes/MutesPlugin";
import { actualUnmuteCmd } from "../functions/actualUnmuteUserCmd"; import { actualUnmuteCmd } from "../functions/actualUnmuteUserCmd";
import { isBanned } from "../functions/isBanned"; import { isBanned } from "../functions/isBanned";
import { waitForReaction } from "knub/dist/helpers";
const opts = { const opts = {
mod: ct.member({ option: true }), mod: ct.member({ option: true }),
@ -57,15 +58,18 @@ export const UnmuteCmd = modActionsCmd({
msg.channel, msg.channel,
`User is banned. Use \`${prefix}forceunmute\` to unmute them anyway.`, `User is banned. Use \`${prefix}forceunmute\` to unmute them anyway.`,
); );
return;
} else { } else {
sendErrorMessage( // Ask the mod if we should upgrade to a forceunmute as the user is not on the server
pluginData, const notOnServerMsg = await msg.channel.createMessage("User not found on the server, forceunmute instead?");
msg.channel, const reply = await waitForReaction(pluginData.client, notOnServerMsg, ["✅", "❌"], msg.author.id);
`User is not on the server. Use \`${prefix}forceunmute\` to unmute them anyway.`,
);
}
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 // Make sure we're allowed to unmute this member

View file

@ -3,7 +3,7 @@ import { commandTypeHelpers as ct } from "../../../commandTypes";
import { updateCase } from "../functions/updateCase"; import { updateCase } from "../functions/updateCase";
export const UpdateCmd = modActionsCmd({ export const UpdateCmd = modActionsCmd({
trigger: "update", trigger: ["update", "reason"],
permission: "can_note", permission: "can_note",
description: description:
"Update the specified case (or, if case number is omitted, your latest case) by adding more notes/details to it", "Update the specified case (or, if case number is omitted, your latest case) by adding more notes/details to it",

View file

@ -57,7 +57,7 @@ export const WarnCmd = modActionsCmd({
let mod = msg.member; let mod = msg.member;
if (args.mod) { if (args.mod) {
if (!hasPermission(pluginData, "can_act_as_other", { message: msg })) { 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; return;
} }
@ -112,7 +112,5 @@ export const WarnCmd = modActionsCmd({
msg.channel, msg.channel,
`Warned **${memberToWarn.user.username}#${memberToWarn.user.discriminator}** (Case #${warnResult.case.case_number})${messageResultText}`, `Warned **${memberToWarn.user.username}#${memberToWarn.user.discriminator}** (Case #${warnResult.case.case_number})${messageResultText}`,
); );
pluginData.state.events.emit("warn", user.id, reason);
}, },
}); });

View file

@ -61,6 +61,7 @@ export const CreateKickCaseOnManualKickEvt = modActionsEvt(
user: stripObjectToScalars(member.user), user: stripObjectToScalars(member.user),
mod: mod ? stripObjectToScalars(mod) : null, mod: mod ? stripObjectToScalars(mod) : null,
caseNumber: createdCase?.case_number ?? 0, caseNumber: createdCase?.case_number ?? 0,
reason: kickAuditLogEntry.reason || "",
}); });
pluginData.state.events.emit("kick", member.id, kickAuditLogEntry.reason || undefined); pluginData.state.events.emit("kick", member.id, kickAuditLogEntry.reason || undefined);

View file

@ -52,7 +52,7 @@ export async function actualKickMemberCmd(
let mod = msg.member; let mod = msg.member;
if (args.mod) { if (args.mod) {
if (!hasPermission(pluginData.config.getForMessage(msg), "can_act_as_other")) { 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; return;
} }

View file

@ -28,7 +28,7 @@ export async function actualMuteUserCmd(
if (args.mod) { if (args.mod) {
if (!hasPermission(pluginData, "can_act_as_other", { message: msg })) { 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; return;
} }

View file

@ -19,7 +19,7 @@ export async function actualUnmuteCmd(
if (args.mod) { if (args.mod) {
if (!hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id })) { 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; return;
} }

View file

@ -127,7 +127,7 @@ export async function banUserId(
banTime: banTime ? humanizeDuration(banTime) : null, banTime: banTime ? humanizeDuration(banTime) : null,
}); });
pluginData.state.events.emit("ban", user.id, reason); pluginData.state.events.emit("ban", user.id, reason, banOptions.isAutomodAction);
return { return {
status: "success", status: "success",

View file

@ -1,16 +1,44 @@
import { GuildPluginData } from "knub"; import { GuildPluginData } from "knub";
import { ModActionsPluginType } from "../types"; 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<ModActionsPluginType>,
userId: string,
timeout: number = 5 * SECONDS,
): Promise<boolean> {
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<ModActionsPluginType>, userId: string): Promise<boolean> {
try { try {
const bans = await pluginData.guild.getBans(); const potentialBan = await Promise.race([pluginData.guild.getBan(userId), sleep(timeout)]);
return bans.some(b => b.user.id === userId); return potentialBan != null;
} catch (e) { } catch (e) {
if (isDiscordHTTPError(e) && e.code === 500) { if (isDiscordRESTError(e) && e.code === 10026) {
// [10026]: Unknown Ban
return false; 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; throw e;
} }
} }

View file

@ -85,7 +85,7 @@ export async function kickMember(
reason, reason,
}); });
pluginData.state.events.emit("kick", member.id, reason); pluginData.state.events.emit("kick", member.id, reason, kickOptions.isAutomodAction);
return { return {
status: "success", status: "success",

View file

@ -82,6 +82,8 @@ export async function warnMember(
reason, reason,
}); });
pluginData.state.events.emit("warn", member.id, reason, warnOptions.isAutomodAction);
return { return {
status: "success", status: "success",
case: createdCase, case: createdCase,

View file

@ -48,9 +48,9 @@ export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export interface ModActionsEvents { export interface ModActionsEvents {
note: (userId: string, reason?: string) => void; note: (userId: string, reason?: string) => void;
warn: (userId: string, reason?: string) => void; warn: (userId: string, reason?: string, isAutomodAction?: boolean) => void;
kick: (userId: string, reason?: string) => void; kick: (userId: string, reason?: string, isAutomodAction?: boolean) => void;
ban: (userId: string, reason?: string) => void; ban: (userId: string, reason?: string, isAutomodAction?: boolean) => void;
unban: (userId: string, reason?: string) => void; unban: (userId: string, reason?: string) => void;
// mute/unmute are in the Mutes plugin // mute/unmute are in the Mutes plugin
} }
@ -126,17 +126,20 @@ export interface WarnOptions {
caseArgs?: Partial<CaseArgs> | null; caseArgs?: Partial<CaseArgs> | null;
contactMethods?: UserNotificationMethod[] | null; contactMethods?: UserNotificationMethod[] | null;
retryPromptChannel?: TextChannel | null; retryPromptChannel?: TextChannel | null;
isAutomodAction?: boolean;
} }
export interface KickOptions { export interface KickOptions {
caseArgs?: Partial<CaseArgs>; caseArgs?: Partial<CaseArgs>;
contactMethods?: UserNotificationMethod[]; contactMethods?: UserNotificationMethod[];
isAutomodAction?: boolean;
} }
export interface BanOptions { export interface BanOptions {
caseArgs?: Partial<CaseArgs>; caseArgs?: Partial<CaseArgs>;
contactMethods?: UserNotificationMethod[]; contactMethods?: UserNotificationMethod[];
deleteMessageDays?: number; deleteMessageDays?: number;
isAutomodAction?: boolean;
} }
export type ModActionType = "note" | "warn" | "mute" | "unmute" | "kick" | "ban" | "unban"; export type ModActionType = "note" | "warn" | "mute" | "unmute" | "kick" | "ban" | "unban";

View file

@ -25,6 +25,7 @@ const defaultOptions = {
config: { config: {
mute_role: null, mute_role: null,
move_to_voice_channel: null, move_to_voice_channel: null,
kick_from_voice_channel: false,
dm_on_mute: false, dm_on_mute: false,
dm_on_update: false, dm_on_update: false,

View file

@ -1,6 +1,7 @@
import { mutesEvt } from "../types"; import { mutesEvt } from "../types";
import { LogType } from "../../../data/LogType"; import { LogType } from "../../../data/LogType";
import { stripObjectToScalars } from "../../../utils"; import { stripObjectToScalars } from "../../../utils";
import { memberRolesLock } from "../../../utils/lockNameHelpers";
/** /**
* Reapply active mutes on join * Reapply active mutes on join
@ -11,9 +12,9 @@ export const ReapplyActiveMuteOnJoinEvt = mutesEvt("guildMemberAdd", async ({ pl
const muteRole = pluginData.config.get().mute_role; const muteRole = pluginData.config.get().mute_role;
if (muteRole) { if (muteRole) {
const memberRolesLock = await pluginData.locks.acquire(`member-roles-${member.id}`); const memberRoleLock = await pluginData.locks.acquire(memberRolesLock(member));
await member.addRole(muteRole); await member.addRole(muteRole);
memberRolesLock.unlock(); memberRoleLock.unlock();
} }
pluginData.state.serverLogs.log(LogType.MEMBER_MUTE_REJOIN, { pluginData.state.serverLogs.log(LogType.MEMBER_MUTE_REJOIN, {

View file

@ -17,8 +17,8 @@ import { CasesPlugin } from "../../Cases/CasesPlugin";
import { CaseTypes } from "../../../data/CaseTypes"; import { CaseTypes } from "../../../data/CaseTypes";
import { LogType } from "../../../data/LogType"; import { LogType } from "../../../data/LogType";
import { Case } from "../../../data/entities/Case"; import { Case } from "../../../data/entities/Case";
import { sendErrorMessage } from "src/pluginUtils"; import { LogsPlugin } from "../../../plugins/Logs/LogsPlugin";
import { LogsPlugin } from "src/plugins/Logs/LogsPlugin"; import { muteLock } from "../../../utils/lockNameHelpers";
export async function muteUser( export async function muteUser(
pluginData: GuildPluginData<MutesPluginType>, pluginData: GuildPluginData<MutesPluginType>,
@ -29,7 +29,7 @@ export async function muteUser(
removeRolesOnMuteOverride: boolean | string[] | null = null, removeRolesOnMuteOverride: boolean | string[] | null = null,
restoreRolesOnMuteOverride: 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; const muteRole = pluginData.config.get().mute_role;
if (!muteRole) { 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) // 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; const cfg = pluginData.config.get();
if (moveToVoiceChannelId) { 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 // TODO: Add back the voiceState check once we figure out how to get voice state for guild members that are loaded on-demand
try { try {
await member.edit({ channelID: moveToVoiceChannelId }); await member.edit({ channelID: moveToVoiceChannel });
} catch (e) {} // tslint:disable-line } catch (e) {} // tslint:disable-line
} }
} }
@ -246,7 +247,7 @@ export async function muteUser(
lock.unlock(); lock.unlock();
pluginData.state.events.emit("mute", user.id, reason); pluginData.state.events.emit("mute", user.id, reason, muteOptions.isAutomodAction);
return { return {
case: theCase, case: theCase,

View file

@ -15,6 +15,7 @@ import { EventEmitter } from "events";
export const ConfigSchema = t.type({ export const ConfigSchema = t.type({
mute_role: tNullable(t.string), mute_role: tNullable(t.string),
move_to_voice_channel: tNullable(t.string), move_to_voice_channel: tNullable(t.string),
kick_from_voice_channel: t.boolean,
dm_on_mute: t.boolean, dm_on_mute: t.boolean,
dm_on_update: t.boolean, dm_on_update: t.boolean,
@ -33,7 +34,7 @@ export const ConfigSchema = t.type({
export type TConfigSchema = t.TypeOf<typeof ConfigSchema>; export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export interface MutesEvents { export interface MutesEvents {
mute: (userId: string, reason?: string) => void; mute: (userId: string, reason?: string, isAutomodAction?: boolean) => void;
unmute: (userId: string, reason?: string) => void; unmute: (userId: string, reason?: string) => void;
} }
@ -74,6 +75,7 @@ export type UnmuteResult = {
export interface MuteOptions { export interface MuteOptions {
caseArgs?: Partial<CaseArgs>; caseArgs?: Partial<CaseArgs>;
contactMethods?: UserNotificationMethod[]; contactMethods?: UserNotificationMethod[];
isAutomodAction?: boolean;
} }
export const mutesCmd = guildCommand<MutesPluginType>(); export const mutesCmd = guildCommand<MutesPluginType>();

View file

@ -7,6 +7,7 @@ import { getMissingPermissions } from "../../../utils/getMissingPermissions";
import { LogsPlugin } from "../../Logs/LogsPlugin"; import { LogsPlugin } from "../../Logs/LogsPlugin";
import { missingPermissionError } from "../../../utils/missingPermissionError"; import { missingPermissionError } from "../../../utils/missingPermissionError";
import { canAssignRole } from "../../../utils/canAssignRole"; import { canAssignRole } from "../../../utils/canAssignRole";
import { memberRolesLock } from "../../../utils/lockNameHelpers";
const p = Constants.Permissions; const p = Constants.Permissions;
@ -17,11 +18,11 @@ export const LoadDataEvt = persistEvt({
const member = meta.args.member; const member = meta.args.member;
const pluginData = meta.pluginData; 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); const persistedData = await pluginData.state.persistedData.find(member.id);
if (!persistedData) { if (!persistedData) {
memberRolesLock.unlock(); memberRoleLock.unlock();
return; return;
} }
@ -79,6 +80,6 @@ export const LoadDataEvt = persistEvt({
}); });
} }
memberRolesLock.unlock(); memberRoleLock.unlock();
}, },
}); });

View file

@ -2,6 +2,7 @@ import { GuildPluginData } from "knub";
import { ReactionRolesPluginType, RoleChangeMode, PendingMemberRoleChanges } from "../types"; import { ReactionRolesPluginType, RoleChangeMode, PendingMemberRoleChanges } from "../types";
import { resolveMember } from "../../../utils"; import { resolveMember } from "../../../utils";
import { logger } from "../../../logger"; import { logger } from "../../../logger";
import { memberRolesLock } from "../../../utils/lockNameHelpers";
const ROLE_CHANGE_BATCH_DEBOUNCE_TIME = 1500; const ROLE_CHANGE_BATCH_DEBOUNCE_TIME = 1500;
@ -18,7 +19,7 @@ export async function addMemberPendingRoleChange(
applyFn: async () => { applyFn: async () => {
pluginData.state.pendingRoleChanges.delete(memberId); 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); const member = await resolveMember(pluginData.client, pluginData.guild, memberId);
if (member) { if (member) {

View file

@ -7,7 +7,7 @@ import { remindersCmd } from "../types";
import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin";
export const RemindCmd = remindersCmd({ export const RemindCmd = remindersCmd({
trigger: ["remind", "remindme"], trigger: ["remind", "remindme", "reminder"],
usage: "!remind 3h Remind me of this in 3 hours please", usage: "!remind 3h Remind me of this in 3 hours please",
permission: "can_use", permission: "can_use",

View file

@ -20,13 +20,21 @@ export async function postDueRemindersLoop(pluginData: GuildPluginData<Reminders
const target = moment.utc(); const target = moment.utc();
const diff = target.diff(moment.utc(reminder.created_at, "YYYY-MM-DD HH:mm:ss")); const diff = target.diff(moment.utc(reminder.created_at, "YYYY-MM-DD HH:mm:ss"));
const result = humanizeDuration(diff, { largest: 2, round: true }); const result = humanizeDuration(diff, { largest: 2, round: true });
await channel.createMessage( await channel.createMessage({
disableLinkPreviews( content: disableLinkPreviews(
`Reminder for <@!${reminder.user_id}>: ${reminder.body} \n\`Set at ${reminder.created_at} (${result} ago)\``, `Reminder for <@!${reminder.user_id}>: ${reminder.body} \n\`Set at ${reminder.created_at} (${result} ago)\``,
), ),
); allowedMentions: {
users: [reminder.user_id],
},
});
} else { } 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) { } catch (e) {
// Probably random Discord internal server error or missing permissions or somesuch // Probably random Discord internal server error or missing permissions or somesuch

View file

@ -6,6 +6,7 @@ import { splitRoleNames } from "../util/splitRoleNames";
import { normalizeRoleNames } from "../util/normalizeRoleNames"; import { normalizeRoleNames } from "../util/normalizeRoleNames";
import { findMatchingRoles } from "../util/findMatchingRoles"; import { findMatchingRoles } from "../util/findMatchingRoles";
import { Role } from "eris"; import { Role } from "eris";
import { memberRolesLock } from "../../../utils/lockNameHelpers";
export const RoleAddCmd = selfGrantableRolesCmd({ export const RoleAddCmd = selfGrantableRolesCmd({
trigger: ["role", "role add"], trigger: ["role", "role add"],
@ -16,7 +17,7 @@ export const RoleAddCmd = selfGrantableRolesCmd({
}, },
async run({ message: msg, args, pluginData }) { 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); const applyingEntries = getApplyingEntries(pluginData, msg);
if (applyingEntries.length === 0) { if (applyingEntries.length === 0) {

View file

@ -5,6 +5,7 @@ import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
import { splitRoleNames } from "../util/splitRoleNames"; import { splitRoleNames } from "../util/splitRoleNames";
import { normalizeRoleNames } from "../util/normalizeRoleNames"; import { normalizeRoleNames } from "../util/normalizeRoleNames";
import { findMatchingRoles } from "../util/findMatchingRoles"; import { findMatchingRoles } from "../util/findMatchingRoles";
import { memberRolesLock } from "../../../utils/lockNameHelpers";
export const RoleRemoveCmd = selfGrantableRolesCmd({ export const RoleRemoveCmd = selfGrantableRolesCmd({
trigger: "role remove", trigger: "role remove",
@ -15,7 +16,7 @@ export const RoleRemoveCmd = selfGrantableRolesCmd({
}, },
async run({ message: msg, args, pluginData }) { 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); const applyingEntries = getApplyingEntries(pluginData, msg);
if (applyingEntries.length === 0) { if (applyingEntries.length === 0) {

View file

@ -10,6 +10,7 @@ import { BOT_SLOWMODE_PERMISSIONS } from "../requiredPermissions";
import { LogsPlugin } from "../../Logs/LogsPlugin"; import { LogsPlugin } from "../../Logs/LogsPlugin";
import { LogType } from "../../../data/LogType"; import { LogType } from "../../../data/LogType";
import { missingPermissionError } from "../../../utils/missingPermissionError"; import { missingPermissionError } from "../../../utils/missingPermissionError";
import { messageLock } from "../../../utils/lockNameHelpers";
export async function onMessageCreate(pluginData: GuildPluginData<SlowmodePluginType>, msg: SavedMessage) { export async function onMessageCreate(pluginData: GuildPluginData<SlowmodePluginType>, msg: SavedMessage) {
if (msg.is_bot) return; if (msg.is_bot) return;
@ -18,7 +19,7 @@ export async function onMessageCreate(pluginData: GuildPluginData<SlowmodePlugin
if (!channel) return; if (!channel) return;
// Don't apply slowmode if the lock was interrupted earlier (e.g. the message was caught by word filters) // Don't apply slowmode if the lock was interrupted earlier (e.g. the message was caught by word filters)
const thisMsgLock = await pluginData.locks.acquire(`message-${msg.id}`); const thisMsgLock = await pluginData.locks.acquire(messageLock(msg));
if (thisMsgLock.interrupted) return; if (thisMsgLock.interrupted) return;
// Check if this channel even *has* a bot-maintained slowmode // Check if this channel even *has* a bot-maintained slowmode

View file

@ -57,6 +57,19 @@ export const StarboardPlugin = zeppelinGuildPlugin<StarboardPluginType>()("starb
stars_required: 5 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 ### Custom star emoji
This is identical to the basic starboard above, but accepts two emoji: the regular star and a custom :mrvnSmile: emoji This is identical to the basic starboard above, but accepts two emoji: the regular star and a custom :mrvnSmile: emoji

View file

@ -3,6 +3,7 @@ import { Message, TextChannel } from "eris";
import { UnknownUser, resolveMember, noop, resolveUser } from "../../../utils"; import { UnknownUser, resolveMember, noop, resolveUser } from "../../../utils";
import { saveMessageToStarboard } from "../util/saveMessageToStarboard"; import { saveMessageToStarboard } from "../util/saveMessageToStarboard";
import { updateStarboardMessageStarCount } from "../util/updateStarboardMessageStarCount"; import { updateStarboardMessageStarCount } from "../util/updateStarboardMessageStarCount";
import { allStarboardsLock } from "../../../utils/lockNameHelpers";
export const StarboardReactionAddEvt = starboardEvt({ export const StarboardReactionAddEvt = starboardEvt({
event: "messageReactionAdd", event: "messageReactionAdd",
@ -36,7 +37,7 @@ export const StarboardReactionAddEvt = starboardEvt({
categoryId: (msg.channel as TextChannel).parentID, 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) const applicableStarboards = Object.values(config.boards)
.filter(board => board.enabled) .filter(board => board.enabled)

View file

@ -1,10 +1,11 @@
import { allStarboardsLock } from "../../../utils/lockNameHelpers";
import { starboardEvt } from "../types"; import { starboardEvt } from "../types";
export const StarboardReactionRemoveEvt = starboardEvt({ export const StarboardReactionRemoveEvt = starboardEvt({
event: "messageReactionRemove", event: "messageReactionRemove",
async listener(meta) { 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); await meta.pluginData.state.starboardReactions.deleteStarboardReaction(meta.args.message.id, meta.args.member.id);
boardLock.unlock(); boardLock.unlock();
}, },
@ -14,7 +15,7 @@ export const StarboardReactionRemoveAllEvt = starboardEvt({
event: "messageReactionRemoveAll", event: "messageReactionRemoveAll",
async listener(meta) { 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); await meta.pluginData.state.starboardReactions.deleteAllStarboardReactionsForMessageId(meta.args.message.id);
boardLock.unlock(); boardLock.unlock();
}, },

View file

@ -12,6 +12,7 @@ const StarboardOpts = t.type({
copy_full_embed: tNullable(t.boolean), copy_full_embed: tNullable(t.boolean),
enabled: tNullable(t.boolean), enabled: tNullable(t.boolean),
show_star_count: t.boolean, show_star_count: t.boolean,
color: tNullable(t.number),
}); });
export type TStarboardOpts = t.TypeOf<typeof StarboardOpts>; export type TStarboardOpts = t.TypeOf<typeof StarboardOpts>;
@ -27,6 +28,7 @@ export const defaultStarboardOpts: Partial<TStarboardOpts> = {
star_emoji: ["⭐"], star_emoji: ["⭐"],
enabled: true, enabled: true,
show_star_count: true, show_star_count: true,
color: null,
}; };
export interface StarboardPluginType extends BasePluginType { export interface StarboardPluginType extends BasePluginType {

View file

@ -8,7 +8,11 @@ const videoAttachmentExtensions = ["mp4", "mkv", "mov"];
type StarboardEmbed = EmbedWith<"footer" | "author" | "fields" | "timestamp">; 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 = { const embed: StarboardEmbed = {
footer: { footer: {
text: `#${(msg.channel as GuildChannel).name}`, text: `#${(msg.channel as GuildChannel).name}`,
@ -20,6 +24,10 @@ export function createStarboardEmbedFromMessage(msg: Message, copyFullEmbed: boo
timestamp: new Date(msg.timestamp).toISOString(), timestamp: new Date(msg.timestamp).toISOString(),
}; };
if (color != null) {
embed.color = color;
}
if (msg.author.avatarURL) { if (msg.author.avatarURL) {
embed.author.icon_url = msg.author.avatarURL; embed.author.icon_url = msg.author.avatarURL;
} }

View file

@ -16,7 +16,7 @@ export async function saveMessageToStarboard(
if (!channel) return; if (!channel) return;
const starCount = (await pluginData.state.starboardReactions.getAllReactionsForMessageId(msg.id)).length; 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)); embed.fields!.push(createStarboardPseudoFooterForMessage(starboard, msg, starboard.star_emoji![0], starCount));
const starboardMessage = await (channel as TextChannel).createMessage({ embed }); const starboardMessage = await (channel as TextChannel).createMessage({ embed });

View file

@ -29,6 +29,7 @@ const defaultOptions: PluginOptions<TagsPluginType> = {
user_tag_cooldown: null, user_tag_cooldown: null,
global_tag_cooldown: null, global_tag_cooldown: null,
user_cooldown: null, user_cooldown: null,
allow_mentions: false,
global_cooldown: null, global_cooldown: null,
auto_delete_command: false, auto_delete_command: false,

View file

@ -15,6 +15,7 @@ export const TagCategory = t.type({
user_tag_cooldown: tNullable(t.union([t.string, t.number])), // Per user, per tag 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 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 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 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 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 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 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 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 global_cooldown: tNullable(t.union([t.string, t.number])), // Any tag use
auto_delete_command: t.boolean, // Any tag auto_delete_command: t.boolean, // Any tag

View file

@ -99,7 +99,11 @@ export async function onMessageCreate(pluginData: GuildPluginData<TagsPluginType
return; return;
} }
const responseMsg = await channel.createMessage(tagResult.renderedContent); const allowMentions = tagResult.category?.allow_mentions ?? config.allow_mentions;
const responseMsg = await channel.createMessage({
...tagResult.renderedContent,
allowedMentions: { roles: allowMentions, users: allowMentions },
});
// Save the command-response message pair once the message is in our database // Save the command-response message pair once the message is in our database
const deleteWithCommand = tagResult.category?.delete_with_command ?? config.delete_with_command; const deleteWithCommand = tagResult.category?.delete_with_command ?? config.delete_with_command;

View file

@ -36,6 +36,7 @@ import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners";
import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin";
import { VcdisconnectCmd } from "./commands/VcdisconnectCmd"; import { VcdisconnectCmd } from "./commands/VcdisconnectCmd";
import { ModActionsPlugin } from "../ModActions/ModActionsPlugin"; import { ModActionsPlugin } from "../ModActions/ModActionsPlugin";
import { refreshMembersIfNeeded } from "./refreshMembers";
const defaultOptions: PluginOptions<UtilityPluginType> = { const defaultOptions: PluginOptions<UtilityPluginType> = {
config: { config: {
@ -156,6 +157,21 @@ export const UtilityPlugin = zeppelinGuildPlugin<UtilityPluginType>()("utility",
sendSuccessMessage(pluginData, activeReloads.get(guild.id)!, "Reloaded!"); sendSuccessMessage(pluginData, activeReloads.get(guild.id)!, "Reloaded!");
activeReloads.delete(guild.id); 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) { onUnload(pluginData) {

Some files were not shown because too many files have changed in this diff Show more