mirror of
https://github.com/ZeppelinBot/Zeppelin.git
synced 2025-03-16 22:21:51 +00:00
Remove unported plugins from the repository
This commit is contained in:
parent
9f059f33af
commit
951c27374f
39 changed files with 19 additions and 14323 deletions
|
@ -3,12 +3,12 @@ import { IPluginOptions } from "knub";
|
||||||
import { pipe } from "fp-ts/lib/pipeable";
|
import { pipe } from "fp-ts/lib/pipeable";
|
||||||
import { fold } from "fp-ts/lib/Either";
|
import { fold } from "fp-ts/lib/Either";
|
||||||
import { PathReporter } from "io-ts/lib/PathReporter";
|
import { PathReporter } from "io-ts/lib/PathReporter";
|
||||||
import { availablePlugins } from "./plugins/availablePlugins";
|
import { guildPlugins } from "./plugins/availablePlugins";
|
||||||
import { ZeppelinPluginClass } from "./plugins/ZeppelinPluginClass";
|
import { ZeppelinPluginClass } from "./plugins/ZeppelinPluginClass";
|
||||||
import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils";
|
import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils";
|
||||||
|
|
||||||
const pluginNameToClass = new Map<string, typeof ZeppelinPluginClass>();
|
const pluginNameToClass = new Map<string, typeof ZeppelinPluginClass>();
|
||||||
for (const pluginClass of availablePlugins) {
|
for (const pluginClass of guildPlugins) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
pluginNameToClass.set(pluginClass.pluginName, pluginClass);
|
pluginNameToClass.set(pluginClass.pluginName, pluginClass);
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,14 @@ import { Configs } from "./data/Configs";
|
||||||
|
|
||||||
require("dotenv").config({ path: path.resolve(process.cwd(), "bot.env") });
|
require("dotenv").config({ path: path.resolve(process.cwd(), "bot.env") });
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// This is here so TypeScript doesn't give an error when importing twemoji
|
||||||
|
// since one of the signatures of twemoji.parse() takes an HTMLElement but
|
||||||
|
// we're not in a browser environment so including the DOM lib would not make
|
||||||
|
// sense
|
||||||
|
type HTMLElement = unknown;
|
||||||
|
}
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
let recentPluginErrors = 0;
|
let recentPluginErrors = 0;
|
||||||
const RECENT_PLUGIN_ERROR_EXIT_THRESHOLD = 5;
|
const RECENT_PLUGIN_ERROR_EXIT_THRESHOLD = 5;
|
||||||
|
@ -79,7 +87,7 @@ moment.tz.setDefault("UTC");
|
||||||
|
|
||||||
import { Client, TextChannel } from "eris";
|
import { Client, TextChannel } from "eris";
|
||||||
import { connect } from "./data/db";
|
import { connect } from "./data/db";
|
||||||
import { availablePlugins, availableGlobalPlugins, basePlugins } from "./plugins/availablePlugins";
|
import { guildPlugins, globalPlugins } from "./plugins/availablePlugins";
|
||||||
import { errorMessage, isDiscordHTTPError, isDiscordRESTError, successMessage } from "./utils";
|
import { errorMessage, isDiscordHTTPError, isDiscordRESTError, successMessage } from "./utils";
|
||||||
import { startUptimeCounter } from "./uptime";
|
import { startUptimeCounter } from "./uptime";
|
||||||
import { AllowedGuilds } from "./data/AllowedGuilds";
|
import { AllowedGuilds } from "./data/AllowedGuilds";
|
||||||
|
@ -107,8 +115,8 @@ connect().then(async () => {
|
||||||
const guildConfigs = new Configs();
|
const guildConfigs = new Configs();
|
||||||
|
|
||||||
const bot = new Knub<IZeppelinGuildConfig, IZeppelinGlobalConfig>(client, {
|
const bot = new Knub<IZeppelinGuildConfig, IZeppelinGlobalConfig>(client, {
|
||||||
guildPlugins: availablePlugins,
|
guildPlugins,
|
||||||
globalPlugins: availableGlobalPlugins,
|
globalPlugins,
|
||||||
|
|
||||||
options: {
|
options: {
|
||||||
canLoadGuild(guildId): Promise<boolean> {
|
canLoadGuild(guildId): Promise<boolean> {
|
||||||
|
@ -124,19 +132,11 @@ connect().then(async () => {
|
||||||
async getEnabledPlugins(this: Knub, guildId, guildConfig): Promise<string[]> {
|
async getEnabledPlugins(this: Knub, guildId, guildConfig): Promise<string[]> {
|
||||||
const configuredPlugins = guildConfig.plugins || {};
|
const configuredPlugins = guildConfig.plugins || {};
|
||||||
const pluginNames: string[] = Array.from(this.guildPlugins.keys());
|
const pluginNames: string[] = Array.from(this.guildPlugins.keys());
|
||||||
const plugins: Array<ZeppelinPlugin> = Array.from(this.guildPlugins.values());
|
const plugins: ZeppelinPlugin[] = Array.from(this.guildPlugins.values());
|
||||||
|
|
||||||
const enabledBasePlugins = pluginNames.filter(n => basePlugins.includes(n));
|
return pluginNames.filter(pluginName => {
|
||||||
const explicitlyEnabledPlugins = pluginNames.filter(pluginName => {
|
|
||||||
return configuredPlugins[pluginName] && configuredPlugins[pluginName].enabled !== false;
|
return configuredPlugins[pluginName] && configuredPlugins[pluginName].enabled !== false;
|
||||||
});
|
});
|
||||||
const enabledPlugins = new Set([...enabledBasePlugins, ...explicitlyEnabledPlugins]);
|
|
||||||
|
|
||||||
const finalEnabledPlugins = new Set([
|
|
||||||
...basePlugins,
|
|
||||||
...explicitlyEnabledPlugins,
|
|
||||||
]);
|
|
||||||
return Array.from(finalEnabledPlugins.values());
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getConfig(id) {
|
async getConfig(id) {
|
||||||
|
|
|
@ -1,159 +0,0 @@
|
||||||
import { IPluginOptions, logger } from "knub";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
import { ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
|
||||||
import { SavedMessage } from "../data/entities/SavedMessage";
|
|
||||||
import { convertDelayStringToMS, MINUTES, sorter, stripObjectToScalars, tDelayString } from "../utils";
|
|
||||||
import { GuildLogs } from "../data/GuildLogs";
|
|
||||||
import { LogType } from "../data/LogType";
|
|
||||||
import moment from "moment-timezone";
|
|
||||||
|
|
||||||
const AntiRaidLevel = t.type({
|
|
||||||
on_join: t.type({
|
|
||||||
kick: t.boolean,
|
|
||||||
ban: t.boolean,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
enabled: t.boolean,
|
|
||||||
delay: tDelayString,
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
interface IDeletionQueueItem {
|
|
||||||
deleteAt: number;
|
|
||||||
message: SavedMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_DELAY = 5 * MINUTES;
|
|
||||||
|
|
||||||
export class AntiRaid extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "auto_delete";
|
|
||||||
public static showInDocs = true;
|
|
||||||
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
public static pluginInfo = {
|
|
||||||
prettyName: "Auto-delete",
|
|
||||||
description: "Allows Zeppelin to auto-delete messages from a channel after a delay",
|
|
||||||
configurationGuide: "Maximum deletion delay is currently 5 minutes",
|
|
||||||
};
|
|
||||||
|
|
||||||
protected guildSavedMessages: GuildSavedMessages;
|
|
||||||
protected guildLogs: GuildLogs;
|
|
||||||
|
|
||||||
protected onMessageCreateFn;
|
|
||||||
protected onMessageDeleteFn;
|
|
||||||
protected onMessageDeleteBulkFn;
|
|
||||||
|
|
||||||
protected deletionQueue: IDeletionQueueItem[];
|
|
||||||
protected nextDeletion: number;
|
|
||||||
protected nextDeletionTimeout;
|
|
||||||
|
|
||||||
protected maxDelayWarningSent = false;
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
enabled: false,
|
|
||||||
delay: "5s",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onLoad() {
|
|
||||||
this.guildSavedMessages = GuildSavedMessages.getGuildInstance(this.guildId);
|
|
||||||
this.guildLogs = new GuildLogs(this.guildId);
|
|
||||||
|
|
||||||
this.deletionQueue = [];
|
|
||||||
|
|
||||||
this.onMessageCreateFn = this.onMessageCreate.bind(this);
|
|
||||||
this.onMessageDeleteFn = this.onMessageDelete.bind(this);
|
|
||||||
this.onMessageDeleteBulkFn = this.onMessageDeleteBulk.bind(this);
|
|
||||||
|
|
||||||
this.guildSavedMessages.events.on("create", this.onMessageCreateFn);
|
|
||||||
this.guildSavedMessages.events.on("delete", this.onMessageDeleteFn);
|
|
||||||
this.guildSavedMessages.events.on("deleteBulk", this.onMessageDeleteBulkFn);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onUnload() {
|
|
||||||
this.guildSavedMessages.events.off("create", this.onMessageCreateFn);
|
|
||||||
this.guildSavedMessages.events.off("delete", this.onMessageDeleteFn);
|
|
||||||
this.guildSavedMessages.events.off("deleteBulk", this.onMessageDeleteFn);
|
|
||||||
clearTimeout(this.nextDeletionTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected addMessageToDeletionQueue(msg: SavedMessage, delay: number) {
|
|
||||||
const deleteAt = Date.now() + delay;
|
|
||||||
this.deletionQueue.push({ deleteAt, message: msg });
|
|
||||||
this.deletionQueue.sort(sorter("deleteAt"));
|
|
||||||
|
|
||||||
this.scheduleNextDeletion();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected scheduleNextDeletion() {
|
|
||||||
if (this.deletionQueue.length === 0) {
|
|
||||||
clearTimeout(this.nextDeletionTimeout);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstDeleteAt = this.deletionQueue[0].deleteAt;
|
|
||||||
clearTimeout(this.nextDeletionTimeout);
|
|
||||||
this.nextDeletionTimeout = setTimeout(() => this.deleteNextItem(), firstDeleteAt - Date.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async deleteNextItem() {
|
|
||||||
const [itemToDelete] = this.deletionQueue.splice(0, 1);
|
|
||||||
if (!itemToDelete) return;
|
|
||||||
|
|
||||||
this.guildLogs.ignoreLog(LogType.MESSAGE_DELETE, itemToDelete.message.id);
|
|
||||||
this.bot.deleteMessage(itemToDelete.message.channel_id, itemToDelete.message.id).catch(logger.warn);
|
|
||||||
|
|
||||||
this.scheduleNextDeletion();
|
|
||||||
|
|
||||||
const user = await this.resolveUser(itemToDelete.message.user_id);
|
|
||||||
const channel = this.guild.channels.get(itemToDelete.message.channel_id);
|
|
||||||
const messageDate = moment(itemToDelete.message.data.timestamp, "x").format("YYYY-MM-DD HH:mm:ss");
|
|
||||||
|
|
||||||
this.guildLogs.log(LogType.MESSAGE_DELETE_AUTO, {
|
|
||||||
message: itemToDelete.message,
|
|
||||||
user: stripObjectToScalars(user),
|
|
||||||
channel: stripObjectToScalars(channel),
|
|
||||||
messageDate,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onMessageCreate(msg: SavedMessage) {
|
|
||||||
const config = this.getConfigForMemberIdAndChannelId(msg.user_id, msg.channel_id);
|
|
||||||
if (config.enabled) {
|
|
||||||
let delay = convertDelayStringToMS(config.delay);
|
|
||||||
|
|
||||||
if (delay > MAX_DELAY) {
|
|
||||||
delay = MAX_DELAY;
|
|
||||||
if (!this.maxDelayWarningSent) {
|
|
||||||
this.guildLogs.log(LogType.BOT_ALERT, {
|
|
||||||
body: `Clamped auto-deletion delay in <#${msg.channel_id}> to 5 minutes`,
|
|
||||||
});
|
|
||||||
this.maxDelayWarningSent = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.addMessageToDeletionQueue(msg, delay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onMessageDelete(msg: SavedMessage) {
|
|
||||||
const indexToDelete = this.deletionQueue.findIndex(item => item.message.id === msg.id);
|
|
||||||
if (indexToDelete > -1) {
|
|
||||||
this.deletionQueue.splice(indexToDelete, 1);
|
|
||||||
this.scheduleNextDeletion();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onMessageDeleteBulk(messages: SavedMessage[]) {
|
|
||||||
for (const msg of messages) {
|
|
||||||
this.onMessageDelete(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,152 +0,0 @@
|
||||||
import { IPluginOptions, logger } from "knub";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
import { ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
|
||||||
import { SavedMessage } from "../data/entities/SavedMessage";
|
|
||||||
import { convertDelayStringToMS, MINUTES, sorter, stripObjectToScalars, tDelayString } from "../utils";
|
|
||||||
import { GuildLogs } from "../data/GuildLogs";
|
|
||||||
import { LogType } from "../data/LogType";
|
|
||||||
import moment from "moment-timezone";
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
enabled: t.boolean,
|
|
||||||
delay: tDelayString,
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
interface IDeletionQueueItem {
|
|
||||||
deleteAt: number;
|
|
||||||
message: SavedMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_DELAY = 5 * MINUTES;
|
|
||||||
|
|
||||||
export class AutoDeletePlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "auto_delete";
|
|
||||||
public static showInDocs = true;
|
|
||||||
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
public static pluginInfo = {
|
|
||||||
prettyName: "Auto-delete",
|
|
||||||
description: "Allows Zeppelin to auto-delete messages from a channel after a delay",
|
|
||||||
configurationGuide: "Maximum deletion delay is currently 5 minutes",
|
|
||||||
};
|
|
||||||
|
|
||||||
protected guildSavedMessages: GuildSavedMessages;
|
|
||||||
protected guildLogs: GuildLogs;
|
|
||||||
|
|
||||||
protected onMessageCreateFn;
|
|
||||||
protected onMessageDeleteFn;
|
|
||||||
protected onMessageDeleteBulkFn;
|
|
||||||
|
|
||||||
protected deletionQueue: IDeletionQueueItem[];
|
|
||||||
protected nextDeletion: number;
|
|
||||||
protected nextDeletionTimeout;
|
|
||||||
|
|
||||||
protected maxDelayWarningSent = false;
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
enabled: false,
|
|
||||||
delay: "5s",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onLoad() {
|
|
||||||
this.guildSavedMessages = GuildSavedMessages.getGuildInstance(this.guildId);
|
|
||||||
this.guildLogs = new GuildLogs(this.guildId);
|
|
||||||
|
|
||||||
this.deletionQueue = [];
|
|
||||||
|
|
||||||
this.onMessageCreateFn = this.onMessageCreate.bind(this);
|
|
||||||
this.onMessageDeleteFn = this.onMessageDelete.bind(this);
|
|
||||||
this.onMessageDeleteBulkFn = this.onMessageDeleteBulk.bind(this);
|
|
||||||
|
|
||||||
this.guildSavedMessages.events.on("create", this.onMessageCreateFn);
|
|
||||||
this.guildSavedMessages.events.on("delete", this.onMessageDeleteFn);
|
|
||||||
this.guildSavedMessages.events.on("deleteBulk", this.onMessageDeleteBulkFn);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onUnload() {
|
|
||||||
this.guildSavedMessages.events.off("create", this.onMessageCreateFn);
|
|
||||||
this.guildSavedMessages.events.off("delete", this.onMessageDeleteFn);
|
|
||||||
this.guildSavedMessages.events.off("deleteBulk", this.onMessageDeleteFn);
|
|
||||||
clearTimeout(this.nextDeletionTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected addMessageToDeletionQueue(msg: SavedMessage, delay: number) {
|
|
||||||
const deleteAt = Date.now() + delay;
|
|
||||||
this.deletionQueue.push({ deleteAt, message: msg });
|
|
||||||
this.deletionQueue.sort(sorter("deleteAt"));
|
|
||||||
|
|
||||||
this.scheduleNextDeletion();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected scheduleNextDeletion() {
|
|
||||||
if (this.deletionQueue.length === 0) {
|
|
||||||
clearTimeout(this.nextDeletionTimeout);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstDeleteAt = this.deletionQueue[0].deleteAt;
|
|
||||||
clearTimeout(this.nextDeletionTimeout);
|
|
||||||
this.nextDeletionTimeout = setTimeout(() => this.deleteNextItem(), firstDeleteAt - Date.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async deleteNextItem() {
|
|
||||||
const [itemToDelete] = this.deletionQueue.splice(0, 1);
|
|
||||||
if (!itemToDelete) return;
|
|
||||||
|
|
||||||
this.guildLogs.ignoreLog(LogType.MESSAGE_DELETE, itemToDelete.message.id);
|
|
||||||
this.bot.deleteMessage(itemToDelete.message.channel_id, itemToDelete.message.id).catch(logger.warn);
|
|
||||||
|
|
||||||
this.scheduleNextDeletion();
|
|
||||||
|
|
||||||
const user = await this.resolveUser(itemToDelete.message.user_id);
|
|
||||||
const channel = this.guild.channels.get(itemToDelete.message.channel_id);
|
|
||||||
const messageDate = moment(itemToDelete.message.data.timestamp, "x").format("YYYY-MM-DD HH:mm:ss");
|
|
||||||
|
|
||||||
this.guildLogs.log(LogType.MESSAGE_DELETE_AUTO, {
|
|
||||||
message: itemToDelete.message,
|
|
||||||
user: stripObjectToScalars(user),
|
|
||||||
channel: stripObjectToScalars(channel),
|
|
||||||
messageDate,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onMessageCreate(msg: SavedMessage) {
|
|
||||||
const config = this.getConfigForMemberIdAndChannelId(msg.user_id, msg.channel_id);
|
|
||||||
if (config.enabled) {
|
|
||||||
let delay = convertDelayStringToMS(config.delay);
|
|
||||||
|
|
||||||
if (delay > MAX_DELAY) {
|
|
||||||
delay = MAX_DELAY;
|
|
||||||
if (!this.maxDelayWarningSent) {
|
|
||||||
this.guildLogs.log(LogType.BOT_ALERT, {
|
|
||||||
body: `Clamped auto-deletion delay in <#${msg.channel_id}> to 5 minutes`,
|
|
||||||
});
|
|
||||||
this.maxDelayWarningSent = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.addMessageToDeletionQueue(msg, delay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onMessageDelete(msg: SavedMessage) {
|
|
||||||
const indexToDelete = this.deletionQueue.findIndex(item => item.message.id === msg.id);
|
|
||||||
if (indexToDelete > -1) {
|
|
||||||
this.deletionQueue.splice(indexToDelete, 1);
|
|
||||||
this.scheduleNextDeletion();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onMessageDeleteBulk(messages: SavedMessage[]) {
|
|
||||||
for (const msg of messages) {
|
|
||||||
this.onMessageDelete(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,181 +0,0 @@
|
||||||
import { decorators as d, IPluginOptions, logger } from "knub";
|
|
||||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
|
||||||
import { SavedMessage } from "../data/entities/SavedMessage";
|
|
||||||
import { GuildAutoReactions } from "../data/GuildAutoReactions";
|
|
||||||
import { Message } from "eris";
|
|
||||||
import { customEmojiRegex, errorMessage, isDiscordRESTError, isEmoji } from "../utils";
|
|
||||||
import { CommandInfo, trimPluginDescription, ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
import { GuildLogs } from "../data/GuildLogs";
|
|
||||||
import { LogType } from "../data/LogType";
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
can_manage: t.boolean,
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
export class AutoReactionsPlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "auto_reactions";
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
public static pluginInfo = {
|
|
||||||
prettyName: "Auto-reactions",
|
|
||||||
description: trimPluginDescription(`
|
|
||||||
Allows setting up automatic reactions to all new messages on a channel
|
|
||||||
`),
|
|
||||||
};
|
|
||||||
|
|
||||||
protected savedMessages: GuildSavedMessages;
|
|
||||||
protected autoReactions: GuildAutoReactions;
|
|
||||||
protected logs: GuildLogs;
|
|
||||||
|
|
||||||
private onMessageCreateFn;
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
can_manage: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
level: ">=100",
|
|
||||||
config: {
|
|
||||||
can_manage: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoad() {
|
|
||||||
this.logs = new GuildLogs(this.guildId);
|
|
||||||
this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId);
|
|
||||||
this.autoReactions = GuildAutoReactions.getGuildInstance(this.guildId);
|
|
||||||
|
|
||||||
this.onMessageCreateFn = this.savedMessages.events.on("create", this.onMessageCreate.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnload() {
|
|
||||||
this.savedMessages.events.off("create", this.onMessageCreateFn);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("auto_reactions", "<channelId:channelId> <reactions...>", {
|
|
||||||
extra: {
|
|
||||||
info: <CommandInfo>{
|
|
||||||
basicUsage: "!auto_reactions 629990160477585428 👍 👎",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@d.permission("can_manage")
|
|
||||||
async setAutoReactionsCmd(msg: Message, args: { channelId: string; reactions: string[] }) {
|
|
||||||
const finalReactions = [];
|
|
||||||
|
|
||||||
for (const reaction of args.reactions) {
|
|
||||||
if (!isEmoji(reaction)) {
|
|
||||||
msg.channel.createMessage(errorMessage("One or more of the specified reactions were invalid!"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let savedValue;
|
|
||||||
|
|
||||||
const customEmojiMatch = reaction.match(customEmojiRegex);
|
|
||||||
if (customEmojiMatch) {
|
|
||||||
// Custom emoji
|
|
||||||
if (!this.canUseEmoji(customEmojiMatch[2])) {
|
|
||||||
msg.channel.createMessage(errorMessage("I can only use regular emojis and custom emojis from this server"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
savedValue = `${customEmojiMatch[1]}:${customEmojiMatch[2]}`;
|
|
||||||
} else {
|
|
||||||
// Unicode emoji
|
|
||||||
savedValue = reaction;
|
|
||||||
}
|
|
||||||
|
|
||||||
finalReactions.push(savedValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.autoReactions.set(args.channelId, finalReactions);
|
|
||||||
this.sendSuccessMessage(msg.channel, `Auto-reactions set for <#${args.channelId}>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("auto_reactions disable", "<channelId:channelId>", {
|
|
||||||
extra: {
|
|
||||||
info: <CommandInfo>{
|
|
||||||
basicUsage: "!auto_reactions disable 629990160477585428",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@d.permission("can_manage")
|
|
||||||
async disableAutoReactionsCmd(msg: Message, args: { channelId: string }) {
|
|
||||||
const autoReaction = await this.autoReactions.getForChannel(args.channelId);
|
|
||||||
if (!autoReaction) {
|
|
||||||
msg.channel.createMessage(errorMessage(`Auto-reactions aren't enabled in <#${args.channelId}>`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.autoReactions.removeFromChannel(args.channelId);
|
|
||||||
this.sendSuccessMessage(msg.channel, `Auto-reactions disabled in <#${args.channelId}>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessageCreate(msg: SavedMessage) {
|
|
||||||
const autoReaction = await this.autoReactions.getForChannel(msg.channel_id);
|
|
||||||
if (!autoReaction) return;
|
|
||||||
|
|
||||||
let realMsg;
|
|
||||||
try {
|
|
||||||
realMsg = await this.bot.getMessage(msg.channel_id, msg.id);
|
|
||||||
} catch (e) {
|
|
||||||
if (isDiscordRESTError(e)) {
|
|
||||||
logger.warn(
|
|
||||||
`Could not load auto-reaction message ${msg.channel_id}/${msg.id} in guild ${this.guild.name} (${this.guildId}) (error code ${e.code})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (e.code === 50001) {
|
|
||||||
// Missing access
|
|
||||||
this.logs.log(LogType.BOT_ALERT, {
|
|
||||||
body: `Could not load auto-reaction message \`${msg.id}\` in <#${msg.channel_id}>. Make sure the bot has **Read Message History** permissions on the channel.`,
|
|
||||||
});
|
|
||||||
} else if (e.code === 10008) {
|
|
||||||
this.logs.log(LogType.BOT_ALERT, {
|
|
||||||
body: `Could not load auto-reaction message \`${msg.id}\` in <#${msg.channel_id}>. Make sure nothing is deleting the message immediately.`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.logs.log(LogType.BOT_ALERT, {
|
|
||||||
body: `Could not load auto-reaction message \`${msg.id}\` in <#${msg.channel_id}>. Error code ${e.code}.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const reaction of autoReaction.reactions) {
|
|
||||||
try {
|
|
||||||
await realMsg.addReaction(reaction);
|
|
||||||
} catch (e) {
|
|
||||||
if (isDiscordRESTError(e)) {
|
|
||||||
logger.warn(
|
|
||||||
`Could not apply auto-reaction to ${msg.channel_id}/${msg.id} in guild ${this.guild.name} (${this.guildId}) (error code ${e.code})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (e.code === 10008) {
|
|
||||||
this.logs.log(LogType.BOT_ALERT, {
|
|
||||||
body: `Could not apply auto-reactions in <#${msg.channel_id}> for message \`${msg.id}\`. Make sure nothing is deleting the message before the reactions are applied.`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.logs.log(LogType.BOT_ALERT, {
|
|
||||||
body: `Could not apply auto-reactions in <#${msg.channel_id}> for message \`${msg.id}\`. Error code ${e.code}.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,101 +0,0 @@
|
||||||
import { ZeppelinPluginInfo, trimPluginDescription } from "../ZeppelinPluginClass";
|
|
||||||
|
|
||||||
export const pluginInfo: PluginInfo = {
|
|
||||||
prettyName: "Automod",
|
|
||||||
description: trimPluginDescription(`
|
|
||||||
Allows specifying automated actions in response to triggers. Example use cases include word filtering and spam prevention.
|
|
||||||
`),
|
|
||||||
configurationGuide: trimPluginDescription(`
|
|
||||||
The automod plugin is very customizable. For a full list of available triggers, actions, and their options, see Config schema at the bottom of this page.
|
|
||||||
|
|
||||||
### Simple word filter
|
|
||||||
Removes any messages that contain the word 'banana' and sends a warning to the user.
|
|
||||||
Moderators (level >= 50) are ignored by the filter based on the override.
|
|
||||||
|
|
||||||
~~~yml
|
|
||||||
automod:
|
|
||||||
config:
|
|
||||||
rules:
|
|
||||||
my_filter:
|
|
||||||
triggers:
|
|
||||||
- match_words:
|
|
||||||
words: ['banana']
|
|
||||||
case_sensitive: false
|
|
||||||
only_full_words: true
|
|
||||||
actions:
|
|
||||||
clean: true
|
|
||||||
warn:
|
|
||||||
reason: 'Do not talk about bananas!'
|
|
||||||
overrides:
|
|
||||||
- level: '>=50'
|
|
||||||
config:
|
|
||||||
rules:
|
|
||||||
my_filter:
|
|
||||||
enabled: false
|
|
||||||
~~~
|
|
||||||
|
|
||||||
### Spam detection
|
|
||||||
This example includes 2 filters:
|
|
||||||
|
|
||||||
- The first one is triggered if a user sends 5 messages within 10 seconds OR 3 attachments within 60 seconds.
|
|
||||||
The messages are deleted and the user is muted for 5 minutes.
|
|
||||||
- The second filter is triggered if a user sends more than 2 emoji within 5 seconds.
|
|
||||||
The messages are deleted but the user is not muted.
|
|
||||||
|
|
||||||
Moderators are ignored by both filters based on the override.
|
|
||||||
|
|
||||||
~~~yml
|
|
||||||
automod:
|
|
||||||
config:
|
|
||||||
rules:
|
|
||||||
my_spam_filter:
|
|
||||||
triggers:
|
|
||||||
- message_spam:
|
|
||||||
amount: 5
|
|
||||||
within: 10s
|
|
||||||
- attachment_spam:
|
|
||||||
amount: 3
|
|
||||||
within: 60s
|
|
||||||
actions:
|
|
||||||
clean: true
|
|
||||||
mute:
|
|
||||||
duration: 5m
|
|
||||||
reason: 'Auto-muted for spam'
|
|
||||||
my_second_filter:
|
|
||||||
triggers:
|
|
||||||
- message_spam:
|
|
||||||
amount: 5
|
|
||||||
within: 10s
|
|
||||||
actions:
|
|
||||||
clean: true
|
|
||||||
overrides:
|
|
||||||
- level: '>=50'
|
|
||||||
config:
|
|
||||||
rules:
|
|
||||||
my_spam_filter:
|
|
||||||
enabled: false
|
|
||||||
my_second_filter:
|
|
||||||
enabled: false
|
|
||||||
~~~
|
|
||||||
|
|
||||||
### Custom status alerts
|
|
||||||
This example sends an alert any time a user with a matching custom status sends a message.
|
|
||||||
|
|
||||||
~~~yml
|
|
||||||
automod:
|
|
||||||
config:
|
|
||||||
rules:
|
|
||||||
bad_custom_statuses:
|
|
||||||
triggers:
|
|
||||||
- match_words:
|
|
||||||
words: ['banana']
|
|
||||||
match_custom_status: true
|
|
||||||
actions:
|
|
||||||
alert:
|
|
||||||
channel: "473087035574321152"
|
|
||||||
text: |-
|
|
||||||
Bad custom status on user <@!{user.id}>:
|
|
||||||
{matchSummary}
|
|
||||||
~~~
|
|
||||||
`),
|
|
||||||
};
|
|
|
@ -1,336 +0,0 @@
|
||||||
import * as t from "io-ts";
|
|
||||||
import { TSafeRegex } from "../../validatorUtils";
|
|
||||||
import { tDelayString, tMessageContent, tNullable } from "../../utils";
|
|
||||||
|
|
||||||
export enum RecentActionType {
|
|
||||||
Message = 1,
|
|
||||||
Mention,
|
|
||||||
Link,
|
|
||||||
Attachment,
|
|
||||||
Emoji,
|
|
||||||
Line,
|
|
||||||
Character,
|
|
||||||
VoiceChannelMove,
|
|
||||||
MemberJoin,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BaseRecentAction {
|
|
||||||
identifier: string;
|
|
||||||
timestamp: number;
|
|
||||||
count: number;
|
|
||||||
actioned?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TextRecentAction = BaseRecentAction & {
|
|
||||||
type:
|
|
||||||
| RecentActionType.Message
|
|
||||||
| RecentActionType.Mention
|
|
||||||
| RecentActionType.Link
|
|
||||||
| RecentActionType.Attachment
|
|
||||||
| RecentActionType.Emoji
|
|
||||||
| RecentActionType.Line
|
|
||||||
| RecentActionType.Character;
|
|
||||||
messageInfo: MessageInfo;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OtherRecentAction = BaseRecentAction & {
|
|
||||||
type: RecentActionType.VoiceChannelMove | RecentActionType.MemberJoin;
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RecentAction = (TextRecentAction | OtherRecentAction) & { expiresAt: number };
|
|
||||||
|
|
||||||
export interface RecentSpam {
|
|
||||||
archiveId: string;
|
|
||||||
actionedUsers: Set<string>;
|
|
||||||
expiresAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MessageInfo = { channelId: string; messageId: string; userId: string };
|
|
||||||
|
|
||||||
export type TextTriggerWithMultipleMatchTypes = {
|
|
||||||
match_messages: boolean;
|
|
||||||
match_embeds: boolean;
|
|
||||||
match_visible_names: boolean;
|
|
||||||
match_usernames: boolean;
|
|
||||||
match_nicknames: boolean;
|
|
||||||
match_custom_status: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface TriggerMatchResult {
|
|
||||||
trigger: string;
|
|
||||||
type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MessageTextTriggerMatchResult<T = any> extends TriggerMatchResult {
|
|
||||||
type: "message" | "embed";
|
|
||||||
str: string;
|
|
||||||
userId: string;
|
|
||||||
messageInfo: MessageInfo;
|
|
||||||
matchedValue: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OtherTextTriggerMatchResult<T = any> extends TriggerMatchResult {
|
|
||||||
type: "username" | "nickname" | "visiblename" | "customstatus";
|
|
||||||
str: string;
|
|
||||||
userId: string;
|
|
||||||
matchedValue: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TextTriggerMatchResult<T = any> = MessageTextTriggerMatchResult<T> | OtherTextTriggerMatchResult<T>;
|
|
||||||
|
|
||||||
export interface TextSpamTriggerMatchResult extends TriggerMatchResult {
|
|
||||||
type: "textspam";
|
|
||||||
actionType: RecentActionType;
|
|
||||||
recentActions: TextRecentAction[];
|
|
||||||
|
|
||||||
// Rule that specified the criteria used for matching the spam
|
|
||||||
rule: TRule;
|
|
||||||
|
|
||||||
// The identifier used to match the recentActions above.
|
|
||||||
// If not null, this should match the identifier of each of the recentActions above.
|
|
||||||
identifier: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OtherSpamTriggerMatchResult extends TriggerMatchResult {
|
|
||||||
type: "otherspam";
|
|
||||||
actionType: RecentActionType;
|
|
||||||
recentActions: OtherRecentAction[];
|
|
||||||
|
|
||||||
// Rule that specified the criteria used for matching the spam
|
|
||||||
rule: TRule;
|
|
||||||
|
|
||||||
// The identifier used to match the recentActions above.
|
|
||||||
// If not null, this should match the identifier of each of the recentActions above.
|
|
||||||
identifier: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OtherTriggerMatchResult extends TriggerMatchResult {
|
|
||||||
type: "other";
|
|
||||||
userId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AnyTriggerMatchResult =
|
|
||||||
| TextTriggerMatchResult
|
|
||||||
| OtherTriggerMatchResult
|
|
||||||
| TextSpamTriggerMatchResult
|
|
||||||
| OtherSpamTriggerMatchResult;
|
|
||||||
|
|
||||||
export type AnySpamTriggerMatchResult = TextSpamTriggerMatchResult | OtherSpamTriggerMatchResult;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TRIGGERS
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const MatchWordsTrigger = t.type({
|
|
||||||
words: t.array(t.string),
|
|
||||||
case_sensitive: t.boolean,
|
|
||||||
only_full_words: t.boolean,
|
|
||||||
normalize: t.boolean,
|
|
||||||
loose_matching: t.boolean,
|
|
||||||
loose_matching_threshold: t.number,
|
|
||||||
match_messages: t.boolean,
|
|
||||||
match_embeds: t.boolean,
|
|
||||||
match_visible_names: t.boolean,
|
|
||||||
match_usernames: t.boolean,
|
|
||||||
match_nicknames: t.boolean,
|
|
||||||
match_custom_status: t.boolean,
|
|
||||||
});
|
|
||||||
export type TMatchWordsTrigger = t.TypeOf<typeof MatchWordsTrigger>;
|
|
||||||
|
|
||||||
export const MatchRegexTrigger = t.type({
|
|
||||||
patterns: t.array(TSafeRegex),
|
|
||||||
case_sensitive: t.boolean,
|
|
||||||
normalize: t.boolean,
|
|
||||||
match_messages: t.boolean,
|
|
||||||
match_embeds: t.boolean,
|
|
||||||
match_visible_names: t.boolean,
|
|
||||||
match_usernames: t.boolean,
|
|
||||||
match_nicknames: t.boolean,
|
|
||||||
match_custom_status: t.boolean,
|
|
||||||
});
|
|
||||||
export type TMatchRegexTrigger = t.TypeOf<typeof MatchRegexTrigger>;
|
|
||||||
|
|
||||||
export const MatchInvitesTrigger = t.type({
|
|
||||||
include_guilds: tNullable(t.array(t.string)),
|
|
||||||
exclude_guilds: tNullable(t.array(t.string)),
|
|
||||||
include_invite_codes: tNullable(t.array(t.string)),
|
|
||||||
exclude_invite_codes: tNullable(t.array(t.string)),
|
|
||||||
allow_group_dm_invites: t.boolean,
|
|
||||||
match_messages: t.boolean,
|
|
||||||
match_embeds: t.boolean,
|
|
||||||
match_visible_names: t.boolean,
|
|
||||||
match_usernames: t.boolean,
|
|
||||||
match_nicknames: t.boolean,
|
|
||||||
match_custom_status: t.boolean,
|
|
||||||
});
|
|
||||||
export type TMatchInvitesTrigger = t.TypeOf<typeof MatchInvitesTrigger>;
|
|
||||||
|
|
||||||
export const MatchLinksTrigger = t.type({
|
|
||||||
include_domains: tNullable(t.array(t.string)),
|
|
||||||
exclude_domains: tNullable(t.array(t.string)),
|
|
||||||
include_subdomains: t.boolean,
|
|
||||||
include_words: tNullable(t.array(t.string)),
|
|
||||||
exclude_words: tNullable(t.array(t.string)),
|
|
||||||
include_regex: tNullable(t.array(TSafeRegex)),
|
|
||||||
exclude_regex: tNullable(t.array(TSafeRegex)),
|
|
||||||
only_real_links: t.boolean,
|
|
||||||
match_messages: t.boolean,
|
|
||||||
match_embeds: t.boolean,
|
|
||||||
match_visible_names: t.boolean,
|
|
||||||
match_usernames: t.boolean,
|
|
||||||
match_nicknames: t.boolean,
|
|
||||||
match_custom_status: t.boolean,
|
|
||||||
});
|
|
||||||
export type TMatchLinksTrigger = t.TypeOf<typeof MatchLinksTrigger>;
|
|
||||||
|
|
||||||
export const MatchAttachmentTypeTrigger = t.type({
|
|
||||||
filetype_blacklist: t.array(t.string),
|
|
||||||
blacklist_enabled: t.boolean,
|
|
||||||
filetype_whitelist: t.array(t.string),
|
|
||||||
whitelist_enabled: t.boolean,
|
|
||||||
});
|
|
||||||
export type TMatchAttachmentTypeTrigger = t.TypeOf<typeof MatchAttachmentTypeTrigger>;
|
|
||||||
|
|
||||||
export const BaseSpamTrigger = t.type({
|
|
||||||
amount: t.number,
|
|
||||||
within: t.string,
|
|
||||||
});
|
|
||||||
export type TBaseSpamTrigger = t.TypeOf<typeof BaseSpamTrigger>;
|
|
||||||
|
|
||||||
export const BaseTextSpamTrigger = t.intersection([
|
|
||||||
BaseSpamTrigger,
|
|
||||||
t.type({
|
|
||||||
per_channel: t.boolean,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
export type TBaseTextSpamTrigger = t.TypeOf<typeof BaseTextSpamTrigger>;
|
|
||||||
|
|
||||||
export const MessageSpamTrigger = BaseTextSpamTrigger;
|
|
||||||
export type TMessageSpamTrigger = t.TypeOf<typeof MessageSpamTrigger>;
|
|
||||||
export const MentionSpamTrigger = BaseTextSpamTrigger;
|
|
||||||
export type TMentionSpamTrigger = t.TypeOf<typeof MentionSpamTrigger>;
|
|
||||||
export const LinkSpamTrigger = BaseTextSpamTrigger;
|
|
||||||
export type TLinkSpamTrigger = t.TypeOf<typeof LinkSpamTrigger>;
|
|
||||||
export const AttachmentSpamTrigger = BaseTextSpamTrigger;
|
|
||||||
export type TAttachmentSpamTrigger = t.TypeOf<typeof AttachmentSpamTrigger>;
|
|
||||||
export const EmojiSpamTrigger = BaseTextSpamTrigger;
|
|
||||||
export type TEmojiSpamTrigger = t.TypeOf<typeof EmojiSpamTrigger>;
|
|
||||||
export const LineSpamTrigger = BaseTextSpamTrigger;
|
|
||||||
export type TLineSpamTrigger = t.TypeOf<typeof LineSpamTrigger>;
|
|
||||||
export const CharacterSpamTrigger = BaseTextSpamTrigger;
|
|
||||||
export type TCharacterSpamTrigger = t.TypeOf<typeof CharacterSpamTrigger>;
|
|
||||||
export const VoiceMoveSpamTrigger = BaseSpamTrigger;
|
|
||||||
export type TVoiceMoveSpamTrigger = t.TypeOf<typeof VoiceMoveSpamTrigger>;
|
|
||||||
|
|
||||||
export const MemberJoinTrigger = t.type({
|
|
||||||
only_new: t.boolean,
|
|
||||||
new_threshold: tDelayString,
|
|
||||||
});
|
|
||||||
export type TMemberJoinTrigger = t.TypeOf<typeof MemberJoinTrigger>;
|
|
||||||
|
|
||||||
export const MemberJoinSpamTrigger = BaseSpamTrigger;
|
|
||||||
export type TMemberJoinSpamTrigger = t.TypeOf<typeof MemberJoinTrigger>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ACTIONS
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const CleanAction = t.boolean;
|
|
||||||
|
|
||||||
export const WarnAction = t.type({
|
|
||||||
reason: tNullable(t.string),
|
|
||||||
notify: tNullable(t.string),
|
|
||||||
notifyChannel: tNullable(t.string),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const MuteAction = t.type({
|
|
||||||
reason: tNullable(t.string),
|
|
||||||
duration: tNullable(tDelayString),
|
|
||||||
notify: tNullable(t.string),
|
|
||||||
notifyChannel: tNullable(t.string),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const KickAction = t.type({
|
|
||||||
reason: tNullable(t.string),
|
|
||||||
notify: tNullable(t.string),
|
|
||||||
notifyChannel: tNullable(t.string),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const BanAction = t.type({
|
|
||||||
reason: tNullable(t.string),
|
|
||||||
notify: tNullable(t.string),
|
|
||||||
notifyChannel: tNullable(t.string),
|
|
||||||
deleteMessageDays: tNullable(t.number),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const AlertAction = t.type({
|
|
||||||
channel: t.string,
|
|
||||||
text: t.string,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ChangeNicknameAction = t.type({
|
|
||||||
name: t.string,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const LogAction = t.boolean;
|
|
||||||
|
|
||||||
export const AddRolesAction = t.array(t.string);
|
|
||||||
export const RemoveRolesAction = t.array(t.string);
|
|
||||||
|
|
||||||
export const SetAntiraidLevelAction = t.string;
|
|
||||||
|
|
||||||
export const ReplyAction = t.union([
|
|
||||||
t.string,
|
|
||||||
t.type({
|
|
||||||
text: tMessageContent,
|
|
||||||
auto_delete: tNullable(t.union([t.string, t.number])),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RULES
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const Rule = t.type({
|
|
||||||
enabled: t.boolean,
|
|
||||||
name: t.string,
|
|
||||||
presets: tNullable(t.array(t.string)),
|
|
||||||
affects_bots: t.boolean,
|
|
||||||
triggers: t.array(
|
|
||||||
t.type({
|
|
||||||
match_words: tNullable(MatchWordsTrigger),
|
|
||||||
match_regex: tNullable(MatchRegexTrigger),
|
|
||||||
match_invites: tNullable(MatchInvitesTrigger),
|
|
||||||
match_links: tNullable(MatchLinksTrigger),
|
|
||||||
match_attachment_type: tNullable(MatchAttachmentTypeTrigger),
|
|
||||||
message_spam: tNullable(MessageSpamTrigger),
|
|
||||||
mention_spam: tNullable(MentionSpamTrigger),
|
|
||||||
link_spam: tNullable(LinkSpamTrigger),
|
|
||||||
attachment_spam: tNullable(AttachmentSpamTrigger),
|
|
||||||
emoji_spam: tNullable(EmojiSpamTrigger),
|
|
||||||
line_spam: tNullable(LineSpamTrigger),
|
|
||||||
character_spam: tNullable(CharacterSpamTrigger),
|
|
||||||
member_join: tNullable(MemberJoinTrigger),
|
|
||||||
member_join_spam: tNullable(MemberJoinSpamTrigger),
|
|
||||||
// voice_move_spam: tNullable(VoiceMoveSpamTrigger), // TODO
|
|
||||||
// TODO: Duplicates trigger
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
actions: t.type({
|
|
||||||
clean: tNullable(CleanAction),
|
|
||||||
warn: tNullable(WarnAction),
|
|
||||||
mute: tNullable(MuteAction),
|
|
||||||
kick: tNullable(KickAction),
|
|
||||||
ban: tNullable(BanAction),
|
|
||||||
alert: tNullable(AlertAction),
|
|
||||||
change_nickname: tNullable(ChangeNicknameAction),
|
|
||||||
log: tNullable(LogAction),
|
|
||||||
add_roles: tNullable(AddRolesAction),
|
|
||||||
remove_roles: tNullable(RemoveRolesAction),
|
|
||||||
set_antiraid_level: tNullable(SetAntiraidLevelAction),
|
|
||||||
reply: tNullable(ReplyAction),
|
|
||||||
}),
|
|
||||||
cooldown: tNullable(t.string),
|
|
||||||
});
|
|
||||||
export type TRule = t.TypeOf<typeof Rule>;
|
|
|
@ -1,288 +0,0 @@
|
||||||
import { decorators as d, IPluginOptions } from "knub";
|
|
||||||
import child_process from "child_process";
|
|
||||||
import { GuildChannel, Message, TextChannel } from "eris";
|
|
||||||
import moment from "moment-timezone";
|
|
||||||
import { createChunkedMessage, errorMessage, noop, sorter, successMessage, tNullable } from "../utils";
|
|
||||||
import { ReactionRolesPlugin } from "./ReactionRoles";
|
|
||||||
import { ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import { GuildArchives } from "../data/GuildArchives";
|
|
||||||
import { GlobalZeppelinPlugin } from "./GlobalZeppelinPlugin";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
import escapeStringRegexp from "escape-string-regexp";
|
|
||||||
|
|
||||||
let activeReload: [string, string] = null;
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
can_use: t.boolean,
|
|
||||||
owners: t.array(t.string),
|
|
||||||
update_cmd: tNullable(t.string),
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A global plugin that allows bot owners to control the bot
|
|
||||||
*/
|
|
||||||
export class BotControlPlugin extends GlobalZeppelinPlugin<TConfigSchema> {
|
|
||||||
public static pluginName = "bot_control";
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
protected archives: GuildArchives;
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions() {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
can_use: false,
|
|
||||||
owners: [],
|
|
||||||
update_cmd: null,
|
|
||||||
},
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
level: ">=100",
|
|
||||||
config: {
|
|
||||||
can_use: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getMemberLevel(member) {
|
|
||||||
return this.isOwner(member.id) ? 100 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async onLoad() {
|
|
||||||
this.archives = new GuildArchives(0);
|
|
||||||
|
|
||||||
if (activeReload) {
|
|
||||||
const [guildId, channelId] = activeReload;
|
|
||||||
activeReload = null;
|
|
||||||
|
|
||||||
const guild = this.bot.guilds.get(guildId);
|
|
||||||
if (guild) {
|
|
||||||
const channel = guild.channels.get(channelId);
|
|
||||||
if (channel instanceof TextChannel) {
|
|
||||||
this.sendSuccessMessage(channel, "Global plugins reloaded!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("bot_full_update")
|
|
||||||
@d.permission("can_use")
|
|
||||||
async fullUpdateCmd(msg: Message) {
|
|
||||||
const updateCmd = this.getConfig().update_cmd;
|
|
||||||
if (!updateCmd) {
|
|
||||||
msg.channel.createMessage(errorMessage("Update command not specified!"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
msg.channel.createMessage("Updating...");
|
|
||||||
const updater = child_process.exec(updateCmd, { cwd: process.cwd() });
|
|
||||||
updater.stderr.on("data", data => {
|
|
||||||
// tslint:disable-next-line
|
|
||||||
console.error(data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("bot_reload_global_plugins")
|
|
||||||
@d.permission("can_use")
|
|
||||||
async reloadGlobalPluginsCmd(msg: Message) {
|
|
||||||
if (activeReload) return;
|
|
||||||
|
|
||||||
if (msg.channel) {
|
|
||||||
activeReload = [(msg.channel as GuildChannel).guild.id, msg.channel.id];
|
|
||||||
await msg.channel.createMessage("Reloading global plugins...");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.knub.reloadAllGlobalPlugins();
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("perf")
|
|
||||||
@d.permission("can_use")
|
|
||||||
async perfCmd(msg: Message) {
|
|
||||||
const perfItems = this.knub.getPerformanceDebugItems();
|
|
||||||
|
|
||||||
if (perfItems.length) {
|
|
||||||
const content = "```" + perfItems.join("\n") + "```";
|
|
||||||
msg.channel.createMessage(content);
|
|
||||||
} else {
|
|
||||||
msg.channel.createMessage(errorMessage("No performance data"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("refresh_reaction_roles_globally")
|
|
||||||
@d.permission("can_use")
|
|
||||||
async refreshAllReactionRolesCmd(msg: Message) {
|
|
||||||
const guilds = this.knub.getLoadedGuilds();
|
|
||||||
for (const guild of guilds) {
|
|
||||||
if (guild.loadedPlugins.has("reaction_roles")) {
|
|
||||||
const rrPlugin = (guild.loadedPlugins.get("reaction_roles") as unknown) as ReactionRolesPlugin;
|
|
||||||
rrPlugin.runAutoRefresh().catch(noop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("guilds", "[search:string$]", {
|
|
||||||
aliases: ["servers"],
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
name: "all",
|
|
||||||
shortcut: "a",
|
|
||||||
isSwitch: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "initialized",
|
|
||||||
shortcut: "i",
|
|
||||||
isSwitch: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "uninitialized",
|
|
||||||
shortcut: "u",
|
|
||||||
isSwitch: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
@d.permission("can_use")
|
|
||||||
async serversCmd(
|
|
||||||
msg: Message,
|
|
||||||
args: { search?: string; all?: boolean; initialized?: boolean; uninitialized?: boolean },
|
|
||||||
) {
|
|
||||||
const showList = Boolean(args.all || args.initialized || args.uninitialized || args.search);
|
|
||||||
const search = args.search && new RegExp([...args.search].map(s => escapeStringRegexp(s)).join(".*"), "i");
|
|
||||||
|
|
||||||
const joinedGuilds = Array.from(this.bot.guilds.values());
|
|
||||||
const loadedGuilds = this.knub.getLoadedGuilds();
|
|
||||||
const loadedGuildsMap = loadedGuilds.reduce((map, guildData) => map.set(guildData.id, guildData), new Map());
|
|
||||||
|
|
||||||
if (showList) {
|
|
||||||
let filteredGuilds = Array.from(joinedGuilds);
|
|
||||||
|
|
||||||
if (args.initialized) {
|
|
||||||
filteredGuilds = filteredGuilds.filter(g => loadedGuildsMap.has(g.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.uninitialized) {
|
|
||||||
filteredGuilds = filteredGuilds.filter(g => !loadedGuildsMap.has(g.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.search) {
|
|
||||||
filteredGuilds = filteredGuilds.filter(g => search.test(`${g.id} ${g.name}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filteredGuilds.length) {
|
|
||||||
filteredGuilds.sort(sorter(g => g.name.toLowerCase()));
|
|
||||||
const longestId = filteredGuilds.reduce((longest, guild) => Math.max(longest, guild.id.length), 0);
|
|
||||||
const lines = filteredGuilds.map(g => {
|
|
||||||
const paddedId = g.id.padEnd(longestId, " ");
|
|
||||||
return `\`${paddedId}\` **${g.name}** (${loadedGuildsMap.has(g.id) ? "initialized" : "not initialized"}) (${
|
|
||||||
g.memberCount
|
|
||||||
} members)`;
|
|
||||||
});
|
|
||||||
createChunkedMessage(msg.channel, lines.join("\n"));
|
|
||||||
} else {
|
|
||||||
msg.channel.createMessage("No servers matched the filters");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const total = joinedGuilds.length;
|
|
||||||
const initialized = joinedGuilds.filter(g => loadedGuildsMap.has(g.id)).length;
|
|
||||||
const unInitialized = total - initialized;
|
|
||||||
|
|
||||||
msg.channel.createMessage(
|
|
||||||
`I am on **${total} total servers**, of which **${initialized} are initialized** and **${unInitialized} are not initialized**`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("leave_guild", "<guildId:string>")
|
|
||||||
@d.permission("can_use")
|
|
||||||
async leaveGuildCmd(msg: Message, args: { guildId: string }) {
|
|
||||||
if (!this.bot.guilds.has(args.guildId)) {
|
|
||||||
msg.channel.createMessage(errorMessage("I am not in that guild"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const guildToLeave = this.bot.guilds.get(args.guildId);
|
|
||||||
const guildName = guildToLeave.name;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.bot.leaveGuild(args.guildId);
|
|
||||||
} catch (e) {
|
|
||||||
msg.channel.createMessage(errorMessage(`Failed to leave guild: ${e.message}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sendSuccessMessage(msg.channel, `Left guild **${guildName}**`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("reload_guild", "<guildId:string>")
|
|
||||||
@d.permission("can_use")
|
|
||||||
async reloadGuildCmd(msg: Message, args: { guildId: string }) {
|
|
||||||
if (!this.bot.guilds.has(args.guildId)) {
|
|
||||||
msg.channel.createMessage(errorMessage("I am not in that guild"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.knub.reloadGuild(args.guildId);
|
|
||||||
} catch (e) {
|
|
||||||
msg.channel.createMessage(errorMessage(`Failed to reload guild: ${e.message}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const guild = this.bot.guilds.get(args.guildId);
|
|
||||||
this.sendSuccessMessage(msg.channel, `Reloaded guild **${guild.name}**`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("reload_all_guilds")
|
|
||||||
@d.permission("can_use")
|
|
||||||
async reloadAllGuilds(msg: Message) {
|
|
||||||
const failedReloads: Map<string, string> = new Map();
|
|
||||||
let reloadCount = 0;
|
|
||||||
|
|
||||||
const loadedGuilds = this.knub.getLoadedGuilds();
|
|
||||||
for (const guildData of loadedGuilds) {
|
|
||||||
try {
|
|
||||||
await this.knub.reloadGuild(guildData.id);
|
|
||||||
reloadCount++;
|
|
||||||
} catch (e) {
|
|
||||||
failedReloads.set(guildData.id, e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failedReloads.size) {
|
|
||||||
const errorLines = Array.from(failedReloads.entries()).map(([guildId, err]) => {
|
|
||||||
const guild = this.bot.guilds.get(guildId);
|
|
||||||
const guildName = guild ? guild.name : "Unknown";
|
|
||||||
return `${guildName} (${guildId}): ${err}`;
|
|
||||||
});
|
|
||||||
createChunkedMessage(msg.channel, `Reloaded ${reloadCount} guild(s). Errors:\n${errorLines.join("\n")}`);
|
|
||||||
} else {
|
|
||||||
this.sendSuccessMessage(msg.channel, `Reloaded ${reloadCount} guild(s)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("show_plugin_config", "<guildId:string> <pluginName:string>")
|
|
||||||
@d.permission("can_use")
|
|
||||||
async showPluginConfig(msg: Message, args: { guildId: string; pluginName: string }) {
|
|
||||||
const guildData = this.knub.getGuildData(args.guildId);
|
|
||||||
if (!guildData) {
|
|
||||||
msg.channel.createMessage(errorMessage(`Guild not loaded`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pluginInstance = guildData.loadedPlugins.get(args.pluginName);
|
|
||||||
if (!pluginInstance) {
|
|
||||||
msg.channel.createMessage(errorMessage(`Plugin not loaded`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(pluginInstance instanceof ZeppelinPluginClass)) {
|
|
||||||
msg.channel.createMessage(errorMessage(`Plugin is not a Zeppelin plugin`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const opts = pluginInstance.getRuntimeOptions();
|
|
||||||
const archiveId = await this.archives.create(JSON.stringify(opts, null, 2), moment().add(15, "minutes"));
|
|
||||||
msg.channel.createMessage(this.archives.getUrl(this.knub.getGlobalConfig().url, archiveId));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,321 +0,0 @@
|
||||||
import { Message, MessageContent, MessageFile, TextChannel } from "eris";
|
|
||||||
import { GuildCases } from "../data/GuildCases";
|
|
||||||
import { CaseTypes } from "../data/CaseTypes";
|
|
||||||
import { Case } from "../data/entities/Case";
|
|
||||||
import moment from "moment-timezone";
|
|
||||||
import { CaseTypeColors } from "../data/CaseTypeColors";
|
|
||||||
import { ZeppelinPluginInfo, trimPluginDescription, ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import { GuildArchives } from "../data/GuildArchives";
|
|
||||||
import { IPluginOptions, logger } from "knub";
|
|
||||||
import { GuildLogs } from "../data/GuildLogs";
|
|
||||||
import { LogType } from "../data/LogType";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
import { isDiscordRESTError, tNullable } from "../utils";
|
|
||||||
import { ERRORS } from "../RecoverablePluginError";
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
log_automatic_actions: t.boolean,
|
|
||||||
case_log_channel: tNullable(t.string),
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Can also be used as a config object for functions that create cases
|
|
||||||
*/
|
|
||||||
export type CaseArgs = {
|
|
||||||
userId: string;
|
|
||||||
modId: string;
|
|
||||||
ppId?: string;
|
|
||||||
type: CaseTypes;
|
|
||||||
auditLogId?: string;
|
|
||||||
reason?: string;
|
|
||||||
automatic?: boolean;
|
|
||||||
postInCaseLogOverride?: boolean;
|
|
||||||
noteDetails?: string[];
|
|
||||||
extraNotes?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CaseNoteArgs = {
|
|
||||||
caseId: number;
|
|
||||||
modId: string;
|
|
||||||
body: string;
|
|
||||||
automatic?: boolean;
|
|
||||||
postInCaseLogOverride?: boolean;
|
|
||||||
noteDetails?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export class CasesPlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "cases";
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
public static pluginInfo: PluginInfo = {
|
|
||||||
prettyName: "Cases",
|
|
||||||
description: trimPluginDescription(`
|
|
||||||
This plugin contains basic configuration for cases created by other plugins
|
|
||||||
`),
|
|
||||||
};
|
|
||||||
|
|
||||||
protected cases: GuildCases;
|
|
||||||
protected archives: GuildArchives;
|
|
||||||
protected logs: GuildLogs;
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
log_automatic_actions: true,
|
|
||||||
case_log_channel: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoad() {
|
|
||||||
this.cases = GuildCases.getGuildInstance(this.guildId);
|
|
||||||
this.archives = GuildArchives.getGuildInstance(this.guildId);
|
|
||||||
this.logs = new GuildLogs(this.guildId);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected resolveCaseId(caseOrCaseId: Case | number): number {
|
|
||||||
return caseOrCaseId instanceof Case ? caseOrCaseId.id : caseOrCaseId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new case and, depending on config, posts it in the case log channel
|
|
||||||
*/
|
|
||||||
public async createCase(args: CaseArgs): Promise<Case> {
|
|
||||||
const user = await this.resolveUser(args.userId);
|
|
||||||
const userName = `${user.username}#${user.discriminator}`;
|
|
||||||
|
|
||||||
const mod = await this.resolveUser(args.modId);
|
|
||||||
const modName = `${mod.username}#${mod.discriminator}`;
|
|
||||||
|
|
||||||
let ppName = null;
|
|
||||||
if (args.ppId) {
|
|
||||||
const pp = await this.resolveUser(args.ppId);
|
|
||||||
ppName = `${pp.username}#${pp.discriminator}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.auditLogId) {
|
|
||||||
const existingAuditLogCase = await this.cases.findByAuditLogId(args.auditLogId);
|
|
||||||
if (existingAuditLogCase) {
|
|
||||||
delete args.auditLogId;
|
|
||||||
logger.warn(`Duplicate audit log ID for mod case: ${args.auditLogId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdCase = await this.cases.create({
|
|
||||||
type: args.type,
|
|
||||||
user_id: args.userId,
|
|
||||||
user_name: userName,
|
|
||||||
mod_id: args.modId,
|
|
||||||
mod_name: modName,
|
|
||||||
audit_log_id: args.auditLogId,
|
|
||||||
pp_id: args.ppId,
|
|
||||||
pp_name: ppName,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (args.reason || (args.noteDetails && args.noteDetails.length)) {
|
|
||||||
await this.createCaseNote({
|
|
||||||
caseId: createdCase.id,
|
|
||||||
modId: args.modId,
|
|
||||||
body: args.reason || "",
|
|
||||||
automatic: args.automatic,
|
|
||||||
postInCaseLogOverride: false,
|
|
||||||
noteDetails: args.noteDetails,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.extraNotes) {
|
|
||||||
for (const extraNote of args.extraNotes) {
|
|
||||||
await this.createCaseNote({
|
|
||||||
caseId: createdCase.id,
|
|
||||||
modId: args.modId,
|
|
||||||
body: extraNote,
|
|
||||||
automatic: args.automatic,
|
|
||||||
postInCaseLogOverride: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = this.getConfig();
|
|
||||||
|
|
||||||
if (
|
|
||||||
config.case_log_channel &&
|
|
||||||
(!args.automatic || config.log_automatic_actions) &&
|
|
||||||
args.postInCaseLogOverride !== false
|
|
||||||
) {
|
|
||||||
await this.postCaseToCaseLogChannel(createdCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
return createdCase;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a case note to an existing case and, depending on config, posts the updated case in the case log channel
|
|
||||||
*/
|
|
||||||
public async createCaseNote(args: CaseNoteArgs): Promise<void> {
|
|
||||||
const theCase = await this.cases.find(this.resolveCaseId(args.caseId));
|
|
||||||
if (!theCase) {
|
|
||||||
this.throwRecoverablePluginError(ERRORS.UNKNOWN_NOTE_CASE);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mod = await this.resolveUser(args.modId);
|
|
||||||
const modName = `${mod.username}#${mod.discriminator}`;
|
|
||||||
|
|
||||||
let body = args.body;
|
|
||||||
|
|
||||||
// Add note details to the beginning of the note
|
|
||||||
if (args.noteDetails && args.noteDetails.length) {
|
|
||||||
body = args.noteDetails.map(d => `__[${d}]__`).join(" ") + " " + body;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.cases.createNote(theCase.id, {
|
|
||||||
mod_id: mod.id,
|
|
||||||
mod_name: modName,
|
|
||||||
body: body || "",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (theCase.mod_id == null) {
|
|
||||||
// If the case has no moderator information, assume the first one to add a note to it did the action
|
|
||||||
await this.cases.update(theCase.id, {
|
|
||||||
mod_id: mod.id,
|
|
||||||
mod_name: modName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const archiveLinkMatch = body && body.match(/(?<=\/archives\/)[a-zA-Z0-9\-]+/g);
|
|
||||||
if (archiveLinkMatch) {
|
|
||||||
for (const archiveId of archiveLinkMatch) {
|
|
||||||
this.archives.makePermanent(archiveId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((!args.automatic || this.getConfig().log_automatic_actions) && args.postInCaseLogOverride !== false) {
|
|
||||||
await this.postCaseToCaseLogChannel(theCase.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a Discord embed for the specified case
|
|
||||||
*/
|
|
||||||
public async getCaseEmbed(caseOrCaseId: Case | number): Promise<MessageContent> {
|
|
||||||
const theCase = await this.cases.with("notes").find(this.resolveCaseId(caseOrCaseId));
|
|
||||||
if (!theCase) return null;
|
|
||||||
|
|
||||||
const createdAt = moment(theCase.created_at);
|
|
||||||
const actionTypeStr = CaseTypes[theCase.type].toUpperCase();
|
|
||||||
|
|
||||||
const embed: any = {
|
|
||||||
title: `${actionTypeStr} - Case #${theCase.case_number}`,
|
|
||||||
footer: {
|
|
||||||
text: `Case created at ${createdAt.format("YYYY-MM-DD [at] HH:mm")}`,
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: "User",
|
|
||||||
value: `${theCase.user_name}\n<@!${theCase.user_id}>`,
|
|
||||||
inline: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Moderator",
|
|
||||||
value: `${theCase.mod_name}\n<@!${theCase.mod_id}>`,
|
|
||||||
inline: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (theCase.pp_id) {
|
|
||||||
embed.fields[1].value += `\np.p. ${theCase.pp_name}\n<@!${theCase.pp_id}>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (theCase.is_hidden) {
|
|
||||||
embed.title += " (hidden)";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CaseTypeColors[theCase.type]) {
|
|
||||||
embed.color = CaseTypeColors[theCase.type];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (theCase.notes.length) {
|
|
||||||
theCase.notes.forEach((note: any) => {
|
|
||||||
const noteDate = moment(note.created_at);
|
|
||||||
embed.fields.push({
|
|
||||||
name: `${note.mod_name} at ${noteDate.format("YYYY-MM-DD [at] HH:mm")}:`,
|
|
||||||
value: note.body,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
embed.fields.push({
|
|
||||||
name: "!!! THIS CASE HAS NO NOTES !!!",
|
|
||||||
value: "\u200B",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { embed };
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getCaseTypeAmountForUserId(userID: string, type: CaseTypes): Promise<number> {
|
|
||||||
const cases = (await this.cases.getByUserId(userID)).filter(c => !c.is_hidden);
|
|
||||||
let typeAmount = 0;
|
|
||||||
|
|
||||||
if (cases.length > 0) {
|
|
||||||
cases.forEach(singleCase => {
|
|
||||||
if (singleCase.type === type.valueOf()) {
|
|
||||||
typeAmount++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return typeAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A helper for posting to the case log channel.
|
|
||||||
* Returns silently if the case log channel isn't specified or is invalid.
|
|
||||||
*/
|
|
||||||
public async postToCaseLogChannel(content: MessageContent, file: MessageFile = null): Promise<Message> {
|
|
||||||
const caseLogChannelId = this.getConfig().case_log_channel;
|
|
||||||
if (!caseLogChannelId) return;
|
|
||||||
|
|
||||||
const caseLogChannel = this.guild.channels.get(caseLogChannelId);
|
|
||||||
if (!caseLogChannel || !(caseLogChannel instanceof TextChannel)) return;
|
|
||||||
|
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
result = await caseLogChannel.createMessage(content, file);
|
|
||||||
} catch (e) {
|
|
||||||
if (isDiscordRESTError(e) && (e.code === 50013 || e.code === 50001)) {
|
|
||||||
logger.warn(
|
|
||||||
`Missing permissions to post mod cases in <#${caseLogChannel.id}> in guild ${this.guild.name} (${this.guild.id})`,
|
|
||||||
);
|
|
||||||
this.logs.log(LogType.BOT_ALERT, {
|
|
||||||
body: `Missing permissions to post mod cases in <#${caseLogChannel.id}>`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A helper to post a case embed to the case log channel
|
|
||||||
*/
|
|
||||||
public async postCaseToCaseLogChannel(caseOrCaseId: Case | number): Promise<Message> {
|
|
||||||
const theCase = await this.cases.find(this.resolveCaseId(caseOrCaseId));
|
|
||||||
if (!theCase) return;
|
|
||||||
|
|
||||||
const caseEmbed = await this.getCaseEmbed(caseOrCaseId);
|
|
||||||
if (!caseEmbed) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
return this.postToCaseLogChannel(caseEmbed);
|
|
||||||
} catch (e) {
|
|
||||||
this.logs.log(LogType.BOT_ALERT, {
|
|
||||||
body: `Failed to post case #${theCase.case_number} to the case log channel`,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,297 +0,0 @@
|
||||||
import { IPluginOptions, logger } from "knub";
|
|
||||||
import { Invite, Embed } from "eris";
|
|
||||||
import escapeStringRegexp from "escape-string-regexp";
|
|
||||||
import { GuildLogs } from "../data/GuildLogs";
|
|
||||||
import { LogType } from "../data/LogType";
|
|
||||||
import {
|
|
||||||
deactivateMentions,
|
|
||||||
disableCodeBlocks,
|
|
||||||
getInviteCodesInString,
|
|
||||||
getUrlsInString,
|
|
||||||
stripObjectToScalars,
|
|
||||||
tNullable,
|
|
||||||
} from "../utils";
|
|
||||||
import { ZalgoRegex } from "../data/Zalgo";
|
|
||||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
|
||||||
import { SavedMessage } from "../data/entities/SavedMessage";
|
|
||||||
import { trimPluginDescription, ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import cloneDeep from "lodash.clonedeep";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
import { TSafeRegex } from "../validatorUtils";
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
filter_zalgo: t.boolean,
|
|
||||||
filter_invites: t.boolean,
|
|
||||||
invite_guild_whitelist: tNullable(t.array(t.string)),
|
|
||||||
invite_guild_blacklist: tNullable(t.array(t.string)),
|
|
||||||
invite_code_whitelist: tNullable(t.array(t.string)),
|
|
||||||
invite_code_blacklist: tNullable(t.array(t.string)),
|
|
||||||
allow_group_dm_invites: t.boolean,
|
|
||||||
filter_domains: t.boolean,
|
|
||||||
domain_whitelist: tNullable(t.array(t.string)),
|
|
||||||
domain_blacklist: tNullable(t.array(t.string)),
|
|
||||||
blocked_tokens: tNullable(t.array(t.string)),
|
|
||||||
blocked_words: tNullable(t.array(t.string)),
|
|
||||||
blocked_regex: tNullable(t.array(TSafeRegex)),
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
export class CensorPlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "censor";
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
public static pluginInfo = {
|
|
||||||
prettyName: "Censor",
|
|
||||||
description: trimPluginDescription(`
|
|
||||||
Censor words, tokens, links, regex, etc.
|
|
||||||
For more advanced filtering, check out the Automod plugin!
|
|
||||||
`),
|
|
||||||
};
|
|
||||||
|
|
||||||
protected serverLogs: GuildLogs;
|
|
||||||
protected savedMessages: GuildSavedMessages;
|
|
||||||
|
|
||||||
private onMessageCreateFn;
|
|
||||||
private onMessageUpdateFn;
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
filter_zalgo: false,
|
|
||||||
filter_invites: false,
|
|
||||||
invite_guild_whitelist: null,
|
|
||||||
invite_guild_blacklist: null,
|
|
||||||
invite_code_whitelist: null,
|
|
||||||
invite_code_blacklist: null,
|
|
||||||
allow_group_dm_invites: false,
|
|
||||||
|
|
||||||
filter_domains: false,
|
|
||||||
domain_whitelist: null,
|
|
||||||
domain_blacklist: null,
|
|
||||||
|
|
||||||
blocked_tokens: null,
|
|
||||||
blocked_words: null,
|
|
||||||
blocked_regex: null,
|
|
||||||
},
|
|
||||||
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
level: ">=50",
|
|
||||||
config: {
|
|
||||||
filter_zalgo: false,
|
|
||||||
filter_invites: false,
|
|
||||||
filter_domains: false,
|
|
||||||
blocked_tokens: null,
|
|
||||||
blocked_words: null,
|
|
||||||
blocked_regex: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoad() {
|
|
||||||
this.serverLogs = new GuildLogs(this.guildId);
|
|
||||||
this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId);
|
|
||||||
|
|
||||||
this.onMessageCreateFn = this.onMessageCreate.bind(this);
|
|
||||||
this.onMessageUpdateFn = this.onMessageUpdate.bind(this);
|
|
||||||
this.savedMessages.events.on("create", this.onMessageCreateFn);
|
|
||||||
this.savedMessages.events.on("update", this.onMessageUpdateFn);
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnload() {
|
|
||||||
this.savedMessages.events.off("create", this.onMessageCreateFn);
|
|
||||||
this.savedMessages.events.off("update", this.onMessageUpdateFn);
|
|
||||||
}
|
|
||||||
|
|
||||||
async censorMessage(savedMessage: SavedMessage, reason: string) {
|
|
||||||
this.serverLogs.ignoreLog(LogType.MESSAGE_DELETE, savedMessage.id);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.bot.deleteMessage(savedMessage.channel_id, savedMessage.id, "Censored");
|
|
||||||
} catch (e) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await this.resolveUser(savedMessage.user_id);
|
|
||||||
const channel = this.guild.channels.get(savedMessage.channel_id);
|
|
||||||
|
|
||||||
this.serverLogs.log(LogType.CENSOR, {
|
|
||||||
user: stripObjectToScalars(user),
|
|
||||||
channel: stripObjectToScalars(channel),
|
|
||||||
reason,
|
|
||||||
message: savedMessage,
|
|
||||||
messageText: disableCodeBlocks(deactivateMentions(savedMessage.data.content)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies word censor filters to the message, if any apply.
|
|
||||||
* @return {boolean} Indicates whether the message was removed
|
|
||||||
*/
|
|
||||||
async applyFiltersToMsg(savedMessage: SavedMessage): Promise<boolean> {
|
|
||||||
const config = this.getConfigForMemberIdAndChannelId(savedMessage.user_id, savedMessage.channel_id);
|
|
||||||
|
|
||||||
let messageContent = savedMessage.data.content || "";
|
|
||||||
if (savedMessage.data.attachments) messageContent += " " + JSON.stringify(savedMessage.data.attachments);
|
|
||||||
if (savedMessage.data.embeds) {
|
|
||||||
const embeds = (savedMessage.data.embeds as Embed[]).map(e => cloneDeep(e));
|
|
||||||
for (const embed of embeds) {
|
|
||||||
if (embed.type === "video") {
|
|
||||||
// Ignore video descriptions as they're not actually shown on the embed
|
|
||||||
delete embed.description;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
messageContent += " " + JSON.stringify(embeds);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter zalgo
|
|
||||||
const filterZalgo = config.filter_zalgo;
|
|
||||||
if (filterZalgo) {
|
|
||||||
const result = ZalgoRegex.exec(messageContent);
|
|
||||||
if (result) {
|
|
||||||
this.censorMessage(savedMessage, "zalgo detected");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter invites
|
|
||||||
const filterInvites = config.filter_invites;
|
|
||||||
if (filterInvites) {
|
|
||||||
const inviteGuildWhitelist = config.invite_guild_whitelist;
|
|
||||||
const inviteGuildBlacklist = config.invite_guild_blacklist;
|
|
||||||
const inviteCodeWhitelist = config.invite_code_whitelist;
|
|
||||||
const inviteCodeBlacklist = config.invite_code_blacklist;
|
|
||||||
const allowGroupDMInvites = config.allow_group_dm_invites;
|
|
||||||
|
|
||||||
const inviteCodes = getInviteCodesInString(messageContent);
|
|
||||||
|
|
||||||
const invites: Array<Invite | null> = await Promise.all(inviteCodes.map(code => this.resolveInvite(code)));
|
|
||||||
|
|
||||||
for (const invite of invites) {
|
|
||||||
// Always filter unknown invites if invite filtering is enabled
|
|
||||||
if (invite == null) {
|
|
||||||
this.censorMessage(savedMessage, `unknown invite not found in whitelist`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!invite.guild && !allowGroupDMInvites) {
|
|
||||||
this.censorMessage(savedMessage, `group dm invites are not allowed`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inviteGuildWhitelist && !inviteGuildWhitelist.includes(invite.guild.id)) {
|
|
||||||
this.censorMessage(
|
|
||||||
savedMessage,
|
|
||||||
`invite guild (**${invite.guild.name}** \`${invite.guild.id}\`) not found in whitelist`,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inviteGuildBlacklist && inviteGuildBlacklist.includes(invite.guild.id)) {
|
|
||||||
this.censorMessage(
|
|
||||||
savedMessage,
|
|
||||||
`invite guild (**${invite.guild.name}** \`${invite.guild.id}\`) found in blacklist`,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inviteCodeWhitelist && !inviteCodeWhitelist.includes(invite.code)) {
|
|
||||||
this.censorMessage(savedMessage, `invite code (\`${invite.code}\`) not found in whitelist`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inviteCodeBlacklist && inviteCodeBlacklist.includes(invite.code)) {
|
|
||||||
this.censorMessage(savedMessage, `invite code (\`${invite.code}\`) found in blacklist`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter domains
|
|
||||||
const filterDomains = config.filter_domains;
|
|
||||||
if (filterDomains) {
|
|
||||||
const domainWhitelist = config.domain_whitelist;
|
|
||||||
const domainBlacklist = config.domain_blacklist;
|
|
||||||
|
|
||||||
const urls = getUrlsInString(messageContent);
|
|
||||||
for (const thisUrl of urls) {
|
|
||||||
if (domainWhitelist && !domainWhitelist.includes(thisUrl.hostname)) {
|
|
||||||
this.censorMessage(savedMessage, `domain (\`${thisUrl.hostname}\`) not found in whitelist`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (domainBlacklist && domainBlacklist.includes(thisUrl.hostname)) {
|
|
||||||
this.censorMessage(savedMessage, `domain (\`${thisUrl.hostname}\`) found in blacklist`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter tokens
|
|
||||||
const blockedTokens = config.blocked_tokens || [];
|
|
||||||
for (const token of blockedTokens) {
|
|
||||||
if (messageContent.toLowerCase().includes(token.toLowerCase())) {
|
|
||||||
this.censorMessage(savedMessage, `blocked token (\`${token}\`) found`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter words
|
|
||||||
const blockedWords = config.blocked_words || [];
|
|
||||||
for (const word of blockedWords) {
|
|
||||||
const regex = new RegExp(`\\b${escapeStringRegexp(word)}\\b`, "i");
|
|
||||||
if (regex.test(messageContent)) {
|
|
||||||
this.censorMessage(savedMessage, `blocked word (\`${word}\`) found`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter regex
|
|
||||||
const blockedRegex: RegExp[] = config.blocked_regex || [];
|
|
||||||
for (const [i, regex] of blockedRegex.entries()) {
|
|
||||||
if (typeof regex.test !== "function") {
|
|
||||||
logger.info(
|
|
||||||
`[DEBUG] Regex <${regex}> was not a regex; index ${i} of censor.blocked_regex for guild ${this.guild.name} (${this.guild.id})`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// We're testing both the original content and content + attachments/embeds here so regexes that use ^ and $ still match the regular content properly
|
|
||||||
if (regex.test(savedMessage.data.content) || regex.test(messageContent)) {
|
|
||||||
this.censorMessage(savedMessage, `blocked regex (\`${regex.source}\`) found`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessageCreate(savedMessage: SavedMessage) {
|
|
||||||
if (savedMessage.is_bot) return;
|
|
||||||
const lock = await this.locks.acquire(`message-${savedMessage.id}`);
|
|
||||||
|
|
||||||
const wasDeleted = await this.applyFiltersToMsg(savedMessage);
|
|
||||||
|
|
||||||
if (wasDeleted) {
|
|
||||||
lock.interrupt();
|
|
||||||
} else {
|
|
||||||
lock.unlock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessageUpdate(savedMessage: SavedMessage) {
|
|
||||||
if (savedMessage.is_bot) return;
|
|
||||||
const lock = await this.locks.acquire(`message-${savedMessage.id}`);
|
|
||||||
|
|
||||||
const wasDeleted = await this.applyFiltersToMsg(savedMessage);
|
|
||||||
|
|
||||||
if (wasDeleted) {
|
|
||||||
lock.interrupt();
|
|
||||||
} else {
|
|
||||||
lock.unlock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,152 +0,0 @@
|
||||||
import { decorators as d, ICommandContext, logger } from "knub";
|
|
||||||
import { GlobalZeppelinPlugin } from "./GlobalZeppelinPlugin";
|
|
||||||
import { Attachment, GuildChannel, Message, TextChannel } from "eris";
|
|
||||||
import { confirm, downloadFile, errorMessage, noop, SECONDS, trimLines } from "../utils";
|
|
||||||
import { ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import moment from "moment-timezone";
|
|
||||||
import https from "https";
|
|
||||||
import fs from "fs";
|
|
||||||
const fsp = fs.promises;
|
|
||||||
|
|
||||||
const MAX_ARCHIVED_MESSAGES = 5000;
|
|
||||||
const MAX_MESSAGES_PER_FETCH = 100;
|
|
||||||
const PROGRESS_UPDATE_INTERVAL = 5 * SECONDS;
|
|
||||||
const MAX_ATTACHMENT_REHOST_SIZE = 1024 * 1024 * 8;
|
|
||||||
|
|
||||||
export class ChannelArchiverPlugin extends ZeppelinPluginClass {
|
|
||||||
public static pluginName = "channel_archiver";
|
|
||||||
public static showInDocs = false;
|
|
||||||
|
|
||||||
protected isOwner(userId) {
|
|
||||||
const owners = this.knub.getGlobalConfig().owners || [];
|
|
||||||
return owners.includes(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async rehostAttachment(attachment: Attachment, targetChannel: TextChannel): Promise<string> {
|
|
||||||
if (attachment.size > MAX_ATTACHMENT_REHOST_SIZE) {
|
|
||||||
return "Attachment too big to rehost";
|
|
||||||
}
|
|
||||||
|
|
||||||
let downloaded;
|
|
||||||
try {
|
|
||||||
downloaded = await downloadFile(attachment.url, 3);
|
|
||||||
} catch (e) {
|
|
||||||
return "Failed to download attachment after 3 tries";
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rehostMessage = await targetChannel.createMessage(`Rehost of attachment ${attachment.id}`, {
|
|
||||||
name: attachment.filename,
|
|
||||||
file: await fsp.readFile(downloaded.path),
|
|
||||||
});
|
|
||||||
return rehostMessage.attachments[0].url;
|
|
||||||
} catch (e) {
|
|
||||||
return "Failed to rehost attachment";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("archive_channel", "<channel:textChannel>", {
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
name: "attachment-channel",
|
|
||||||
type: "textChannel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "messages",
|
|
||||||
type: "number",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
preFilters: [
|
|
||||||
(command, context: ICommandContext) => {
|
|
||||||
return (context.plugin as ChannelArchiverPlugin).isOwner(context.message.author.id);
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
protected async archiveCmd(
|
|
||||||
msg: Message,
|
|
||||||
args: { channel: TextChannel; "attachment-channel"?: TextChannel; messages?: number },
|
|
||||||
) {
|
|
||||||
if (!this.isOwner(msg.author.id)) return;
|
|
||||||
|
|
||||||
if (!args["attachment-channel"]) {
|
|
||||||
const confirmed = await confirm(
|
|
||||||
this.bot,
|
|
||||||
msg.channel,
|
|
||||||
msg.author.id,
|
|
||||||
"No `-attachment-channel` specified. Continue? Attachments will not be available in the log if their message is deleted.",
|
|
||||||
);
|
|
||||||
if (!confirmed) {
|
|
||||||
msg.channel.createMessage(errorMessage("Canceled"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxMessagesToArchive = args.messages ? Math.min(args.messages, MAX_ARCHIVED_MESSAGES) : MAX_ARCHIVED_MESSAGES;
|
|
||||||
if (maxMessagesToArchive <= 0) return;
|
|
||||||
|
|
||||||
const archiveLines = [];
|
|
||||||
let archivedMessages = 0;
|
|
||||||
let previousId;
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
|
||||||
const progressMsg = await msg.channel.createMessage("Creating archive...");
|
|
||||||
const progressUpdateInterval = setInterval(() => {
|
|
||||||
const secondsSinceStart = Math.round((Date.now() - startTime) / 1000);
|
|
||||||
progressMsg
|
|
||||||
.edit(`Creating archive...\n**Status:** ${archivedMessages} messages archived in ${secondsSinceStart} seconds`)
|
|
||||||
.catch(() => clearInterval(progressUpdateInterval));
|
|
||||||
}, PROGRESS_UPDATE_INTERVAL);
|
|
||||||
|
|
||||||
while (archivedMessages < maxMessagesToArchive) {
|
|
||||||
const messagesToFetch = Math.min(MAX_MESSAGES_PER_FETCH, maxMessagesToArchive - archivedMessages);
|
|
||||||
const messages = await args.channel.getMessages(messagesToFetch, previousId);
|
|
||||||
if (messages.length === 0) break;
|
|
||||||
|
|
||||||
for (const message of messages) {
|
|
||||||
const ts = moment.utc(message.timestamp).format("YYYY-MM-DD HH:mm:ss");
|
|
||||||
let content = `[${ts}] [${message.author.id}] [${message.author.username}#${
|
|
||||||
message.author.discriminator
|
|
||||||
}]: ${message.content || "<no text content>"}`;
|
|
||||||
|
|
||||||
if (message.attachments.length) {
|
|
||||||
if (args["attachment-channel"]) {
|
|
||||||
const rehostedAttachmentUrl = await this.rehostAttachment(
|
|
||||||
message.attachments[0],
|
|
||||||
args["attachment-channel"],
|
|
||||||
);
|
|
||||||
content += `\n-- Attachment: ${rehostedAttachmentUrl}`;
|
|
||||||
} else {
|
|
||||||
content += `\n-- Attachment: ${message.attachments[0].url}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.reactions && Object.keys(message.reactions).length > 0) {
|
|
||||||
const reactionCounts = [];
|
|
||||||
for (const [emoji, info] of Object.entries(message.reactions)) {
|
|
||||||
reactionCounts.push(`${info.count}x ${emoji}`);
|
|
||||||
}
|
|
||||||
content += `\n-- Reactions: ${reactionCounts.join(", ")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
archiveLines.push(content);
|
|
||||||
previousId = message.id;
|
|
||||||
archivedMessages++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearInterval(progressUpdateInterval);
|
|
||||||
|
|
||||||
archiveLines.reverse();
|
|
||||||
|
|
||||||
const nowTs = moment().format("YYYY-MM-DD HH:mm:ss");
|
|
||||||
|
|
||||||
let result = `Archived ${archiveLines.length} messages from #${args.channel.name} at ${nowTs}`;
|
|
||||||
result += `\n\n${archiveLines.join("\n")}\n`;
|
|
||||||
|
|
||||||
progressMsg.delete().catch(noop);
|
|
||||||
msg.channel.createMessage("Archive created!", {
|
|
||||||
file: Buffer.from(result),
|
|
||||||
name: `archive-${args.channel.name}-${moment().format("YYYY-MM-DD-HH-mm-ss")}.txt`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,119 +0,0 @@
|
||||||
import { decorators as d, IPluginOptions, logger } from "knub";
|
|
||||||
import { trimPluginDescription, ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import { Member, Channel, GuildChannel, PermissionOverwrite, Permission, Message, TextChannel } from "eris";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
import { tNullable } from "../utils";
|
|
||||||
|
|
||||||
// Permissions using these numbers: https://abal.moe/Eris/docs/reference (add all allowed/denied ones up)
|
|
||||||
const CompanionChannelOpts = t.type({
|
|
||||||
voice_channel_ids: t.array(t.string),
|
|
||||||
text_channel_ids: t.array(t.string),
|
|
||||||
permissions: t.number,
|
|
||||||
enabled: tNullable(t.boolean),
|
|
||||||
});
|
|
||||||
type TCompanionChannelOpts = t.TypeOf<typeof CompanionChannelOpts>;
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
entries: t.record(t.string, CompanionChannelOpts),
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
interface ICompanionChannelMap {
|
|
||||||
[channelId: string]: TCompanionChannelOpts;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultCompanionChannelOpts: Partial<TCompanionChannelOpts> = {
|
|
||||||
enabled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
export class CompanionChannelPlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "companion_channels";
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
public static pluginInfo = {
|
|
||||||
prettyName: "Companion channels",
|
|
||||||
description: trimPluginDescription(`
|
|
||||||
Set up 'companion channels' between text and voice channels.
|
|
||||||
Once set up, any time a user joins one of the specified voice channels,
|
|
||||||
they'll get channel permissions applied to them for the text channels.
|
|
||||||
`),
|
|
||||||
};
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
entries: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns an array of companion channel opts that match the given userId and voiceChannelId,
|
|
||||||
* with default companion channel opts applied as well
|
|
||||||
*/
|
|
||||||
protected getCompanionChannelOptsForVoiceChannelId(userId, voiceChannelId): TCompanionChannelOpts[] {
|
|
||||||
const config = this.getConfigForMemberIdAndChannelId(userId, voiceChannelId);
|
|
||||||
return Object.values(config.entries)
|
|
||||||
.filter(opts => opts.voice_channel_ids.includes(voiceChannelId))
|
|
||||||
.map(opts => Object.assign({}, defaultCompanionChannelOpts, opts));
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleCompanionPermissions(userId: string, voiceChannelId?: string, oldChannelId?: string) {
|
|
||||||
const permsToDelete: Set<string> = new Set(); // channelId[]
|
|
||||||
const oldPerms: Map<string, number> = new Map(); // channelId => permissions
|
|
||||||
const permsToSet: Map<string, number> = new Map(); // channelId => permissions
|
|
||||||
|
|
||||||
const oldChannelOptsArr: TCompanionChannelOpts[] = oldChannelId
|
|
||||||
? this.getCompanionChannelOptsForVoiceChannelId(userId, oldChannelId)
|
|
||||||
: [];
|
|
||||||
const newChannelOptsArr: TCompanionChannelOpts[] = voiceChannelId
|
|
||||||
? this.getCompanionChannelOptsForVoiceChannelId(userId, voiceChannelId)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
for (const oldChannelOpts of oldChannelOptsArr) {
|
|
||||||
for (const channelId of oldChannelOpts.text_channel_ids) {
|
|
||||||
oldPerms.set(channelId, oldChannelOpts.permissions);
|
|
||||||
permsToDelete.add(channelId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const newChannelOpts of newChannelOptsArr) {
|
|
||||||
for (const channelId of newChannelOpts.text_channel_ids) {
|
|
||||||
if (oldPerms.get(channelId) !== newChannelOpts.permissions) {
|
|
||||||
// Update text channel perms if the channel we transitioned from didn't already have the same text channel perms
|
|
||||||
permsToSet.set(channelId, newChannelOpts.permissions);
|
|
||||||
}
|
|
||||||
if (permsToDelete.has(channelId)) {
|
|
||||||
permsToDelete.delete(channelId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const channelId of permsToDelete) {
|
|
||||||
const channel = this.guild.channels.get(channelId);
|
|
||||||
if (!channel || !(channel instanceof TextChannel)) continue;
|
|
||||||
channel.deletePermission(userId, `Companion Channel for ${oldChannelId} | User Left`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [channelId, permissions] of permsToSet) {
|
|
||||||
const channel = this.guild.channels.get(channelId);
|
|
||||||
if (!channel || !(channel instanceof TextChannel)) continue;
|
|
||||||
channel.editPermission(userId, permissions, 0, "member", `Companion Channel for ${voiceChannelId} | User Joined`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("voiceChannelJoin")
|
|
||||||
onVoiceChannelJoin(member: Member, voiceChannel: Channel) {
|
|
||||||
this.handleCompanionPermissions(member.id, voiceChannel.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("voiceChannelSwitch")
|
|
||||||
onVoiceChannelSwitch(member: Member, newChannel: Channel, oldChannel: Channel) {
|
|
||||||
this.handleCompanionPermissions(member.id, newChannel.id, oldChannel.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("voiceChannelLeave")
|
|
||||||
onVoiceChannelLeave(member: Member, voiceChannel: Channel) {
|
|
||||||
this.handleCompanionPermissions(member.id, null, voiceChannel.id);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,201 +0,0 @@
|
||||||
import { ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import { IPluginOptions } from "knub";
|
|
||||||
import { Message, TextChannel, VoiceChannel } from "eris";
|
|
||||||
import { renderTemplate } from "../templateFormatter";
|
|
||||||
import { stripObjectToScalars } from "../utils";
|
|
||||||
import { CasesPlugin } from "./Cases";
|
|
||||||
import { CaseTypes } from "../data/CaseTypes";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
|
|
||||||
// Triggers
|
|
||||||
const CommandTrigger = t.type({
|
|
||||||
type: t.literal("command"),
|
|
||||||
name: t.string,
|
|
||||||
params: t.string,
|
|
||||||
can_use: t.boolean,
|
|
||||||
});
|
|
||||||
type TCommandTrigger = t.TypeOf<typeof CommandTrigger>;
|
|
||||||
|
|
||||||
const AnyTrigger = CommandTrigger; // TODO: Make into a union once we have more triggers
|
|
||||||
type TAnyTrigger = t.TypeOf<typeof AnyTrigger>;
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
const AddRoleAction = t.type({
|
|
||||||
type: t.literal("add_role"),
|
|
||||||
target: t.string,
|
|
||||||
role: t.union([t.string, t.array(t.string)]),
|
|
||||||
});
|
|
||||||
type TAddRoleAction = t.TypeOf<typeof AddRoleAction>;
|
|
||||||
|
|
||||||
const CreateCaseAction = t.type({
|
|
||||||
type: t.literal("create_case"),
|
|
||||||
case_type: t.string,
|
|
||||||
mod: t.string,
|
|
||||||
target: t.string,
|
|
||||||
reason: t.string,
|
|
||||||
});
|
|
||||||
type TCreateCaseAction = t.TypeOf<typeof CreateCaseAction>;
|
|
||||||
|
|
||||||
const MoveToVoiceChannelAction = t.type({
|
|
||||||
type: t.literal("move_to_vc"),
|
|
||||||
target: t.string,
|
|
||||||
channel: t.string,
|
|
||||||
});
|
|
||||||
type TMoveToVoiceChannelAction = t.TypeOf<typeof MoveToVoiceChannelAction>;
|
|
||||||
|
|
||||||
const MessageAction = t.type({
|
|
||||||
type: t.literal("message"),
|
|
||||||
channel: t.string,
|
|
||||||
content: t.string,
|
|
||||||
});
|
|
||||||
type TMessageAction = t.TypeOf<typeof MessageAction>;
|
|
||||||
|
|
||||||
const AnyAction = t.union([AddRoleAction, CreateCaseAction, MoveToVoiceChannelAction, MessageAction]);
|
|
||||||
type TAnyAction = t.TypeOf<typeof AnyAction>;
|
|
||||||
|
|
||||||
// Full config schema
|
|
||||||
const CustomEvent = t.type({
|
|
||||||
name: t.string,
|
|
||||||
trigger: AnyTrigger,
|
|
||||||
actions: t.array(AnyAction),
|
|
||||||
});
|
|
||||||
type TCustomEvent = t.TypeOf<typeof CustomEvent>;
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
events: t.record(t.string, CustomEvent),
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
class ActionError extends Error {}
|
|
||||||
|
|
||||||
export class CustomEventsPlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "custom_events";
|
|
||||||
public static showInDocs = false;
|
|
||||||
public static dependencies = ["cases"];
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
private clearTriggers: () => void;
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
events: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoad() {
|
|
||||||
for (const [key, event] of Object.entries(this.getConfig().events)) {
|
|
||||||
if (event.trigger.type === "command") {
|
|
||||||
this.addCommand(
|
|
||||||
event.trigger.name,
|
|
||||||
event.trigger.params,
|
|
||||||
(msg, args) => {
|
|
||||||
const strippedMsg = stripObjectToScalars(msg, ["channel", "author"]);
|
|
||||||
this.runEvent(event, { msg, args }, { args, msg: strippedMsg });
|
|
||||||
},
|
|
||||||
{
|
|
||||||
extra: {
|
|
||||||
requiredPermission: `events.${key}.trigger.can_use`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnload() {
|
|
||||||
// TODO: Run this.clearTriggers() once we actually have something there
|
|
||||||
}
|
|
||||||
|
|
||||||
async runEvent(event: TCustomEvent, eventData: any, values: any) {
|
|
||||||
try {
|
|
||||||
for (const action of event.actions) {
|
|
||||||
if (action.type === "add_role") {
|
|
||||||
await this.addRoleAction(action, values, event, eventData);
|
|
||||||
} else if (action.type === "create_case") {
|
|
||||||
await this.createCaseAction(action, values, event, eventData);
|
|
||||||
} else if (action.type === "move_to_vc") {
|
|
||||||
await this.moveToVoiceChannelAction(action, values, event, eventData);
|
|
||||||
} else if (action.type === "message") {
|
|
||||||
await this.messageAction(action, values);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ActionError) {
|
|
||||||
if (event.trigger.type === "command") {
|
|
||||||
this.sendErrorMessage((eventData.msg as Message).channel, e.message);
|
|
||||||
} else {
|
|
||||||
// TODO: Where to log action errors from other kinds of triggers?
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async addRoleAction(action: TAddRoleAction, values: any, event: TCustomEvent, eventData: any) {
|
|
||||||
const targetId = await renderTemplate(action.target, values, false);
|
|
||||||
const target = await this.getMember(targetId);
|
|
||||||
if (!target) throw new ActionError(`Unknown target member: ${targetId}`);
|
|
||||||
|
|
||||||
if (event.trigger.type === "command" && !this.canActOn((eventData.msg as Message).member, target)) {
|
|
||||||
throw new ActionError("Missing permissions");
|
|
||||||
}
|
|
||||||
|
|
||||||
const rolesToAdd = Array.isArray(action.role) ? action.role : [action.role];
|
|
||||||
await target.edit({
|
|
||||||
roles: Array.from(new Set([...target.roles, ...rolesToAdd])),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async createCaseAction(action: TCreateCaseAction, values: any, event: TCustomEvent, eventData: any) {
|
|
||||||
const modId = await renderTemplate(action.mod, values, false);
|
|
||||||
const targetId = await renderTemplate(action.target, values, false);
|
|
||||||
|
|
||||||
const reason = await renderTemplate(action.reason, values, false);
|
|
||||||
|
|
||||||
if (CaseTypes[action.case_type] == null) {
|
|
||||||
throw new ActionError(`Invalid case type: ${action.type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
|
|
||||||
await casesPlugin.createCase({
|
|
||||||
userId: targetId,
|
|
||||||
modId,
|
|
||||||
type: CaseTypes[action.case_type],
|
|
||||||
reason: `__[${event.name}]__ ${reason}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async moveToVoiceChannelAction(action: TMoveToVoiceChannelAction, values: any, event: TCustomEvent, eventData: any) {
|
|
||||||
const targetId = await renderTemplate(action.target, values, false);
|
|
||||||
const target = await this.getMember(targetId);
|
|
||||||
if (!target) throw new ActionError("Unknown target member");
|
|
||||||
|
|
||||||
if (event.trigger.type === "command" && !this.canActOn((eventData.msg as Message).member, target)) {
|
|
||||||
throw new ActionError("Missing permissions");
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetChannelId = await renderTemplate(action.channel, values, false);
|
|
||||||
const targetChannel = this.guild.channels.get(targetChannelId);
|
|
||||||
if (!targetChannel) throw new ActionError("Unknown target channel");
|
|
||||||
if (!(targetChannel instanceof VoiceChannel)) throw new ActionError("Target channel is not a voice channel");
|
|
||||||
|
|
||||||
if (!target.voiceState.channelID) return;
|
|
||||||
await target.edit({
|
|
||||||
channelID: targetChannel.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async messageAction(action: TMessageAction, values: any) {
|
|
||||||
const targetChannelId = await renderTemplate(action.channel, values, false);
|
|
||||||
const targetChannel = this.guild.channels.get(targetChannelId);
|
|
||||||
if (!targetChannel) throw new ActionError("Unknown target channel");
|
|
||||||
if (!(targetChannel instanceof TextChannel)) throw new ActionError("Target channel is not a text channel");
|
|
||||||
|
|
||||||
await targetChannel.createMessage({ content: action.content });
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,119 +0,0 @@
|
||||||
import { GlobalPlugin, IBasePluginConfig, IPluginOptions, logger, configUtils } from "knub";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
import { pipe } from "fp-ts/lib/pipeable";
|
|
||||||
import { fold } from "fp-ts/lib/Either";
|
|
||||||
import { PathReporter } from "io-ts/lib/PathReporter";
|
|
||||||
import { deepKeyIntersect, isSnowflake, isUnicodeEmoji, resolveMember, resolveUser, UnknownUser } from "../utils";
|
|
||||||
import { Member, User } from "eris";
|
|
||||||
import { performance } from "perf_hooks";
|
|
||||||
import { decodeAndValidateStrict, StrictValidationError } from "../validatorUtils";
|
|
||||||
import { TZeppelinKnub } from "../types";
|
|
||||||
|
|
||||||
const SLOW_RESOLVE_THRESHOLD = 1500;
|
|
||||||
|
|
||||||
export class GlobalZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends GlobalPlugin<TConfig> {
|
|
||||||
public static configSchema: t.TypeC<any>;
|
|
||||||
public static dependencies = [];
|
|
||||||
|
|
||||||
protected readonly knub: TZeppelinKnub;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Since we want to do type checking without creating instances of every plugin,
|
|
||||||
* we need a static version of getDefaultOptions(). This static version is then,
|
|
||||||
* by turn, called from getDefaultOptions() so everything still works as expected.
|
|
||||||
*/
|
|
||||||
public static getStaticDefaultOptions() {
|
|
||||||
// Implemented by plugin
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrapper to fetch the real default options from getStaticDefaultOptions()
|
|
||||||
*/
|
|
||||||
protected getDefaultOptions(): IPluginOptions<TConfig> {
|
|
||||||
return (this.constructor as typeof GlobalZeppelinPlugin).getStaticDefaultOptions() as IPluginOptions<TConfig>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merges the given options and default options and decodes them according to the config schema of the plugin (if any).
|
|
||||||
* Throws on any decoding/validation errors.
|
|
||||||
*
|
|
||||||
* Intended as an augmented, static replacement for Plugin.getMergedConfig() which is why this is also called from
|
|
||||||
* getMergedConfig().
|
|
||||||
*
|
|
||||||
* Like getStaticDefaultOptions(), we also want to use this function for type checking without creating an instance of
|
|
||||||
* the plugin, which is why this has to be a static function.
|
|
||||||
*/
|
|
||||||
protected static mergeAndDecodeStaticOptions(options: any): IPluginOptions {
|
|
||||||
const defaultOptions: any = this.getStaticDefaultOptions();
|
|
||||||
const mergedConfig = configUtils.mergeConfig({}, defaultOptions.config || {}, options.config || {});
|
|
||||||
const mergedOverrides = options.replaceDefaultOverrides
|
|
||||||
? options.overrides
|
|
||||||
: (defaultOptions.overrides || []).concat(options.overrides || []);
|
|
||||||
|
|
||||||
const decodedConfig = this.configSchema ? decodeAndValidateStrict(this.configSchema, mergedConfig) : mergedConfig;
|
|
||||||
if (decodedConfig instanceof StrictValidationError) {
|
|
||||||
throw decodedConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
const decodedOverrides = [];
|
|
||||||
for (const override of mergedOverrides) {
|
|
||||||
const overrideConfigMergedWithBaseConfig = configUtils.mergeConfig({}, mergedConfig, override.config);
|
|
||||||
const decodedOverrideConfig = this.configSchema
|
|
||||||
? decodeAndValidateStrict(this.configSchema, overrideConfigMergedWithBaseConfig)
|
|
||||||
: overrideConfigMergedWithBaseConfig;
|
|
||||||
if (decodedOverrideConfig instanceof StrictValidationError) {
|
|
||||||
throw decodedOverrideConfig;
|
|
||||||
}
|
|
||||||
decodedOverrides.push({ ...override, config: deepKeyIntersect(decodedOverrideConfig, override.config) });
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
config: decodedConfig,
|
|
||||||
overrides: decodedOverrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrapper that calls mergeAndValidateStaticOptions()
|
|
||||||
*/
|
|
||||||
protected getMergedOptions(): IPluginOptions<TConfig> {
|
|
||||||
if (!this.mergedPluginOptions) {
|
|
||||||
this.mergedPluginOptions = ((this
|
|
||||||
.constructor as unknown) as typeof GlobalZeppelinPlugin).mergeAndDecodeStaticOptions(this.pluginOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.mergedPluginOptions as IPluginOptions<TConfig>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run static type checks and other validations on the given options
|
|
||||||
*/
|
|
||||||
public static validateOptions(options: any): string[] | null {
|
|
||||||
// Validate config values
|
|
||||||
if (this.configSchema) {
|
|
||||||
try {
|
|
||||||
this.mergeAndDecodeStaticOptions(options);
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof StrictValidationError) {
|
|
||||||
return e.getErrors();
|
|
||||||
}
|
|
||||||
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No errors, return null
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async runLoad(): Promise<any> {
|
|
||||||
const mergedOptions = this.getMergedOptions(); // This implicitly also validates the config
|
|
||||||
return super.runLoad();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected isOwner(userId) {
|
|
||||||
const owners = this.knub.getGlobalConfig().owners || [];
|
|
||||||
return owners.includes(userId);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,52 +0,0 @@
|
||||||
import moment from "moment-timezone";
|
|
||||||
import { ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import { Configs } from "../data/Configs";
|
|
||||||
import { logger } from "knub";
|
|
||||||
import { GlobalZeppelinPlugin } from "./GlobalZeppelinPlugin";
|
|
||||||
import { DBDateFormat } from "../utils";
|
|
||||||
|
|
||||||
const CHECK_INTERVAL = 1000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Temporary solution to reloading guilds when their config changes
|
|
||||||
* And you know what they say about temporary solutions...
|
|
||||||
*/
|
|
||||||
export class GuildConfigReloader extends GlobalZeppelinPlugin {
|
|
||||||
public static pluginName = "guild_config_reloader";
|
|
||||||
|
|
||||||
protected guildConfigs: Configs;
|
|
||||||
private unloaded = false;
|
|
||||||
private highestConfigId;
|
|
||||||
private nextCheckTimeout;
|
|
||||||
|
|
||||||
async onLoad() {
|
|
||||||
this.guildConfigs = new Configs();
|
|
||||||
|
|
||||||
this.highestConfigId = await this.guildConfigs.getHighestId();
|
|
||||||
this.reloadChangedGuilds();
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnload() {
|
|
||||||
clearTimeout(this.nextCheckTimeout);
|
|
||||||
this.unloaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async reloadChangedGuilds() {
|
|
||||||
if (this.unloaded) return;
|
|
||||||
|
|
||||||
const changedConfigs = await this.guildConfigs.getActiveLargerThanId(this.highestConfigId);
|
|
||||||
for (const item of changedConfigs) {
|
|
||||||
if (!item.key.startsWith("guild-")) continue;
|
|
||||||
|
|
||||||
const guildId = item.key.slice("guild-".length);
|
|
||||||
logger.info(`Config changed, reloading guild ${guildId}`);
|
|
||||||
await this.knub.reloadGuild(guildId);
|
|
||||||
|
|
||||||
if (item.id > this.highestConfigId) {
|
|
||||||
this.highestConfigId = item.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.nextCheckTimeout = setTimeout(() => this.reloadChangedGuilds(), CHECK_INTERVAL);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
import { ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import { AllowedGuilds } from "../data/AllowedGuilds";
|
|
||||||
import { MINUTES } from "../utils";
|
|
||||||
|
|
||||||
export class GuildInfoSaverPlugin extends ZeppelinPluginClass {
|
|
||||||
public static pluginName = "guild_info_saver";
|
|
||||||
public static showInDocs = false;
|
|
||||||
protected allowedGuilds: AllowedGuilds;
|
|
||||||
private updateInterval;
|
|
||||||
|
|
||||||
onLoad() {
|
|
||||||
this.allowedGuilds = new AllowedGuilds();
|
|
||||||
|
|
||||||
this.updateGuildInfo();
|
|
||||||
this.updateInterval = setInterval(() => this.updateGuildInfo(), 60 * MINUTES);
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnload() {
|
|
||||||
clearInterval(this.updateInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected updateGuildInfo() {
|
|
||||||
this.allowedGuilds.updateInfo(this.guildId, this.guild.name, this.guild.iconURL, this.guild.ownerID);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,341 +0,0 @@
|
||||||
import { decorators as d, IPluginOptions, getInviteLink, logger } from "knub";
|
|
||||||
import { trimPluginDescription, ZeppelinPluginClass, CommandInfo } from "./ZeppelinPluginClass";
|
|
||||||
import humanizeDuration from "humanize-duration";
|
|
||||||
import { Message, Member, Guild, TextableChannel, VoiceChannel, Channel, User } from "eris";
|
|
||||||
import { GuildVCAlerts } from "../data/GuildVCAlerts";
|
|
||||||
import moment from "moment-timezone";
|
|
||||||
import { resolveMember, sorter, createChunkedMessage, MINUTES, SECONDS } from "../utils";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
can_where: t.boolean,
|
|
||||||
can_alert: t.boolean,
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
const ALERT_LOOP_TIME = 30 * SECONDS;
|
|
||||||
|
|
||||||
export class LocatePlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "locate_user";
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
public static pluginInfo = {
|
|
||||||
prettyName: "Locate user",
|
|
||||||
description: trimPluginDescription(`
|
|
||||||
This plugin allows users with access to the commands the following:
|
|
||||||
* Instantly receive an invite to the voice channel of a user
|
|
||||||
* Be notified as soon as a user switches or joins a voice channel
|
|
||||||
`),
|
|
||||||
};
|
|
||||||
|
|
||||||
private alerts: GuildVCAlerts;
|
|
||||||
private outdatedAlertsTimeout: NodeJS.Timeout;
|
|
||||||
private usersWithAlerts: string[] = [];
|
|
||||||
private unloaded = false;
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
can_where: false,
|
|
||||||
can_alert: false,
|
|
||||||
},
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
level: ">=50",
|
|
||||||
config: {
|
|
||||||
can_where: true,
|
|
||||||
can_alert: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoad() {
|
|
||||||
this.alerts = GuildVCAlerts.getGuildInstance(this.guildId);
|
|
||||||
this.outdatedAlertsLoop();
|
|
||||||
this.fillActiveAlertsList();
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnload() {
|
|
||||||
clearTimeout(this.outdatedAlertsTimeout);
|
|
||||||
this.unloaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async outdatedAlertsLoop() {
|
|
||||||
const outdatedAlerts = await this.alerts.getOutdatedAlerts();
|
|
||||||
|
|
||||||
for (const alert of outdatedAlerts) {
|
|
||||||
await this.alerts.delete(alert.id);
|
|
||||||
await this.removeUserIdFromActiveAlerts(alert.user_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.unloaded) {
|
|
||||||
this.outdatedAlertsTimeout = setTimeout(() => this.outdatedAlertsLoop(), ALERT_LOOP_TIME);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fillActiveAlertsList() {
|
|
||||||
const allAlerts = await this.alerts.getAllGuildAlerts();
|
|
||||||
|
|
||||||
allAlerts.forEach(alert => {
|
|
||||||
if (!this.usersWithAlerts.includes(alert.user_id)) {
|
|
||||||
this.usersWithAlerts.push(alert.user_id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("where", "<member:resolvedMember>", {
|
|
||||||
aliases: ["w"],
|
|
||||||
extra: {
|
|
||||||
info: <CommandInfo>{
|
|
||||||
description: "Posts an instant invite to the voice channel that `<member>` is in",
|
|
||||||
basicUsage: "!w 108552944961454080",
|
|
||||||
parameterDescriptions: {
|
|
||||||
member: "The member that we want to find",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@d.permission("can_where")
|
|
||||||
async whereCmd(msg: Message, args: { member: Member }) {
|
|
||||||
const member = await resolveMember(this.bot, this.guild, args.member.id);
|
|
||||||
sendWhere.call(this, this.guild, member, msg.channel, `${msg.member.mention} | `);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("follow", "<member:resolvedMember> [reminder:string$]", {
|
|
||||||
aliases: ["f", "vcalert", "vca"],
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
name: "duration",
|
|
||||||
shortcut: "d",
|
|
||||||
type: "delay",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "active",
|
|
||||||
shortcut: "a",
|
|
||||||
isSwitch: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
extra: {
|
|
||||||
info: <CommandInfo>{
|
|
||||||
description: "Sets up an alert that notifies you any time `<member>` switches or joins voice channels",
|
|
||||||
basicUsage: "!f 108552944961454080",
|
|
||||||
examples: trimPluginDescription(`
|
|
||||||
To get an alert for 1 hour:
|
|
||||||
\`!f 108552944961454080 -d 1h\`
|
|
||||||
|
|
||||||
To get an alert for 2 hours and 30 minutes with the reminder "Earrape":
|
|
||||||
\`!f 108552944961454080 -d 2h30m Earrape\`
|
|
||||||
*Note: The duration must be specified before the reminder, otherwise it will be part of it*
|
|
||||||
|
|
||||||
To get an alert for 3 days and be moved to the channel:
|
|
||||||
\`!f 108552944961454080 -d 3d -a\`
|
|
||||||
*Note: As with the duration, active must be specified before the rminder, otherwise it will be part of it*
|
|
||||||
`),
|
|
||||||
optionDescriptions: {
|
|
||||||
duration: "How long the alert shall be active. The alert will be automatically deleted after this time",
|
|
||||||
active: "A switch that, when true, will move you to the channel the user joined",
|
|
||||||
},
|
|
||||||
parameterDescriptions: {
|
|
||||||
member: "The server member we want to set as the alerts target",
|
|
||||||
reminder: "Any text that will be displayed every time the alert triggers",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@d.permission("can_alert")
|
|
||||||
async followCmd(msg: Message, args: { member: Member; reminder?: string; duration?: number; active?: boolean }) {
|
|
||||||
const time = args.duration || 10 * MINUTES;
|
|
||||||
const alertTime = moment().add(time, "millisecond");
|
|
||||||
const body = args.reminder || "None";
|
|
||||||
const active = args.active || false;
|
|
||||||
|
|
||||||
if (time < 30 * SECONDS) {
|
|
||||||
this.sendErrorMessage(msg.channel, "Sorry, but the minimum duration for an alert is 30 seconds!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.alerts.add(
|
|
||||||
msg.author.id,
|
|
||||||
args.member.id,
|
|
||||||
msg.channel.id,
|
|
||||||
alertTime.format("YYYY-MM-DD HH:mm:ss"),
|
|
||||||
body,
|
|
||||||
active,
|
|
||||||
);
|
|
||||||
if (!this.usersWithAlerts.includes(args.member.id)) {
|
|
||||||
this.usersWithAlerts.push(args.member.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (active) {
|
|
||||||
this.sendSuccessMessage(
|
|
||||||
msg.channel,
|
|
||||||
`Every time ${args.member.mention} joins or switches VC in the next ${humanizeDuration(
|
|
||||||
time,
|
|
||||||
)} i will notify and move you.\nPlease make sure to be in a voice channel, otherwise i cannot move you!`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.sendSuccessMessage(
|
|
||||||
msg.channel,
|
|
||||||
`Every time ${args.member.mention} joins or switches VC in the next ${humanizeDuration(
|
|
||||||
time,
|
|
||||||
)} i will notify you`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("follows", [], {
|
|
||||||
aliases: ["fs", "vcalerts", "vca"],
|
|
||||||
extra: {
|
|
||||||
info: <CommandInfo>{
|
|
||||||
description: "Displays all of your active alerts ordered by expiration time",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@d.permission("can_alert")
|
|
||||||
async listFollowCmd(msg: Message) {
|
|
||||||
const alerts = await this.alerts.getAlertsByRequestorId(msg.member.id);
|
|
||||||
if (alerts.length === 0) {
|
|
||||||
this.sendErrorMessage(msg.channel, "You have no active alerts!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
alerts.sort(sorter("expires_at"));
|
|
||||||
const longestNum = (alerts.length + 1).toString().length;
|
|
||||||
const lines = Array.from(alerts.entries()).map(([i, alert]) => {
|
|
||||||
const num = i + 1;
|
|
||||||
const paddedNum = num.toString().padStart(longestNum, " ");
|
|
||||||
return `\`${paddedNum}.\` \`${alert.expires_at}\` **Target:** <@!${alert.user_id}> **Reminder:** \`${
|
|
||||||
alert.body
|
|
||||||
}\` **Active:** ${alert.active.valueOf()}`;
|
|
||||||
});
|
|
||||||
await createChunkedMessage(msg.channel, lines.join("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("follows delete", "<num:number>", {
|
|
||||||
aliases: ["fs d", "vcalerts delete", "vcalerts d", "vca d"],
|
|
||||||
extra: {
|
|
||||||
info: <CommandInfo>{
|
|
||||||
description:
|
|
||||||
"Deletes the alert at the position <num>.\nThe value needed for <num> can be found using `!follows` (`!fs`)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@d.permission("can_alert")
|
|
||||||
async deleteFollowCmd(msg: Message, args: { num: number }) {
|
|
||||||
const alerts = await this.alerts.getAlertsByRequestorId(msg.member.id);
|
|
||||||
alerts.sort(sorter("expires_at"));
|
|
||||||
|
|
||||||
if (args.num > alerts.length || args.num <= 0) {
|
|
||||||
this.sendErrorMessage(msg.channel, "Unknown alert!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toDelete = alerts[args.num - 1];
|
|
||||||
await this.alerts.delete(toDelete.id);
|
|
||||||
|
|
||||||
this.sendSuccessMessage(msg.channel, "Alert deleted");
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("voiceChannelJoin")
|
|
||||||
async userJoinedVC(member: Member, channel: Channel) {
|
|
||||||
if (this.usersWithAlerts.includes(member.id)) {
|
|
||||||
this.sendAlerts(member.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("voiceChannelSwitch")
|
|
||||||
async userSwitchedVC(member: Member, newChannel: Channel, oldChannel: Channel) {
|
|
||||||
if (this.usersWithAlerts.includes(member.id)) {
|
|
||||||
this.sendAlerts(member.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("voiceChannelLeave")
|
|
||||||
async userLeftVC(member: Member, channel: Channel) {
|
|
||||||
const triggeredAlerts = await this.alerts.getAlertsByUserId(member.id);
|
|
||||||
const voiceChannel = channel as VoiceChannel;
|
|
||||||
|
|
||||||
triggeredAlerts.forEach(alert => {
|
|
||||||
const txtChannel = this.bot.getChannel(alert.channel_id) as TextableChannel;
|
|
||||||
txtChannel.createMessage(
|
|
||||||
`🔴 <@!${alert.requestor_id}> the user <@!${alert.user_id}> disconnected out of \`${voiceChannel.name}\``,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("guildBanAdd")
|
|
||||||
async onGuildBanAdd(_, user: User) {
|
|
||||||
const alerts = await this.alerts.getAlertsByUserId(user.id);
|
|
||||||
alerts.forEach(alert => {
|
|
||||||
this.alerts.delete(alert.id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendAlerts(userId: string) {
|
|
||||||
const triggeredAlerts = await this.alerts.getAlertsByUserId(userId);
|
|
||||||
const member = await resolveMember(this.bot, this.guild, userId);
|
|
||||||
|
|
||||||
triggeredAlerts.forEach(alert => {
|
|
||||||
const prepend = `<@!${alert.requestor_id}>, an alert requested by you has triggered!\nReminder: \`${alert.body}\`\n`;
|
|
||||||
const txtChannel = this.bot.getChannel(alert.channel_id) as TextableChannel;
|
|
||||||
sendWhere.call(this, this.guild, member, txtChannel, prepend);
|
|
||||||
if (alert.active) {
|
|
||||||
this.moveMember(alert.requestor_id, member, txtChannel);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeUserIdFromActiveAlerts(userId: string) {
|
|
||||||
const index = this.usersWithAlerts.indexOf(userId);
|
|
||||||
if (index > -1) {
|
|
||||||
this.usersWithAlerts.splice(index, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async moveMember(toMoveID: string, target: Member, errorChannel: TextableChannel) {
|
|
||||||
const modMember: Member = await this.bot.getRESTGuildMember(this.guildId, toMoveID);
|
|
||||||
if (modMember.voiceState.channelID != null) {
|
|
||||||
try {
|
|
||||||
await modMember.edit({
|
|
||||||
channelID: target.voiceState.channelID,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
this.sendErrorMessage(errorChannel, "Failed to move you. Are you in a voice channel?");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.sendErrorMessage(errorChannel, "Failed to move you. Are you in a voice channel?");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendWhere(guild: Guild, member: Member, channel: TextableChannel, prepend: string) {
|
|
||||||
const voice = guild.channels.get(member.voiceState.channelID) as VoiceChannel;
|
|
||||||
|
|
||||||
if (voice == null) {
|
|
||||||
channel.createMessage(prepend + "That user is not in a channel");
|
|
||||||
} else {
|
|
||||||
let invite = null;
|
|
||||||
try {
|
|
||||||
invite = await createInvite(voice);
|
|
||||||
} catch (e) {
|
|
||||||
this.sendErrorMessage(channel, "Cannot create an invite to that channel!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
channel.createMessage(
|
|
||||||
prepend + ` ${member.mention} is in the following channel: \`${voice.name}\` ${getInviteLink(invite)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createInvite(vc: VoiceChannel) {
|
|
||||||
const existingInvites = await vc.getInvites();
|
|
||||||
|
|
||||||
if (existingInvites.length !== 0) {
|
|
||||||
return existingInvites[0];
|
|
||||||
} else {
|
|
||||||
return vc.createInvite(undefined);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,637 +0,0 @@
|
||||||
import { decorators as d, IPluginOptions, logger } from "knub";
|
|
||||||
import { GuildLogs } from "../data/GuildLogs";
|
|
||||||
import { LogType } from "../data/LogType";
|
|
||||||
import { Attachment, Channel, Constants as ErisConstants, Embed, Member, TextChannel, User } from "eris";
|
|
||||||
import {
|
|
||||||
createChunkedMessage,
|
|
||||||
findRelevantAuditLogEntry,
|
|
||||||
isDiscordRESTError,
|
|
||||||
messageSummary,
|
|
||||||
noop,
|
|
||||||
stripObjectToScalars,
|
|
||||||
UnknownUser,
|
|
||||||
useMediaUrls,
|
|
||||||
verboseChannelMention,
|
|
||||||
verboseUserMention,
|
|
||||||
verboseUserName,
|
|
||||||
} from "../utils";
|
|
||||||
import DefaultLogMessages from "../data/DefaultLogMessages.json";
|
|
||||||
import moment from "moment-timezone";
|
|
||||||
import humanizeDuration from "humanize-duration";
|
|
||||||
import isEqual from "lodash.isequal";
|
|
||||||
import diff from "lodash.difference";
|
|
||||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
|
||||||
import { SavedMessage } from "../data/entities/SavedMessage";
|
|
||||||
import { GuildArchives } from "../data/GuildArchives";
|
|
||||||
import { GuildCases } from "../data/GuildCases";
|
|
||||||
import { ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import { renderTemplate, TemplateParseError } from "../templateFormatter";
|
|
||||||
import cloneDeep from "lodash.clonedeep";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
import { TSafeRegex } from "../validatorUtils";
|
|
||||||
|
|
||||||
const LogChannel = t.partial({
|
|
||||||
include: t.array(t.string),
|
|
||||||
exclude: t.array(t.string),
|
|
||||||
batched: t.boolean,
|
|
||||||
batch_time: t.number,
|
|
||||||
excluded_users: t.array(t.string),
|
|
||||||
excluded_message_regexes: t.array(TSafeRegex),
|
|
||||||
excluded_channels: t.array(t.string),
|
|
||||||
});
|
|
||||||
type TLogChannel = t.TypeOf<typeof LogChannel>;
|
|
||||||
|
|
||||||
const LogChannelMap = t.record(t.string, LogChannel);
|
|
||||||
type TLogChannelMap = t.TypeOf<typeof LogChannelMap>;
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
channels: LogChannelMap,
|
|
||||||
format: t.intersection([
|
|
||||||
t.record(t.string, t.string),
|
|
||||||
t.type({
|
|
||||||
timestamp: t.string,
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
ping_user: t.boolean,
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
export class LogsPlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "logs";
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
public static pluginInfo = {
|
|
||||||
prettyName: "Logs",
|
|
||||||
};
|
|
||||||
|
|
||||||
protected guildLogs: GuildLogs;
|
|
||||||
protected savedMessages: GuildSavedMessages;
|
|
||||||
protected archives: GuildArchives;
|
|
||||||
protected cases: GuildCases;
|
|
||||||
|
|
||||||
protected logListener;
|
|
||||||
|
|
||||||
protected batches: Map<string, string[]>;
|
|
||||||
|
|
||||||
private onMessageDeleteFn;
|
|
||||||
private onMessageDeleteBulkFn;
|
|
||||||
private onMessageUpdateFn;
|
|
||||||
|
|
||||||
private excludedUserProps = ["user", "member", "mod"];
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
channels: {},
|
|
||||||
format: {
|
|
||||||
timestamp: "YYYY-MM-DD HH:mm:ss",
|
|
||||||
...DefaultLogMessages,
|
|
||||||
},
|
|
||||||
ping_user: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
level: ">=50",
|
|
||||||
config: {
|
|
||||||
ping_user: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoad() {
|
|
||||||
this.guildLogs = new GuildLogs(this.guildId);
|
|
||||||
this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId);
|
|
||||||
this.archives = GuildArchives.getGuildInstance(this.guildId);
|
|
||||||
this.cases = GuildCases.getGuildInstance(this.guildId);
|
|
||||||
|
|
||||||
this.logListener = ({ type, data }) => this.log(type, data);
|
|
||||||
this.guildLogs.on("log", this.logListener);
|
|
||||||
|
|
||||||
this.batches = new Map();
|
|
||||||
|
|
||||||
this.onMessageDeleteFn = this.onMessageDelete.bind(this);
|
|
||||||
this.savedMessages.events.on("delete", this.onMessageDeleteFn);
|
|
||||||
|
|
||||||
this.onMessageDeleteBulkFn = this.onMessageDeleteBulk.bind(this);
|
|
||||||
this.savedMessages.events.on("deleteBulk", this.onMessageDeleteBulkFn);
|
|
||||||
|
|
||||||
this.onMessageUpdateFn = this.onMessageUpdate.bind(this);
|
|
||||||
this.savedMessages.events.on("update", this.onMessageUpdateFn);
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnload() {
|
|
||||||
this.guildLogs.removeListener("log", this.logListener);
|
|
||||||
|
|
||||||
this.savedMessages.events.off("delete", this.onMessageDeleteFn);
|
|
||||||
this.savedMessages.events.off("deleteBulk", this.onMessageDeleteBulkFn);
|
|
||||||
this.savedMessages.events.off("update", this.onMessageUpdateFn);
|
|
||||||
}
|
|
||||||
|
|
||||||
async log(type, data) {
|
|
||||||
const logChannels: TLogChannelMap = this.getConfig().channels;
|
|
||||||
const typeStr = LogType[type];
|
|
||||||
|
|
||||||
logChannelLoop: for (const [channelId, opts] of Object.entries(logChannels)) {
|
|
||||||
const channel = this.guild.channels.get(channelId);
|
|
||||||
if (!channel || !(channel instanceof TextChannel)) continue;
|
|
||||||
|
|
||||||
if ((opts.include && opts.include.includes(typeStr)) || (opts.exclude && !opts.exclude.includes(typeStr))) {
|
|
||||||
// If this log entry is about an excluded user, skip it
|
|
||||||
// TODO: Quick and dirty solution, look into changing at some point
|
|
||||||
if (opts.excluded_users) {
|
|
||||||
for (const prop of this.excludedUserProps) {
|
|
||||||
if (data && data[prop] && opts.excluded_users.includes(data[prop].id)) {
|
|
||||||
continue logChannelLoop;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If this entry is from an excluded channel, skip it
|
|
||||||
if (opts.excluded_channels) {
|
|
||||||
if (
|
|
||||||
type === LogType.MESSAGE_DELETE ||
|
|
||||||
type === LogType.MESSAGE_DELETE_BARE ||
|
|
||||||
type === LogType.MESSAGE_EDIT ||
|
|
||||||
type === LogType.MESSAGE_SPAM_DETECTED ||
|
|
||||||
type === LogType.CENSOR ||
|
|
||||||
type === LogType.CLEAN
|
|
||||||
) {
|
|
||||||
if (opts.excluded_channels.includes(data.channel.id)) {
|
|
||||||
continue logChannelLoop;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If this entry contains a message with an excluded regex, skip it
|
|
||||||
if (type === LogType.MESSAGE_DELETE && opts.excluded_message_regexes && data.message.data.content) {
|
|
||||||
for (const regex of opts.excluded_message_regexes) {
|
|
||||||
if (regex.test(data.message.data.content)) {
|
|
||||||
continue logChannelLoop;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === LogType.MESSAGE_EDIT && opts.excluded_message_regexes && data.before.data.content) {
|
|
||||||
for (const regex of opts.excluded_message_regexes) {
|
|
||||||
if (regex.test(data.before.data.content)) {
|
|
||||||
continue logChannelLoop;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = await this.getLogMessage(type, data);
|
|
||||||
if (message) {
|
|
||||||
const batched = opts.batched ?? true; // Default to batched unless explicitly disabled
|
|
||||||
const batchTime = opts.batch_time ?? 1000;
|
|
||||||
|
|
||||||
if (batched) {
|
|
||||||
// If we're batching log messages, gather all log messages within the set batch_time into a single message
|
|
||||||
if (!this.batches.has(channel.id)) {
|
|
||||||
this.batches.set(channel.id, []);
|
|
||||||
setTimeout(async () => {
|
|
||||||
const batchedMessage = this.batches.get(channel.id).join("\n");
|
|
||||||
this.batches.delete(channel.id);
|
|
||||||
createChunkedMessage(channel, batchedMessage).catch(noop);
|
|
||||||
}, batchTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.batches.get(channel.id).push(message);
|
|
||||||
} else {
|
|
||||||
// If we're not batching log messages, just send them immediately
|
|
||||||
await createChunkedMessage(channel, message).catch(noop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getLogMessage(type, data): Promise<string> {
|
|
||||||
const config = this.getConfig();
|
|
||||||
const format = config.format[LogType[type]] || "";
|
|
||||||
if (format === "") return;
|
|
||||||
|
|
||||||
let formatted;
|
|
||||||
try {
|
|
||||||
const values = {
|
|
||||||
...data,
|
|
||||||
userMention: async inputUserOrMember => {
|
|
||||||
if (!inputUserOrMember) return "";
|
|
||||||
|
|
||||||
const usersOrMembers = Array.isArray(inputUserOrMember) ? inputUserOrMember : [inputUserOrMember];
|
|
||||||
|
|
||||||
const mentions = [];
|
|
||||||
for (const userOrMember of usersOrMembers) {
|
|
||||||
let user;
|
|
||||||
let member;
|
|
||||||
|
|
||||||
if (userOrMember.user) {
|
|
||||||
member = userOrMember;
|
|
||||||
user = member.user;
|
|
||||||
} else {
|
|
||||||
user = userOrMember;
|
|
||||||
member = this.guild.members.get(user.id) || { id: user.id, user };
|
|
||||||
}
|
|
||||||
|
|
||||||
const memberConfig = this.getMatchingConfig({ member, userId: user.id }) || ({} as any);
|
|
||||||
|
|
||||||
mentions.push(memberConfig.ping_user ? verboseUserMention(user) : verboseUserName(user));
|
|
||||||
}
|
|
||||||
|
|
||||||
return mentions.join(", ");
|
|
||||||
},
|
|
||||||
channelMention: channel => {
|
|
||||||
if (!channel) return "";
|
|
||||||
return verboseChannelMention(channel);
|
|
||||||
},
|
|
||||||
messageSummary: (msg: SavedMessage) => {
|
|
||||||
if (!msg) return "";
|
|
||||||
return messageSummary(msg);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (type === LogType.BOT_ALERT) {
|
|
||||||
const valuesWithoutTmplEval = { ...values };
|
|
||||||
values.tmplEval = str => {
|
|
||||||
return renderTemplate(str, valuesWithoutTmplEval);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
formatted = await renderTemplate(format, values);
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof TemplateParseError) {
|
|
||||||
logger.error(`Error when parsing template:\nError: ${e.message}\nTemplate: ${format}`);
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
formatted = formatted.trim();
|
|
||||||
|
|
||||||
const timestampFormat = config.format.timestamp;
|
|
||||||
if (timestampFormat) {
|
|
||||||
const timestamp = moment().format(timestampFormat);
|
|
||||||
return `\`[${timestamp}]\` ${formatted}`;
|
|
||||||
} else {
|
|
||||||
return formatted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async findRelevantAuditLogEntry(actionType: number, userId: string, attempts?: number, attemptDelay?: number) {
|
|
||||||
try {
|
|
||||||
return await findRelevantAuditLogEntry(this.guild, actionType, userId, attempts, attemptDelay);
|
|
||||||
} catch (e) {
|
|
||||||
if (isDiscordRESTError(e) && e.code === 50013) {
|
|
||||||
this.guildLogs.log(LogType.BOT_ALERT, {
|
|
||||||
body: "Missing permissions to read audit log",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("guildMemberAdd")
|
|
||||||
async onMemberJoin(_, member) {
|
|
||||||
const newThreshold = moment().valueOf() - 1000 * 60 * 60;
|
|
||||||
const accountAge = humanizeDuration(moment().valueOf() - member.createdAt, {
|
|
||||||
largest: 2,
|
|
||||||
round: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.guildLogs.log(LogType.MEMBER_JOIN, {
|
|
||||||
member: stripObjectToScalars(member, ["user", "roles"]),
|
|
||||||
new: member.createdAt >= newThreshold ? " :new:" : "",
|
|
||||||
account_age: accountAge,
|
|
||||||
});
|
|
||||||
|
|
||||||
const cases = (await this.cases.with("notes").getByUserId(member.id)).filter(c => !c.is_hidden);
|
|
||||||
cases.sort((a, b) => (a.created_at > b.created_at ? -1 : 1));
|
|
||||||
|
|
||||||
if (cases.length) {
|
|
||||||
const recentCaseLines = [];
|
|
||||||
const recentCases = cases.slice(0, 2);
|
|
||||||
for (const theCase of recentCases) {
|
|
||||||
recentCaseLines.push(this.cases.getSummaryText(theCase));
|
|
||||||
}
|
|
||||||
|
|
||||||
let recentCaseSummary = recentCaseLines.join("\n");
|
|
||||||
if (recentCases.length < cases.length) {
|
|
||||||
const remaining = cases.length - recentCases.length;
|
|
||||||
if (remaining === 1) {
|
|
||||||
recentCaseSummary += `\n*+${remaining} case*`;
|
|
||||||
} else {
|
|
||||||
recentCaseSummary += `\n*+${remaining} cases*`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.guildLogs.log(LogType.MEMBER_JOIN_WITH_PRIOR_RECORDS, {
|
|
||||||
member: stripObjectToScalars(member, ["user", "roles"]),
|
|
||||||
recentCaseSummary,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("guildMemberRemove")
|
|
||||||
onMemberLeave(_, member) {
|
|
||||||
this.guildLogs.log(LogType.MEMBER_LEAVE, {
|
|
||||||
member: stripObjectToScalars(member, ["user", "roles"]),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("guildBanAdd")
|
|
||||||
async onMemberBan(_, user) {
|
|
||||||
const relevantAuditLogEntry = await this.findRelevantAuditLogEntry(
|
|
||||||
ErisConstants.AuditLogActions.MEMBER_BAN_ADD,
|
|
||||||
user.id,
|
|
||||||
);
|
|
||||||
const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : new UnknownUser();
|
|
||||||
|
|
||||||
this.guildLogs.log(
|
|
||||||
LogType.MEMBER_BAN,
|
|
||||||
{
|
|
||||||
mod: stripObjectToScalars(mod),
|
|
||||||
user: stripObjectToScalars(user),
|
|
||||||
},
|
|
||||||
user.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("guildBanRemove")
|
|
||||||
async onMemberUnban(_, user) {
|
|
||||||
const relevantAuditLogEntry = await this.findRelevantAuditLogEntry(
|
|
||||||
ErisConstants.AuditLogActions.MEMBER_BAN_REMOVE,
|
|
||||||
user.id,
|
|
||||||
);
|
|
||||||
const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : new UnknownUser();
|
|
||||||
|
|
||||||
this.guildLogs.log(
|
|
||||||
LogType.MEMBER_UNBAN,
|
|
||||||
{
|
|
||||||
mod: stripObjectToScalars(mod),
|
|
||||||
userId: user.id,
|
|
||||||
},
|
|
||||||
user.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("guildMemberUpdate")
|
|
||||||
async onMemberUpdate(_, member: Member, oldMember: Member) {
|
|
||||||
if (!oldMember) return;
|
|
||||||
|
|
||||||
const logMember = stripObjectToScalars(member, ["user", "roles"]);
|
|
||||||
|
|
||||||
if (member.nick !== oldMember.nick) {
|
|
||||||
this.guildLogs.log(LogType.MEMBER_NICK_CHANGE, {
|
|
||||||
member: logMember,
|
|
||||||
oldNick: oldMember.nick != null ? oldMember.nick : "<none>",
|
|
||||||
newNick: member.nick != null ? member.nick : "<none>",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isEqual(oldMember.roles, member.roles)) {
|
|
||||||
const addedRoles = diff(member.roles, oldMember.roles);
|
|
||||||
const removedRoles = diff(oldMember.roles, member.roles);
|
|
||||||
let skip = false;
|
|
||||||
|
|
||||||
if (
|
|
||||||
addedRoles.length &&
|
|
||||||
removedRoles.length &&
|
|
||||||
this.guildLogs.isLogIgnored(LogType.MEMBER_ROLE_CHANGES, member.id)
|
|
||||||
) {
|
|
||||||
skip = true;
|
|
||||||
} else if (addedRoles.length && this.guildLogs.isLogIgnored(LogType.MEMBER_ROLE_ADD, member.id)) {
|
|
||||||
skip = true;
|
|
||||||
} else if (removedRoles.length && this.guildLogs.isLogIgnored(LogType.MEMBER_ROLE_REMOVE, member.id)) {
|
|
||||||
skip = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!skip) {
|
|
||||||
const relevantAuditLogEntry = await this.findRelevantAuditLogEntry(
|
|
||||||
ErisConstants.AuditLogActions.MEMBER_ROLE_UPDATE,
|
|
||||||
member.id,
|
|
||||||
);
|
|
||||||
const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : new UnknownUser();
|
|
||||||
|
|
||||||
if (addedRoles.length && removedRoles.length) {
|
|
||||||
// Roles added *and* removed
|
|
||||||
this.guildLogs.log(
|
|
||||||
LogType.MEMBER_ROLE_CHANGES,
|
|
||||||
{
|
|
||||||
member: logMember,
|
|
||||||
addedRoles: addedRoles
|
|
||||||
.map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` })
|
|
||||||
.map(r => r.name)
|
|
||||||
.join(", "),
|
|
||||||
removedRoles: removedRoles
|
|
||||||
.map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` })
|
|
||||||
.map(r => r.name)
|
|
||||||
.join(", "),
|
|
||||||
mod: stripObjectToScalars(mod),
|
|
||||||
},
|
|
||||||
member.id,
|
|
||||||
);
|
|
||||||
} else if (addedRoles.length) {
|
|
||||||
// Roles added
|
|
||||||
this.guildLogs.log(
|
|
||||||
LogType.MEMBER_ROLE_ADD,
|
|
||||||
{
|
|
||||||
member: logMember,
|
|
||||||
roles: addedRoles
|
|
||||||
.map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` })
|
|
||||||
.map(r => r.name)
|
|
||||||
.join(", "),
|
|
||||||
mod: stripObjectToScalars(mod),
|
|
||||||
},
|
|
||||||
member.id,
|
|
||||||
);
|
|
||||||
} else if (removedRoles.length && !addedRoles.length) {
|
|
||||||
// Roles removed
|
|
||||||
this.guildLogs.log(
|
|
||||||
LogType.MEMBER_ROLE_REMOVE,
|
|
||||||
{
|
|
||||||
member: logMember,
|
|
||||||
roles: removedRoles
|
|
||||||
.map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` })
|
|
||||||
.map(r => r.name)
|
|
||||||
.join(", "),
|
|
||||||
mod: stripObjectToScalars(mod),
|
|
||||||
},
|
|
||||||
member.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("userUpdate", null, false)
|
|
||||||
async onUserUpdate(user: User, oldUser: User) {
|
|
||||||
if (!oldUser) return;
|
|
||||||
|
|
||||||
if (!this.guild.members.has(user.id)) return;
|
|
||||||
|
|
||||||
if (user.username !== oldUser.username || user.discriminator !== oldUser.discriminator) {
|
|
||||||
this.guildLogs.log(LogType.MEMBER_USERNAME_CHANGE, {
|
|
||||||
user: stripObjectToScalars(user),
|
|
||||||
oldName: `${oldUser.username}#${oldUser.discriminator}`,
|
|
||||||
newName: `${user.username}#${user.discriminator}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("channelCreate")
|
|
||||||
onChannelCreate(channel) {
|
|
||||||
this.guildLogs.log(LogType.CHANNEL_CREATE, {
|
|
||||||
channel: stripObjectToScalars(channel),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("channelDelete")
|
|
||||||
onChannelDelete(channel) {
|
|
||||||
this.guildLogs.log(LogType.CHANNEL_DELETE, {
|
|
||||||
channel: stripObjectToScalars(channel),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("guildRoleCreate")
|
|
||||||
onRoleCreate(_, role) {
|
|
||||||
this.guildLogs.log(LogType.ROLE_CREATE, {
|
|
||||||
role: stripObjectToScalars(role),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("guildRoleDelete")
|
|
||||||
onRoleDelete(_, role) {
|
|
||||||
this.guildLogs.log(LogType.ROLE_DELETE, {
|
|
||||||
role: stripObjectToScalars(role),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Uses events from savesMessages
|
|
||||||
async onMessageUpdate(savedMessage: SavedMessage, oldSavedMessage: SavedMessage) {
|
|
||||||
// To log a message update, either the message content or a rich embed has to change
|
|
||||||
let logUpdate = false;
|
|
||||||
|
|
||||||
const oldEmbedsToCompare = ((oldSavedMessage.data.embeds || []) as Embed[])
|
|
||||||
.map(e => cloneDeep(e))
|
|
||||||
.filter(e => (e as Embed).type === "rich");
|
|
||||||
|
|
||||||
const newEmbedsToCompare = ((savedMessage.data.embeds || []) as Embed[])
|
|
||||||
.map(e => cloneDeep(e))
|
|
||||||
.filter(e => (e as Embed).type === "rich");
|
|
||||||
|
|
||||||
for (const embed of [...oldEmbedsToCompare, ...newEmbedsToCompare]) {
|
|
||||||
if (embed.thumbnail) {
|
|
||||||
delete embed.thumbnail.width;
|
|
||||||
delete embed.thumbnail.height;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (embed.image) {
|
|
||||||
delete embed.image.width;
|
|
||||||
delete embed.image.height;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
oldSavedMessage.data.content !== savedMessage.data.content ||
|
|
||||||
oldEmbedsToCompare.length !== newEmbedsToCompare.length ||
|
|
||||||
JSON.stringify(oldEmbedsToCompare) !== JSON.stringify(newEmbedsToCompare)
|
|
||||||
) {
|
|
||||||
logUpdate = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!logUpdate) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await this.resolveUser(savedMessage.user_id);
|
|
||||||
const channel = this.guild.channels.get(savedMessage.channel_id);
|
|
||||||
|
|
||||||
this.guildLogs.log(LogType.MESSAGE_EDIT, {
|
|
||||||
user: stripObjectToScalars(user),
|
|
||||||
channel: stripObjectToScalars(channel),
|
|
||||||
before: oldSavedMessage,
|
|
||||||
after: savedMessage,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Uses events from savesMessages
|
|
||||||
async onMessageDelete(savedMessage: SavedMessage) {
|
|
||||||
const user = await this.resolveUser(savedMessage.user_id);
|
|
||||||
const channel = this.guild.channels.get(savedMessage.channel_id);
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
// Replace attachment URLs with media URLs
|
|
||||||
if (savedMessage.data.attachments) {
|
|
||||||
for (const attachment of savedMessage.data.attachments as Attachment[]) {
|
|
||||||
attachment.url = useMediaUrls(attachment.url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.guildLogs.log(
|
|
||||||
LogType.MESSAGE_DELETE,
|
|
||||||
{
|
|
||||||
user: stripObjectToScalars(user),
|
|
||||||
channel: stripObjectToScalars(channel),
|
|
||||||
messageDate: moment(savedMessage.data.timestamp, "x").format(this.getConfig().format.timestamp),
|
|
||||||
message: savedMessage,
|
|
||||||
},
|
|
||||||
savedMessage.id,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.guildLogs.log(
|
|
||||||
LogType.MESSAGE_DELETE_BARE,
|
|
||||||
{
|
|
||||||
messageId: savedMessage.id,
|
|
||||||
channel: stripObjectToScalars(channel),
|
|
||||||
},
|
|
||||||
savedMessage.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Uses events from savesMessages
|
|
||||||
async onMessageDeleteBulk(savedMessages: SavedMessage[]) {
|
|
||||||
const channel = this.guild.channels.get(savedMessages[0].channel_id);
|
|
||||||
const archiveId = await this.archives.createFromSavedMessages(savedMessages, this.guild);
|
|
||||||
const archiveUrl = this.archives.getUrl(this.knub.getGlobalConfig().url, archiveId);
|
|
||||||
|
|
||||||
this.guildLogs.log(
|
|
||||||
LogType.MESSAGE_DELETE_BULK,
|
|
||||||
{
|
|
||||||
count: savedMessages.length,
|
|
||||||
channel,
|
|
||||||
archiveUrl,
|
|
||||||
},
|
|
||||||
savedMessages[0].id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("voiceChannelJoin")
|
|
||||||
onVoiceChannelJoin(member: Member, channel: Channel) {
|
|
||||||
this.guildLogs.log(LogType.VOICE_CHANNEL_JOIN, {
|
|
||||||
member: stripObjectToScalars(member, ["user", "roles"]),
|
|
||||||
channel: stripObjectToScalars(channel),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("voiceChannelLeave")
|
|
||||||
onVoiceChannelLeave(member: Member, channel: Channel) {
|
|
||||||
this.guildLogs.log(LogType.VOICE_CHANNEL_LEAVE, {
|
|
||||||
member: stripObjectToScalars(member, ["user", "roles"]),
|
|
||||||
channel: stripObjectToScalars(channel),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("voiceChannelSwitch")
|
|
||||||
onVoiceChannelSwitch(member: Member, newChannel: Channel, oldChannel: Channel) {
|
|
||||||
this.guildLogs.log(LogType.VOICE_CHANNEL_MOVE, {
|
|
||||||
member: stripObjectToScalars(member, ["user", "roles"]),
|
|
||||||
oldChannel: stripObjectToScalars(oldChannel),
|
|
||||||
newChannel: stripObjectToScalars(newChannel),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,140 +0,0 @@
|
||||||
import { Plugin, decorators as d, IPluginOptions } from "knub";
|
|
||||||
import { GuildChannel, Message, TextChannel } from "eris";
|
|
||||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
|
||||||
import { successMessage } from "../utils";
|
|
||||||
import { ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
can_manage: t.boolean,
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
export class MessageSaverPlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "message_saver";
|
|
||||||
public static showInDocs = false;
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
protected savedMessages: GuildSavedMessages;
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
can_manage: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
level: ">=100",
|
|
||||||
config: {
|
|
||||||
can_manage: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoad() {
|
|
||||||
this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("messageCreate", "guild", false)
|
|
||||||
async onMessageCreate(msg: Message) {
|
|
||||||
// Only save regular chat messages
|
|
||||||
if (msg.type !== 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.savedMessages.createFromMsg(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("messageDelete", "guild", false)
|
|
||||||
async onMessageDelete(msg: Message) {
|
|
||||||
if (msg.type != null && msg.type !== 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.savedMessages.markAsDeleted(msg.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("messageUpdate", "guild", false)
|
|
||||||
async onMessageUpdate(msg: Message) {
|
|
||||||
if (msg.type !== 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.savedMessages.saveEditFromMsg(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("messageDeleteBulk", "guild", false)
|
|
||||||
async onMessageBulkDelete(messages: Message[]) {
|
|
||||||
const ids = messages.map(m => m.id);
|
|
||||||
await this.savedMessages.markBulkAsDeleted(ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveMessagesToDB(channel: GuildChannel & TextChannel, ids: string[]) {
|
|
||||||
const failed = [];
|
|
||||||
for (const id of ids) {
|
|
||||||
const savedMessage = await this.savedMessages.find(id);
|
|
||||||
if (savedMessage) continue;
|
|
||||||
|
|
||||||
let thisMsg: Message;
|
|
||||||
|
|
||||||
try {
|
|
||||||
thisMsg = await channel.getMessage(id);
|
|
||||||
|
|
||||||
if (!thisMsg) {
|
|
||||||
failed.push(id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.savedMessages.createFromMsg(thisMsg, { is_permanent: true });
|
|
||||||
} catch (e) {
|
|
||||||
failed.push(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
savedCount: ids.length - failed.length,
|
|
||||||
failed,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("save_messages_to_db", "<channel:channel> <ids:string...>")
|
|
||||||
@d.permission("can_manage")
|
|
||||||
async saveMessageCmd(msg: Message, args: { channel: GuildChannel & TextChannel; ids: string[] }) {
|
|
||||||
await msg.channel.createMessage("Saving specified messages...");
|
|
||||||
|
|
||||||
const { savedCount, failed } = await this.saveMessagesToDB(args.channel, args.ids);
|
|
||||||
|
|
||||||
if (failed.length) {
|
|
||||||
this.sendSuccessMessage(
|
|
||||||
msg.channel,
|
|
||||||
`Saved ${savedCount} messages. The following messages could not be saved: ${failed.join(", ")}`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.sendSuccessMessage(msg.channel, `Saved ${savedCount} messages!`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("save_pins_to_db", "<channel:channel>")
|
|
||||||
@d.permission("can_manage")
|
|
||||||
async savePinsCmd(msg: Message, args: { channel: GuildChannel & TextChannel }) {
|
|
||||||
await msg.channel.createMessage(`Saving pins from <#${args.channel.id}>...`);
|
|
||||||
|
|
||||||
const pins = await args.channel.getPins();
|
|
||||||
const { savedCount, failed } = await this.saveMessagesToDB(
|
|
||||||
args.channel,
|
|
||||||
pins.map(m => m.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (failed.length) {
|
|
||||||
this.sendSuccessMessage(
|
|
||||||
msg.channel,
|
|
||||||
`Saved ${savedCount} messages. The following messages could not be saved: ${failed.join(", ")}`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.sendSuccessMessage(msg.channel, `Saved ${savedCount} messages!`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,762 +0,0 @@
|
||||||
import { Member, Message, TextChannel, User } from "eris";
|
|
||||||
import { GuildCases } from "../data/GuildCases";
|
|
||||||
import moment from "moment-timezone";
|
|
||||||
import { ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import { GuildMutes } from "../data/GuildMutes";
|
|
||||||
import {
|
|
||||||
chunkMessageLines,
|
|
||||||
DBDateFormat,
|
|
||||||
errorMessage,
|
|
||||||
UserNotificationResult,
|
|
||||||
noop,
|
|
||||||
notifyUser,
|
|
||||||
stripObjectToScalars,
|
|
||||||
successMessage,
|
|
||||||
tNullable,
|
|
||||||
ucfirst,
|
|
||||||
UnknownUser,
|
|
||||||
UserNotificationMethod,
|
|
||||||
trimLines,
|
|
||||||
MINUTES,
|
|
||||||
} from "../utils";
|
|
||||||
import humanizeDuration from "humanize-duration";
|
|
||||||
import { LogType } from "../data/LogType";
|
|
||||||
import { GuildLogs } from "../data/GuildLogs";
|
|
||||||
import { decorators as d, IPluginOptions, logger } from "knub";
|
|
||||||
import { Mute } from "../data/entities/Mute";
|
|
||||||
import { renderTemplate } from "../templateFormatter";
|
|
||||||
import { CaseTypes } from "../data/CaseTypes";
|
|
||||||
import { CaseArgs, CasesPlugin } from "./Cases";
|
|
||||||
import { Case } from "../data/entities/Case";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
import { ERRORS, RecoverablePluginError } from "../RecoverablePluginError";
|
|
||||||
import { GuildArchives } from "src/data/GuildArchives";
|
|
||||||
import { humanizeDurationShort } from "../humanizeDurationShort";
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
mute_role: tNullable(t.string),
|
|
||||||
move_to_voice_channel: tNullable(t.string),
|
|
||||||
|
|
||||||
dm_on_mute: t.boolean,
|
|
||||||
dm_on_update: t.boolean,
|
|
||||||
message_on_mute: t.boolean,
|
|
||||||
message_on_update: t.boolean,
|
|
||||||
message_channel: tNullable(t.string),
|
|
||||||
mute_message: tNullable(t.string),
|
|
||||||
timed_mute_message: tNullable(t.string),
|
|
||||||
update_mute_message: tNullable(t.string),
|
|
||||||
|
|
||||||
can_view_list: t.boolean,
|
|
||||||
can_cleanup: t.boolean,
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
interface IMuteWithDetails extends Mute {
|
|
||||||
member?: Member;
|
|
||||||
banned?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MuteResult = {
|
|
||||||
case: Case;
|
|
||||||
notifyResult: UserNotificationResult;
|
|
||||||
updatedExistingMute: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UnmuteResult = {
|
|
||||||
case: Case;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface MuteOptions {
|
|
||||||
caseArgs?: Partial<CaseArgs>;
|
|
||||||
contactMethods?: UserNotificationMethod[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const EXPIRED_MUTE_CHECK_INTERVAL = 60 * 1000;
|
|
||||||
let FIRST_CHECK_TIME = Date.now();
|
|
||||||
const FIRST_CHECK_INCREMENT = 5 * 1000;
|
|
||||||
|
|
||||||
export class MutesPlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "mutes";
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
public static pluginInfo = {
|
|
||||||
prettyName: "Mutes",
|
|
||||||
};
|
|
||||||
|
|
||||||
protected mutes: GuildMutes;
|
|
||||||
protected cases: GuildCases;
|
|
||||||
protected serverLogs: GuildLogs;
|
|
||||||
private muteClearIntervalId: NodeJS.Timer;
|
|
||||||
archives: GuildArchives;
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
mute_role: null,
|
|
||||||
move_to_voice_channel: null,
|
|
||||||
|
|
||||||
dm_on_mute: false,
|
|
||||||
dm_on_update: false,
|
|
||||||
message_on_mute: false,
|
|
||||||
message_on_update: false,
|
|
||||||
message_channel: null,
|
|
||||||
mute_message: "You have been muted on the {guildName} server. Reason given: {reason}",
|
|
||||||
timed_mute_message: "You have been muted on the {guildName} server for {time}. Reason given: {reason}",
|
|
||||||
update_mute_message: "Your mute on the {guildName} server has been updated to {time}.",
|
|
||||||
|
|
||||||
can_view_list: false,
|
|
||||||
can_cleanup: false,
|
|
||||||
},
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
level: ">=50",
|
|
||||||
config: {
|
|
||||||
can_view_list: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
level: ">=100",
|
|
||||||
config: {
|
|
||||||
can_cleanup: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onLoad() {
|
|
||||||
this.mutes = GuildMutes.getGuildInstance(this.guildId);
|
|
||||||
this.cases = GuildCases.getGuildInstance(this.guildId);
|
|
||||||
this.serverLogs = new GuildLogs(this.guildId);
|
|
||||||
this.archives = GuildArchives.getGuildInstance(this.guildId);
|
|
||||||
|
|
||||||
// Check for expired mutes every 5s
|
|
||||||
const firstCheckTime = Math.max(Date.now(), FIRST_CHECK_TIME) + FIRST_CHECK_INCREMENT;
|
|
||||||
FIRST_CHECK_TIME = firstCheckTime;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
this.clearExpiredMutes();
|
|
||||||
this.muteClearIntervalId = setInterval(() => this.clearExpiredMutes(), EXPIRED_MUTE_CHECK_INTERVAL);
|
|
||||||
}, firstCheckTime - Date.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onUnload() {
|
|
||||||
clearInterval(this.muteClearIntervalId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async muteUser(
|
|
||||||
userId: string,
|
|
||||||
muteTime: number = null,
|
|
||||||
reason: string = null,
|
|
||||||
muteOptions: MuteOptions = {},
|
|
||||||
): Promise<MuteResult> {
|
|
||||||
const lock = await this.locks.acquire(`mute-${userId}`);
|
|
||||||
|
|
||||||
const muteRole = this.getConfig().mute_role;
|
|
||||||
if (!muteRole) {
|
|
||||||
lock.unlock();
|
|
||||||
this.throwRecoverablePluginError(ERRORS.NO_MUTE_ROLE_IN_CONFIG);
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeUntilUnmute = muteTime ? humanizeDuration(muteTime) : "indefinite";
|
|
||||||
|
|
||||||
// No mod specified -> mark Zeppelin as the mod
|
|
||||||
if (!muteOptions.caseArgs?.modId) {
|
|
||||||
muteOptions.caseArgs = muteOptions.caseArgs ?? {};
|
|
||||||
muteOptions.caseArgs.modId = this.bot.user.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await this.resolveUser(userId);
|
|
||||||
const member = await this.getMember(user.id, true); // Grab the fresh member so we don't have stale role info
|
|
||||||
const config = this.getMatchingConfig({ member, userId });
|
|
||||||
|
|
||||||
if (member) {
|
|
||||||
// Apply mute role if it's missing
|
|
||||||
if (!member.roles.includes(muteRole)) {
|
|
||||||
await member.addRole(muteRole);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 = this.getConfig().move_to_voice_channel;
|
|
||||||
if (moveToVoiceChannelId) {
|
|
||||||
// TODO: Add back the voiceState check once we figure out how to get voice state for guild members that are loaded on-demand
|
|
||||||
try {
|
|
||||||
await member.edit({ channelID: moveToVoiceChannelId });
|
|
||||||
} catch (e) {} // tslint:disable-line
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the user is already muted, update the duration of their existing mute
|
|
||||||
const existingMute = await this.mutes.findExistingMuteForUserId(user.id);
|
|
||||||
let notifyResult: UserNotificationResult = { method: null, success: true };
|
|
||||||
|
|
||||||
if (existingMute) {
|
|
||||||
await this.mutes.updateExpiryTime(user.id, muteTime);
|
|
||||||
} else {
|
|
||||||
await this.mutes.addMute(user.id, muteTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
const template = existingMute
|
|
||||||
? config.update_mute_message
|
|
||||||
: muteTime
|
|
||||||
? config.timed_mute_message
|
|
||||||
: config.mute_message;
|
|
||||||
|
|
||||||
const muteMessage =
|
|
||||||
template &&
|
|
||||||
(await renderTemplate(template, {
|
|
||||||
guildName: this.guild.name,
|
|
||||||
reason: reason || "None",
|
|
||||||
time: timeUntilUnmute,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (muteMessage && user instanceof User) {
|
|
||||||
let contactMethods = [];
|
|
||||||
|
|
||||||
if (muteOptions?.contactMethods) {
|
|
||||||
contactMethods = muteOptions.contactMethods;
|
|
||||||
} else {
|
|
||||||
const useDm = existingMute ? config.dm_on_update : config.dm_on_mute;
|
|
||||||
if (useDm) {
|
|
||||||
contactMethods.push({ type: "dm" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const useChannel = existingMute ? config.message_on_update : config.message_on_mute;
|
|
||||||
const channel = config.message_channel && this.guild.channels.get(config.message_channel);
|
|
||||||
if (useChannel && channel instanceof TextChannel) {
|
|
||||||
contactMethods.push({ type: "channel", channel });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
notifyResult = await notifyUser(user, muteMessage, contactMethods);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create/update a case
|
|
||||||
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
|
|
||||||
let theCase;
|
|
||||||
|
|
||||||
if (existingMute && existingMute.case_id) {
|
|
||||||
// Update old case
|
|
||||||
// Since mutes can often have multiple notes (extraNotes), we won't post each case note individually,
|
|
||||||
// but instead we'll post the entire case afterwards
|
|
||||||
theCase = await this.cases.find(existingMute.case_id);
|
|
||||||
const noteDetails = [`Mute updated to ${muteTime ? timeUntilUnmute : "indefinite"}`];
|
|
||||||
const reasons = [reason, ...(muteOptions.caseArgs?.extraNotes || [])];
|
|
||||||
for (const noteReason of reasons) {
|
|
||||||
await casesPlugin.createCaseNote({
|
|
||||||
caseId: existingMute.case_id,
|
|
||||||
modId: muteOptions.caseArgs?.modId,
|
|
||||||
body: noteReason,
|
|
||||||
noteDetails,
|
|
||||||
postInCaseLogOverride: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (muteOptions.caseArgs?.postInCaseLogOverride !== false) {
|
|
||||||
casesPlugin.postCaseToCaseLogChannel(existingMute.case_id);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Create new case
|
|
||||||
const noteDetails = [`Muted ${muteTime ? `for ${timeUntilUnmute}` : "indefinitely"}`];
|
|
||||||
if (notifyResult.text) {
|
|
||||||
noteDetails.push(ucfirst(notifyResult.text));
|
|
||||||
}
|
|
||||||
|
|
||||||
theCase = await casesPlugin.createCase({
|
|
||||||
...(muteOptions.caseArgs || {}),
|
|
||||||
userId,
|
|
||||||
modId: muteOptions.caseArgs?.modId,
|
|
||||||
type: CaseTypes.Mute,
|
|
||||||
reason,
|
|
||||||
noteDetails,
|
|
||||||
});
|
|
||||||
await this.mutes.setCaseId(user.id, theCase.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log the action
|
|
||||||
const mod = await this.resolveUser(muteOptions.caseArgs?.modId);
|
|
||||||
if (muteTime) {
|
|
||||||
this.serverLogs.log(LogType.MEMBER_TIMED_MUTE, {
|
|
||||||
mod: stripObjectToScalars(mod),
|
|
||||||
user: stripObjectToScalars(user),
|
|
||||||
time: timeUntilUnmute,
|
|
||||||
reason,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.serverLogs.log(LogType.MEMBER_MUTE, {
|
|
||||||
mod: stripObjectToScalars(mod),
|
|
||||||
user: stripObjectToScalars(user),
|
|
||||||
reason,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
lock.unlock();
|
|
||||||
|
|
||||||
return {
|
|
||||||
case: theCase,
|
|
||||||
notifyResult,
|
|
||||||
updatedExistingMute: !!existingMute,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async unmuteUser(
|
|
||||||
userId: string,
|
|
||||||
unmuteTime: number = null,
|
|
||||||
caseArgs: Partial<CaseArgs> = {},
|
|
||||||
): Promise<UnmuteResult> {
|
|
||||||
const existingMute = await this.mutes.findExistingMuteForUserId(userId);
|
|
||||||
const user = await this.resolveUser(userId);
|
|
||||||
const member = await this.getMember(userId, true); // Grab the fresh member so we don't have stale role info
|
|
||||||
|
|
||||||
if (!existingMute && !this.hasMutedRole(member)) return;
|
|
||||||
|
|
||||||
if (unmuteTime) {
|
|
||||||
// Schedule timed unmute (= just set the mute's duration)
|
|
||||||
if (!existingMute) {
|
|
||||||
await this.mutes.addMute(userId, unmuteTime);
|
|
||||||
} else {
|
|
||||||
await this.mutes.updateExpiryTime(userId, unmuteTime);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Unmute immediately
|
|
||||||
if (member) {
|
|
||||||
const muteRole = this.getConfig().mute_role;
|
|
||||||
if (member.roles.includes(muteRole)) {
|
|
||||||
await member.removeRole(muteRole);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.warn(
|
|
||||||
`Member ${userId} not found in guild ${this.guild.name} (${this.guildId}) when attempting to unmute`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (existingMute) {
|
|
||||||
await this.mutes.clear(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeUntilUnmute = unmuteTime && humanizeDuration(unmuteTime);
|
|
||||||
|
|
||||||
// Create a case
|
|
||||||
const noteDetails = [];
|
|
||||||
if (unmuteTime) {
|
|
||||||
noteDetails.push(`Scheduled unmute in ${timeUntilUnmute}`);
|
|
||||||
} else {
|
|
||||||
noteDetails.push(`Unmuted immediately`);
|
|
||||||
}
|
|
||||||
if (!existingMute) {
|
|
||||||
noteDetails.push(`Removed external mute`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
|
|
||||||
const createdCase = await casesPlugin.createCase({
|
|
||||||
...caseArgs,
|
|
||||||
userId,
|
|
||||||
modId: caseArgs.modId,
|
|
||||||
type: CaseTypes.Unmute,
|
|
||||||
noteDetails,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log the action
|
|
||||||
const mod = this.bot.users.get(caseArgs.modId);
|
|
||||||
if (unmuteTime) {
|
|
||||||
this.serverLogs.log(LogType.MEMBER_TIMED_UNMUTE, {
|
|
||||||
mod: stripObjectToScalars(mod),
|
|
||||||
user: stripObjectToScalars(user),
|
|
||||||
time: timeUntilUnmute,
|
|
||||||
reason: caseArgs.reason,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.serverLogs.log(LogType.MEMBER_UNMUTE, {
|
|
||||||
mod: stripObjectToScalars(mod),
|
|
||||||
user: stripObjectToScalars(user),
|
|
||||||
reason: caseArgs.reason,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
case: createdCase,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public hasMutedRole(member: Member) {
|
|
||||||
if (member.roles.includes(this.getConfig().mute_role)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("mutes", [], {
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
name: "age",
|
|
||||||
shortcut: "a",
|
|
||||||
type: "delay",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "left",
|
|
||||||
shortcut: "l",
|
|
||||||
isSwitch: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "manual",
|
|
||||||
shortcut: "m",
|
|
||||||
isSwitch: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "export",
|
|
||||||
shortcut: "e",
|
|
||||||
isSwitch: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
@d.permission("can_view_list")
|
|
||||||
protected async muteListCmd(
|
|
||||||
msg: Message,
|
|
||||||
args: { age?: number; left?: boolean; manual?: boolean; export?: boolean },
|
|
||||||
) {
|
|
||||||
const listMessagePromise = msg.channel.createMessage("Loading mutes...");
|
|
||||||
const mutesPerPage = 10;
|
|
||||||
let totalMutes = 0;
|
|
||||||
let hasFilters = false;
|
|
||||||
|
|
||||||
let hasReactions = false;
|
|
||||||
let clearReactionsFn;
|
|
||||||
let clearReactionsTimeout;
|
|
||||||
const clearReactionsDebounce = 5 * MINUTES;
|
|
||||||
|
|
||||||
let lines = [];
|
|
||||||
|
|
||||||
// Active, logged mutes
|
|
||||||
const activeMutes = await this.mutes.getActiveMutes();
|
|
||||||
activeMutes.sort((a, b) => {
|
|
||||||
if (a.expires_at == null && b.expires_at != null) return 1;
|
|
||||||
if (b.expires_at == null && a.expires_at != null) return -1;
|
|
||||||
if (a.expires_at == null && b.expires_at == null) {
|
|
||||||
return a.created_at > b.created_at ? -1 : 1;
|
|
||||||
}
|
|
||||||
return a.expires_at > b.expires_at ? 1 : -1;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (args.manual) {
|
|
||||||
// Show only manual mutes (i.e. "Muted" role added without a logged mute)
|
|
||||||
const muteUserIds = new Set(activeMutes.map(m => m.user_id));
|
|
||||||
const manuallyMutedMembers = [];
|
|
||||||
const muteRole = this.getConfig().mute_role;
|
|
||||||
|
|
||||||
if (muteRole) {
|
|
||||||
this.guild.members.forEach(member => {
|
|
||||||
if (muteUserIds.has(member.id)) return;
|
|
||||||
if (member.roles.includes(muteRole)) manuallyMutedMembers.push(member);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
totalMutes = manuallyMutedMembers.length;
|
|
||||||
|
|
||||||
lines = manuallyMutedMembers.map(member => {
|
|
||||||
return `<@!${member.id}> (**${member.user.username}#${member.user.discriminator}**, \`${member.id}\`) 🔧 Manual mute`;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Show filtered active mutes (but not manual mutes)
|
|
||||||
let filteredMutes: IMuteWithDetails[] = activeMutes;
|
|
||||||
let bannedIds: string[] = null;
|
|
||||||
|
|
||||||
// Filter: mute age
|
|
||||||
if (args.age) {
|
|
||||||
const cutoff = moment()
|
|
||||||
.subtract(args.age, "ms")
|
|
||||||
.format(DBDateFormat);
|
|
||||||
filteredMutes = filteredMutes.filter(m => m.created_at <= cutoff);
|
|
||||||
hasFilters = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch some extra details for each mute: the muted member, and whether they've been banned
|
|
||||||
for (const [index, mute] of filteredMutes.entries()) {
|
|
||||||
const muteWithDetails = { ...mute };
|
|
||||||
|
|
||||||
const member = await this.getMember(mute.user_id);
|
|
||||||
|
|
||||||
if (!member) {
|
|
||||||
if (!bannedIds) {
|
|
||||||
const bans = await this.guild.getBans();
|
|
||||||
bannedIds = bans.map(u => u.user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
muteWithDetails.banned = bannedIds.includes(mute.user_id);
|
|
||||||
} else {
|
|
||||||
muteWithDetails.member = member;
|
|
||||||
}
|
|
||||||
|
|
||||||
filteredMutes[index] = muteWithDetails;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter: left the server
|
|
||||||
if (args.left != null) {
|
|
||||||
filteredMutes = filteredMutes.filter(m => (args.left && !m.member) || (!args.left && m.member));
|
|
||||||
hasFilters = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
totalMutes = filteredMutes.length;
|
|
||||||
|
|
||||||
// Create a message line for each mute
|
|
||||||
const caseIds = filteredMutes.map(m => m.case_id).filter(v => !!v);
|
|
||||||
const muteCases = caseIds.length ? await this.cases.get(caseIds) : [];
|
|
||||||
const muteCasesById = muteCases.reduce((map, c) => map.set(c.id, c), new Map());
|
|
||||||
|
|
||||||
lines = filteredMutes.map(mute => {
|
|
||||||
const user = this.bot.users.get(mute.user_id);
|
|
||||||
const username = user ? `${user.username}#${user.discriminator}` : "Unknown#0000";
|
|
||||||
const theCase = muteCasesById.get(mute.case_id);
|
|
||||||
const caseName = theCase ? `Case #${theCase.case_number}` : "No case";
|
|
||||||
|
|
||||||
let line = `<@!${mute.user_id}> (**${username}**, \`${mute.user_id}\`) 📋 ${caseName}`;
|
|
||||||
|
|
||||||
if (mute.expires_at) {
|
|
||||||
const timeUntilExpiry = moment().diff(moment(mute.expires_at, DBDateFormat));
|
|
||||||
const humanizedTime = humanizeDurationShort(timeUntilExpiry, { largest: 2, round: true });
|
|
||||||
line += ` ⏰ Expires in ${humanizedTime}`;
|
|
||||||
} else {
|
|
||||||
line += ` ⏰ Indefinite`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeFromMute = moment(mute.created_at, DBDateFormat).diff(moment());
|
|
||||||
const humanizedTimeFromMute = humanizeDurationShort(timeFromMute, { largest: 2, round: true });
|
|
||||||
line += ` 🕒 Muted ${humanizedTimeFromMute} ago`;
|
|
||||||
|
|
||||||
if (mute.banned) {
|
|
||||||
line += ` 🔨 Banned`;
|
|
||||||
} else if (!mute.member) {
|
|
||||||
line += ` ❌ Left server`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return line;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const listMessage = await listMessagePromise;
|
|
||||||
|
|
||||||
let currentPage = 1;
|
|
||||||
const totalPages = Math.ceil(lines.length / mutesPerPage);
|
|
||||||
|
|
||||||
const drawListPage = async page => {
|
|
||||||
page = Math.max(1, Math.min(totalPages, page));
|
|
||||||
currentPage = page;
|
|
||||||
|
|
||||||
const pageStart = (page - 1) * mutesPerPage;
|
|
||||||
const pageLines = lines.slice(pageStart, pageStart + mutesPerPage);
|
|
||||||
|
|
||||||
const pageRangeText = `${pageStart + 1}–${pageStart + pageLines.length} of ${totalMutes}`;
|
|
||||||
|
|
||||||
let message;
|
|
||||||
if (args.manual) {
|
|
||||||
message = `Showing manual mutes ${pageRangeText}:`;
|
|
||||||
} else if (hasFilters) {
|
|
||||||
message = `Showing filtered active mutes ${pageRangeText}:`;
|
|
||||||
} else {
|
|
||||||
message = `Showing active mutes ${pageRangeText}:`;
|
|
||||||
}
|
|
||||||
|
|
||||||
message += "\n\n" + pageLines.join("\n");
|
|
||||||
|
|
||||||
listMessage.edit(message);
|
|
||||||
bumpClearReactionsTimeout();
|
|
||||||
};
|
|
||||||
|
|
||||||
const bumpClearReactionsTimeout = () => {
|
|
||||||
if (!hasReactions) return;
|
|
||||||
clearTimeout(clearReactionsTimeout);
|
|
||||||
clearReactionsTimeout = setTimeout(clearReactionsFn, clearReactionsDebounce);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (totalMutes === 0) {
|
|
||||||
if (args.manual) {
|
|
||||||
listMessage.edit("No manual mutes found!");
|
|
||||||
} else if (hasFilters) {
|
|
||||||
listMessage.edit("No mutes found with the specified filters!");
|
|
||||||
} else {
|
|
||||||
listMessage.edit("No active mutes!");
|
|
||||||
}
|
|
||||||
} else if (args.export) {
|
|
||||||
const archiveId = await this.archives.create(lines.join("\n"), moment().add(1, "hour"));
|
|
||||||
const url = await this.archives.getUrl(this.knub.getGlobalConfig().url, archiveId);
|
|
||||||
|
|
||||||
await listMessage.edit(`Exported mutes: ${url}`);
|
|
||||||
} else {
|
|
||||||
drawListPage(1);
|
|
||||||
|
|
||||||
if (totalPages > 1) {
|
|
||||||
hasReactions = true;
|
|
||||||
listMessage.addReaction("⬅");
|
|
||||||
listMessage.addReaction("➡");
|
|
||||||
|
|
||||||
const removeListenerFn = this.on("messageReactionAdd", (rMsg: Message, emoji, userId) => {
|
|
||||||
if (rMsg.id !== listMessage.id) return;
|
|
||||||
if (userId !== msg.author.id) return;
|
|
||||||
if (!["⬅", "➡"].includes(emoji.name)) return;
|
|
||||||
|
|
||||||
if (emoji.name === "⬅" && currentPage > 1) {
|
|
||||||
drawListPage(currentPage - 1);
|
|
||||||
} else if (emoji.name === "➡" && currentPage < totalPages) {
|
|
||||||
drawListPage(currentPage + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
rMsg.removeReaction(emoji.name, userId).catch(noop);
|
|
||||||
});
|
|
||||||
|
|
||||||
clearReactionsFn = () => {
|
|
||||||
listMessage.removeReactions().catch(noop);
|
|
||||||
removeListenerFn();
|
|
||||||
};
|
|
||||||
bumpClearReactionsTimeout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reapply active mutes on join
|
|
||||||
*/
|
|
||||||
@d.event("guildMemberAdd")
|
|
||||||
protected async onGuildMemberAdd(_, member: Member) {
|
|
||||||
const mute = await this.mutes.findExistingMuteForUserId(member.id);
|
|
||||||
if (mute) {
|
|
||||||
const muteRole = this.getConfig().mute_role;
|
|
||||||
await member.addRole(muteRole);
|
|
||||||
|
|
||||||
this.serverLogs.log(LogType.MEMBER_MUTE_REJOIN, {
|
|
||||||
member: stripObjectToScalars(member, ["user", "roles"]),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear active mute from the member if the member is banned
|
|
||||||
*/
|
|
||||||
@d.event("guildBanAdd")
|
|
||||||
protected async onGuildBanAdd(_, user: User) {
|
|
||||||
const mute = await this.mutes.findExistingMuteForUserId(user.id);
|
|
||||||
if (mute) {
|
|
||||||
this.mutes.clear(user.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* COMMAND: Clear dangling mutes for members who have been banned
|
|
||||||
*/
|
|
||||||
@d.command("clear_banned_mutes")
|
|
||||||
@d.permission("can_cleanup")
|
|
||||||
protected async clearBannedMutesCmd(msg: Message) {
|
|
||||||
await msg.channel.createMessage("Clearing mutes from banned users...");
|
|
||||||
|
|
||||||
const activeMutes = await this.mutes.getActiveMutes();
|
|
||||||
|
|
||||||
// Mismatch in Eris docs and actual result here, based on Eris's code comments anyway
|
|
||||||
const bans: Array<{ reason: string; user: User }> = (await this.guild.getBans()) as any;
|
|
||||||
const bannedIds = bans.map(b => b.user.id);
|
|
||||||
|
|
||||||
await msg.channel.createMessage(
|
|
||||||
`Found ${activeMutes.length} mutes and ${bannedIds.length} bans, cross-referencing...`,
|
|
||||||
);
|
|
||||||
|
|
||||||
let cleared = 0;
|
|
||||||
for (const mute of activeMutes) {
|
|
||||||
if (bannedIds.includes(mute.user_id)) {
|
|
||||||
await this.mutes.clear(mute.user_id);
|
|
||||||
cleared++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sendSuccessMessage(msg.channel, `Cleared ${cleared} mutes from banned users!`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear active mute from the member if the mute role is removed
|
|
||||||
*/
|
|
||||||
@d.event("guildMemberUpdate")
|
|
||||||
protected async onGuildMemberUpdate(_, member: Member) {
|
|
||||||
const muteRole = this.getConfig().mute_role;
|
|
||||||
if (!muteRole) return;
|
|
||||||
|
|
||||||
const mute = await this.mutes.findExistingMuteForUserId(member.id);
|
|
||||||
if (!mute) return;
|
|
||||||
|
|
||||||
if (!member.roles.includes(muteRole)) {
|
|
||||||
await this.mutes.clear(muteRole);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* COMMAND: Clear dangling mutes for members whose mute role was removed by other means
|
|
||||||
*/
|
|
||||||
@d.command("clear_mutes_without_role")
|
|
||||||
@d.permission("can_cleanup")
|
|
||||||
protected async clearMutesWithoutRoleCmd(msg: Message) {
|
|
||||||
const activeMutes = await this.mutes.getActiveMutes();
|
|
||||||
const muteRole = this.getConfig().mute_role;
|
|
||||||
if (!muteRole) return;
|
|
||||||
|
|
||||||
await msg.channel.createMessage("Clearing mutes from members that don't have the mute role...");
|
|
||||||
|
|
||||||
let cleared = 0;
|
|
||||||
for (const mute of activeMutes) {
|
|
||||||
const member = await this.getMember(mute.user_id);
|
|
||||||
if (!member) continue;
|
|
||||||
|
|
||||||
if (!member.roles.includes(muteRole)) {
|
|
||||||
await this.mutes.clear(mute.user_id);
|
|
||||||
cleared++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sendSuccessMessage(msg.channel, `Cleared ${cleared} mutes from members that don't have the mute role`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("clear_mute", "<userIds:string...>")
|
|
||||||
@d.permission("can_cleanup")
|
|
||||||
protected async clearMuteCmd(msg: Message, args: { userIds: string[] }) {
|
|
||||||
const failed = [];
|
|
||||||
for (const id of args.userIds) {
|
|
||||||
const mute = await this.mutes.findExistingMuteForUserId(id);
|
|
||||||
if (!mute) {
|
|
||||||
failed.push(id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await this.mutes.clear(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failed.length !== args.userIds.length) {
|
|
||||||
this.sendSuccessMessage(msg.channel, `**${args.userIds.length - failed.length} active mute(s) cleared**`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failed.length) {
|
|
||||||
this.sendErrorMessage(
|
|
||||||
msg.channel,
|
|
||||||
`**${failed.length}/${args.userIds.length} IDs failed**, they are not muted: ${failed.join(" ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async clearExpiredMutes() {
|
|
||||||
const expiredMutes = await this.mutes.getExpiredMutes();
|
|
||||||
for (const mute of expiredMutes) {
|
|
||||||
const member = await this.getMember(mute.user_id);
|
|
||||||
|
|
||||||
if (member) {
|
|
||||||
try {
|
|
||||||
await member.removeRole(this.getConfig().mute_role);
|
|
||||||
} catch (e) {
|
|
||||||
this.serverLogs.log(LogType.BOT_ALERT, {
|
|
||||||
body: `Failed to remove mute role from {userMention(member)}`,
|
|
||||||
member: stripObjectToScalars(member),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.mutes.clear(mute.user_id);
|
|
||||||
|
|
||||||
this.serverLogs.log(LogType.MEMBER_MUTE_EXPIRED, {
|
|
||||||
member: member
|
|
||||||
? stripObjectToScalars(member, ["user", "roles"])
|
|
||||||
: { id: mute.user_id, user: new UnknownUser({ id: mute.user_id }) },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,105 +0,0 @@
|
||||||
import { decorators as d, IPluginOptions } from "knub";
|
|
||||||
import { GuildNicknameHistory, MAX_NICKNAME_ENTRIES_PER_USER } from "../data/GuildNicknameHistory";
|
|
||||||
import { Member, Message } from "eris";
|
|
||||||
import { ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import { createChunkedMessage, DAYS, disableCodeBlocks } from "../utils";
|
|
||||||
import { MAX_USERNAME_ENTRIES_PER_USER, UsernameHistory } from "../data/UsernameHistory";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
import { NICKNAME_RETENTION_PERIOD } from "../data/cleanup/nicknames";
|
|
||||||
import moment from "moment-timezone";
|
|
||||||
import { USERNAME_RETENTION_PERIOD } from "../data/cleanup/usernames";
|
|
||||||
import { Queue } from "../Queue";
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
can_view: t.boolean,
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
export class NameHistoryPlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "name_history";
|
|
||||||
public static showInDocs = false;
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
protected nicknameHistory: GuildNicknameHistory;
|
|
||||||
protected usernameHistory: UsernameHistory;
|
|
||||||
|
|
||||||
protected updateQueue: Queue;
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
can_view: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
level: ">=50",
|
|
||||||
config: {
|
|
||||||
can_view: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoad() {
|
|
||||||
this.nicknameHistory = GuildNicknameHistory.getGuildInstance(this.guildId);
|
|
||||||
this.usernameHistory = new UsernameHistory();
|
|
||||||
this.updateQueue = new Queue();
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("names", "<userId:userId>")
|
|
||||||
@d.permission("can_view")
|
|
||||||
async namesCmd(msg: Message, args: { userId: string }) {
|
|
||||||
const nicknames = await this.nicknameHistory.getByUserId(args.userId);
|
|
||||||
const usernames = await this.usernameHistory.getByUserId(args.userId);
|
|
||||||
|
|
||||||
if (nicknames.length === 0 && usernames.length === 0) {
|
|
||||||
return this.sendErrorMessage(msg.channel, "No name history found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const nicknameRows = nicknames.map(
|
|
||||||
r => `\`[${r.timestamp}]\` ${r.nickname ? `**${disableCodeBlocks(r.nickname)}**` : "*None*"}`,
|
|
||||||
);
|
|
||||||
const usernameRows = usernames.map(r => `\`[${r.timestamp}]\` **${disableCodeBlocks(r.username)}**`);
|
|
||||||
|
|
||||||
const user = this.bot.users.get(args.userId);
|
|
||||||
const currentUsername = user ? `${user.username}#${user.discriminator}` : args.userId;
|
|
||||||
|
|
||||||
const nicknameDays = Math.round(NICKNAME_RETENTION_PERIOD / DAYS);
|
|
||||||
const usernameDays = Math.round(USERNAME_RETENTION_PERIOD / DAYS);
|
|
||||||
|
|
||||||
let message = `Name history for **${currentUsername}**:`;
|
|
||||||
if (nicknameRows.length) {
|
|
||||||
message += `\n\n__Last ${MAX_NICKNAME_ENTRIES_PER_USER} nicknames within ${nicknameDays} days:__\n${nicknameRows.join(
|
|
||||||
"\n",
|
|
||||||
)}`;
|
|
||||||
}
|
|
||||||
if (usernameRows.length) {
|
|
||||||
message += `\n\n__Last ${MAX_USERNAME_ENTRIES_PER_USER} usernames within ${usernameDays} days:__\n${usernameRows.join(
|
|
||||||
"\n",
|
|
||||||
)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
createChunkedMessage(msg.channel, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateNickname(member: Member) {
|
|
||||||
if (!member) return;
|
|
||||||
const latestEntry = await this.nicknameHistory.getLastEntry(member.id);
|
|
||||||
if (!latestEntry || latestEntry.nickname !== member.nick) {
|
|
||||||
if (!latestEntry && member.nick == null) return; // No need to save "no nickname" if there's no previous data
|
|
||||||
await this.nicknameHistory.addEntry(member.id, member.nick);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("messageCreate")
|
|
||||||
async onMessage(msg: Message) {
|
|
||||||
this.updateQueue.add(() => this.updateNickname(msg.member));
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("voiceChannelJoin")
|
|
||||||
async onVoiceChannelJoin(member: Member) {
|
|
||||||
this.updateQueue.add(() => this.updateNickname(member));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,112 +0,0 @@
|
||||||
import { decorators as d, IPluginOptions } from "knub";
|
|
||||||
import { GuildPersistedData, IPartialPersistData } from "../data/GuildPersistedData";
|
|
||||||
import intersection from "lodash.intersection";
|
|
||||||
import { Member, MemberOptions } from "eris";
|
|
||||||
import { GuildLogs } from "../data/GuildLogs";
|
|
||||||
import { LogType } from "../data/LogType";
|
|
||||||
import { stripObjectToScalars } from "../utils";
|
|
||||||
import { trimPluginDescription, ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
persisted_roles: t.array(t.string),
|
|
||||||
persist_nicknames: t.boolean,
|
|
||||||
persist_voice_mutes: t.boolean, // Deprecated, here to not break old configs
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
export class PersistPlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "persist";
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
public static pluginInfo = {
|
|
||||||
prettyName: "Persist",
|
|
||||||
description: trimPluginDescription(`
|
|
||||||
Blah
|
|
||||||
`),
|
|
||||||
};
|
|
||||||
|
|
||||||
protected persistedData: GuildPersistedData;
|
|
||||||
protected logs: GuildLogs;
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
persisted_roles: [],
|
|
||||||
persist_nicknames: false,
|
|
||||||
persist_voice_mutes: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoad() {
|
|
||||||
this.persistedData = GuildPersistedData.getGuildInstance(this.guildId);
|
|
||||||
this.logs = new GuildLogs(this.guildId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("guildMemberRemove")
|
|
||||||
onGuildMemberRemove(_, member: Member) {
|
|
||||||
let persist = false;
|
|
||||||
const persistData: IPartialPersistData = {};
|
|
||||||
const config = this.getConfig();
|
|
||||||
|
|
||||||
const persistedRoles = config.persisted_roles;
|
|
||||||
if (persistedRoles.length && member.roles) {
|
|
||||||
const rolesToPersist = intersection(persistedRoles, member.roles);
|
|
||||||
if (rolesToPersist.length) {
|
|
||||||
persist = true;
|
|
||||||
persistData.roles = rolesToPersist;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.persist_nicknames && member.nick) {
|
|
||||||
persist = true;
|
|
||||||
persistData.nickname = member.nick;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (persist) {
|
|
||||||
this.persistedData.set(member.id, persistData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("guildMemberAdd")
|
|
||||||
async onGuildMemberAdd(_, member: Member) {
|
|
||||||
const memberRolesLock = await this.locks.acquire(`member-roles-${member.id}`);
|
|
||||||
|
|
||||||
const persistedData = await this.persistedData.find(member.id);
|
|
||||||
if (!persistedData) {
|
|
||||||
memberRolesLock.unlock();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toRestore: MemberOptions = {};
|
|
||||||
const config = this.getConfig();
|
|
||||||
const restoredData = [];
|
|
||||||
|
|
||||||
const persistedRoles = config.persisted_roles;
|
|
||||||
if (persistedRoles.length) {
|
|
||||||
const rolesToRestore = intersection(persistedRoles, persistedData.roles);
|
|
||||||
if (rolesToRestore.length) {
|
|
||||||
restoredData.push("roles");
|
|
||||||
toRestore.roles = Array.from(new Set([...rolesToRestore, ...member.roles]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.persist_nicknames && persistedData.nickname) {
|
|
||||||
restoredData.push("nickname");
|
|
||||||
toRestore.nick = persistedData.nickname;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (restoredData.length) {
|
|
||||||
await member.edit(toRestore, "Restored upon rejoin");
|
|
||||||
await this.persistedData.clear(member.id);
|
|
||||||
|
|
||||||
this.logs.log(LogType.MEMBER_RESTORE, {
|
|
||||||
member: stripObjectToScalars(member, ["user", "roles"]),
|
|
||||||
restoredData: restoredData.join(", "),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
memberRolesLock.unlock();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,148 +0,0 @@
|
||||||
import { decorators as d, IPluginOptions } from "knub";
|
|
||||||
import { Message, Role, TextableChannel } from "eris";
|
|
||||||
import { GuildPingableRoles } from "../data/GuildPingableRoles";
|
|
||||||
import { PingableRole } from "../data/entities/PingableRole";
|
|
||||||
import { errorMessage, successMessage } from "../utils";
|
|
||||||
import { ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
can_manage: t.boolean,
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
const TIMEOUT = 10 * 1000;
|
|
||||||
|
|
||||||
export class PingableRolesPlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "pingable_roles";
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
public static pluginInfo = {
|
|
||||||
prettyName: "Pingable roles",
|
|
||||||
};
|
|
||||||
|
|
||||||
protected pingableRoles: GuildPingableRoles;
|
|
||||||
protected cache: Map<string, PingableRole[]>;
|
|
||||||
protected timeouts: Map<string, any>;
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
can_manage: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
level: ">=100",
|
|
||||||
config: {
|
|
||||||
can_manage: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoad() {
|
|
||||||
this.pingableRoles = GuildPingableRoles.getGuildInstance(this.guildId);
|
|
||||||
|
|
||||||
this.cache = new Map();
|
|
||||||
this.timeouts = new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async getPingableRolesForChannel(channelId: string): Promise<PingableRole[]> {
|
|
||||||
if (!this.cache.has(channelId)) {
|
|
||||||
this.cache.set(channelId, await this.pingableRoles.getForChannel(channelId));
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.cache.get(channelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("pingable_role disable", "<channelId:channelId> <role:role>")
|
|
||||||
@d.permission("can_manage")
|
|
||||||
async disablePingableRoleCmd(msg: Message, args: { channelId: string; role: Role }) {
|
|
||||||
const pingableRole = await this.pingableRoles.getByChannelAndRoleId(args.channelId, args.role.id);
|
|
||||||
if (!pingableRole) {
|
|
||||||
msg.channel.createMessage(errorMessage(`**${args.role.name}** is not set as pingable in <#${args.channelId}>`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.pingableRoles.delete(args.channelId, args.role.id);
|
|
||||||
this.cache.delete(args.channelId);
|
|
||||||
|
|
||||||
this.sendSuccessMessage(msg.channel, `**${args.role.name}** is no longer set as pingable in <#${args.channelId}>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("pingable_role", "<channelId:channelId> <role:role>")
|
|
||||||
@d.permission("can_manage")
|
|
||||||
async setPingableRoleCmd(msg: Message, args: { channelId: string; role: Role }) {
|
|
||||||
const existingPingableRole = await this.pingableRoles.getByChannelAndRoleId(args.channelId, args.role.id);
|
|
||||||
if (existingPingableRole) {
|
|
||||||
msg.channel.createMessage(
|
|
||||||
errorMessage(`**${args.role.name}** is already set as pingable in <#${args.channelId}>`),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.pingableRoles.add(args.channelId, args.role.id);
|
|
||||||
this.cache.delete(args.channelId);
|
|
||||||
|
|
||||||
this.sendSuccessMessage(msg.channel, `**${args.role.name}** has been set as pingable in <#${args.channelId}>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("typingStart")
|
|
||||||
async onTypingStart(channel: TextableChannel) {
|
|
||||||
const pingableRoles = await this.getPingableRolesForChannel(channel.id);
|
|
||||||
if (pingableRoles.length === 0) return;
|
|
||||||
|
|
||||||
if (this.timeouts.has(channel.id)) {
|
|
||||||
clearTimeout(this.timeouts.get(channel.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.enablePingableRoles(pingableRoles);
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
this.disablePingableRoles(pingableRoles);
|
|
||||||
}, TIMEOUT);
|
|
||||||
this.timeouts.set(channel.id, timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("messageCreate")
|
|
||||||
async onMessageCreate(msg: Message) {
|
|
||||||
const pingableRoles = await this.getPingableRolesForChannel(msg.channel.id);
|
|
||||||
if (pingableRoles.length === 0) return;
|
|
||||||
|
|
||||||
if (this.timeouts.has(msg.channel.id)) {
|
|
||||||
clearTimeout(this.timeouts.get(msg.channel.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.disablePingableRoles(pingableRoles);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected enablePingableRoles(pingableRoles: PingableRole[]) {
|
|
||||||
for (const pingableRole of pingableRoles) {
|
|
||||||
const role = this.guild.roles.get(pingableRole.role_id);
|
|
||||||
if (!role) continue;
|
|
||||||
|
|
||||||
role.edit(
|
|
||||||
{
|
|
||||||
mentionable: true,
|
|
||||||
},
|
|
||||||
"Enable pingable role",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected disablePingableRoles(pingableRoles: PingableRole[]) {
|
|
||||||
for (const pingableRole of pingableRoles) {
|
|
||||||
const role = this.guild.roles.get(pingableRole.role_id);
|
|
||||||
if (!role) continue;
|
|
||||||
|
|
||||||
role.edit(
|
|
||||||
{
|
|
||||||
mentionable: false,
|
|
||||||
},
|
|
||||||
"Disable pingable role",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,698 +0,0 @@
|
||||||
import { decorators as d, IPluginOptions, logger } from "knub";
|
|
||||||
import { Attachment, Channel, EmbedBase, Message, MessageContent, Role, TextChannel, User } from "eris";
|
|
||||||
import {
|
|
||||||
errorMessage,
|
|
||||||
downloadFile,
|
|
||||||
getRoleMentions,
|
|
||||||
trimLines,
|
|
||||||
DBDateFormat,
|
|
||||||
convertDelayStringToMS,
|
|
||||||
SECONDS,
|
|
||||||
sorter,
|
|
||||||
disableCodeBlocks,
|
|
||||||
deactivateMentions,
|
|
||||||
createChunkedMessage,
|
|
||||||
stripObjectToScalars,
|
|
||||||
isValidEmbed,
|
|
||||||
MINUTES,
|
|
||||||
StrictMessageContent,
|
|
||||||
DAYS,
|
|
||||||
} from "../utils";
|
|
||||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
|
||||||
import { ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
|
|
||||||
import fs from "fs";
|
|
||||||
import { GuildScheduledPosts } from "../data/GuildScheduledPosts";
|
|
||||||
import moment, { Moment } from "moment-timezone";
|
|
||||||
import { GuildLogs } from "../data/GuildLogs";
|
|
||||||
import { LogType } from "../data/LogType";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
import humanizeDuration from "humanize-duration";
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
can_post: t.boolean,
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
const fsp = fs.promises;
|
|
||||||
|
|
||||||
const COLOR_MATCH_REGEX = /^#?([0-9a-f]{6})$/;
|
|
||||||
|
|
||||||
const SCHEDULED_POST_CHECK_INTERVAL = 5 * SECONDS;
|
|
||||||
const SCHEDULED_POST_PREVIEW_TEXT_LENGTH = 50;
|
|
||||||
|
|
||||||
const MIN_REPEAT_TIME = 5 * MINUTES;
|
|
||||||
const MAX_REPEAT_TIME = Math.pow(2, 32);
|
|
||||||
const MAX_REPEAT_UNTIL = moment().add(100, "years");
|
|
||||||
|
|
||||||
export class PostPlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "post";
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
public static pluginInfo = {
|
|
||||||
prettyName: "Post",
|
|
||||||
};
|
|
||||||
|
|
||||||
protected savedMessages: GuildSavedMessages;
|
|
||||||
protected scheduledPosts: GuildScheduledPosts;
|
|
||||||
protected logs: GuildLogs;
|
|
||||||
|
|
||||||
private scheduledPostLoopTimeout;
|
|
||||||
|
|
||||||
onLoad() {
|
|
||||||
this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId);
|
|
||||||
this.scheduledPosts = GuildScheduledPosts.getGuildInstance(this.guildId);
|
|
||||||
this.logs = new GuildLogs(this.guildId);
|
|
||||||
|
|
||||||
this.scheduledPostLoop();
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnload() {
|
|
||||||
clearTimeout(this.scheduledPostLoopTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
can_post: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
level: ">=100",
|
|
||||||
config: {
|
|
||||||
can_post: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
protected formatContent(str) {
|
|
||||||
return str.replace(/\\n/g, "\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async postMessage(
|
|
||||||
channel: TextChannel,
|
|
||||||
content: MessageContent,
|
|
||||||
attachments: Attachment[] = [],
|
|
||||||
enableMentions: boolean = false,
|
|
||||||
): Promise<Message> {
|
|
||||||
if (typeof content === "string") {
|
|
||||||
content = { content };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content && content.content) {
|
|
||||||
content.content = this.formatContent(content.content);
|
|
||||||
}
|
|
||||||
|
|
||||||
let downloadedAttachment;
|
|
||||||
let file;
|
|
||||||
if (attachments.length) {
|
|
||||||
downloadedAttachment = await downloadFile(attachments[0].url);
|
|
||||||
file = {
|
|
||||||
name: attachments[0].filename,
|
|
||||||
file: await fsp.readFile(downloadedAttachment.path),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const rolesMadeMentionable: Role[] = [];
|
|
||||||
if (enableMentions && content.content) {
|
|
||||||
const mentionedRoleIds = getRoleMentions(content.content);
|
|
||||||
if (mentionedRoleIds != null) {
|
|
||||||
for (const roleId of mentionedRoleIds) {
|
|
||||||
const role = this.guild.roles.get(roleId);
|
|
||||||
if (role && !role.mentionable) {
|
|
||||||
await role.edit({
|
|
||||||
mentionable: true,
|
|
||||||
});
|
|
||||||
rolesMadeMentionable.push(role);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
content.disableEveryone = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdMsg = await channel.createMessage(content, file);
|
|
||||||
this.savedMessages.setPermanent(createdMsg.id);
|
|
||||||
|
|
||||||
for (const role of rolesMadeMentionable) {
|
|
||||||
role.edit({
|
|
||||||
mentionable: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (downloadedAttachment) {
|
|
||||||
downloadedAttachment.deleteFn();
|
|
||||||
}
|
|
||||||
|
|
||||||
return createdMsg;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected parseScheduleTime(str): Moment {
|
|
||||||
const dt1 = moment(str, "YYYY-MM-DD HH:mm:ss");
|
|
||||||
if (dt1 && dt1.isValid()) return dt1;
|
|
||||||
|
|
||||||
const dt2 = moment(str, "YYYY-MM-DD HH:mm");
|
|
||||||
if (dt2 && dt2.isValid()) return dt2;
|
|
||||||
|
|
||||||
const date = moment(str, "YYYY-MM-DD");
|
|
||||||
if (date && date.isValid()) return date;
|
|
||||||
|
|
||||||
const t1 = moment(str, "HH:mm:ss");
|
|
||||||
if (t1 && t1.isValid()) {
|
|
||||||
if (t1.isBefore(moment())) t1.add(1, "day");
|
|
||||||
return t1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const t2 = moment(str, "HH:mm");
|
|
||||||
if (t2 && t2.isValid()) {
|
|
||||||
if (t2.isBefore(moment())) t2.add(1, "day");
|
|
||||||
return t2;
|
|
||||||
}
|
|
||||||
|
|
||||||
const delayStringMS = convertDelayStringToMS(str, "m");
|
|
||||||
if (delayStringMS) {
|
|
||||||
return moment().add(delayStringMS, "ms");
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async scheduledPostLoop() {
|
|
||||||
const duePosts = await this.scheduledPosts.getDueScheduledPosts();
|
|
||||||
for (const post of duePosts) {
|
|
||||||
const channel = this.guild.channels.get(post.channel_id);
|
|
||||||
if (channel instanceof TextChannel) {
|
|
||||||
const [username, discriminator] = post.author_name.split("#");
|
|
||||||
const author: Partial<User> = this.bot.users.get(post.author_id) || {
|
|
||||||
id: post.author_id,
|
|
||||||
username,
|
|
||||||
discriminator,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const postedMessage = await this.postMessage(channel, post.content, post.attachments, post.enable_mentions);
|
|
||||||
this.logs.log(LogType.POSTED_SCHEDULED_MESSAGE, {
|
|
||||||
author: stripObjectToScalars(author),
|
|
||||||
channel: stripObjectToScalars(channel),
|
|
||||||
messageId: postedMessage.id,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
this.logs.log(LogType.BOT_ALERT, {
|
|
||||||
body: `Failed to post scheduled message by {userMention(author)} to {channelMention(channel)}`,
|
|
||||||
channel: stripObjectToScalars(channel),
|
|
||||||
author: stripObjectToScalars(author),
|
|
||||||
});
|
|
||||||
logger.warn(
|
|
||||||
`Failed to post scheduled message to #${channel.name} (${channel.id}) on ${this.guild.name} (${this.guildId})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let shouldClear = true;
|
|
||||||
|
|
||||||
if (post.repeat_interval) {
|
|
||||||
const nextPostAt = moment().add(post.repeat_interval, "ms");
|
|
||||||
|
|
||||||
if (post.repeat_until) {
|
|
||||||
const repeatUntil = moment(post.repeat_until, DBDateFormat);
|
|
||||||
if (nextPostAt.isSameOrBefore(repeatUntil)) {
|
|
||||||
await this.scheduledPosts.update(post.id, {
|
|
||||||
post_at: nextPostAt.format(DBDateFormat),
|
|
||||||
});
|
|
||||||
shouldClear = false;
|
|
||||||
}
|
|
||||||
} else if (post.repeat_times) {
|
|
||||||
if (post.repeat_times > 1) {
|
|
||||||
await this.scheduledPosts.update(post.id, {
|
|
||||||
post_at: nextPostAt.format(DBDateFormat),
|
|
||||||
repeat_times: post.repeat_times - 1,
|
|
||||||
});
|
|
||||||
shouldClear = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldClear) {
|
|
||||||
await this.scheduledPosts.delete(post.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.scheduledPostLoopTimeout = setTimeout(() => this.scheduledPostLoop(), SCHEDULED_POST_CHECK_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Since !post and !post_embed have a lot of overlap for post scheduling, repeating, etc., that functionality is abstracted out to here
|
|
||||||
*/
|
|
||||||
async actualPostCmd(
|
|
||||||
msg: Message,
|
|
||||||
targetChannel: Channel,
|
|
||||||
content: StrictMessageContent,
|
|
||||||
opts?: {
|
|
||||||
"enable-mentions"?: boolean;
|
|
||||||
schedule?: string;
|
|
||||||
repeat?: number;
|
|
||||||
"repeat-until"?: string;
|
|
||||||
"repeat-times"?: number;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
if (!(targetChannel instanceof TextChannel)) {
|
|
||||||
msg.channel.createMessage(errorMessage("Channel is not a text channel"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content == null && msg.attachments.length === 0) {
|
|
||||||
msg.channel.createMessage(errorMessage("Message content or attachment required"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.repeat) {
|
|
||||||
if (opts.repeat < MIN_REPEAT_TIME) {
|
|
||||||
return this.sendErrorMessage(msg.channel, `Minimum time for -repeat is ${humanizeDuration(MIN_REPEAT_TIME)}`);
|
|
||||||
}
|
|
||||||
if (opts.repeat > MAX_REPEAT_TIME) {
|
|
||||||
return this.sendErrorMessage(msg.channel, `Max time for -repeat is ${humanizeDuration(MAX_REPEAT_TIME)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If this is a scheduled or repeated post, figure out the next post date
|
|
||||||
let postAt;
|
|
||||||
if (opts.schedule) {
|
|
||||||
// Schedule the post to be posted later
|
|
||||||
postAt = this.parseScheduleTime(opts.schedule);
|
|
||||||
if (!postAt) {
|
|
||||||
return this.sendErrorMessage(msg.channel, "Invalid schedule time");
|
|
||||||
}
|
|
||||||
} else if (opts.repeat) {
|
|
||||||
postAt = moment().add(opts.repeat, "ms");
|
|
||||||
}
|
|
||||||
|
|
||||||
// For repeated posts, make sure repeat-until or repeat-times is specified
|
|
||||||
let repeatUntil: moment.Moment = null;
|
|
||||||
let repeatTimes: number = null;
|
|
||||||
let repeatDetailsStr: string = null;
|
|
||||||
|
|
||||||
if (opts["repeat-until"]) {
|
|
||||||
repeatUntil = this.parseScheduleTime(opts["repeat-until"]);
|
|
||||||
|
|
||||||
// Invalid time
|
|
||||||
if (!repeatUntil) {
|
|
||||||
return this.sendErrorMessage(msg.channel, "Invalid time specified for -repeat-until");
|
|
||||||
}
|
|
||||||
if (repeatUntil.isBefore(moment())) {
|
|
||||||
return this.sendErrorMessage(msg.channel, "You can't set -repeat-until in the past");
|
|
||||||
}
|
|
||||||
if (repeatUntil.isAfter(MAX_REPEAT_UNTIL)) {
|
|
||||||
return this.sendErrorMessage(
|
|
||||||
msg.channel,
|
|
||||||
"Unfortunately, -repeat-until can only be at most 100 years into the future. Maybe 99 years would be enough?",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (opts["repeat-times"]) {
|
|
||||||
repeatTimes = opts["repeat-times"];
|
|
||||||
if (repeatTimes <= 0) {
|
|
||||||
return this.sendErrorMessage(msg.channel, "-repeat-times must be 1 or more");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (repeatUntil && repeatTimes) {
|
|
||||||
return this.sendErrorMessage(msg.channel, "You can only use one of -repeat-until or -repeat-times at once");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.repeat && !repeatUntil && !repeatTimes) {
|
|
||||||
return this.sendErrorMessage(
|
|
||||||
msg.channel,
|
|
||||||
"You must specify -repeat-until or -repeat-times for repeated messages",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.repeat) {
|
|
||||||
repeatDetailsStr = repeatUntil
|
|
||||||
? `every ${humanizeDuration(opts.repeat)} until ${repeatUntil.format(DBDateFormat)}`
|
|
||||||
: `every ${humanizeDuration(opts.repeat)}, ${repeatTimes} times in total`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save schedule/repeat information in DB
|
|
||||||
if (postAt) {
|
|
||||||
if (postAt < moment()) {
|
|
||||||
return this.sendErrorMessage(msg.channel, "Post can't be scheduled to be posted in the past");
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.scheduledPosts.create({
|
|
||||||
author_id: msg.author.id,
|
|
||||||
author_name: `${msg.author.username}#${msg.author.discriminator}`,
|
|
||||||
channel_id: targetChannel.id,
|
|
||||||
content,
|
|
||||||
attachments: msg.attachments,
|
|
||||||
post_at: postAt.format(DBDateFormat),
|
|
||||||
enable_mentions: opts["enable-mentions"],
|
|
||||||
repeat_interval: opts.repeat,
|
|
||||||
repeat_until: repeatUntil ? repeatUntil.format(DBDateFormat) : null,
|
|
||||||
repeat_times: repeatTimes ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (opts.repeat) {
|
|
||||||
this.logs.log(LogType.SCHEDULED_REPEATED_MESSAGE, {
|
|
||||||
author: stripObjectToScalars(msg.author),
|
|
||||||
channel: stripObjectToScalars(targetChannel),
|
|
||||||
date: postAt.format("YYYY-MM-DD"),
|
|
||||||
time: postAt.format("HH:mm:ss"),
|
|
||||||
repeatInterval: humanizeDuration(opts.repeat),
|
|
||||||
repeatDetails: repeatDetailsStr,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.logs.log(LogType.SCHEDULED_MESSAGE, {
|
|
||||||
author: stripObjectToScalars(msg.author),
|
|
||||||
channel: stripObjectToScalars(targetChannel),
|
|
||||||
date: postAt.format("YYYY-MM-DD"),
|
|
||||||
time: postAt.format("HH:mm:ss"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// When the message isn't scheduled for later, post it immediately
|
|
||||||
if (!opts.schedule) {
|
|
||||||
await this.postMessage(targetChannel, content, msg.attachments, opts["enable-mentions"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.repeat) {
|
|
||||||
this.logs.log(LogType.REPEATED_MESSAGE, {
|
|
||||||
author: stripObjectToScalars(msg.author),
|
|
||||||
channel: stripObjectToScalars(targetChannel),
|
|
||||||
date: postAt.format("YYYY-MM-DD"),
|
|
||||||
time: postAt.format("HH:mm:ss"),
|
|
||||||
repeatInterval: humanizeDuration(opts.repeat),
|
|
||||||
repeatDetails: repeatDetailsStr,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bot reply schenanigans
|
|
||||||
let successMessage = opts.schedule
|
|
||||||
? `Message scheduled to be posted in <#${targetChannel.id}> on ${postAt.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`
|
|
||||||
: `Message posted in <#${targetChannel.id}>`;
|
|
||||||
|
|
||||||
if (opts.repeat) {
|
|
||||||
successMessage += `. Message will be automatically reposted every ${humanizeDuration(opts.repeat)}`;
|
|
||||||
|
|
||||||
if (repeatUntil) {
|
|
||||||
successMessage += ` until ${repeatUntil.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`;
|
|
||||||
} else if (repeatTimes) {
|
|
||||||
successMessage += `, ${repeatTimes} times in total`;
|
|
||||||
}
|
|
||||||
|
|
||||||
successMessage += ".";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetChannel.id !== msg.channel.id || opts.schedule || opts.repeat) {
|
|
||||||
this.sendSuccessMessage(msg.channel, successMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* COMMAND: Post a regular text message as the bot to the specified channel
|
|
||||||
*/
|
|
||||||
@d.command("post", "<channel:channel> [content:string$]", {
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
name: "enable-mentions",
|
|
||||||
isSwitch: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "schedule",
|
|
||||||
type: "string",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "repeat",
|
|
||||||
type: "delay",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "repeat-until",
|
|
||||||
type: "string",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "repeat-times",
|
|
||||||
type: "number",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
@d.permission("can_post")
|
|
||||||
async postCmd(
|
|
||||||
msg: Message,
|
|
||||||
args: {
|
|
||||||
channel: Channel;
|
|
||||||
content?: string;
|
|
||||||
"enable-mentions": boolean;
|
|
||||||
schedule?: string;
|
|
||||||
repeat?: number;
|
|
||||||
"repeat-until"?: string;
|
|
||||||
"repeat-times"?: number;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
this.actualPostCmd(msg, args.channel, { content: args.content }, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* COMMAND: Post a message with an embed as the bot to the specified channel
|
|
||||||
*/
|
|
||||||
@d.command("post_embed", "<channel:channel> [maincontent:string$]", {
|
|
||||||
options: [
|
|
||||||
{ name: "title", type: "string" },
|
|
||||||
{ name: "content", type: "string" },
|
|
||||||
{ name: "color", type: "string" },
|
|
||||||
{ name: "schedule", type: "string" },
|
|
||||||
{ name: "raw", isSwitch: true, shortcut: "r" },
|
|
||||||
{
|
|
||||||
name: "repeat",
|
|
||||||
type: "delay",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "repeat-until",
|
|
||||||
type: "string",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "repeat-times",
|
|
||||||
type: "number",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
@d.permission("can_post")
|
|
||||||
async postEmbedCmd(
|
|
||||||
msg: Message,
|
|
||||||
args: {
|
|
||||||
channel: Channel;
|
|
||||||
title?: string;
|
|
||||||
maincontent?: string;
|
|
||||||
content?: string;
|
|
||||||
color?: string;
|
|
||||||
schedule?: string;
|
|
||||||
raw?: boolean;
|
|
||||||
repeat?: number;
|
|
||||||
"repeat-until"?: string;
|
|
||||||
"repeat-times"?: number;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const content = args.content || args.maincontent;
|
|
||||||
|
|
||||||
if (!args.title && !content) {
|
|
||||||
msg.channel.createMessage(errorMessage("Title or content required"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let color = null;
|
|
||||||
if (args.color) {
|
|
||||||
const colorMatch = args.color.match(COLOR_MATCH_REGEX);
|
|
||||||
if (!colorMatch) {
|
|
||||||
msg.channel.createMessage(errorMessage("Invalid color specified, use hex colors"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
color = parseInt(colorMatch[1], 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
let embed: EmbedBase = {};
|
|
||||||
if (args.title) embed.title = args.title;
|
|
||||||
if (color) embed.color = color;
|
|
||||||
|
|
||||||
if (content) {
|
|
||||||
if (args.raw) {
|
|
||||||
let parsed;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(content);
|
|
||||||
} catch (e) {
|
|
||||||
this.sendErrorMessage(msg.channel, "Syntax error in embed JSON");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isValidEmbed(parsed)) {
|
|
||||||
this.sendErrorMessage(msg.channel, "Embed is not valid");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
embed = Object.assign({}, embed, parsed);
|
|
||||||
} else {
|
|
||||||
embed.description = this.formatContent(content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.actualPostCmd(msg, args.channel, { embed }, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* COMMAND: Edit the specified message posted by the bot
|
|
||||||
*/
|
|
||||||
@d.command("edit", "<messageId:string> <content:string$>")
|
|
||||||
@d.permission("can_post")
|
|
||||||
async editCmd(msg, args: { messageId: string; content: string }) {
|
|
||||||
const savedMessage = await this.savedMessages.find(args.messageId);
|
|
||||||
if (!savedMessage) {
|
|
||||||
msg.channel.createMessage(errorMessage("Unknown message"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (savedMessage.user_id !== this.bot.user.id) {
|
|
||||||
msg.channel.createMessage(errorMessage("Message wasn't posted by me"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.bot.editMessage(savedMessage.channel_id, savedMessage.id, this.formatContent(args.content));
|
|
||||||
this.sendSuccessMessage(msg.channel, "Message edited");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* COMMAND: Edit the specified message with an embed posted by the bot
|
|
||||||
*/
|
|
||||||
@d.command("edit_embed", "<messageId:string> [maincontent:string$]", {
|
|
||||||
options: [
|
|
||||||
{ name: "title", type: "string" },
|
|
||||||
{ name: "content", type: "string" },
|
|
||||||
{ name: "color", type: "string" },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
@d.permission("can_post")
|
|
||||||
async editEmbedCmd(
|
|
||||||
msg: Message,
|
|
||||||
args: { messageId: string; title?: string; maincontent?: string; content?: string; color?: string },
|
|
||||||
) {
|
|
||||||
const savedMessage = await this.savedMessages.find(args.messageId);
|
|
||||||
if (!savedMessage) {
|
|
||||||
msg.channel.createMessage(errorMessage("Unknown message"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = args.content || args.maincontent;
|
|
||||||
|
|
||||||
let color = null;
|
|
||||||
if (args.color) {
|
|
||||||
const colorMatch = args.color.match(COLOR_MATCH_REGEX);
|
|
||||||
if (!colorMatch) {
|
|
||||||
msg.channel.createMessage(errorMessage("Invalid color specified, use hex colors"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
color = parseInt(colorMatch[1], 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
const embed: EmbedBase = savedMessage.data.embeds[0];
|
|
||||||
if (args.title) embed.title = args.title;
|
|
||||||
if (content) embed.description = this.formatContent(content);
|
|
||||||
if (color) embed.color = color;
|
|
||||||
|
|
||||||
await this.bot.editMessage(savedMessage.channel_id, savedMessage.id, { embed });
|
|
||||||
await this.sendSuccessMessage(msg.channel, "Embed edited");
|
|
||||||
|
|
||||||
if (args.content) {
|
|
||||||
const prefix = this.guildConfig.prefix || "!";
|
|
||||||
msg.channel.createMessage(
|
|
||||||
trimLines(`
|
|
||||||
<@!${msg.author.id}> You can now specify an embed's content directly at the end of the command:
|
|
||||||
\`${prefix}edit_embed -title "Some title" content goes here\`
|
|
||||||
The \`-content\` option will soon be removed in favor of this.
|
|
||||||
`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("scheduled_posts", [], {
|
|
||||||
aliases: ["scheduled_posts list"],
|
|
||||||
})
|
|
||||||
@d.permission("can_post")
|
|
||||||
async scheduledPostListCmd(msg: Message) {
|
|
||||||
const scheduledPosts = await this.scheduledPosts.all();
|
|
||||||
if (scheduledPosts.length === 0) {
|
|
||||||
msg.channel.createMessage("No scheduled posts");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduledPosts.sort(sorter("post_at"));
|
|
||||||
|
|
||||||
let i = 1;
|
|
||||||
const postLines = scheduledPosts.map(p => {
|
|
||||||
let previewText =
|
|
||||||
p.content.content || (p.content.embed && (p.content.embed.description || p.content.embed.title)) || "";
|
|
||||||
|
|
||||||
const isTruncated = previewText.length > SCHEDULED_POST_PREVIEW_TEXT_LENGTH;
|
|
||||||
|
|
||||||
previewText = disableCodeBlocks(deactivateMentions(previewText))
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.slice(0, SCHEDULED_POST_PREVIEW_TEXT_LENGTH);
|
|
||||||
|
|
||||||
const parts = [`\`#${i++}\` \`[${p.post_at}]\` ${previewText}${isTruncated ? "..." : ""}`];
|
|
||||||
if (p.attachments.length) parts.push("*(with attachment)*");
|
|
||||||
if (p.content.embed) parts.push("*(embed)*");
|
|
||||||
if (p.repeat_until) {
|
|
||||||
parts.push(`*(repeated every ${humanizeDuration(p.repeat_interval)} until ${p.repeat_until})*`);
|
|
||||||
}
|
|
||||||
if (p.repeat_times) {
|
|
||||||
parts.push(
|
|
||||||
`*(repeated every ${humanizeDuration(p.repeat_interval)}, ${p.repeat_times} more ${
|
|
||||||
p.repeat_times === 1 ? "time" : "times"
|
|
||||||
})*`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
parts.push(`*(${p.author_name})*`);
|
|
||||||
|
|
||||||
return parts.join(" ");
|
|
||||||
});
|
|
||||||
|
|
||||||
const finalMessage = trimLines(`
|
|
||||||
${postLines.join("\n")}
|
|
||||||
|
|
||||||
Use \`scheduled_posts <num>\` to view a scheduled post in full
|
|
||||||
Use \`scheduled_posts delete <num>\` to delete a scheduled post
|
|
||||||
`);
|
|
||||||
createChunkedMessage(msg.channel, finalMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("scheduled_posts delete", "<num:number>", {
|
|
||||||
aliases: ["scheduled_posts d"],
|
|
||||||
})
|
|
||||||
@d.permission("can_post")
|
|
||||||
async scheduledPostDeleteCmd(msg: Message, args: { num: number }) {
|
|
||||||
const scheduledPosts = await this.scheduledPosts.all();
|
|
||||||
scheduledPosts.sort(sorter("post_at"));
|
|
||||||
const post = scheduledPosts[args.num - 1];
|
|
||||||
if (!post) {
|
|
||||||
return this.sendErrorMessage(msg.channel, "Scheduled post not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.scheduledPosts.delete(post.id);
|
|
||||||
this.sendSuccessMessage(msg.channel, "Scheduled post deleted!");
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("scheduled_posts", "<num:number>", {
|
|
||||||
aliases: ["scheduled_posts show"],
|
|
||||||
})
|
|
||||||
@d.permission("can_post")
|
|
||||||
async scheduledPostShowCmd(msg: Message, args: { num: number }) {
|
|
||||||
const scheduledPosts = await this.scheduledPosts.all();
|
|
||||||
scheduledPosts.sort(sorter("post_at"));
|
|
||||||
const post = scheduledPosts[args.num - 1];
|
|
||||||
if (!post) {
|
|
||||||
return this.sendErrorMessage(msg.channel, "Scheduled post not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.postMessage(msg.channel as TextChannel, post.content, post.attachments, post.enable_mentions);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,433 +0,0 @@
|
||||||
import { decorators as d, IPluginOptions, logger } from "knub";
|
|
||||||
import { CustomEmoji, errorMessage, isDiscordRESTError, isSnowflake, noop, sleep } from "../utils";
|
|
||||||
import { GuildReactionRoles } from "../data/GuildReactionRoles";
|
|
||||||
import { Message, TextChannel } from "eris";
|
|
||||||
import { ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
|
||||||
import { Queue } from "../Queue";
|
|
||||||
import { ReactionRole } from "../data/entities/ReactionRole";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
import { ERRORS, RecoverablePluginError } from "../RecoverablePluginError";
|
|
||||||
import Timeout = NodeJS.Timeout;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Either of:
|
|
||||||
* [emojiId, roleId]
|
|
||||||
* [emojiId, roleId, emojiName]
|
|
||||||
* Where emojiId is either the snowflake of a custom emoji, or the actual unicode emoji
|
|
||||||
*/
|
|
||||||
const ReactionRolePair = t.union([t.tuple([t.string, t.string, t.string]), t.tuple([t.string, t.string])]);
|
|
||||||
type TReactionRolePair = t.TypeOf<typeof ReactionRolePair>;
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
auto_refresh_interval: t.number,
|
|
||||||
can_manage: t.boolean,
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
type ReactionRolePair = [string, string, string?];
|
|
||||||
|
|
||||||
const MIN_AUTO_REFRESH = 1000 * 60 * 15; // 15min minimum, let's not abuse the API
|
|
||||||
const CLEAR_ROLES_EMOJI = "❌";
|
|
||||||
const ROLE_CHANGE_BATCH_DEBOUNCE_TIME = 1500;
|
|
||||||
|
|
||||||
type RoleChangeMode = "+" | "-";
|
|
||||||
|
|
||||||
type PendingMemberRoleChanges = {
|
|
||||||
timeout: Timeout;
|
|
||||||
applyFn: () => void;
|
|
||||||
changes: Array<{
|
|
||||||
mode: RoleChangeMode;
|
|
||||||
roleId: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ReactionRolesPlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "reaction_roles";
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
public static pluginInfo = {
|
|
||||||
prettyName: "Reaction roles",
|
|
||||||
};
|
|
||||||
|
|
||||||
protected reactionRoles: GuildReactionRoles;
|
|
||||||
protected savedMessages: GuildSavedMessages;
|
|
||||||
|
|
||||||
protected reactionRemoveQueue: Queue;
|
|
||||||
protected roleChangeQueue: Queue;
|
|
||||||
protected pendingRoleChanges: Map<string, PendingMemberRoleChanges>;
|
|
||||||
protected pendingRefreshes: Set<string>;
|
|
||||||
|
|
||||||
private autoRefreshTimeout;
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
auto_refresh_interval: MIN_AUTO_REFRESH,
|
|
||||||
|
|
||||||
can_manage: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
level: ">=100",
|
|
||||||
config: {
|
|
||||||
can_manage: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async onLoad() {
|
|
||||||
this.reactionRoles = GuildReactionRoles.getGuildInstance(this.guildId);
|
|
||||||
this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId);
|
|
||||||
this.reactionRemoveQueue = new Queue();
|
|
||||||
this.roleChangeQueue = new Queue();
|
|
||||||
this.pendingRoleChanges = new Map();
|
|
||||||
this.pendingRefreshes = new Set();
|
|
||||||
|
|
||||||
let autoRefreshInterval = this.getConfig().auto_refresh_interval;
|
|
||||||
if (autoRefreshInterval != null) {
|
|
||||||
autoRefreshInterval = Math.max(MIN_AUTO_REFRESH, autoRefreshInterval);
|
|
||||||
this.autoRefreshLoop(autoRefreshInterval);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onUnload() {
|
|
||||||
if (this.autoRefreshTimeout) {
|
|
||||||
clearTimeout(this.autoRefreshTimeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async autoRefreshLoop(interval: number) {
|
|
||||||
this.autoRefreshTimeout = setTimeout(async () => {
|
|
||||||
await this.runAutoRefresh();
|
|
||||||
this.autoRefreshLoop(interval);
|
|
||||||
}, interval);
|
|
||||||
}
|
|
||||||
|
|
||||||
async runAutoRefresh() {
|
|
||||||
// Refresh reaction roles on all reaction role messages
|
|
||||||
const reactionRoles = await this.reactionRoles.all();
|
|
||||||
const idPairs = new Set(reactionRoles.map(r => `${r.channel_id}-${r.message_id}`));
|
|
||||||
for (const pair of idPairs) {
|
|
||||||
const [channelId, messageId] = pair.split("-");
|
|
||||||
await this.refreshReactionRoles(channelId, messageId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refreshes the reaction roles in a message. Basically just calls applyReactionRoleReactionsToMessage().
|
|
||||||
*/
|
|
||||||
async refreshReactionRoles(channelId: string, messageId: string) {
|
|
||||||
const pendingKey = `${channelId}-${messageId}`;
|
|
||||||
if (this.pendingRefreshes.has(pendingKey)) return;
|
|
||||||
this.pendingRefreshes.add(pendingKey);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const reactionRoles = await this.reactionRoles.getForMessage(messageId);
|
|
||||||
await this.applyReactionRoleReactionsToMessage(channelId, messageId, reactionRoles);
|
|
||||||
} finally {
|
|
||||||
this.pendingRefreshes.delete(pendingKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies the reactions from the specified reaction roles to a message
|
|
||||||
*/
|
|
||||||
async applyReactionRoleReactionsToMessage(channelId: string, messageId: string, reactionRoles: ReactionRole[]) {
|
|
||||||
const channel = this.guild.channels.get(channelId) as TextChannel;
|
|
||||||
if (!channel) return;
|
|
||||||
|
|
||||||
let targetMessage;
|
|
||||||
try {
|
|
||||||
targetMessage = await channel.getMessage(messageId);
|
|
||||||
} catch (e) {
|
|
||||||
if (isDiscordRESTError(e)) {
|
|
||||||
if (e.code === 10008) {
|
|
||||||
// Unknown message, remove reaction roles from the message
|
|
||||||
logger.warn(
|
|
||||||
`Removed reaction roles from unknown message ${channelId}/${messageId} in guild ${this.guild.name} (${this.guildId})`,
|
|
||||||
);
|
|
||||||
await this.reactionRoles.removeFromMessage(messageId);
|
|
||||||
} else {
|
|
||||||
logger.warn(
|
|
||||||
`Error when applying reaction roles to message ${channelId}/${messageId} in guild ${this.guild.name} (${this.guildId}), error code ${e.code}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove old reactions, if any
|
|
||||||
const removeSleep = sleep(1250);
|
|
||||||
await targetMessage.removeReactions();
|
|
||||||
await removeSleep;
|
|
||||||
|
|
||||||
// Add reaction role reactions
|
|
||||||
for (const rr of reactionRoles) {
|
|
||||||
const emoji = isSnowflake(rr.emoji) ? `foo:${rr.emoji}` : rr.emoji;
|
|
||||||
|
|
||||||
const sleepTime = sleep(1250); // Make sure we only add 1 reaction per ~second so as not to hit rate limits
|
|
||||||
await targetMessage.addReaction(emoji);
|
|
||||||
await sleepTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the "clear reactions" button
|
|
||||||
await targetMessage.addReaction(CLEAR_ROLES_EMOJI);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a pending role change for a member. After a delay, all pending role changes for a member are applied at once.
|
|
||||||
* This delay is refreshed any time new pending changes are added (i.e. "debounced").
|
|
||||||
*/
|
|
||||||
async addMemberPendingRoleChange(memberId: string, mode: RoleChangeMode, roleId: string) {
|
|
||||||
if (!this.pendingRoleChanges.has(memberId)) {
|
|
||||||
const newPendingRoleChangeObj: PendingMemberRoleChanges = {
|
|
||||||
timeout: null,
|
|
||||||
changes: [],
|
|
||||||
applyFn: async () => {
|
|
||||||
this.pendingRoleChanges.delete(memberId);
|
|
||||||
|
|
||||||
const lock = await this.locks.acquire(`member-roles-${memberId}`);
|
|
||||||
|
|
||||||
const member = await this.getMember(memberId);
|
|
||||||
if (member) {
|
|
||||||
const newRoleIds = new Set(member.roles);
|
|
||||||
for (const change of newPendingRoleChangeObj.changes) {
|
|
||||||
if (change.mode === "+") newRoleIds.add(change.roleId);
|
|
||||||
else newRoleIds.delete(change.roleId);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await member.edit(
|
|
||||||
{
|
|
||||||
roles: Array.from(newRoleIds.values()),
|
|
||||||
},
|
|
||||||
"Reaction roles",
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn(
|
|
||||||
`Failed to apply role changes to ${member.username}#${member.discriminator} (${member.id}): ${e.message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lock.unlock();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
this.pendingRoleChanges.set(memberId, newPendingRoleChangeObj);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingRoleChangeObj = this.pendingRoleChanges.get(memberId);
|
|
||||||
pendingRoleChangeObj.changes.push({ mode, roleId });
|
|
||||||
|
|
||||||
if (pendingRoleChangeObj.timeout) clearTimeout(pendingRoleChangeObj.timeout);
|
|
||||||
pendingRoleChangeObj.timeout = setTimeout(
|
|
||||||
() => this.roleChangeQueue.add(pendingRoleChangeObj.applyFn),
|
|
||||||
ROLE_CHANGE_BATCH_DEBOUNCE_TIME,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* COMMAND: Clear reaction roles from the specified message
|
|
||||||
*/
|
|
||||||
@d.command("reaction_roles clear", "<messageId:string>")
|
|
||||||
@d.permission("can_manage")
|
|
||||||
async clearReactionRolesCmd(msg: Message, args: { messageId: string }) {
|
|
||||||
const savedMessage = await this.savedMessages.find(args.messageId);
|
|
||||||
if (!savedMessage) {
|
|
||||||
msg.channel.createMessage(errorMessage("Unknown message"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingReactionRoles = this.reactionRoles.getForMessage(args.messageId);
|
|
||||||
if (!existingReactionRoles) {
|
|
||||||
msg.channel.createMessage(errorMessage("Message doesn't have reaction roles on it"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.reactionRoles.removeFromMessage(args.messageId);
|
|
||||||
|
|
||||||
const channel = this.guild.channels.get(savedMessage.channel_id) as TextChannel;
|
|
||||||
const targetMessage = await channel.getMessage(savedMessage.id);
|
|
||||||
await targetMessage.removeReactions();
|
|
||||||
|
|
||||||
this.sendSuccessMessage(msg.channel, "Reaction roles cleared");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* COMMAND: Refresh reaction roles in the specified message by removing all reactions and re-adding them
|
|
||||||
*/
|
|
||||||
@d.command("reaction_roles refresh", "<messageId:string>")
|
|
||||||
@d.permission("can_manage")
|
|
||||||
async refreshReactionRolesCmd(msg: Message, args: { messageId: string }) {
|
|
||||||
const savedMessage = await this.savedMessages.find(args.messageId);
|
|
||||||
if (!savedMessage) {
|
|
||||||
msg.channel.createMessage(errorMessage("Unknown message"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.pendingRefreshes.has(`${savedMessage.channel_id}-${savedMessage.id}`)) {
|
|
||||||
msg.channel.createMessage(errorMessage("Another refresh in progress"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.refreshReactionRoles(savedMessage.channel_id, savedMessage.id);
|
|
||||||
|
|
||||||
this.sendSuccessMessage(msg.channel, "Reaction roles refreshed");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* COMMAND: Initialize reaction roles on a message.
|
|
||||||
* The second parameter, reactionRolePairs, is a list of emoji/role pairs separated by a newline. For example:
|
|
||||||
* :zep_twitch: = 473086848831455234
|
|
||||||
* :zep_ps4: = 543184300250759188
|
|
||||||
*/
|
|
||||||
@d.command("reaction_roles", "<messageId:string> <reactionRolePairs:string$>", {
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
name: "exclusive",
|
|
||||||
shortcut: "e",
|
|
||||||
isSwitch: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
@d.permission("can_manage")
|
|
||||||
async reactionRolesCmd(msg: Message, args: { messageId: string; reactionRolePairs: string; exclusive?: boolean }) {
|
|
||||||
const savedMessage = await this.savedMessages.find(args.messageId);
|
|
||||||
if (!savedMessage) {
|
|
||||||
msg.channel.createMessage(errorMessage("Unknown message"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const channel = await this.guild.channels.get(savedMessage.channel_id);
|
|
||||||
if (!channel || !(channel instanceof TextChannel)) {
|
|
||||||
msg.channel.createMessage(errorMessage("Channel no longer exists"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetMessage = await channel.getMessage(args.messageId);
|
|
||||||
if (!targetMessage) {
|
|
||||||
msg.channel.createMessage(errorMessage("Unknown message (2)"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear old reaction roles for the message from the DB
|
|
||||||
await this.reactionRoles.removeFromMessage(targetMessage.id);
|
|
||||||
|
|
||||||
// Turn "emoji = role" pairs into an array of tuples of the form [emoji, roleId]
|
|
||||||
// Emoji is either a unicode emoji or the snowflake of a custom emoji
|
|
||||||
const emojiRolePairs: TReactionRolePair[] = args.reactionRolePairs
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.map(v => v.split("=").map(v => v.trim())) // tslint:disable-line
|
|
||||||
.map(
|
|
||||||
(pair): TReactionRolePair => {
|
|
||||||
const customEmojiMatch = pair[0].match(/^<a?:(.*?):(\d+)>$/);
|
|
||||||
if (customEmojiMatch) {
|
|
||||||
return [customEmojiMatch[2], pair[1], customEmojiMatch[1]];
|
|
||||||
} else {
|
|
||||||
return pair as TReactionRolePair;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify the specified emojis and roles are valid and usable
|
|
||||||
for (const pair of emojiRolePairs) {
|
|
||||||
if (pair[0] === CLEAR_ROLES_EMOJI) {
|
|
||||||
msg.channel.createMessage(
|
|
||||||
errorMessage(`The emoji for clearing roles (${CLEAR_ROLES_EMOJI}) is reserved and cannot be used`),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!this.canUseEmoji(pair[0])) {
|
|
||||||
msg.channel.createMessage(
|
|
||||||
errorMessage("I can only use regular emojis and custom emojis from servers I'm on"),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof RecoverablePluginError && e.code === ERRORS.INVALID_EMOJI) {
|
|
||||||
msg.channel.createMessage(errorMessage(`Invalid emoji: ${pair[0]}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.guild.roles.has(pair[1])) {
|
|
||||||
msg.channel.createMessage(errorMessage(`Unknown role ${pair[1]}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the new reaction roles to the database
|
|
||||||
for (const pair of emojiRolePairs) {
|
|
||||||
await this.reactionRoles.add(channel.id, targetMessage.id, pair[0], pair[1], args.exclusive);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply the reactions themselves
|
|
||||||
const reactionRoles = await this.reactionRoles.getForMessage(targetMessage.id);
|
|
||||||
await this.applyReactionRoleReactionsToMessage(targetMessage.channel.id, targetMessage.id, reactionRoles);
|
|
||||||
|
|
||||||
this.sendSuccessMessage(msg.channel, "Reaction roles added");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When a reaction is added to a message with reaction roles, see which role that reaction matches (if any) and queue
|
|
||||||
* those role changes for the member. Multiple role changes in rapid succession are batched and applied at once.
|
|
||||||
* Reacting with CLEAR_ROLES_EMOJI will queue a removal of all roles granted by this message's reaction roles.
|
|
||||||
*/
|
|
||||||
@d.event("messageReactionAdd")
|
|
||||||
async onAddReaction(msg: Message, emoji: CustomEmoji, userId: string) {
|
|
||||||
// Make sure this message has reaction roles on it
|
|
||||||
const reactionRoles = await this.reactionRoles.getForMessage(msg.id);
|
|
||||||
if (reactionRoles.length === 0) return;
|
|
||||||
|
|
||||||
const member = await this.getMember(userId);
|
|
||||||
if (!member) return;
|
|
||||||
|
|
||||||
if (emoji.name === CLEAR_ROLES_EMOJI) {
|
|
||||||
// User reacted with "clear roles" emoji -> clear their roles
|
|
||||||
const reactionRoleRoleIds = reactionRoles.map(rr => rr.role_id);
|
|
||||||
for (const roleId of reactionRoleRoleIds) {
|
|
||||||
this.addMemberPendingRoleChange(userId, "-", roleId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.reactionRemoveQueue.add(async () => {
|
|
||||||
await msg.channel.removeMessageReaction(msg.id, CLEAR_ROLES_EMOJI, userId);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// User reacted with a reaction role emoji -> add the role
|
|
||||||
const matchingReactionRole = await this.reactionRoles.getByMessageAndEmoji(msg.id, emoji.id || emoji.name);
|
|
||||||
if (!matchingReactionRole) return;
|
|
||||||
|
|
||||||
// If the reaction role is exclusive, remove any other roles in the message first
|
|
||||||
if (matchingReactionRole.is_exclusive) {
|
|
||||||
const messageReactionRoles = await this.reactionRoles.getForMessage(msg.id);
|
|
||||||
for (const reactionRole of messageReactionRoles) {
|
|
||||||
this.addMemberPendingRoleChange(userId, "-", reactionRole.role_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.addMemberPendingRoleChange(userId, "+", matchingReactionRole.role_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the reaction after a small delay
|
|
||||||
setTimeout(() => {
|
|
||||||
this.reactionRemoveQueue.add(async () => {
|
|
||||||
const reaction = emoji.id ? `${emoji.name}:${emoji.id}` : emoji.name;
|
|
||||||
const wait = sleep(1500);
|
|
||||||
await msg.channel.removeMessageReaction(msg.id, reaction, userId).catch(noop);
|
|
||||||
await wait;
|
|
||||||
});
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,198 +0,0 @@
|
||||||
import { decorators as d, IPluginOptions } from "knub";
|
|
||||||
import { ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import { GuildReminders } from "../data/GuildReminders";
|
|
||||||
import { Message, TextChannel } from "eris";
|
|
||||||
import moment from "moment-timezone";
|
|
||||||
import humanizeDuration from "humanize-duration";
|
|
||||||
import {
|
|
||||||
convertDelayStringToMS,
|
|
||||||
createChunkedMessage,
|
|
||||||
disableLinkPreviews,
|
|
||||||
errorMessage,
|
|
||||||
sorter,
|
|
||||||
successMessage,
|
|
||||||
} from "../utils";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
can_use: t.boolean,
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
const REMINDER_LOOP_TIME = 10 * 1000;
|
|
||||||
const MAX_TRIES = 3;
|
|
||||||
|
|
||||||
export class RemindersPlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "reminders";
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
public static pluginInfo = {
|
|
||||||
prettyName: "Reminders",
|
|
||||||
};
|
|
||||||
|
|
||||||
protected reminders: GuildReminders;
|
|
||||||
protected tries: Map<number, number>;
|
|
||||||
|
|
||||||
private postRemindersTimeout;
|
|
||||||
private unloaded = false;
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
can_use: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
level: ">=50",
|
|
||||||
config: {
|
|
||||||
can_use: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoad() {
|
|
||||||
this.reminders = GuildReminders.getGuildInstance(this.guildId);
|
|
||||||
this.tries = new Map();
|
|
||||||
this.postDueRemindersLoop();
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnload() {
|
|
||||||
clearTimeout(this.postRemindersTimeout);
|
|
||||||
this.unloaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async postDueRemindersLoop() {
|
|
||||||
const pendingReminders = await this.reminders.getDueReminders();
|
|
||||||
for (const reminder of pendingReminders) {
|
|
||||||
const channel = this.guild.channels.get(reminder.channel_id);
|
|
||||||
if (channel && channel instanceof TextChannel) {
|
|
||||||
try {
|
|
||||||
// Only show created at date if one exists
|
|
||||||
if (moment(reminder.created_at).isValid()) {
|
|
||||||
const target = moment();
|
|
||||||
const diff = target.diff(moment(reminder.created_at, "YYYY-MM-DD HH:mm:ss"));
|
|
||||||
const result = humanizeDuration(diff, { largest: 2, round: true });
|
|
||||||
await channel.createMessage(
|
|
||||||
disableLinkPreviews(
|
|
||||||
`Reminder for <@!${reminder.user_id}>: ${reminder.body} \n\`Set at ${reminder.created_at} (${result} ago)\``,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await channel.createMessage(disableLinkPreviews(`Reminder for <@!${reminder.user_id}>: ${reminder.body}`));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Probably random Discord internal server error or missing permissions or somesuch
|
|
||||||
// Try again next round unless we've already tried to post this a bunch of times
|
|
||||||
const tries = this.tries.get(reminder.id) || 0;
|
|
||||||
if (tries < MAX_TRIES) {
|
|
||||||
this.tries.set(reminder.id, tries + 1);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.reminders.delete(reminder.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.unloaded) {
|
|
||||||
this.postRemindersTimeout = setTimeout(() => this.postDueRemindersLoop(), REMINDER_LOOP_TIME);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("remind", "<time:string> [reminder:string$]", {
|
|
||||||
aliases: ["remindme"],
|
|
||||||
})
|
|
||||||
@d.permission("can_use")
|
|
||||||
async addReminderCmd(msg: Message, args: { time: string; reminder?: string }) {
|
|
||||||
const now = moment();
|
|
||||||
|
|
||||||
let reminderTime;
|
|
||||||
if (args.time.match(/^\d{4}-\d{1,2}-\d{1,2}$/)) {
|
|
||||||
// Date in YYYY-MM-DD format, remind at current time on that date
|
|
||||||
reminderTime = moment(args.time, "YYYY-M-D").set({
|
|
||||||
hour: now.hour(),
|
|
||||||
minute: now.minute(),
|
|
||||||
second: now.second(),
|
|
||||||
});
|
|
||||||
} else if (args.time.match(/^\d{4}-\d{1,2}-\d{1,2}T\d{2}:\d{2}$/)) {
|
|
||||||
// Date and time in YYYY-MM-DD[T]HH:mm format
|
|
||||||
reminderTime = moment(args.time, "YYYY-M-D[T]HH:mm").second(0);
|
|
||||||
} else {
|
|
||||||
// "Delay string" i.e. e.g. "2h30m"
|
|
||||||
const ms = convertDelayStringToMS(args.time);
|
|
||||||
if (ms === null) {
|
|
||||||
msg.channel.createMessage(errorMessage("Invalid reminder time"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
reminderTime = moment().add(ms, "millisecond");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!reminderTime.isValid() || reminderTime.isBefore(now)) {
|
|
||||||
msg.channel.createMessage(errorMessage("Invalid reminder time"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reminderBody = args.reminder || `https://discord.com/channels/${this.guildId}/${msg.channel.id}/${msg.id}`;
|
|
||||||
await this.reminders.add(
|
|
||||||
msg.author.id,
|
|
||||||
msg.channel.id,
|
|
||||||
reminderTime.format("YYYY-MM-DD HH:mm:ss"),
|
|
||||||
reminderBody,
|
|
||||||
moment().format("YYYY-MM-DD HH:mm:ss"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const msUntilReminder = reminderTime.diff(now);
|
|
||||||
const timeUntilReminder = humanizeDuration(msUntilReminder, { largest: 2, round: true });
|
|
||||||
this.sendSuccessMessage(
|
|
||||||
msg.channel,
|
|
||||||
`I will remind you in **${timeUntilReminder}** at **${reminderTime.format("YYYY-MM-DD, HH:mm")}**`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("reminders")
|
|
||||||
@d.permission("can_use")
|
|
||||||
async reminderListCmd(msg: Message) {
|
|
||||||
const reminders = await this.reminders.getRemindersByUserId(msg.author.id);
|
|
||||||
if (reminders.length === 0) {
|
|
||||||
msg.channel.createMessage(errorMessage("No reminders"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
reminders.sort(sorter("remind_at"));
|
|
||||||
const longestNum = (reminders.length + 1).toString().length;
|
|
||||||
const lines = Array.from(reminders.entries()).map(([i, reminder]) => {
|
|
||||||
const num = i + 1;
|
|
||||||
const paddedNum = num.toString().padStart(longestNum, " ");
|
|
||||||
const target = moment(reminder.remind_at, "YYYY-MM-DD HH:mm:ss");
|
|
||||||
const diff = target.diff(moment());
|
|
||||||
const result = humanizeDuration(diff, { largest: 2, round: true });
|
|
||||||
return `\`${paddedNum}.\` \`${reminder.remind_at} (${result})\` ${reminder.body}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
createChunkedMessage(msg.channel, lines.join("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("reminders delete", "<num:number>", {
|
|
||||||
aliases: ["reminders d"],
|
|
||||||
})
|
|
||||||
@d.permission("can_use")
|
|
||||||
async deleteReminderCmd(msg: Message, args: { num: number }) {
|
|
||||||
const reminders = await this.reminders.getRemindersByUserId(msg.author.id);
|
|
||||||
reminders.sort(sorter("remind_at"));
|
|
||||||
const lastNum = reminders.length + 1;
|
|
||||||
|
|
||||||
if (args.num > lastNum || args.num < 0) {
|
|
||||||
msg.channel.createMessage(errorMessage("Unknown reminder"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toDelete = reminders[args.num - 1];
|
|
||||||
await this.reminders.delete(toDelete.id);
|
|
||||||
|
|
||||||
this.sendSuccessMessage(msg.channel, "Reminder deleted");
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,321 +0,0 @@
|
||||||
import { trimPluginDescription, ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
import { resolveMember, stripObjectToScalars, successMessage, verboseUserMention } from "../utils";
|
|
||||||
import { decorators as d, IPluginOptions, logger } from "knub";
|
|
||||||
import { GuildChannel, Member, Message } from "eris";
|
|
||||||
import { GuildLogs } from "../data/GuildLogs";
|
|
||||||
import { LogType } from "../data/LogType";
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
can_assign: t.boolean,
|
|
||||||
can_mass_assign: t.boolean,
|
|
||||||
assignable_roles: t.array(t.string),
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
export class RolesPlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "roles";
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
public static pluginInfo = {
|
|
||||||
prettyName: "Roles",
|
|
||||||
description: trimPluginDescription(`
|
|
||||||
Enables authorised users to add and remove whitelisted roles with a command.
|
|
||||||
`),
|
|
||||||
};
|
|
||||||
|
|
||||||
protected logs: GuildLogs;
|
|
||||||
|
|
||||||
onLoad() {
|
|
||||||
this.logs = new GuildLogs(this.guildId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
can_assign: false,
|
|
||||||
can_mass_assign: false,
|
|
||||||
assignable_roles: [],
|
|
||||||
},
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
level: ">=50",
|
|
||||||
config: {
|
|
||||||
can_assign: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
level: ">=100",
|
|
||||||
config: {
|
|
||||||
can_mass_assign: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("addrole", "<member:member> <role:string$>", {
|
|
||||||
extra: {
|
|
||||||
info: {
|
|
||||||
description: "Add a role to the specified member",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@d.permission("can_assign")
|
|
||||||
async addRoleCmd(msg: Message, args: { member: Member; role: string }) {
|
|
||||||
if (!this.canActOn(msg.member, args.member, true)) {
|
|
||||||
return this.sendErrorMessage(msg.channel, "Cannot add roles to this user: insufficient permissions");
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleId = await this.resolveRoleId(args.role);
|
|
||||||
if (!roleId) {
|
|
||||||
return this.sendErrorMessage(msg.channel, "Invalid role id");
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = this.getConfigForMsg(msg);
|
|
||||||
if (!config.assignable_roles.includes(roleId)) {
|
|
||||||
return this.sendErrorMessage(msg.channel, "You cannot assign that role");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanity check: make sure the role is configured properly
|
|
||||||
const role = (msg.channel as GuildChannel).guild.roles.get(roleId);
|
|
||||||
if (!role) {
|
|
||||||
this.logs.log(LogType.BOT_ALERT, {
|
|
||||||
body: `Unknown role configured for 'roles' plugin: ${roleId}`,
|
|
||||||
});
|
|
||||||
return this.sendErrorMessage(msg.channel, "You cannot assign that role");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.member.roles.includes(roleId)) {
|
|
||||||
return this.sendErrorMessage(msg.channel, "Member already has that role");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, args.member.id);
|
|
||||||
|
|
||||||
await args.member.addRole(roleId);
|
|
||||||
|
|
||||||
this.logs.log(LogType.MEMBER_ROLE_ADD, {
|
|
||||||
member: stripObjectToScalars(args.member, ["user", "roles"]),
|
|
||||||
roles: role.name,
|
|
||||||
mod: stripObjectToScalars(msg.author),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.sendSuccessMessage(msg.channel, `Added role **${role.name}** to ${verboseUserMention(args.member.user)}!`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("massaddrole", "<role:string> <members:string...>")
|
|
||||||
@d.permission("can_mass_assign")
|
|
||||||
async massAddRoleCmd(msg: Message, args: { role: string; members: string[] }) {
|
|
||||||
msg.channel.createMessage(`Resolving members...`);
|
|
||||||
|
|
||||||
const members = [];
|
|
||||||
const unknownMembers = [];
|
|
||||||
for (const memberId of args.members) {
|
|
||||||
const member = await resolveMember(this.bot, this.guild, memberId);
|
|
||||||
if (member) members.push(member);
|
|
||||||
else unknownMembers.push(memberId);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const member of members) {
|
|
||||||
if (!this.canActOn(msg.member, member, true)) {
|
|
||||||
return this.sendErrorMessage(
|
|
||||||
msg.channel,
|
|
||||||
"Cannot add roles to 1 or more specified members: insufficient permissions",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleId = await this.resolveRoleId(args.role);
|
|
||||||
if (!roleId) {
|
|
||||||
return this.sendErrorMessage(msg.channel, "Invalid role id");
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = this.getConfigForMsg(msg);
|
|
||||||
if (!config.assignable_roles.includes(roleId)) {
|
|
||||||
return this.sendErrorMessage(msg.channel, "You cannot assign that role");
|
|
||||||
}
|
|
||||||
|
|
||||||
const role = this.guild.roles.get(roleId);
|
|
||||||
if (!role) {
|
|
||||||
this.logs.log(LogType.BOT_ALERT, {
|
|
||||||
body: `Unknown role configured for 'roles' plugin: ${roleId}`,
|
|
||||||
});
|
|
||||||
return this.sendErrorMessage(msg.channel, "You cannot assign that role");
|
|
||||||
}
|
|
||||||
|
|
||||||
const membersWithoutTheRole = members.filter(m => !m.roles.includes(roleId));
|
|
||||||
let assigned = 0;
|
|
||||||
const failed = [];
|
|
||||||
const alreadyHadRole = members.length - membersWithoutTheRole.length;
|
|
||||||
|
|
||||||
msg.channel.createMessage(
|
|
||||||
`Adding role **${role.name}** to ${membersWithoutTheRole.length} ${
|
|
||||||
membersWithoutTheRole.length === 1 ? "member" : "members"
|
|
||||||
}...`,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const member of membersWithoutTheRole) {
|
|
||||||
try {
|
|
||||||
this.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, member.id);
|
|
||||||
await member.addRole(roleId);
|
|
||||||
this.logs.log(LogType.MEMBER_ROLE_ADD, {
|
|
||||||
member: stripObjectToScalars(member, ["user", "roles"]),
|
|
||||||
roles: role.name,
|
|
||||||
mod: stripObjectToScalars(msg.author),
|
|
||||||
});
|
|
||||||
assigned++;
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn(`Error when adding role via !massaddrole: ${e.message}`);
|
|
||||||
failed.push(member.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let resultMessage = `Added role **${role.name}** to ${assigned} ${assigned === 1 ? "member" : "members"}!`;
|
|
||||||
if (alreadyHadRole) {
|
|
||||||
resultMessage += ` ${alreadyHadRole} ${alreadyHadRole === 1 ? "member" : "members"} already had the role.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failed.length) {
|
|
||||||
resultMessage += `\nFailed to add the role to the following members: ${failed.join(", ")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (unknownMembers.length) {
|
|
||||||
resultMessage += `\nUnknown members: ${unknownMembers.join(", ")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
msg.channel.createMessage(successMessage(resultMessage));
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("removerole", "<member:member> <role:string$>", {
|
|
||||||
extra: {
|
|
||||||
info: {
|
|
||||||
description: "Remove a role from the specified member",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@d.permission("can_assign")
|
|
||||||
async removeRoleCmd(msg: Message, args: { member: Member; role: string }) {
|
|
||||||
if (!this.canActOn(msg.member, args.member, true)) {
|
|
||||||
return this.sendErrorMessage(msg.channel, "Cannot remove roles from this user: insufficient permissions");
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleId = await this.resolveRoleId(args.role);
|
|
||||||
if (!roleId) {
|
|
||||||
return this.sendErrorMessage(msg.channel, "Invalid role id");
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = this.getConfigForMsg(msg);
|
|
||||||
if (!config.assignable_roles.includes(roleId)) {
|
|
||||||
return this.sendErrorMessage(msg.channel, "You cannot remove that role");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanity check: make sure the role is configured properly
|
|
||||||
const role = (msg.channel as GuildChannel).guild.roles.get(roleId);
|
|
||||||
if (!role) {
|
|
||||||
this.logs.log(LogType.BOT_ALERT, {
|
|
||||||
body: `Unknown role configured for 'roles' plugin: ${roleId}`,
|
|
||||||
});
|
|
||||||
return this.sendErrorMessage(msg.channel, "You cannot remove that role");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!args.member.roles.includes(roleId)) {
|
|
||||||
return this.sendErrorMessage(msg.channel, "Member doesn't have that role");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, args.member.id);
|
|
||||||
|
|
||||||
await args.member.removeRole(roleId);
|
|
||||||
|
|
||||||
this.logs.log(LogType.MEMBER_ROLE_REMOVE, {
|
|
||||||
member: stripObjectToScalars(args.member, ["user", "roles"]),
|
|
||||||
roles: role.name,
|
|
||||||
mod: stripObjectToScalars(msg.author),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.sendSuccessMessage(
|
|
||||||
msg.channel,
|
|
||||||
`Removed role **${role.name}** removed from ${verboseUserMention(args.member.user)}!`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("massremoverole", "<role:string> <members:string...>")
|
|
||||||
@d.permission("can_mass_assign")
|
|
||||||
async massRemoveRoleCmd(msg: Message, args: { role: string; members: string[] }) {
|
|
||||||
const members = [];
|
|
||||||
const unknownMembers = [];
|
|
||||||
for (const memberId of args.members) {
|
|
||||||
const member = await resolveMember(this.bot, this.guild, memberId);
|
|
||||||
if (member) members.push(member);
|
|
||||||
else unknownMembers.push(memberId);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const member of members) {
|
|
||||||
if (!this.canActOn(msg.member, member, true)) {
|
|
||||||
return this.sendErrorMessage(
|
|
||||||
msg.channel,
|
|
||||||
"Cannot add roles to 1 or more specified members: insufficient permissions",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleId = await this.resolveRoleId(args.role);
|
|
||||||
if (!roleId) {
|
|
||||||
return this.sendErrorMessage(msg.channel, "Invalid role id");
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = this.getConfigForMsg(msg);
|
|
||||||
if (!config.assignable_roles.includes(roleId)) {
|
|
||||||
return this.sendErrorMessage(msg.channel, "You cannot remove that role");
|
|
||||||
}
|
|
||||||
|
|
||||||
const role = this.guild.roles.get(roleId);
|
|
||||||
if (!role) {
|
|
||||||
this.logs.log(LogType.BOT_ALERT, {
|
|
||||||
body: `Unknown role configured for 'roles' plugin: ${roleId}`,
|
|
||||||
});
|
|
||||||
return this.sendErrorMessage(msg.channel, "You cannot remove that role");
|
|
||||||
}
|
|
||||||
|
|
||||||
const membersWithTheRole = members.filter(m => m.roles.includes(roleId));
|
|
||||||
let assigned = 0;
|
|
||||||
const failed = [];
|
|
||||||
const didNotHaveRole = members.length - membersWithTheRole.length;
|
|
||||||
|
|
||||||
msg.channel.createMessage(
|
|
||||||
`Removing role **${role.name}** from ${membersWithTheRole.length} ${
|
|
||||||
membersWithTheRole.length === 1 ? "member" : "members"
|
|
||||||
}...`,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const member of membersWithTheRole) {
|
|
||||||
try {
|
|
||||||
this.logs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, member.id);
|
|
||||||
await member.removeRole(roleId);
|
|
||||||
this.logs.log(LogType.MEMBER_ROLE_REMOVE, {
|
|
||||||
member: stripObjectToScalars(member, ["user", "roles"]),
|
|
||||||
roles: role.name,
|
|
||||||
mod: stripObjectToScalars(msg.author),
|
|
||||||
});
|
|
||||||
assigned++;
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn(`Error when removing role via !massremoverole: ${e.message}`);
|
|
||||||
failed.push(member.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let resultMessage = `Removed role **${role.name}** from ${assigned} ${assigned === 1 ? "member" : "members"}!`;
|
|
||||||
if (didNotHaveRole) {
|
|
||||||
resultMessage += ` ${didNotHaveRole} ${didNotHaveRole === 1 ? "member" : "members"} didn't have the role.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failed.length) {
|
|
||||||
resultMessage += `\nFailed to remove the role from the following members: ${failed.join(", ")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (unknownMembers.length) {
|
|
||||||
resultMessage += `\nUnknown members: ${unknownMembers.join(", ")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
msg.channel.createMessage(successMessage(resultMessage));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,349 +0,0 @@
|
||||||
import { decorators as d, IPluginOptions } from "knub";
|
|
||||||
import { GuildChannel, Message, Role, TextChannel } from "eris";
|
|
||||||
import { asSingleLine, chunkArray, errorMessage, sorter, successMessage, tDeepPartial, trimLines } from "../utils";
|
|
||||||
import { trimPluginDescription, ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
|
|
||||||
const RoleMap = t.record(t.string, t.array(t.string));
|
|
||||||
|
|
||||||
const SelfGrantableRoleEntry = t.type({
|
|
||||||
roles: RoleMap,
|
|
||||||
can_use: t.boolean,
|
|
||||||
can_ignore_cooldown: t.boolean,
|
|
||||||
max_roles: t.number,
|
|
||||||
});
|
|
||||||
const PartialRoleEntry = t.partial(SelfGrantableRoleEntry.props);
|
|
||||||
type TSelfGrantableRoleEntry = t.TypeOf<typeof SelfGrantableRoleEntry>;
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
entries: t.record(t.string, SelfGrantableRoleEntry),
|
|
||||||
mention_roles: t.boolean,
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
const PartialConfigSchema = tDeepPartial(ConfigSchema);
|
|
||||||
|
|
||||||
const defaultSelfGrantableRoleEntry: t.TypeOf<typeof PartialRoleEntry> = {
|
|
||||||
can_use: false,
|
|
||||||
can_ignore_cooldown: false,
|
|
||||||
max_roles: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
export class SelfGrantableRolesPlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "self_grantable_roles";
|
|
||||||
public static showInDocs = true;
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
public static pluginInfo = {
|
|
||||||
prettyName: "Self-grantable roles",
|
|
||||||
description: trimPluginDescription(`
|
|
||||||
Allows users to grant themselves roles via a command
|
|
||||||
`),
|
|
||||||
configurationGuide: trimPluginDescription(`
|
|
||||||
### Basic configuration
|
|
||||||
In this example, users can add themselves platform roles on the channel 473087035574321152 by using the
|
|
||||||
\`!role\` command. For example, \`!role pc ps4\` to add both the "pc" and "ps4" roles as specified below.
|
|
||||||
|
|
||||||
~~~yml
|
|
||||||
self_grantable_roles:
|
|
||||||
config:
|
|
||||||
entries:
|
|
||||||
basic:
|
|
||||||
roles:
|
|
||||||
"543184300250759188": ["pc", "computer"]
|
|
||||||
"534710505915547658": ["ps4", "ps", "playstation"]
|
|
||||||
"473085927053590538": ["xbox", "xb1", "xb"]
|
|
||||||
overrides:
|
|
||||||
- channel: "473087035574321152"
|
|
||||||
config:
|
|
||||||
entries:
|
|
||||||
basic:
|
|
||||||
roles:
|
|
||||||
can_use: true
|
|
||||||
~~~
|
|
||||||
|
|
||||||
### Maximum number of roles
|
|
||||||
This is identical to the basic example above, but users can only choose 1 role.
|
|
||||||
|
|
||||||
~~~yml
|
|
||||||
self_grantable_roles:
|
|
||||||
config:
|
|
||||||
entries:
|
|
||||||
basic:
|
|
||||||
roles:
|
|
||||||
"543184300250759188": ["pc", "computer"]
|
|
||||||
"534710505915547658": ["ps4", "ps", "playstation"]
|
|
||||||
"473085927053590538": ["xbox", "xb1", "xb"]
|
|
||||||
max_roles: 1
|
|
||||||
overrides:
|
|
||||||
- channel: "473087035574321152"
|
|
||||||
config:
|
|
||||||
entries:
|
|
||||||
basic:
|
|
||||||
roles:
|
|
||||||
can_use: true
|
|
||||||
~~~
|
|
||||||
`),
|
|
||||||
};
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
entries: {},
|
|
||||||
mention_roles: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static preprocessStaticConfig(config: t.TypeOf<typeof PartialConfigSchema>) {
|
|
||||||
for (const [key, entry] of Object.entries(config.entries)) {
|
|
||||||
// Apply default entry config
|
|
||||||
config.entries[key] = { ...defaultSelfGrantableRoleEntry, ...entry };
|
|
||||||
|
|
||||||
// Normalize alias names
|
|
||||||
if (entry.roles) {
|
|
||||||
for (const [roleId, aliases] of Object.entries(entry.roles)) {
|
|
||||||
entry.roles[roleId] = aliases.map(a => a.toLowerCase());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected splitRoleNames(roleNames: string[]) {
|
|
||||||
return roleNames
|
|
||||||
.map(v => v.split(/[\s,]+/))
|
|
||||||
.flat()
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected normalizeRoleNames(roleNames: string[]) {
|
|
||||||
return roleNames.map(v => v.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getApplyingEntries(msg): TSelfGrantableRoleEntry[] {
|
|
||||||
const config = this.getConfigForMsg(msg);
|
|
||||||
return Object.entries(config.entries)
|
|
||||||
.filter(
|
|
||||||
([k, e]) => e.can_use && !(!e.can_ignore_cooldown && this.cooldowns.isOnCooldown(`${k}:${msg.author.id}`)),
|
|
||||||
)
|
|
||||||
.map(pair => pair[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected findMatchingRoles(roleNames, entries: TSelfGrantableRoleEntry[]): string[] {
|
|
||||||
const aliasToRoleId = entries.reduce((map, entry) => {
|
|
||||||
for (const [roleId, aliases] of Object.entries(entry.roles)) {
|
|
||||||
for (const alias of aliases) {
|
|
||||||
map.set(alias, roleId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return map;
|
|
||||||
}, new Map());
|
|
||||||
|
|
||||||
return roleNames.map(roleName => aliasToRoleId.get(roleName)).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("role help", [], {
|
|
||||||
aliases: ["role"],
|
|
||||||
})
|
|
||||||
async roleHelpCmd(msg: Message) {
|
|
||||||
const applyingEntries = this.getApplyingEntries(msg);
|
|
||||||
if (applyingEntries.length === 0) return;
|
|
||||||
|
|
||||||
const allPrimaryAliases = [];
|
|
||||||
for (const entry of applyingEntries) {
|
|
||||||
for (const aliases of Object.values(entry.roles)) {
|
|
||||||
if (aliases[0]) {
|
|
||||||
allPrimaryAliases.push(aliases[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const prefix = this.guildConfig.prefix;
|
|
||||||
const [firstRole, secondRole] = allPrimaryAliases;
|
|
||||||
|
|
||||||
const help1 = asSingleLine(`
|
|
||||||
To give yourself a role, type e.g. \`${prefix}role ${firstRole}\` where **${firstRole}** is the role you want.
|
|
||||||
${secondRole ? `You can also add multiple roles at once, e.g. \`${prefix}role ${firstRole} ${secondRole}\`` : ""}
|
|
||||||
`);
|
|
||||||
|
|
||||||
const help2 = asSingleLine(`
|
|
||||||
To remove a role, type \`${prefix}role remove ${firstRole}\`,
|
|
||||||
again replacing **${firstRole}** with the role you want to remove.
|
|
||||||
`);
|
|
||||||
|
|
||||||
const helpMessage = trimLines(`
|
|
||||||
${help1}
|
|
||||||
|
|
||||||
${help2}
|
|
||||||
|
|
||||||
**Roles available to you:**
|
|
||||||
${allPrimaryAliases.join(", ")}
|
|
||||||
`);
|
|
||||||
|
|
||||||
const helpEmbed = {
|
|
||||||
title: "How to get roles",
|
|
||||||
description: helpMessage,
|
|
||||||
color: parseInt("42bff4", 16),
|
|
||||||
};
|
|
||||||
|
|
||||||
msg.channel.createMessage({ embed: helpEmbed });
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("role remove", "<roleNames:string...>")
|
|
||||||
async roleRemoveCmd(msg: Message, args: { roleNames: string[] }) {
|
|
||||||
const lock = await this.locks.acquire(`grantableRoles:${msg.author.id}`);
|
|
||||||
|
|
||||||
const applyingEntries = this.getApplyingEntries(msg);
|
|
||||||
if (applyingEntries.length === 0) {
|
|
||||||
lock.unlock();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleNames = this.normalizeRoleNames(this.splitRoleNames(args.roleNames));
|
|
||||||
const matchedRoleIds = this.findMatchingRoles(roleNames, applyingEntries);
|
|
||||||
|
|
||||||
const rolesToRemove = Array.from(matchedRoleIds.values()).map(id => this.guild.roles.get(id));
|
|
||||||
const roleIdsToRemove = rolesToRemove.map(r => r.id);
|
|
||||||
|
|
||||||
// Remove the roles
|
|
||||||
if (rolesToRemove.length) {
|
|
||||||
const newRoleIds = msg.member.roles.filter(roleId => !roleIdsToRemove.includes(roleId));
|
|
||||||
|
|
||||||
try {
|
|
||||||
await msg.member.edit({
|
|
||||||
roles: newRoleIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
const removedRolesStr = rolesToRemove.map(r => `**${r.name}**`);
|
|
||||||
const removedRolesWord = rolesToRemove.length === 1 ? "role" : "roles";
|
|
||||||
|
|
||||||
if (rolesToRemove.length !== roleNames.length) {
|
|
||||||
this.sendSuccessMessage(
|
|
||||||
msg.channel,
|
|
||||||
`<@!${msg.author.id}> Removed ${removedRolesStr.join(", ")} ${removedRolesWord};` +
|
|
||||||
` couldn't recognize the other roles you mentioned`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.sendSuccessMessage(
|
|
||||||
msg.channel,
|
|
||||||
`<@!${msg.author.id}> Removed ${removedRolesStr.join(", ")} ${removedRolesWord}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
this.sendSuccessMessage(msg.channel, `<@!${msg.author.id}> Got an error while trying to remove the roles`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
msg.channel.createMessage(
|
|
||||||
errorMessage(`<@!${msg.author.id}> Unknown ${args.roleNames.length === 1 ? "role" : "roles"}`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
lock.unlock();
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("role", "<roleNames:string...>")
|
|
||||||
async roleCmd(msg: Message, args: { roleNames: string[] }) {
|
|
||||||
const lock = await this.locks.acquire(`grantableRoles:${msg.author.id}`);
|
|
||||||
|
|
||||||
const applyingEntries = this.getApplyingEntries(msg);
|
|
||||||
if (applyingEntries.length === 0) {
|
|
||||||
lock.unlock();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleNames = this.normalizeRoleNames(this.splitRoleNames(args.roleNames));
|
|
||||||
const matchedRoleIds = this.findMatchingRoles(roleNames, applyingEntries);
|
|
||||||
|
|
||||||
const hasUnknownRoles = matchedRoleIds.length !== roleNames.length;
|
|
||||||
|
|
||||||
const rolesToAdd: Map<string, Role> = Array.from(matchedRoleIds.values())
|
|
||||||
.map(id => this.guild.roles.get(id))
|
|
||||||
.filter(Boolean)
|
|
||||||
.reduce((map, role) => {
|
|
||||||
map.set(role.id, role);
|
|
||||||
return map;
|
|
||||||
}, new Map());
|
|
||||||
|
|
||||||
if (!rolesToAdd.size) {
|
|
||||||
this.sendErrorMessage(
|
|
||||||
msg.channel,
|
|
||||||
`<@!${msg.author.id}> Unknown ${args.roleNames.length === 1 ? "role" : "roles"}`,
|
|
||||||
);
|
|
||||||
lock.unlock();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grant the roles
|
|
||||||
const newRoleIds = new Set([...rolesToAdd.keys(), ...msg.member.roles]);
|
|
||||||
|
|
||||||
// Remove extra roles (max_roles) for each entry
|
|
||||||
const skipped: Set<Role> = new Set();
|
|
||||||
const removed: Set<Role> = new Set();
|
|
||||||
|
|
||||||
for (const entry of applyingEntries) {
|
|
||||||
if (entry.max_roles === 0) continue;
|
|
||||||
|
|
||||||
let foundRoles = 0;
|
|
||||||
|
|
||||||
for (const roleId of newRoleIds) {
|
|
||||||
if (entry.roles[roleId]) {
|
|
||||||
if (foundRoles < entry.max_roles) {
|
|
||||||
foundRoles++;
|
|
||||||
} else {
|
|
||||||
newRoleIds.delete(roleId);
|
|
||||||
rolesToAdd.delete(roleId);
|
|
||||||
|
|
||||||
if (msg.member.roles.includes(roleId)) {
|
|
||||||
removed.add(this.guild.roles.get(roleId));
|
|
||||||
} else {
|
|
||||||
skipped.add(this.guild.roles.get(roleId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await msg.member.edit({
|
|
||||||
roles: Array.from(newRoleIds),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
this.sendErrorMessage(msg.channel, `<@!${msg.author.id}> Got an error while trying to grant you the roles`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mentionRoles = this.getConfig().mention_roles;
|
|
||||||
const addedRolesStr = Array.from(rolesToAdd.values()).map(r => (mentionRoles ? `<@&${r.id}>` : `**${r.name}**`));
|
|
||||||
const addedRolesWord = rolesToAdd.size === 1 ? "role" : "roles";
|
|
||||||
|
|
||||||
const messageParts = [];
|
|
||||||
messageParts.push(`Granted you the ${addedRolesStr.join(", ")} ${addedRolesWord}`);
|
|
||||||
|
|
||||||
if (skipped.size || removed.size) {
|
|
||||||
const skippedRolesStr = skipped.size
|
|
||||||
? "skipped " +
|
|
||||||
Array.from(skipped.values())
|
|
||||||
.map(r => (mentionRoles ? `<@&${r.id}>` : `**${r.name}**`))
|
|
||||||
.join(",")
|
|
||||||
: null;
|
|
||||||
const removedRolesStr = removed.size
|
|
||||||
? "removed " + Array.from(removed.values()).map(r => (mentionRoles ? `<@&${r.id}>` : `**${r.name}**`))
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const skippedRemovedStr = [skippedRolesStr, removedRolesStr].filter(Boolean).join(" and ");
|
|
||||||
|
|
||||||
messageParts.push(`${skippedRemovedStr} due to role limits`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasUnknownRoles) {
|
|
||||||
messageParts.push("couldn't recognize some of the roles");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sendSuccessMessage(msg.channel, `<@!${msg.author.id}> ${messageParts.join("; ")}`);
|
|
||||||
|
|
||||||
lock.unlock();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,440 +0,0 @@
|
||||||
import { decorators as d, IPluginOptions, logger } from "knub";
|
|
||||||
import { GuildChannel, Message, TextChannel, Constants as ErisConstants, User } from "eris";
|
|
||||||
import {
|
|
||||||
convertDelayStringToMS,
|
|
||||||
createChunkedMessage,
|
|
||||||
errorMessage,
|
|
||||||
isDiscordRESTError,
|
|
||||||
noop,
|
|
||||||
stripObjectToScalars,
|
|
||||||
successMessage,
|
|
||||||
UnknownUser,
|
|
||||||
} from "../utils";
|
|
||||||
import { GuildSlowmodes } from "../data/GuildSlowmodes";
|
|
||||||
import humanizeDuration from "humanize-duration";
|
|
||||||
import { ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import { SavedMessage } from "../data/entities/SavedMessage";
|
|
||||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
|
||||||
import { GuildLogs } from "../data/GuildLogs";
|
|
||||||
import { LogType } from "../data/LogType";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
use_native_slowmode: t.boolean,
|
|
||||||
|
|
||||||
can_manage: t.boolean,
|
|
||||||
is_affected: t.boolean,
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
const NATIVE_SLOWMODE_LIMIT = 6 * 60 * 60; // 6 hours
|
|
||||||
const MAX_SLOWMODE = 60 * 60 * 24 * 365 * 100; // 100 years
|
|
||||||
const BOT_SLOWMODE_CLEAR_INTERVAL = 60 * 1000;
|
|
||||||
|
|
||||||
export class SlowmodePlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "slowmode";
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
public static pluginInfo = {
|
|
||||||
prettyName: "Slowmode",
|
|
||||||
};
|
|
||||||
|
|
||||||
protected slowmodes: GuildSlowmodes;
|
|
||||||
protected savedMessages: GuildSavedMessages;
|
|
||||||
protected logs: GuildLogs;
|
|
||||||
protected clearInterval;
|
|
||||||
|
|
||||||
private onMessageCreateFn;
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
use_native_slowmode: true,
|
|
||||||
|
|
||||||
can_manage: false,
|
|
||||||
is_affected: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
level: ">=50",
|
|
||||||
config: {
|
|
||||||
can_manage: true,
|
|
||||||
is_affected: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoad() {
|
|
||||||
this.slowmodes = GuildSlowmodes.getGuildInstance(this.guildId);
|
|
||||||
this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId);
|
|
||||||
this.logs = new GuildLogs(this.guildId);
|
|
||||||
this.clearInterval = setInterval(() => this.clearExpiredSlowmodes(), BOT_SLOWMODE_CLEAR_INTERVAL);
|
|
||||||
|
|
||||||
this.onMessageCreateFn = this.onMessageCreate.bind(this);
|
|
||||||
this.savedMessages.events.on("create", this.onMessageCreateFn);
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnload() {
|
|
||||||
clearInterval(this.clearInterval);
|
|
||||||
this.savedMessages.events.off("create", this.onMessageCreateFn);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies a bot-maintained slowmode to the specified user id on the specified channel.
|
|
||||||
* This sets the channel permissions so the user is unable to send messages there, and saves the slowmode in the db.
|
|
||||||
*/
|
|
||||||
async applyBotSlowmodeToUserId(channel: GuildChannel & TextChannel, userId: string) {
|
|
||||||
// Deny sendMessage permission from the user. If there are existing permission overwrites, take those into account.
|
|
||||||
const existingOverride = channel.permissionOverwrites.get(userId);
|
|
||||||
const newDeniedPermissions =
|
|
||||||
(existingOverride ? existingOverride.deny : 0) | ErisConstants.Permissions.sendMessages;
|
|
||||||
const newAllowedPermissions =
|
|
||||||
(existingOverride ? existingOverride.allow : 0) & ~ErisConstants.Permissions.sendMessages;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await channel.editPermission(userId, newAllowedPermissions, newDeniedPermissions, "member");
|
|
||||||
} catch (e) {
|
|
||||||
const user = this.bot.users.get(userId) || new UnknownUser({ id: userId });
|
|
||||||
|
|
||||||
if (isDiscordRESTError(e) && e.code === 50013) {
|
|
||||||
logger.warn(
|
|
||||||
`Missing permissions to apply bot slowmode to user ${userId} on channel ${channel.name} (${channel.id}) on server ${this.guild.name} (${this.guildId})`,
|
|
||||||
);
|
|
||||||
this.logs.log(LogType.BOT_ALERT, {
|
|
||||||
body: `Missing permissions to apply bot slowmode to {userMention(user)} in {channelMention(channel)}`,
|
|
||||||
user: stripObjectToScalars(user),
|
|
||||||
channel: stripObjectToScalars(channel),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.logs.log(LogType.BOT_ALERT, {
|
|
||||||
body: `Failed to apply bot slowmode to {userMention(user)} in {channelMention(channel)}`,
|
|
||||||
user: stripObjectToScalars(user),
|
|
||||||
channel: stripObjectToScalars(channel),
|
|
||||||
});
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.slowmodes.addSlowmodeUser(channel.id, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears bot-maintained slowmode from the specified user id on the specified channel.
|
|
||||||
* This reverts the channel permissions changed above and clears the database entry.
|
|
||||||
*/
|
|
||||||
async clearBotSlowmodeFromUserId(channel: GuildChannel & TextChannel, userId: string, force = false) {
|
|
||||||
try {
|
|
||||||
// Remove permission overrides from the channel for this user
|
|
||||||
// Previously we diffed the overrides so we could clear the "send messages" override without touching other
|
|
||||||
// overrides. Unfortunately, it seems that was a bit buggy - we didn't always receive the event for the changed
|
|
||||||
// overrides and then we also couldn't diff against them. For consistency's sake, we just delete the override now.
|
|
||||||
await channel.deletePermission(userId);
|
|
||||||
} catch (e) {
|
|
||||||
if (!force) {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.slowmodes.clearSlowmodeUser(channel.id, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disable slowmode on the specified channel. Clears any existing slowmode perms.
|
|
||||||
*/
|
|
||||||
async disableBotSlowmodeForChannel(channel: GuildChannel & TextChannel) {
|
|
||||||
// Disable channel slowmode
|
|
||||||
await this.slowmodes.deleteChannelSlowmode(channel.id);
|
|
||||||
|
|
||||||
// Remove currently applied slowmodes
|
|
||||||
const users = await this.slowmodes.getChannelSlowmodeUsers(channel.id);
|
|
||||||
const failedUsers = [];
|
|
||||||
|
|
||||||
for (const slowmodeUser of users) {
|
|
||||||
try {
|
|
||||||
await this.clearBotSlowmodeFromUserId(channel, slowmodeUser.user_id);
|
|
||||||
} catch (e) {
|
|
||||||
// Removing the slowmode failed. Record this so the permissions can be changed manually, and remove the database entry.
|
|
||||||
failedUsers.push(slowmodeUser.user_id);
|
|
||||||
await this.slowmodes.clearSlowmodeUser(channel.id, slowmodeUser.user_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { failedUsers };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* COMMAND: Disable slowmode on the specified channel
|
|
||||||
*/
|
|
||||||
@d.command("slowmode disable", "<channel:channel>")
|
|
||||||
@d.permission("can_manage")
|
|
||||||
async disableSlowmodeCmd(msg: Message, args: { channel: GuildChannel & TextChannel }) {
|
|
||||||
const botSlowmode = await this.slowmodes.getChannelSlowmode(args.channel.id);
|
|
||||||
const hasNativeSlowmode = args.channel.rateLimitPerUser;
|
|
||||||
|
|
||||||
if (!botSlowmode && hasNativeSlowmode === 0) {
|
|
||||||
msg.channel.createMessage(errorMessage("Channel is not on slowmode!"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initMsg = await msg.channel.createMessage("Disabling slowmode...");
|
|
||||||
|
|
||||||
// Disable bot-maintained slowmode
|
|
||||||
let failedUsers = [];
|
|
||||||
if (botSlowmode) {
|
|
||||||
const result = await this.disableBotSlowmodeForChannel(args.channel);
|
|
||||||
failedUsers = result.failedUsers;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disable native slowmode
|
|
||||||
if (hasNativeSlowmode) {
|
|
||||||
await args.channel.edit({ rateLimitPerUser: 0 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failedUsers.length) {
|
|
||||||
this.sendSuccessMessage(
|
|
||||||
msg.channel,
|
|
||||||
`Slowmode disabled! Failed to clear slowmode from the following users:\n\n<@!${failedUsers.join(">\n<@!")}>`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.sendSuccessMessage(msg.channel, "Slowmode disabled!");
|
|
||||||
initMsg.delete().catch(noop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* COMMAND: Clear slowmode from a specific user on a specific channel
|
|
||||||
*/
|
|
||||||
@d.command("slowmode clear", "<channel:channel> <user:resolvedUserLoose>", {
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
name: "force",
|
|
||||||
isSwitch: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
@d.permission("can_manage")
|
|
||||||
async clearSlowmodeCmd(msg: Message, args: { channel: GuildChannel & TextChannel; user: User; force?: boolean }) {
|
|
||||||
const channelSlowmode = await this.slowmodes.getChannelSlowmode(args.channel.id);
|
|
||||||
if (!channelSlowmode) {
|
|
||||||
msg.channel.createMessage(errorMessage("Channel doesn't have slowmode!"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.clearBotSlowmodeFromUserId(args.channel, args.user.id, args.force);
|
|
||||||
} catch (e) {
|
|
||||||
return this.sendErrorMessage(
|
|
||||||
msg.channel,
|
|
||||||
`Failed to clear slowmode from **${args.user.username}#${args.user.discriminator}** in <#${args.channel.id}>`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sendSuccessMessage(
|
|
||||||
msg.channel,
|
|
||||||
`Slowmode cleared from **${args.user.username}#${args.user.discriminator}** in <#${args.channel.id}>`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("slowmode list")
|
|
||||||
@d.permission("can_manage")
|
|
||||||
async slowmodeListCmd(msg: Message) {
|
|
||||||
const channels = this.guild.channels;
|
|
||||||
const slowmodes: Array<{ channel: GuildChannel; seconds: number; native: boolean }> = [];
|
|
||||||
|
|
||||||
for (const channel of channels.values()) {
|
|
||||||
if (!(channel instanceof TextChannel)) continue;
|
|
||||||
|
|
||||||
// Bot slowmode
|
|
||||||
const botSlowmode = await this.slowmodes.getChannelSlowmode(channel.id);
|
|
||||||
if (botSlowmode) {
|
|
||||||
slowmodes.push({ channel, seconds: botSlowmode.slowmode_seconds, native: false });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Native slowmode
|
|
||||||
if (channel.rateLimitPerUser) {
|
|
||||||
slowmodes.push({ channel, seconds: channel.rateLimitPerUser, native: true });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (slowmodes.length) {
|
|
||||||
const lines = slowmodes.map(slowmode => {
|
|
||||||
const humanized = humanizeDuration(slowmode.seconds * 1000);
|
|
||||||
|
|
||||||
const type = slowmode.native ? "native slowmode" : "bot slowmode";
|
|
||||||
|
|
||||||
return `<#${slowmode.channel.id}> **${humanized}** ${type}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
createChunkedMessage(msg.channel, lines.join("\n"));
|
|
||||||
} else {
|
|
||||||
msg.channel.createMessage(errorMessage("No active slowmodes!"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("slowmode", "[channel:channel]")
|
|
||||||
@d.permission("can_manage")
|
|
||||||
async showSlowmodeCmd(msg: Message, args: { channel: GuildChannel & TextChannel }) {
|
|
||||||
const channel = args.channel || msg.channel;
|
|
||||||
|
|
||||||
if (channel == null || !(channel instanceof TextChannel)) {
|
|
||||||
msg.channel.createMessage(errorMessage("Channel must be a text channel"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentSlowmode = channel.rateLimitPerUser;
|
|
||||||
let isNative = true;
|
|
||||||
|
|
||||||
if (!currentSlowmode) {
|
|
||||||
const botSlowmode = await this.slowmodes.getChannelSlowmode(channel.id);
|
|
||||||
if (botSlowmode) {
|
|
||||||
currentSlowmode = botSlowmode.slowmode_seconds;
|
|
||||||
isNative = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentSlowmode) {
|
|
||||||
const humanized = humanizeDuration(channel.rateLimitPerUser * 1000);
|
|
||||||
const slowmodeType = isNative ? "native" : "bot-maintained";
|
|
||||||
msg.channel.createMessage(`The current slowmode of <#${channel.id}> is **${humanized}** (${slowmodeType})`);
|
|
||||||
} else {
|
|
||||||
msg.channel.createMessage("Channel is not on slowmode");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* COMMAND: Set slowmode for the specified channel
|
|
||||||
*/
|
|
||||||
@d.command("slowmode", "<channel:channel> <time:string>", {
|
|
||||||
overloads: ["<time:string>"],
|
|
||||||
})
|
|
||||||
@d.permission("can_manage")
|
|
||||||
async slowmodeCmd(msg: Message, args: { channel?: GuildChannel & TextChannel; time: string }) {
|
|
||||||
const channel = args.channel || msg.channel;
|
|
||||||
|
|
||||||
if (channel == null || !(channel instanceof TextChannel)) {
|
|
||||||
msg.channel.createMessage(errorMessage("Channel must be a text channel"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const seconds = Math.ceil(convertDelayStringToMS(args.time, "s") / 1000);
|
|
||||||
const useNativeSlowmode = this.getConfigForChannel(channel).use_native_slowmode && seconds <= NATIVE_SLOWMODE_LIMIT;
|
|
||||||
|
|
||||||
if (seconds === 0) {
|
|
||||||
return this.disableSlowmodeCmd(msg, { channel });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seconds > MAX_SLOWMODE) {
|
|
||||||
this.sendErrorMessage(msg.channel, `Sorry, slowmodes can be at most 100 years long. Maybe 99 would be enough?`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (useNativeSlowmode) {
|
|
||||||
// Native slowmode
|
|
||||||
|
|
||||||
// If there is an existing bot-maintained slowmode, disable that first
|
|
||||||
const existingBotSlowmode = await this.slowmodes.getChannelSlowmode(channel.id);
|
|
||||||
if (existingBotSlowmode) {
|
|
||||||
await this.disableBotSlowmodeForChannel(channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set slowmode
|
|
||||||
try {
|
|
||||||
await channel.edit({
|
|
||||||
rateLimitPerUser: seconds,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
return this.sendErrorMessage(msg.channel, "Failed to set native slowmode (check permissions)");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Bot-maintained slowmode
|
|
||||||
|
|
||||||
// If there is an existing native slowmode, disable that first
|
|
||||||
if (channel.rateLimitPerUser) {
|
|
||||||
await channel.edit({
|
|
||||||
rateLimitPerUser: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.slowmodes.setChannelSlowmode(channel.id, seconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
const humanizedSlowmodeTime = humanizeDuration(seconds * 1000);
|
|
||||||
const slowmodeType = useNativeSlowmode ? "native slowmode" : "bot-maintained slowmode";
|
|
||||||
this.sendSuccessMessage(
|
|
||||||
msg.channel,
|
|
||||||
`Set ${humanizedSlowmodeTime} slowmode for <#${channel.id}> (${slowmodeType})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EVENT: On every message, check if the channel has a bot-maintained slowmode. If it does, apply slowmode to the user.
|
|
||||||
* If the user already had slowmode but was still able to send a message (e.g. sending a lot of messages at once),
|
|
||||||
* remove the messages sent after slowmode was applied.
|
|
||||||
*/
|
|
||||||
async onMessageCreate(msg: SavedMessage) {
|
|
||||||
if (msg.is_bot) return;
|
|
||||||
|
|
||||||
const channel = this.guild.channels.get(msg.channel_id) as GuildChannel & TextChannel;
|
|
||||||
if (!channel) return;
|
|
||||||
|
|
||||||
// Don't apply slowmode if the lock was interrupted earlier (e.g. the message was caught by word filters)
|
|
||||||
const thisMsgLock = await this.locks.acquire(`message-${msg.id}`);
|
|
||||||
if (thisMsgLock.interrupted) return;
|
|
||||||
|
|
||||||
// Check if this channel even *has* a bot-maintained slowmode
|
|
||||||
const channelSlowmode = await this.slowmodes.getChannelSlowmode(channel.id);
|
|
||||||
if (!channelSlowmode) return thisMsgLock.unlock();
|
|
||||||
|
|
||||||
// Make sure this user is affected by the slowmode
|
|
||||||
const member = await this.getMember(msg.user_id);
|
|
||||||
const isAffected = this.hasPermission("is_affected", { channelId: channel.id, userId: msg.user_id, member });
|
|
||||||
if (!isAffected) return thisMsgLock.unlock();
|
|
||||||
|
|
||||||
// Delete any extra messages sent after a slowmode was already applied
|
|
||||||
const userHasSlowmode = await this.slowmodes.userHasSlowmode(channel.id, msg.user_id);
|
|
||||||
if (userHasSlowmode) {
|
|
||||||
const message = await channel.getMessage(msg.id);
|
|
||||||
if (message) {
|
|
||||||
message.delete();
|
|
||||||
return thisMsgLock.interrupt();
|
|
||||||
}
|
|
||||||
|
|
||||||
return thisMsgLock.unlock();
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.applyBotSlowmodeToUserId(channel, msg.user_id);
|
|
||||||
thisMsgLock.unlock();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears all expired bot-maintained user slowmodes in this guild
|
|
||||||
*/
|
|
||||||
async clearExpiredSlowmodes() {
|
|
||||||
const expiredSlowmodeUsers = await this.slowmodes.getExpiredSlowmodeUsers();
|
|
||||||
for (const user of expiredSlowmodeUsers) {
|
|
||||||
const channel = this.guild.channels.get(user.channel_id);
|
|
||||||
if (!channel) {
|
|
||||||
await this.slowmodes.clearSlowmodeUser(user.channel_id, user.user_id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.clearBotSlowmodeFromUserId(channel as GuildChannel & TextChannel, user.user_id);
|
|
||||||
} catch (e) {
|
|
||||||
logger.error(e);
|
|
||||||
|
|
||||||
const realUser = this.bot.users.get(user.user_id) || new UnknownUser({ id: user.user_id });
|
|
||||||
this.logs.log(LogType.BOT_ALERT, {
|
|
||||||
body: `Failed to clear slowmode permissions from {userMention(user)} in {channelMention(channel)}`,
|
|
||||||
user: stripObjectToScalars(realUser),
|
|
||||||
channel: stripObjectToScalars(channel),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,514 +0,0 @@
|
||||||
import { decorators as d, IPluginOptions, logger } from "knub";
|
|
||||||
import { Channel, Member } from "eris";
|
|
||||||
import {
|
|
||||||
convertDelayStringToMS,
|
|
||||||
getEmojiInString,
|
|
||||||
getRoleMentions,
|
|
||||||
getUrlsInString,
|
|
||||||
getUserMentions,
|
|
||||||
noop,
|
|
||||||
stripObjectToScalars,
|
|
||||||
tNullable,
|
|
||||||
trimLines,
|
|
||||||
} from "../utils";
|
|
||||||
import { LogType } from "../data/LogType";
|
|
||||||
import { GuildLogs } from "../data/GuildLogs";
|
|
||||||
import { CaseTypes } from "../data/CaseTypes";
|
|
||||||
import { GuildArchives } from "../data/GuildArchives";
|
|
||||||
import moment from "moment-timezone";
|
|
||||||
import { SavedMessage } from "../data/entities/SavedMessage";
|
|
||||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
|
||||||
import { GuildMutes } from "../data/GuildMutes";
|
|
||||||
import { trimPluginDescription, ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import { MuteResult, MutesPlugin } from "./Mutes";
|
|
||||||
import { CasesPlugin } from "./Cases";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
|
|
||||||
const BaseSingleSpamConfig = t.type({
|
|
||||||
interval: t.number,
|
|
||||||
count: t.number,
|
|
||||||
mute: tNullable(t.boolean),
|
|
||||||
mute_time: tNullable(t.number),
|
|
||||||
clean: tNullable(t.boolean),
|
|
||||||
});
|
|
||||||
type TBaseSingleSpamConfig = t.TypeOf<typeof BaseSingleSpamConfig>;
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
max_censor: tNullable(BaseSingleSpamConfig),
|
|
||||||
max_messages: tNullable(BaseSingleSpamConfig),
|
|
||||||
max_mentions: tNullable(BaseSingleSpamConfig),
|
|
||||||
max_links: tNullable(BaseSingleSpamConfig),
|
|
||||||
max_attachments: tNullable(BaseSingleSpamConfig),
|
|
||||||
max_emojis: tNullable(BaseSingleSpamConfig),
|
|
||||||
max_newlines: tNullable(BaseSingleSpamConfig),
|
|
||||||
max_duplicates: tNullable(BaseSingleSpamConfig),
|
|
||||||
max_characters: tNullable(BaseSingleSpamConfig),
|
|
||||||
max_voice_moves: tNullable(BaseSingleSpamConfig),
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
enum RecentActionType {
|
|
||||||
Message = 1,
|
|
||||||
Mention,
|
|
||||||
Link,
|
|
||||||
Attachment,
|
|
||||||
Emoji,
|
|
||||||
Newline,
|
|
||||||
Censor,
|
|
||||||
Character,
|
|
||||||
VoiceChannelMove,
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IRecentAction<T> {
|
|
||||||
type: RecentActionType;
|
|
||||||
userId: string;
|
|
||||||
actionGroupId: string;
|
|
||||||
extraData: T;
|
|
||||||
timestamp: number;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_INTERVAL = 300;
|
|
||||||
|
|
||||||
const SPAM_ARCHIVE_EXPIRY_DAYS = 90;
|
|
||||||
|
|
||||||
export class SpamPlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "spam";
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
public static pluginInfo = {
|
|
||||||
prettyName: "Spam protection",
|
|
||||||
description: trimPluginDescription(`
|
|
||||||
Basic spam detection and auto-muting.
|
|
||||||
For more advanced spam filtering, check out the Automod plugin!
|
|
||||||
`),
|
|
||||||
};
|
|
||||||
|
|
||||||
protected logs: GuildLogs;
|
|
||||||
protected archives: GuildArchives;
|
|
||||||
protected savedMessages: GuildSavedMessages;
|
|
||||||
protected mutes: GuildMutes;
|
|
||||||
|
|
||||||
private onMessageCreateFn;
|
|
||||||
|
|
||||||
// Handle spam detection with a queue so we don't have overlapping detections on the same user
|
|
||||||
protected spamDetectionQueue: Promise<void>;
|
|
||||||
|
|
||||||
// List of recent potentially-spammy actions
|
|
||||||
protected recentActions: Array<IRecentAction<any>>;
|
|
||||||
|
|
||||||
// A map of userId => channelId => msgId
|
|
||||||
// Keeps track of the last handled (= spam detected and acted on) message ID per user, per channel
|
|
||||||
// TODO: Prevent this from growing infinitely somehow
|
|
||||||
protected lastHandledMsgIds: Map<string, Map<string, string>>;
|
|
||||||
|
|
||||||
private expiryInterval;
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
max_censor: null,
|
|
||||||
max_messages: null,
|
|
||||||
max_mentions: null,
|
|
||||||
max_links: null,
|
|
||||||
max_attachments: null,
|
|
||||||
max_emojis: null,
|
|
||||||
max_newlines: null,
|
|
||||||
max_duplicates: null,
|
|
||||||
max_characters: null,
|
|
||||||
max_voice_moves: null,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Default override to make mods immune to the spam filter
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
level: ">=50",
|
|
||||||
config: {
|
|
||||||
max_messages: null,
|
|
||||||
max_mentions: null,
|
|
||||||
max_links: null,
|
|
||||||
max_attachments: null,
|
|
||||||
max_emojis: null,
|
|
||||||
max_newlines: null,
|
|
||||||
max_duplicates: null,
|
|
||||||
max_characters: null,
|
|
||||||
max_voice_moves: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoad() {
|
|
||||||
this.logs = new GuildLogs(this.guildId);
|
|
||||||
this.archives = GuildArchives.getGuildInstance(this.guildId);
|
|
||||||
this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId);
|
|
||||||
this.mutes = GuildMutes.getGuildInstance(this.guildId);
|
|
||||||
|
|
||||||
this.recentActions = [];
|
|
||||||
this.expiryInterval = setInterval(() => this.clearOldRecentActions(), 1000 * 60);
|
|
||||||
this.lastHandledMsgIds = new Map();
|
|
||||||
|
|
||||||
this.spamDetectionQueue = Promise.resolve();
|
|
||||||
|
|
||||||
this.onMessageCreateFn = this.onMessageCreate.bind(this);
|
|
||||||
this.savedMessages.events.on("create", this.onMessageCreateFn);
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnload() {
|
|
||||||
clearInterval(this.expiryInterval);
|
|
||||||
this.savedMessages.events.off("create", this.onMessageCreateFn);
|
|
||||||
}
|
|
||||||
|
|
||||||
addRecentAction(
|
|
||||||
type: RecentActionType,
|
|
||||||
userId: string,
|
|
||||||
actionGroupId: string,
|
|
||||||
extraData: any,
|
|
||||||
timestamp: number,
|
|
||||||
count = 1,
|
|
||||||
) {
|
|
||||||
this.recentActions.push({ type, userId, actionGroupId, extraData, timestamp, count });
|
|
||||||
}
|
|
||||||
|
|
||||||
getRecentActions(type: RecentActionType, userId: string, actionGroupId: string, since: number) {
|
|
||||||
return this.recentActions.filter(action => {
|
|
||||||
if (action.timestamp < since) return false;
|
|
||||||
if (action.type !== type) return false;
|
|
||||||
if (action.actionGroupId !== actionGroupId) return false;
|
|
||||||
if (action.userId !== userId) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getRecentActionCount(type: RecentActionType, userId: string, actionGroupId: string, since: number) {
|
|
||||||
return this.recentActions.reduce((count, action) => {
|
|
||||||
if (action.timestamp < since) return count;
|
|
||||||
if (action.type !== type) return count;
|
|
||||||
if (action.actionGroupId !== actionGroupId) return count;
|
|
||||||
if (action.userId !== userId) return false;
|
|
||||||
return count + action.count;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearRecentUserActions(type: RecentActionType, userId: string, actionGroupId: string) {
|
|
||||||
this.recentActions = this.recentActions.filter(action => {
|
|
||||||
return action.type !== type || action.userId !== userId || action.actionGroupId !== actionGroupId;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
clearOldRecentActions() {
|
|
||||||
// TODO: Figure out expiry time from longest interval in the config?
|
|
||||||
const expiryTimestamp = Date.now() - 1000 * MAX_INTERVAL;
|
|
||||||
this.recentActions = this.recentActions.filter(action => action.timestamp >= expiryTimestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveSpamArchives(savedMessages: SavedMessage[]) {
|
|
||||||
const expiresAt = moment().add(SPAM_ARCHIVE_EXPIRY_DAYS, "days");
|
|
||||||
const archiveId = await this.archives.createFromSavedMessages(savedMessages, this.guild, expiresAt);
|
|
||||||
|
|
||||||
const baseUrl = this.knub.getGlobalConfig().url;
|
|
||||||
return this.archives.getUrl(baseUrl, archiveId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async logAndDetectMessageSpam(
|
|
||||||
savedMessage: SavedMessage,
|
|
||||||
type: RecentActionType,
|
|
||||||
spamConfig: TBaseSingleSpamConfig,
|
|
||||||
actionCount: number,
|
|
||||||
description: string,
|
|
||||||
) {
|
|
||||||
if (actionCount === 0) return;
|
|
||||||
|
|
||||||
// Make sure we're not handling some messages twice
|
|
||||||
if (this.lastHandledMsgIds.has(savedMessage.user_id)) {
|
|
||||||
const channelMap = this.lastHandledMsgIds.get(savedMessage.user_id);
|
|
||||||
if (channelMap.has(savedMessage.channel_id)) {
|
|
||||||
const lastHandledMsgId = channelMap.get(savedMessage.channel_id);
|
|
||||||
if (lastHandledMsgId >= savedMessage.id) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.spamDetectionQueue = this.spamDetectionQueue.then(
|
|
||||||
async () => {
|
|
||||||
const timestamp = moment(savedMessage.posted_at).valueOf();
|
|
||||||
const member = await this.getMember(savedMessage.user_id);
|
|
||||||
|
|
||||||
// Log this action...
|
|
||||||
this.addRecentAction(type, savedMessage.user_id, savedMessage.channel_id, savedMessage, timestamp, actionCount);
|
|
||||||
|
|
||||||
// ...and then check if it trips the spam filters
|
|
||||||
const since = timestamp - 1000 * spamConfig.interval;
|
|
||||||
const recentActionsCount = this.getRecentActionCount(
|
|
||||||
type,
|
|
||||||
savedMessage.user_id,
|
|
||||||
savedMessage.channel_id,
|
|
||||||
since,
|
|
||||||
);
|
|
||||||
|
|
||||||
// If the user tripped the spam filter...
|
|
||||||
if (recentActionsCount > spamConfig.count) {
|
|
||||||
const recentActions = this.getRecentActions(type, savedMessage.user_id, savedMessage.channel_id, since);
|
|
||||||
|
|
||||||
// Start by muting them, if enabled
|
|
||||||
let muteResult: MuteResult;
|
|
||||||
if (spamConfig.mute && member) {
|
|
||||||
const mutesPlugin = this.getPlugin<MutesPlugin>("mutes");
|
|
||||||
const muteTime = spamConfig.mute_time
|
|
||||||
? convertDelayStringToMS(spamConfig.mute_time.toString())
|
|
||||||
: 120 * 1000;
|
|
||||||
muteResult = await mutesPlugin.muteUser(member.id, muteTime, "Automatic spam detection", {
|
|
||||||
caseArgs: {
|
|
||||||
modId: this.bot.user.id,
|
|
||||||
postInCaseLogOverride: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the offending message IDs
|
|
||||||
// We also get the IDs of any messages after the last offending message, to account for lag before detection
|
|
||||||
const savedMessages = recentActions.map(a => a.extraData as SavedMessage);
|
|
||||||
const msgIds = savedMessages.map(m => m.id);
|
|
||||||
const lastDetectedMsgId = msgIds[msgIds.length - 1];
|
|
||||||
|
|
||||||
const additionalMessages = await this.savedMessages.getUserMessagesByChannelAfterId(
|
|
||||||
savedMessage.user_id,
|
|
||||||
savedMessage.channel_id,
|
|
||||||
lastDetectedMsgId,
|
|
||||||
);
|
|
||||||
additionalMessages.forEach(m => msgIds.push(m.id));
|
|
||||||
|
|
||||||
// Then, if enabled, remove the spam messages
|
|
||||||
if (spamConfig.clean !== false) {
|
|
||||||
msgIds.forEach(id => this.logs.ignoreLog(LogType.MESSAGE_DELETE, id));
|
|
||||||
this.bot.deleteMessages(savedMessage.channel_id, msgIds).catch(noop);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the ID of the last handled message
|
|
||||||
const uniqueMessages = Array.from(new Set([...savedMessages, ...additionalMessages]));
|
|
||||||
uniqueMessages.sort((a, b) => (a.id > b.id ? 1 : -1));
|
|
||||||
const lastHandledMsgId = uniqueMessages.reduce((last: string, m: SavedMessage): string => {
|
|
||||||
return !last || m.id > last ? m.id : last;
|
|
||||||
}, null);
|
|
||||||
|
|
||||||
if (!this.lastHandledMsgIds.has(savedMessage.user_id)) {
|
|
||||||
this.lastHandledMsgIds.set(savedMessage.user_id, new Map());
|
|
||||||
}
|
|
||||||
|
|
||||||
const channelMap = this.lastHandledMsgIds.get(savedMessage.user_id);
|
|
||||||
channelMap.set(savedMessage.channel_id, lastHandledMsgId);
|
|
||||||
|
|
||||||
// Clear the handled actions from recentActions
|
|
||||||
this.clearRecentUserActions(type, savedMessage.user_id, savedMessage.channel_id);
|
|
||||||
|
|
||||||
// Generate a log from the detected messages
|
|
||||||
const channel = this.guild.channels.get(savedMessage.channel_id);
|
|
||||||
const archiveUrl = await this.saveSpamArchives(uniqueMessages);
|
|
||||||
|
|
||||||
// Create a case
|
|
||||||
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
|
|
||||||
if (muteResult) {
|
|
||||||
// If the user was muted, the mute already generated a case - in that case, just update the case with extra details
|
|
||||||
// This will also post the case in the case log channel, which we didn't do with the mute initially to avoid
|
|
||||||
// posting the case on the channel twice: once with the initial reason, and then again with the note from here
|
|
||||||
const updateText = trimLines(`
|
|
||||||
Details: ${description} (over ${spamConfig.count} in ${spamConfig.interval}s)
|
|
||||||
${archiveUrl}
|
|
||||||
`);
|
|
||||||
casesPlugin.createCaseNote({
|
|
||||||
caseId: muteResult.case.id,
|
|
||||||
modId: muteResult.case.mod_id,
|
|
||||||
body: updateText,
|
|
||||||
automatic: true,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// If the user was not muted, create a note case of the detected spam instead
|
|
||||||
const caseText = trimLines(`
|
|
||||||
Automatic spam detection: ${description} (over ${spamConfig.count} in ${spamConfig.interval}s)
|
|
||||||
${archiveUrl}
|
|
||||||
`);
|
|
||||||
|
|
||||||
casesPlugin.createCase({
|
|
||||||
userId: savedMessage.user_id,
|
|
||||||
modId: this.bot.user.id,
|
|
||||||
type: CaseTypes.Note,
|
|
||||||
reason: caseText,
|
|
||||||
automatic: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a log entry
|
|
||||||
this.logs.log(LogType.MESSAGE_SPAM_DETECTED, {
|
|
||||||
member: stripObjectToScalars(member, ["user", "roles"]),
|
|
||||||
channel: stripObjectToScalars(channel),
|
|
||||||
description,
|
|
||||||
limit: spamConfig.count,
|
|
||||||
interval: spamConfig.interval,
|
|
||||||
archiveUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
err => {
|
|
||||||
logger.error(`Error while detecting spam:\n${err}`);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async logAndDetectOtherSpam(
|
|
||||||
type: RecentActionType,
|
|
||||||
spamConfig: any,
|
|
||||||
userId: string,
|
|
||||||
actionCount: number,
|
|
||||||
actionGroupId: string,
|
|
||||||
timestamp: number,
|
|
||||||
extraData = null,
|
|
||||||
description: string,
|
|
||||||
) {
|
|
||||||
this.spamDetectionQueue = this.spamDetectionQueue.then(async () => {
|
|
||||||
// Log this action...
|
|
||||||
this.addRecentAction(type, userId, actionGroupId, extraData, timestamp, actionCount);
|
|
||||||
|
|
||||||
// ...and then check if it trips the spam filters
|
|
||||||
const since = timestamp - 1000 * spamConfig.interval;
|
|
||||||
const recentActionsCount = this.getRecentActionCount(type, userId, actionGroupId, since);
|
|
||||||
|
|
||||||
if (recentActionsCount > spamConfig.count) {
|
|
||||||
const member = await this.getMember(userId);
|
|
||||||
const details = `${description} (over ${spamConfig.count} in ${spamConfig.interval}s)`;
|
|
||||||
|
|
||||||
if (spamConfig.mute && member) {
|
|
||||||
const mutesPlugin = this.getPlugin<MutesPlugin>("mutes");
|
|
||||||
const muteTime = spamConfig.mute_time ? convertDelayStringToMS(spamConfig.mute_time.toString()) : 120 * 1000;
|
|
||||||
await mutesPlugin.muteUser(member.id, muteTime, "Automatic spam detection", {
|
|
||||||
caseArgs: {
|
|
||||||
modId: this.bot.user.id,
|
|
||||||
extraNotes: [`Details: ${details}`],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// If we're not muting the user, just add a note on them
|
|
||||||
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
|
|
||||||
await casesPlugin.createCase({
|
|
||||||
userId,
|
|
||||||
modId: this.bot.user.id,
|
|
||||||
type: CaseTypes.Note,
|
|
||||||
reason: `Automatic spam detection: ${details}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear recent cases
|
|
||||||
this.clearRecentUserActions(RecentActionType.VoiceChannelMove, userId, actionGroupId);
|
|
||||||
|
|
||||||
this.logs.log(LogType.OTHER_SPAM_DETECTED, {
|
|
||||||
member: stripObjectToScalars(member, ["user", "roles"]),
|
|
||||||
description,
|
|
||||||
limit: spamConfig.count,
|
|
||||||
interval: spamConfig.interval,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// For interoperability with the Censor plugin
|
|
||||||
async logCensor(savedMessage: SavedMessage) {
|
|
||||||
const config = this.getConfigForMemberIdAndChannelId(savedMessage.user_id, savedMessage.channel_id);
|
|
||||||
const spamConfig = config.max_censor;
|
|
||||||
|
|
||||||
if (spamConfig) {
|
|
||||||
this.logAndDetectMessageSpam(savedMessage, RecentActionType.Censor, spamConfig, 1, "too many censored messages");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessageCreate(savedMessage: SavedMessage) {
|
|
||||||
if (savedMessage.is_bot) return;
|
|
||||||
|
|
||||||
const config = this.getConfigForMemberIdAndChannelId(savedMessage.user_id, savedMessage.channel_id);
|
|
||||||
|
|
||||||
const maxMessages = config.max_messages;
|
|
||||||
if (maxMessages) {
|
|
||||||
this.logAndDetectMessageSpam(savedMessage, RecentActionType.Message, maxMessages, 1, "too many messages");
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxMentions = config.max_mentions;
|
|
||||||
const mentions = savedMessage.data.content
|
|
||||||
? [...getUserMentions(savedMessage.data.content), ...getRoleMentions(savedMessage.data.content)]
|
|
||||||
: [];
|
|
||||||
if (maxMentions && mentions.length) {
|
|
||||||
this.logAndDetectMessageSpam(
|
|
||||||
savedMessage,
|
|
||||||
RecentActionType.Mention,
|
|
||||||
maxMentions,
|
|
||||||
mentions.length,
|
|
||||||
"too many mentions",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxLinks = config.max_links;
|
|
||||||
if (maxLinks && savedMessage.data.content && typeof savedMessage.data.content === "string") {
|
|
||||||
const links = getUrlsInString(savedMessage.data.content);
|
|
||||||
this.logAndDetectMessageSpam(savedMessage, RecentActionType.Link, maxLinks, links.length, "too many links");
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxAttachments = config.max_attachments;
|
|
||||||
if (maxAttachments && savedMessage.data.attachments) {
|
|
||||||
this.logAndDetectMessageSpam(
|
|
||||||
savedMessage,
|
|
||||||
RecentActionType.Attachment,
|
|
||||||
maxAttachments,
|
|
||||||
savedMessage.data.attachments.length,
|
|
||||||
"too many attachments",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxEmojis = config.max_emojis;
|
|
||||||
if (maxEmojis && savedMessage.data.content) {
|
|
||||||
const emojiCount = getEmojiInString(savedMessage.data.content).length;
|
|
||||||
this.logAndDetectMessageSpam(savedMessage, RecentActionType.Emoji, maxEmojis, emojiCount, "too many emoji");
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxNewlines = config.max_newlines;
|
|
||||||
if (maxNewlines && savedMessage.data.content) {
|
|
||||||
const newlineCount = (savedMessage.data.content.match(/\n/g) || []).length;
|
|
||||||
this.logAndDetectMessageSpam(
|
|
||||||
savedMessage,
|
|
||||||
RecentActionType.Newline,
|
|
||||||
maxNewlines,
|
|
||||||
newlineCount,
|
|
||||||
"too many newlines",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxCharacters = config.max_characters;
|
|
||||||
if (maxCharacters && savedMessage.data.content) {
|
|
||||||
const characterCount = [...savedMessage.data.content.trim()].length;
|
|
||||||
this.logAndDetectMessageSpam(
|
|
||||||
savedMessage,
|
|
||||||
RecentActionType.Character,
|
|
||||||
maxCharacters,
|
|
||||||
characterCount,
|
|
||||||
"too many characters",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Max duplicates check
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("voiceChannelJoin")
|
|
||||||
@d.event("voiceChannelSwitch")
|
|
||||||
onVoiceChannelSwitch(member: Member, channel: Channel) {
|
|
||||||
const config = this.getConfigForMemberIdAndChannelId(member.id, channel.id);
|
|
||||||
const maxVoiceMoves = config.max_voice_moves;
|
|
||||||
if (maxVoiceMoves) {
|
|
||||||
this.logAndDetectOtherSpam(
|
|
||||||
RecentActionType.VoiceChannelMove,
|
|
||||||
maxVoiceMoves,
|
|
||||||
member.id,
|
|
||||||
1,
|
|
||||||
"0",
|
|
||||||
Date.now(),
|
|
||||||
null,
|
|
||||||
"too many voice channel moves",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,386 +0,0 @@
|
||||||
import { decorators as d, IPluginOptions } from "knub";
|
|
||||||
import { ZeppelinPluginClass, trimPluginDescription } from "./ZeppelinPluginClass";
|
|
||||||
import { Embed, EmbedBase, GuildChannel, Message, TextChannel } from "eris";
|
|
||||||
import {
|
|
||||||
errorMessage,
|
|
||||||
getUrlsInString,
|
|
||||||
messageLink,
|
|
||||||
noop,
|
|
||||||
successMessage,
|
|
||||||
TDeepPartialProps,
|
|
||||||
tNullable,
|
|
||||||
tDeepPartial,
|
|
||||||
UnknownUser,
|
|
||||||
EMPTY_CHAR,
|
|
||||||
} from "../utils";
|
|
||||||
import path from "path";
|
|
||||||
import moment from "moment-timezone";
|
|
||||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
|
||||||
import { SavedMessage } from "../data/entities/SavedMessage";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
import { GuildStarboardMessages } from "../data/GuildStarboardMessages";
|
|
||||||
import { StarboardMessage } from "../data/entities/StarboardMessage";
|
|
||||||
import { GuildStarboardReactions } from "../data/GuildStarboardReactions";
|
|
||||||
|
|
||||||
const StarboardOpts = t.type({
|
|
||||||
channel_id: t.string,
|
|
||||||
stars_required: t.number,
|
|
||||||
star_emoji: tNullable(t.array(t.string)),
|
|
||||||
copy_full_embed: tNullable(t.boolean),
|
|
||||||
enabled: tNullable(t.boolean),
|
|
||||||
});
|
|
||||||
type TStarboardOpts = t.TypeOf<typeof StarboardOpts>;
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
boards: t.record(t.string, StarboardOpts),
|
|
||||||
can_migrate: t.boolean,
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
const PartialConfigSchema = tDeepPartial(ConfigSchema);
|
|
||||||
|
|
||||||
const defaultStarboardOpts: Partial<TStarboardOpts> = {
|
|
||||||
star_emoji: ["⭐"],
|
|
||||||
enabled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
export class StarboardPlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "starboard";
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
public static pluginInfo = {
|
|
||||||
prettyName: "Starboard",
|
|
||||||
description: trimPluginDescription(`
|
|
||||||
This plugin allows you to set up starboards on your server. Starboards are like user voted pins where messages with enough reactions get immortalized on a "starboard" channel.
|
|
||||||
`),
|
|
||||||
configurationGuide: trimPluginDescription(`
|
|
||||||
### Note on emojis
|
|
||||||
To specify emoji in the config, you need to use the emoji's "raw form".
|
|
||||||
To obtain this, post the emoji with a backslash in front of it.
|
|
||||||
|
|
||||||
- Example with a default emoji: "\:star:" => "⭐"
|
|
||||||
- Example with a custom emoji: "\:mrvnSmile:" => "<:mrvnSmile:543000534102310933>"
|
|
||||||
|
|
||||||
### Basic starboard
|
|
||||||
Any message on the server that gets 5 star reactions will be posted into the starboard channel (604342689038729226).
|
|
||||||
|
|
||||||
~~~yml
|
|
||||||
starboard:
|
|
||||||
config:
|
|
||||||
boards:
|
|
||||||
basic:
|
|
||||||
channel_id: "604342689038729226"
|
|
||||||
stars_required: 5
|
|
||||||
~~~
|
|
||||||
|
|
||||||
### Custom star emoji
|
|
||||||
This is identical to the basic starboard above, but accepts two emoji: the regular star and a custom :mrvnSmile: emoji
|
|
||||||
|
|
||||||
~~~yml
|
|
||||||
starboard:
|
|
||||||
config:
|
|
||||||
boards:
|
|
||||||
basic:
|
|
||||||
channel_id: "604342689038729226"
|
|
||||||
star_emoji: ["⭐", "<:mrvnSmile:543000534102310933>"]
|
|
||||||
stars_required: 5
|
|
||||||
~~~
|
|
||||||
|
|
||||||
### Limit starboard to a specific channel
|
|
||||||
This is identical to the basic starboard above, but only works from a specific channel (473087035574321152).
|
|
||||||
|
|
||||||
~~~yml
|
|
||||||
starboard:
|
|
||||||
config:
|
|
||||||
boards:
|
|
||||||
basic:
|
|
||||||
enabled: false # The starboard starts disabled and is then enabled in a channel override below
|
|
||||||
channel_id: "604342689038729226"
|
|
||||||
stars_required: 5
|
|
||||||
overrides:
|
|
||||||
- channel: "473087035574321152"
|
|
||||||
config:
|
|
||||||
boards:
|
|
||||||
basic:
|
|
||||||
enabled: true
|
|
||||||
~~~
|
|
||||||
`),
|
|
||||||
};
|
|
||||||
|
|
||||||
protected savedMessages: GuildSavedMessages;
|
|
||||||
protected starboardMessages: GuildStarboardMessages;
|
|
||||||
protected starboardReactions: GuildStarboardReactions;
|
|
||||||
|
|
||||||
private onMessageDeleteFn;
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
can_migrate: false,
|
|
||||||
boards: {},
|
|
||||||
},
|
|
||||||
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
level: ">=100",
|
|
||||||
config: {
|
|
||||||
can_migrate: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static preprocessStaticConfig(config: t.TypeOf<typeof PartialConfigSchema>) {
|
|
||||||
if (config.boards) {
|
|
||||||
for (const [name, opts] of Object.entries(config.boards)) {
|
|
||||||
config.boards[name] = Object.assign({}, defaultStarboardOpts, config.boards[name]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getStarboardOptsForStarboardChannel(starboardChannel): TStarboardOpts[] {
|
|
||||||
const config = this.getConfigForChannel(starboardChannel);
|
|
||||||
|
|
||||||
const configs = Object.values(config.boards).filter(opts => opts.channel_id === starboardChannel.id);
|
|
||||||
configs.forEach(cfg => {
|
|
||||||
if (cfg.enabled == null) cfg.enabled = defaultStarboardOpts.enabled;
|
|
||||||
if (cfg.star_emoji == null) cfg.star_emoji = defaultStarboardOpts.star_emoji;
|
|
||||||
if (cfg.stars_required == null) cfg.stars_required = defaultStarboardOpts.stars_required;
|
|
||||||
if (cfg.copy_full_embed == null) cfg.copy_full_embed = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
return configs;
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoad() {
|
|
||||||
this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId);
|
|
||||||
this.starboardMessages = GuildStarboardMessages.getGuildInstance(this.guildId);
|
|
||||||
this.starboardReactions = GuildStarboardReactions.getGuildInstance(this.guildId);
|
|
||||||
|
|
||||||
this.onMessageDeleteFn = this.onMessageDelete.bind(this);
|
|
||||||
this.savedMessages.events.on("delete", this.onMessageDeleteFn);
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnload() {
|
|
||||||
this.savedMessages.events.off("delete", this.onMessageDeleteFn);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When a reaction is added to a message, check if there are any applicable starboards and if the reactions reach
|
|
||||||
* the required threshold. If they do, post the message in the starboard channel.
|
|
||||||
*/
|
|
||||||
@d.event("messageReactionAdd")
|
|
||||||
@d.lock("starboardReaction")
|
|
||||||
async onMessageReactionAdd(msg: Message, emoji: { id: string; name: string }, userId: string) {
|
|
||||||
if (!msg.author) {
|
|
||||||
// Message is not cached, fetch it
|
|
||||||
try {
|
|
||||||
msg = await msg.channel.getMessage(msg.id);
|
|
||||||
} catch (e) {
|
|
||||||
// Sometimes we get this event for messages we can't fetch with getMessage; ignore silently
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No self-votes!
|
|
||||||
if (msg.author.id === userId) return;
|
|
||||||
|
|
||||||
const user = await this.resolveUser(userId);
|
|
||||||
if (user instanceof UnknownUser) return;
|
|
||||||
if (user.bot) return;
|
|
||||||
|
|
||||||
const config = this.getConfigForMemberIdAndChannelId(userId, msg.channel.id);
|
|
||||||
const applicableStarboards = Object.values(config.boards)
|
|
||||||
.filter(board => board.enabled)
|
|
||||||
// Can't star messages in the starboard channel itself
|
|
||||||
.filter(board => board.channel_id !== msg.channel.id)
|
|
||||||
// Matching emoji
|
|
||||||
.filter(board => {
|
|
||||||
return board.star_emoji.some((boardEmoji: string) => {
|
|
||||||
if (emoji.id) {
|
|
||||||
// Custom emoji
|
|
||||||
const customEmojiMatch = boardEmoji.match(/^<?:.+?:(\d+)>?$/);
|
|
||||||
if (customEmojiMatch) {
|
|
||||||
return customEmojiMatch[1] === emoji.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return boardEmoji === emoji.id;
|
|
||||||
} else {
|
|
||||||
// Unicode emoji
|
|
||||||
return emoji.name === boardEmoji;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const starboard of applicableStarboards) {
|
|
||||||
// Save reaction into the database
|
|
||||||
await this.starboardReactions.createStarboardReaction(msg.id, userId).catch(noop);
|
|
||||||
|
|
||||||
// If the message has already been posted to this starboard, we don't need to do anything else
|
|
||||||
const starboardMessages = await this.starboardMessages.getMatchingStarboardMessages(starboard.channel_id, msg.id);
|
|
||||||
if (starboardMessages.length > 0) continue;
|
|
||||||
|
|
||||||
const reactions = await this.starboardReactions.getAllReactionsForMessageId(msg.id);
|
|
||||||
const reactionsCount = reactions.length;
|
|
||||||
if (reactionsCount >= starboard.stars_required) {
|
|
||||||
await this.saveMessageToStarboard(msg, starboard);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("messageReactionRemove")
|
|
||||||
async onStarboardReactionRemove(msg: Message, emoji: { id: string; name: string }, userId: string) {
|
|
||||||
await this.starboardReactions.deleteStarboardReaction(msg.id, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("messageReactionRemoveAll")
|
|
||||||
async onMessageReactionRemoveAll(msg: Message) {
|
|
||||||
await this.starboardReactions.deleteAllStarboardReactionsForMessageId(msg.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves/posts a message to the specified starboard.
|
|
||||||
* The message is posted as an embed and image attachments are included as the embed image.
|
|
||||||
*/
|
|
||||||
async saveMessageToStarboard(msg: Message, starboard: TStarboardOpts) {
|
|
||||||
const channel = this.guild.channels.get(starboard.channel_id);
|
|
||||||
if (!channel) return;
|
|
||||||
|
|
||||||
const time = moment(msg.timestamp, "x").format("YYYY-MM-DD [at] HH:mm:ss [UTC]");
|
|
||||||
|
|
||||||
const embed: EmbedBase = {
|
|
||||||
footer: {
|
|
||||||
text: `#${(msg.channel as GuildChannel).name}`,
|
|
||||||
},
|
|
||||||
author: {
|
|
||||||
name: `${msg.author.username}#${msg.author.discriminator}`,
|
|
||||||
},
|
|
||||||
fields: [],
|
|
||||||
timestamp: new Date(msg.timestamp).toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (msg.author.avatarURL) {
|
|
||||||
embed.author.icon_url = msg.author.avatarURL;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.content) {
|
|
||||||
embed.description = msg.content;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge media and - if copy_full_embed is enabled - fields and title from the first embed in the original message
|
|
||||||
if (msg.embeds.length > 0) {
|
|
||||||
if (msg.embeds[0].image) embed.image = msg.embeds[0].image;
|
|
||||||
|
|
||||||
if (starboard.copy_full_embed) {
|
|
||||||
if (msg.embeds[0].title) {
|
|
||||||
const titleText = msg.embeds[0].url ? `[${msg.embeds[0].title}](${msg.embeds[0].url})` : msg.embeds[0].title;
|
|
||||||
embed.fields.push({ name: EMPTY_CHAR, value: titleText });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.embeds[0].fields) embed.fields.push(...msg.embeds[0].fields);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there are no embeds, add the first image attachment explicitly
|
|
||||||
else if (msg.attachments.length) {
|
|
||||||
for (const attachment of msg.attachments) {
|
|
||||||
const ext = path
|
|
||||||
.extname(attachment.filename)
|
|
||||||
.slice(1)
|
|
||||||
.toLowerCase();
|
|
||||||
if (!["jpeg", "jpg", "png", "gif", "webp"].includes(ext)) continue;
|
|
||||||
|
|
||||||
embed.image = { url: attachment.url };
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
embed.fields.push({ name: EMPTY_CHAR, value: `[Jump to message](${messageLink(msg)})` });
|
|
||||||
|
|
||||||
const starboardMessage = await (channel as TextChannel).createMessage({ embed });
|
|
||||||
await this.starboardMessages.createStarboardMessage(channel.id, msg.id, starboardMessage.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a message from the specified starboard
|
|
||||||
*/
|
|
||||||
async removeMessageFromStarboard(msg: StarboardMessage) {
|
|
||||||
await this.bot.deleteMessage(msg.starboard_channel_id, msg.starboard_message_id).catch(noop);
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeMessageFromStarboardMessages(starboard_message_id: string, channel_id: string) {
|
|
||||||
await this.starboardMessages.deleteStarboardMessage(starboard_message_id, channel_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When a message is deleted, also delete it from any starboards it's been posted in.
|
|
||||||
* Likewise, if a starboard message (i.e. the bot message in the starboard) is deleted, remove it from the database.
|
|
||||||
* This function is called in response to GuildSavedMessages events.
|
|
||||||
*/
|
|
||||||
async onMessageDelete(msg: SavedMessage) {
|
|
||||||
// Deleted source message
|
|
||||||
const starboardMessages = await this.starboardMessages.getStarboardMessagesForMessageId(msg.id);
|
|
||||||
for (const starboardMessage of starboardMessages) {
|
|
||||||
this.removeMessageFromStarboard(starboardMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deleted message from the starboard
|
|
||||||
const deletedStarboardMessages = await this.starboardMessages.getStarboardMessagesForStarboardMessageId(msg.id);
|
|
||||||
if (deletedStarboardMessages.length === 0) return;
|
|
||||||
|
|
||||||
for (const starboardMessage of deletedStarboardMessages) {
|
|
||||||
this.removeMessageFromStarboardMessages(
|
|
||||||
starboardMessage.starboard_message_id,
|
|
||||||
starboardMessage.starboard_channel_id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("starboard migrate_pins", "<pinChannel:channel> <starboardName:string>", {
|
|
||||||
extra: {
|
|
||||||
info: {
|
|
||||||
description:
|
|
||||||
"Posts all pins from a channel to the specified starboard. The pins are NOT unpinned automatically.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@d.permission("can_migrate")
|
|
||||||
async migratePinsCmd(msg: Message, args: { pinChannel: GuildChannel; starboardName: string }) {
|
|
||||||
const config = await this.getConfig();
|
|
||||||
const starboard = config.boards[args.starboardName];
|
|
||||||
if (!starboard) {
|
|
||||||
this.sendErrorMessage(msg.channel, "Unknown starboard specified");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(args.pinChannel instanceof TextChannel)) {
|
|
||||||
this.sendErrorMessage(msg.channel, "Unknown/invalid pin channel id");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const starboardChannel = this.guild.channels.get(starboard.channel_id);
|
|
||||||
if (!starboardChannel || !(starboardChannel instanceof TextChannel)) {
|
|
||||||
this.sendErrorMessage(msg.channel, "Starboard has an unknown/invalid channel id");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
msg.channel.createMessage(`Migrating pins from <#${args.pinChannel.id}> to <#${starboardChannel.id}>...`);
|
|
||||||
|
|
||||||
const pins = await args.pinChannel.getPins();
|
|
||||||
pins.reverse(); // Migrate pins starting from the oldest message
|
|
||||||
|
|
||||||
for (const pin of pins) {
|
|
||||||
const existingStarboardMessage = await this.starboardMessages.getMatchingStarboardMessages(
|
|
||||||
starboardChannel.id,
|
|
||||||
pin.id,
|
|
||||||
);
|
|
||||||
if (existingStarboardMessage.length > 0) continue;
|
|
||||||
await this.saveMessageToStarboard(pin, starboard);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sendSuccessMessage(msg.channel, `Pins migrated from <#${args.pinChannel.id}> to <#${starboardChannel.id}>!`);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,164 +0,0 @@
|
||||||
import { ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
import { convertDelayStringToMS, DAYS, HOURS, tAlphanumeric, tDateTime, tDeepPartial, tDelayString } from "../utils";
|
|
||||||
import { IPluginOptions } from "knub";
|
|
||||||
import moment from "moment-timezone";
|
|
||||||
import { GuildStats } from "../data/GuildStats";
|
|
||||||
import { Message } from "eris";
|
|
||||||
import escapeStringRegexp from "escape-string-regexp";
|
|
||||||
import { SavedMessage } from "../data/entities/SavedMessage";
|
|
||||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
|
||||||
|
|
||||||
//region TYPES
|
|
||||||
|
|
||||||
const tBaseSource = t.type({
|
|
||||||
name: tAlphanumeric,
|
|
||||||
track: t.boolean,
|
|
||||||
retention_period: tDelayString,
|
|
||||||
});
|
|
||||||
|
|
||||||
const tMemberMessagesSource = t.intersection([
|
|
||||||
tBaseSource,
|
|
||||||
t.type({
|
|
||||||
type: t.literal("member_messages"),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
type TMemberMessagesSource = t.TypeOf<typeof tMemberMessagesSource>;
|
|
||||||
|
|
||||||
const tChannelMessagesSource = t.intersection([
|
|
||||||
tBaseSource,
|
|
||||||
t.type({
|
|
||||||
type: t.literal("channel_messages"),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
type TChannelMessagesSource = t.TypeOf<typeof tChannelMessagesSource>;
|
|
||||||
|
|
||||||
const tKeywordsSource = t.intersection([
|
|
||||||
tBaseSource,
|
|
||||||
t.type({
|
|
||||||
type: t.literal("keywords"),
|
|
||||||
keywords: t.array(t.string),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
type TKeywordsSource = t.TypeOf<typeof tKeywordsSource>;
|
|
||||||
|
|
||||||
const tSource = t.union([tMemberMessagesSource, tChannelMessagesSource, tKeywordsSource]);
|
|
||||||
type TSource = t.TypeOf<typeof tSource>;
|
|
||||||
|
|
||||||
const tConfigSchema = t.type({
|
|
||||||
sources: t.record(tAlphanumeric, tSource),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TConfigSchema = t.TypeOf<typeof tConfigSchema>;
|
|
||||||
const tPartialConfigSchema = tDeepPartial(tConfigSchema);
|
|
||||||
|
|
||||||
//endregion
|
|
||||||
//region CONSTANTS
|
|
||||||
|
|
||||||
const DEFAULT_RETENTION_PERIOD = "4w";
|
|
||||||
|
|
||||||
//endregion
|
|
||||||
//region PLUGIN
|
|
||||||
|
|
||||||
export class StatsPlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "stats";
|
|
||||||
public static configSchema = tConfigSchema;
|
|
||||||
public static showInDocs = false;
|
|
||||||
|
|
||||||
protected stats: GuildStats;
|
|
||||||
protected savedMessages: GuildSavedMessages;
|
|
||||||
|
|
||||||
private onMessageCreateFn;
|
|
||||||
private cleanStatsInterval;
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
sources: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static applyDefaultsToSource(source: Partial<TSource>) {
|
|
||||||
if (source.track == null) {
|
|
||||||
source.track = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source.retention_period == null) {
|
|
||||||
source.retention_period = DEFAULT_RETENTION_PERIOD;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static preprocessStaticConfig(config: t.TypeOf<typeof tPartialConfigSchema>) {
|
|
||||||
if (config.sources) {
|
|
||||||
for (const [key, source] of Object.entries(config.sources)) {
|
|
||||||
source.name = key;
|
|
||||||
this.applyDefaultsToSource(source);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onLoad() {
|
|
||||||
this.stats = GuildStats.getGuildInstance(this.guildId);
|
|
||||||
this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId);
|
|
||||||
|
|
||||||
this.onMessageCreateFn = this.savedMessages.events.on("create", msg => this.onMessageCreate(msg));
|
|
||||||
|
|
||||||
this.cleanOldStats();
|
|
||||||
this.cleanStatsInterval = setInterval(() => this.cleanOldStats(), 1 * DAYS);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onUnload() {
|
|
||||||
this.savedMessages.events.off("create", this.onMessageCreateFn);
|
|
||||||
clearInterval(this.cleanStatsInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async cleanOldStats() {
|
|
||||||
const config = this.getConfig();
|
|
||||||
for (const source of Object.values(config.sources)) {
|
|
||||||
const cutoffMS = convertDelayStringToMS(source.retention_period);
|
|
||||||
const cutoff = moment()
|
|
||||||
.subtract(cutoffMS, "ms")
|
|
||||||
.format("YYYY-MM-DD HH:mm:ss");
|
|
||||||
await this.stats.deleteOldValues(source.name, cutoff);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected saveMemberMessagesStats(source: TMemberMessagesSource, msg: SavedMessage) {
|
|
||||||
this.stats.saveValue(source.name, msg.user_id, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected saveChannelMessagesStats(source: TChannelMessagesSource, msg: SavedMessage) {
|
|
||||||
this.stats.saveValue(source.name, msg.channel_id, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected saveKeywordsStats(source: TKeywordsSource, msg: SavedMessage) {
|
|
||||||
const content = msg.data.content;
|
|
||||||
if (!content) return;
|
|
||||||
|
|
||||||
for (const keyword of source.keywords) {
|
|
||||||
const regex = new RegExp(`\\b${escapeStringRegexp(keyword)}\\b`, "i");
|
|
||||||
if (content.match(regex)) {
|
|
||||||
this.stats.saveValue(source.name, "keyword", 1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMessageCreate(msg: SavedMessage) {
|
|
||||||
const config = this.getConfigForMemberIdAndChannelId(msg.user_id, msg.channel_id);
|
|
||||||
for (const source of Object.values(config.sources)) {
|
|
||||||
if (!source.track) continue;
|
|
||||||
|
|
||||||
if (source.type === "member_messages") {
|
|
||||||
this.saveMemberMessagesStats(source, msg);
|
|
||||||
} else if (source.type === "channel_messages") {
|
|
||||||
this.saveChannelMessagesStats(source, msg);
|
|
||||||
} else if (source.type === "keywords") {
|
|
||||||
this.saveKeywordsStats(source, msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,548 +0,0 @@
|
||||||
import { decorators as d, IPluginOptions, logger } from "knub";
|
|
||||||
import { Member, Message, TextChannel } from "eris";
|
|
||||||
import {
|
|
||||||
convertDelayStringToMS,
|
|
||||||
errorMessage,
|
|
||||||
renderRecursively,
|
|
||||||
StrictMessageContent,
|
|
||||||
stripObjectToScalars,
|
|
||||||
tEmbed,
|
|
||||||
tNullable,
|
|
||||||
tStrictMessageContent,
|
|
||||||
} from "../utils";
|
|
||||||
import { GuildTags } from "../data/GuildTags";
|
|
||||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
|
||||||
import { SavedMessage } from "../data/entities/SavedMessage";
|
|
||||||
import moment from "moment-timezone";
|
|
||||||
import humanizeDuration from "humanize-duration";
|
|
||||||
import { ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import { parseTemplate, renderTemplate, TemplateParseError } from "../templateFormatter";
|
|
||||||
import { GuildArchives } from "../data/GuildArchives";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
import { parseArguments } from "knub-command-manager";
|
|
||||||
import escapeStringRegexp from "escape-string-regexp";
|
|
||||||
import { validate } from "../validatorUtils";
|
|
||||||
import { GuildLogs } from "../data/GuildLogs";
|
|
||||||
import { LogType } from "../data/LogType";
|
|
||||||
|
|
||||||
const Tag = t.union([t.string, tEmbed]);
|
|
||||||
|
|
||||||
const TagCategory = t.type({
|
|
||||||
prefix: tNullable(t.string),
|
|
||||||
delete_with_command: tNullable(t.boolean),
|
|
||||||
|
|
||||||
user_tag_cooldown: tNullable(t.union([t.string, t.number])), // Per user, per tag
|
|
||||||
user_category_cooldown: tNullable(t.union([t.string, t.number])), // Per user, per tag category
|
|
||||||
global_tag_cooldown: tNullable(t.union([t.string, t.number])), // Any user, per tag
|
|
||||||
global_category_cooldown: tNullable(t.union([t.string, t.number])), // Any user, per category
|
|
||||||
|
|
||||||
tags: t.record(t.string, Tag),
|
|
||||||
|
|
||||||
can_use: tNullable(t.boolean),
|
|
||||||
});
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
prefix: t.string,
|
|
||||||
delete_with_command: t.boolean,
|
|
||||||
|
|
||||||
user_tag_cooldown: tNullable(t.union([t.string, t.number])), // Per user, per tag
|
|
||||||
global_tag_cooldown: tNullable(t.union([t.string, t.number])), // Any user, per tag
|
|
||||||
user_cooldown: tNullable(t.union([t.string, t.number])), // Per user
|
|
||||||
global_cooldown: tNullable(t.union([t.string, t.number])), // Any tag use
|
|
||||||
|
|
||||||
categories: t.record(t.string, TagCategory),
|
|
||||||
|
|
||||||
can_create: t.boolean,
|
|
||||||
can_use: t.boolean,
|
|
||||||
can_list: t.boolean,
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
export class TagsPlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "tags";
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
public static pluginInfo = {
|
|
||||||
prettyName: "Tags",
|
|
||||||
};
|
|
||||||
|
|
||||||
protected archives: GuildArchives;
|
|
||||||
protected tags: GuildTags;
|
|
||||||
protected savedMessages: GuildSavedMessages;
|
|
||||||
protected logs: GuildLogs;
|
|
||||||
|
|
||||||
private onMessageCreateFn;
|
|
||||||
private onMessageDeleteFn;
|
|
||||||
|
|
||||||
protected tagFunctions;
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
prefix: "!!",
|
|
||||||
delete_with_command: true,
|
|
||||||
|
|
||||||
user_tag_cooldown: null,
|
|
||||||
global_tag_cooldown: null,
|
|
||||||
user_cooldown: null,
|
|
||||||
global_cooldown: null,
|
|
||||||
|
|
||||||
categories: {},
|
|
||||||
|
|
||||||
can_create: false,
|
|
||||||
can_use: false,
|
|
||||||
can_list: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
level: ">=50",
|
|
||||||
config: {
|
|
||||||
can_use: true,
|
|
||||||
can_create: true,
|
|
||||||
can_list: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoad() {
|
|
||||||
this.archives = GuildArchives.getGuildInstance(this.guildId);
|
|
||||||
this.tags = GuildTags.getGuildInstance(this.guildId);
|
|
||||||
this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId);
|
|
||||||
this.logs = new GuildLogs(this.guildId);
|
|
||||||
|
|
||||||
this.onMessageCreateFn = this.onMessageCreate.bind(this);
|
|
||||||
this.savedMessages.events.on("create", this.onMessageCreateFn);
|
|
||||||
|
|
||||||
this.onMessageDeleteFn = this.onMessageDelete.bind(this);
|
|
||||||
this.savedMessages.events.on("delete", this.onMessageDeleteFn);
|
|
||||||
|
|
||||||
this.tagFunctions = {
|
|
||||||
parseDateTime(str) {
|
|
||||||
if (typeof str === "number") {
|
|
||||||
return str; // Unix timestamp
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof str !== "string") {
|
|
||||||
return Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
return moment(str, "YYYY-MM-DD HH:mm:ss").valueOf();
|
|
||||||
},
|
|
||||||
|
|
||||||
countdown(toDate) {
|
|
||||||
const target = moment(this.parseDateTime(toDate));
|
|
||||||
|
|
||||||
const now = moment();
|
|
||||||
if (!target.isValid()) return "";
|
|
||||||
|
|
||||||
const diff = target.diff(now);
|
|
||||||
const result = humanizeDuration(diff, { largest: 2, round: true });
|
|
||||||
return diff >= 0 ? result : `${result} ago`;
|
|
||||||
},
|
|
||||||
|
|
||||||
now() {
|
|
||||||
return Date.now();
|
|
||||||
},
|
|
||||||
|
|
||||||
timeAdd(...args) {
|
|
||||||
let reference;
|
|
||||||
let delay;
|
|
||||||
|
|
||||||
if (args.length >= 2) {
|
|
||||||
// (time, delay)
|
|
||||||
reference = this.parseDateTime(args[0]);
|
|
||||||
delay = args[1];
|
|
||||||
} else {
|
|
||||||
// (delay), implicit "now" as time
|
|
||||||
reference = Date.now();
|
|
||||||
delay = args[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
const delayMS = convertDelayStringToMS(delay);
|
|
||||||
return moment(reference)
|
|
||||||
.add(delayMS)
|
|
||||||
.valueOf();
|
|
||||||
},
|
|
||||||
|
|
||||||
timeSub(...args) {
|
|
||||||
let reference;
|
|
||||||
let delay;
|
|
||||||
|
|
||||||
if (args.length >= 2) {
|
|
||||||
// (time, delay)
|
|
||||||
reference = this.parseDateTime(args[0]);
|
|
||||||
delay = args[1];
|
|
||||||
} else {
|
|
||||||
// (delay), implicit "now" as time
|
|
||||||
reference = Date.now();
|
|
||||||
delay = args[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
const delayMS = convertDelayStringToMS(delay);
|
|
||||||
return moment(reference)
|
|
||||||
.subtract(delayMS)
|
|
||||||
.valueOf();
|
|
||||||
},
|
|
||||||
|
|
||||||
timeAgo(delay) {
|
|
||||||
return this.timeSub(delay);
|
|
||||||
},
|
|
||||||
|
|
||||||
formatTime(time, format) {
|
|
||||||
const parsed = this.parseDateTime(time);
|
|
||||||
return moment(parsed).format(format);
|
|
||||||
},
|
|
||||||
|
|
||||||
discordDateFormat(time) {
|
|
||||||
const parsed = time ? this.parseDateTime(time) : Date.now();
|
|
||||||
|
|
||||||
return moment(parsed).format("YYYY-MM-DD");
|
|
||||||
},
|
|
||||||
|
|
||||||
mention: input => {
|
|
||||||
if (typeof input !== "string") {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.match(/^<(@#)(!&)\d+>$/)) {
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.guild.members.has(input) || this.bot.users.has(input)) {
|
|
||||||
return `<@!${input}>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.guild.channels.has(input) || this.bot.channelGuildMap[input]) {
|
|
||||||
return `<#${input}>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return "";
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [name, fn] of Object.entries(this.tagFunctions)) {
|
|
||||||
this.tagFunctions[name] = (fn as any).bind(this.tagFunctions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnload() {
|
|
||||||
this.savedMessages.events.off("create", this.onMessageCreateFn);
|
|
||||||
this.savedMessages.events.off("delete", this.onMessageDeleteFn);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("tag list", [], {
|
|
||||||
aliases: ["tags", "taglist"],
|
|
||||||
})
|
|
||||||
@d.permission("can_list")
|
|
||||||
async tagListCmd(msg: Message) {
|
|
||||||
const tags = await this.tags.all();
|
|
||||||
if (tags.length === 0) {
|
|
||||||
msg.channel.createMessage(`No tags created yet! Use \`tag create\` command to create one.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prefix = this.getConfigForMsg(msg).prefix;
|
|
||||||
const tagNames = tags.map(tag => tag.tag).sort();
|
|
||||||
msg.channel.createMessage(`
|
|
||||||
Available tags (use with ${prefix}tag): \`\`\`${tagNames.join(", ")}\`\`\`
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("tag delete", "<tag:string>")
|
|
||||||
@d.permission("can_create")
|
|
||||||
async deleteTagCmd(msg: Message, args: { tag: string }) {
|
|
||||||
const tag = await this.tags.find(args.tag);
|
|
||||||
if (!tag) {
|
|
||||||
msg.channel.createMessage(errorMessage("No tag with that name"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.tags.delete(args.tag);
|
|
||||||
this.sendSuccessMessage(msg.channel, "Tag deleted!");
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("tag eval", "<body:string$>")
|
|
||||||
@d.permission("can_create")
|
|
||||||
async evalTagCmd(msg: Message, args: { body: string }) {
|
|
||||||
const rendered = await this.renderTag(args.body);
|
|
||||||
msg.channel.createMessage(rendered);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("tag", "<tag:string> <body:string$>")
|
|
||||||
@d.permission("can_create")
|
|
||||||
async tagCmd(msg: Message, args: { tag: string; body: string }) {
|
|
||||||
try {
|
|
||||||
parseTemplate(args.body);
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof TemplateParseError) {
|
|
||||||
msg.channel.createMessage(errorMessage(`Invalid tag syntax: ${e.message}`));
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.tags.createOrUpdate(args.tag, args.body, msg.author.id);
|
|
||||||
|
|
||||||
const prefix = this.getConfig().prefix;
|
|
||||||
this.sendSuccessMessage(msg.channel, `Tag set! Use it with: \`${prefix}${args.tag}\``);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.command("tag", "<tag:string>", {
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
name: "delete",
|
|
||||||
shortcut: "d",
|
|
||||||
isSwitch: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
@d.permission("can_create")
|
|
||||||
async tagSourceCmd(msg: Message, args: { tag: string; delete?: boolean }) {
|
|
||||||
if (args.delete) {
|
|
||||||
return this.deleteTagCmd(msg, { tag: args.tag });
|
|
||||||
}
|
|
||||||
|
|
||||||
const tag = await this.tags.find(args.tag);
|
|
||||||
if (!tag) {
|
|
||||||
msg.channel.createMessage(errorMessage("No tag with that name"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const archiveId = await this.archives.create(tag.body, moment().add(10, "minutes"));
|
|
||||||
const url = this.archives.getUrl(this.knub.getGlobalConfig().url, archiveId);
|
|
||||||
|
|
||||||
msg.channel.createMessage(`Tag source:\n${url}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async renderTag(body, args = [], extraData = {}) {
|
|
||||||
const dynamicVars = {};
|
|
||||||
const maxTagFnCalls = 25;
|
|
||||||
let tagFnCalls = 0;
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
args,
|
|
||||||
...extraData,
|
|
||||||
...this.tagFunctions,
|
|
||||||
set(name, val) {
|
|
||||||
if (typeof name !== "string") return;
|
|
||||||
dynamicVars[name] = val;
|
|
||||||
},
|
|
||||||
get(name) {
|
|
||||||
return dynamicVars[name] == null ? "" : dynamicVars[name];
|
|
||||||
},
|
|
||||||
tag: async (name, ...subTagArgs) => {
|
|
||||||
if (tagFnCalls++ > maxTagFnCalls) return "\\_recursion\\_";
|
|
||||||
if (typeof name !== "string") return "";
|
|
||||||
if (name === "") return "";
|
|
||||||
// TODO: Incorporate tag categories here
|
|
||||||
const subTag = await this.tags.find(name);
|
|
||||||
if (!subTag) return "";
|
|
||||||
return renderTemplate(subTag.body, { ...data, args: subTagArgs });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return renderTemplate(body, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async renderSafeTagFromMessage(
|
|
||||||
str: string,
|
|
||||||
prefix: string,
|
|
||||||
tagName: string,
|
|
||||||
tagBody: t.TypeOf<typeof Tag>,
|
|
||||||
member: Member,
|
|
||||||
): Promise<StrictMessageContent | null> {
|
|
||||||
const variableStr = str.slice(prefix.length + tagName.length).trim();
|
|
||||||
const tagArgs = parseArguments(variableStr).map(v => v.value);
|
|
||||||
|
|
||||||
const renderTagString = async _str => {
|
|
||||||
let rendered = await this.renderTag(_str, tagArgs, {
|
|
||||||
member: stripObjectToScalars(member, ["user"]),
|
|
||||||
user: stripObjectToScalars(member.user),
|
|
||||||
});
|
|
||||||
rendered = rendered.trim();
|
|
||||||
|
|
||||||
return rendered;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format the string
|
|
||||||
try {
|
|
||||||
return typeof tagBody === "string"
|
|
||||||
? { content: await renderTagString(tagBody) }
|
|
||||||
: await renderRecursively(tagBody, renderTagString);
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof TemplateParseError) {
|
|
||||||
logger.warn(`Invalid tag format!\nError: ${e.message}\nFormat: ${tagBody}`);
|
|
||||||
return null;
|
|
||||||
} else {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessageCreate(msg: SavedMessage) {
|
|
||||||
if (msg.is_bot) return;
|
|
||||||
if (!msg.data.content) return;
|
|
||||||
|
|
||||||
const member = await this.getMember(msg.user_id);
|
|
||||||
if (!member) return;
|
|
||||||
|
|
||||||
const config = this.getConfigForMemberIdAndChannelId(msg.user_id, msg.channel_id);
|
|
||||||
let deleteWithCommand = false;
|
|
||||||
|
|
||||||
// Find potential matching tag, looping through categories first and checking dynamic tags last
|
|
||||||
let renderedTag = null;
|
|
||||||
let matchedTagName;
|
|
||||||
const cooldowns = [];
|
|
||||||
|
|
||||||
for (const [name, category] of Object.entries(config.categories)) {
|
|
||||||
const canUse = category.can_use != null ? category.can_use : config.can_use;
|
|
||||||
if (canUse !== true) continue;
|
|
||||||
|
|
||||||
const prefix = category.prefix != null ? category.prefix : config.prefix;
|
|
||||||
if (prefix !== "" && !msg.data.content.startsWith(prefix)) continue;
|
|
||||||
|
|
||||||
const withoutPrefix = msg.data.content.slice(prefix.length);
|
|
||||||
|
|
||||||
for (const [tagName, tagBody] of Object.entries(category.tags)) {
|
|
||||||
const regex = new RegExp(`^${escapeStringRegexp(tagName)}(?:\s|$)`);
|
|
||||||
if (regex.test(withoutPrefix)) {
|
|
||||||
renderedTag = await this.renderSafeTagFromMessage(
|
|
||||||
msg.data.content,
|
|
||||||
prefix,
|
|
||||||
tagName,
|
|
||||||
category.tags[tagName],
|
|
||||||
member,
|
|
||||||
);
|
|
||||||
if (renderedTag) {
|
|
||||||
matchedTagName = tagName;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (renderedTag) {
|
|
||||||
if (category.user_tag_cooldown) {
|
|
||||||
const delay = convertDelayStringToMS(String(category.user_tag_cooldown), "s");
|
|
||||||
cooldowns.push([`tags-category-${name}-user-${msg.user_id}-tag-${matchedTagName}`, delay]);
|
|
||||||
}
|
|
||||||
if (category.global_tag_cooldown) {
|
|
||||||
const delay = convertDelayStringToMS(String(category.global_tag_cooldown), "s");
|
|
||||||
cooldowns.push([`tags-category-${name}-tag-${matchedTagName}`, delay]);
|
|
||||||
}
|
|
||||||
if (category.user_category_cooldown) {
|
|
||||||
const delay = convertDelayStringToMS(String(category.user_category_cooldown), "s");
|
|
||||||
cooldowns.push([`tags-category-${name}-user--${msg.user_id}`, delay]);
|
|
||||||
}
|
|
||||||
if (category.global_category_cooldown) {
|
|
||||||
const delay = convertDelayStringToMS(String(category.global_category_cooldown), "s");
|
|
||||||
cooldowns.push([`tags-category-${name}`, delay]);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteWithCommand =
|
|
||||||
category.delete_with_command != null ? category.delete_with_command : config.delete_with_command;
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Matching tag was not found from the config, try a dynamic tag
|
|
||||||
if (!renderedTag) {
|
|
||||||
if (config.can_use !== true) return;
|
|
||||||
|
|
||||||
const prefix = config.prefix;
|
|
||||||
if (!msg.data.content.startsWith(prefix)) return;
|
|
||||||
|
|
||||||
const tagNameMatch = msg.data.content.slice(prefix.length).match(/^\S+/);
|
|
||||||
if (tagNameMatch === null) return;
|
|
||||||
|
|
||||||
const tagName = tagNameMatch[0];
|
|
||||||
const tag = await this.tags.find(tagName);
|
|
||||||
if (!tag) return;
|
|
||||||
|
|
||||||
matchedTagName = tagName;
|
|
||||||
|
|
||||||
renderedTag = await this.renderSafeTagFromMessage(msg.data.content, prefix, tagName, tag.body, member);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!renderedTag) return;
|
|
||||||
|
|
||||||
if (config.user_tag_cooldown) {
|
|
||||||
const delay = convertDelayStringToMS(String(config.user_tag_cooldown), "s");
|
|
||||||
cooldowns.push([`tags-user-${msg.user_id}-tag-${matchedTagName}`, delay]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.global_tag_cooldown) {
|
|
||||||
const delay = convertDelayStringToMS(String(config.global_tag_cooldown), "s");
|
|
||||||
cooldowns.push([`tags-tag-${matchedTagName}`, delay]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.user_cooldown) {
|
|
||||||
const delay = convertDelayStringToMS(String(config.user_cooldown), "s");
|
|
||||||
cooldowns.push([`tags-user-${matchedTagName}`, delay]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.global_cooldown) {
|
|
||||||
const delay = convertDelayStringToMS(String(config.global_cooldown), "s");
|
|
||||||
cooldowns.push([`tags`, delay]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isOnCooldown = cooldowns.some(cd => this.cooldowns.isOnCooldown(cd[0]));
|
|
||||||
if (isOnCooldown) return;
|
|
||||||
|
|
||||||
for (const cd of cooldowns) {
|
|
||||||
this.cooldowns.setCooldown(cd[0], cd[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteWithCommand = config.delete_with_command;
|
|
||||||
|
|
||||||
const validationError = await validate(tStrictMessageContent, renderedTag);
|
|
||||||
if (validationError) {
|
|
||||||
this.logs.log(LogType.BOT_ALERT, {
|
|
||||||
body: `Rendering tag ${matchedTagName} resulted in an invalid message: ${validationError.message}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const channel = this.guild.channels.get(msg.channel_id) as TextChannel;
|
|
||||||
const responseMsg = await channel.createMessage(renderedTag);
|
|
||||||
|
|
||||||
// Save the command-response message pair once the message is in our database
|
|
||||||
if (deleteWithCommand) {
|
|
||||||
this.savedMessages.onceMessageAvailable(responseMsg.id, async () => {
|
|
||||||
await this.tags.addResponse(msg.id, responseMsg.id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessageDelete(msg: SavedMessage) {
|
|
||||||
// Command message was deleted -> delete the response as well
|
|
||||||
const commandMsgResponse = await this.tags.findResponseByCommandMessageId(msg.id);
|
|
||||||
if (commandMsgResponse) {
|
|
||||||
const channel = this.guild.channels.get(msg.channel_id) as TextChannel;
|
|
||||||
if (!channel) return;
|
|
||||||
|
|
||||||
const responseMsg = await this.savedMessages.find(commandMsgResponse.response_message_id);
|
|
||||||
if (!responseMsg || responseMsg.deleted_at != null) return;
|
|
||||||
|
|
||||||
await channel.deleteMessage(commandMsgResponse.response_message_id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response was deleted -> delete the command message as well
|
|
||||||
const responseMsgResponse = await this.tags.findResponseByResponseMessageId(msg.id);
|
|
||||||
if (responseMsgResponse) {
|
|
||||||
const channel = this.guild.channels.get(msg.channel_id) as TextChannel;
|
|
||||||
if (!channel) return;
|
|
||||||
|
|
||||||
const commandMsg = await this.savedMessages.find(responseMsgResponse.command_message_id);
|
|
||||||
if (!commandMsg || commandMsg.deleted_at != null) return;
|
|
||||||
|
|
||||||
await channel.deleteMessage(responseMsgResponse.command_message_id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,38 +0,0 @@
|
||||||
import { decorators as d, GlobalPlugin } from "knub";
|
|
||||||
import { UsernameHistory } from "../data/UsernameHistory";
|
|
||||||
import { Member, Message, User } from "eris";
|
|
||||||
import { GlobalZeppelinPlugin } from "./GlobalZeppelinPlugin";
|
|
||||||
import { Queue } from "../Queue";
|
|
||||||
|
|
||||||
export class UsernameSaver extends GlobalZeppelinPlugin {
|
|
||||||
public static pluginName = "username_saver";
|
|
||||||
|
|
||||||
protected usernameHistory: UsernameHistory;
|
|
||||||
protected updateQueue: Queue;
|
|
||||||
|
|
||||||
async onLoad() {
|
|
||||||
this.usernameHistory = new UsernameHistory();
|
|
||||||
this.updateQueue = new Queue();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async updateUsername(user: User) {
|
|
||||||
if (!user) return;
|
|
||||||
const newUsername = `${user.username}#${user.discriminator}`;
|
|
||||||
const latestEntry = await this.usernameHistory.getLastEntry(user.id);
|
|
||||||
if (!latestEntry || newUsername !== latestEntry.username) {
|
|
||||||
await this.usernameHistory.addEntry(user.id, newUsername);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("messageCreate", null)
|
|
||||||
async onMessage(msg: Message) {
|
|
||||||
if (msg.author.bot) return;
|
|
||||||
this.updateQueue.add(() => this.updateUsername(msg.author));
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("voiceChannelJoin", null)
|
|
||||||
async onVoiceChannelJoin(member: Member) {
|
|
||||||
if (member.user.bot) return;
|
|
||||||
this.updateQueue.add(() => this.updateUsername(member.user));
|
|
||||||
}
|
|
||||||
}
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,80 +0,0 @@
|
||||||
import { ZeppelinPluginClass } from "./ZeppelinPluginClass";
|
|
||||||
import { decorators as d, IPluginOptions } from "knub";
|
|
||||||
import { Member, TextChannel } from "eris";
|
|
||||||
import { renderTemplate } from "../templateFormatter";
|
|
||||||
import { createChunkedMessage, stripObjectToScalars, tNullable } from "../utils";
|
|
||||||
import { LogType } from "../data/LogType";
|
|
||||||
import { GuildLogs } from "../data/GuildLogs";
|
|
||||||
import * as t from "io-ts";
|
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
|
||||||
send_dm: t.boolean,
|
|
||||||
send_to_channel: tNullable(t.string),
|
|
||||||
message: t.string,
|
|
||||||
});
|
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
export class WelcomeMessagePlugin extends ZeppelinPluginClass<TConfigSchema> {
|
|
||||||
public static pluginName = "welcome_message";
|
|
||||||
public static configSchema = ConfigSchema;
|
|
||||||
|
|
||||||
public static pluginInfo = {
|
|
||||||
prettyName: "Welcome message",
|
|
||||||
};
|
|
||||||
|
|
||||||
protected logs: GuildLogs;
|
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
send_dm: false,
|
|
||||||
send_to_channel: null,
|
|
||||||
message: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onLoad() {
|
|
||||||
this.logs = new GuildLogs(this.guildId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@d.event("guildMemberAdd")
|
|
||||||
async onGuildMemberAdd(_, member: Member) {
|
|
||||||
const config = this.getConfig();
|
|
||||||
if (!config.message) return;
|
|
||||||
if (!config.send_dm && !config.send_to_channel) return;
|
|
||||||
|
|
||||||
const formatted = await renderTemplate(config.message, {
|
|
||||||
member: stripObjectToScalars(member, ["user"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (config.send_dm) {
|
|
||||||
const dmChannel = await member.user.getDMChannel();
|
|
||||||
if (!dmChannel) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createChunkedMessage(dmChannel, formatted);
|
|
||||||
} catch (e) {
|
|
||||||
this.logs.log(LogType.BOT_ALERT, {
|
|
||||||
body: `Failed send a welcome DM to {userMention(member)}`,
|
|
||||||
member: stripObjectToScalars(member),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.send_to_channel) {
|
|
||||||
const channel = this.guild.channels.get(config.send_to_channel);
|
|
||||||
if (!channel || !(channel instanceof TextChannel)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createChunkedMessage(channel, formatted);
|
|
||||||
} catch (e) {
|
|
||||||
this.logs.log(LogType.BOT_ALERT, {
|
|
||||||
body: `Failed send a welcome message for {userMention(member)} to {channelMention(channel)}`,
|
|
||||||
member: stripObjectToScalars(member),
|
|
||||||
channel: stripObjectToScalars(channel),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,89 +1,8 @@
|
||||||
import { MessageSaverPlugin } from "./MessageSaver";
|
import { UtilityPlugin } from "./Utility/UtilityPlugin";
|
||||||
import { NameHistoryPlugin } from "./NameHistory";
|
|
||||||
import { CasesPlugin } from "./Cases";
|
|
||||||
import { MutesPlugin } from "./Mutes";
|
|
||||||
import { UtilityPlugin } from "./Utility";
|
|
||||||
import { ModActionsPlugin } from "./ModActions";
|
|
||||||
import { LogsPlugin } from "./Logs";
|
|
||||||
import { PostPlugin } from "./Post";
|
|
||||||
import { ReactionRolesPlugin } from "./ReactionRoles";
|
|
||||||
import { CensorPlugin } from "./Censor";
|
|
||||||
import { PersistPlugin } from "./Persist";
|
|
||||||
import { SpamPlugin } from "./Spam";
|
|
||||||
import { TagsPlugin } from "./Tags";
|
|
||||||
import { SlowmodePlugin } from "./Slowmode";
|
|
||||||
import { StarboardPlugin } from "./Starboard";
|
|
||||||
import { AutoReactionsPlugin } from "./AutoReactionsPlugin";
|
|
||||||
import { PingableRolesPlugin } from "./PingableRolesPlugin";
|
|
||||||
import { SelfGrantableRolesPlugin } from "./SelfGrantableRolesPlugin";
|
|
||||||
import { RemindersPlugin } from "./Reminders";
|
|
||||||
import { WelcomeMessagePlugin } from "./WelcomeMessage";
|
|
||||||
import { BotControlPlugin } from "./BotControl";
|
|
||||||
import { UsernameSaver } from "./UsernameSaver";
|
|
||||||
import { CustomEventsPlugin } from "./CustomEvents";
|
|
||||||
import { GuildInfoSaverPlugin } from "./GuildInfoSaver";
|
|
||||||
import { CompanionChannelPlugin } from "./CompanionChannels";
|
|
||||||
import { LocatePlugin } from "./LocateUser";
|
|
||||||
import { GuildConfigReloader } from "./GuildConfigReloader";
|
|
||||||
import { ChannelArchiverPlugin } from "./ChannelArchiver";
|
|
||||||
import { AutomodPlugin } from "./Automod/Automod";
|
|
||||||
import { RolesPlugin } from "./Roles";
|
|
||||||
import { AutoDeletePlugin } from "./AutoDelete";
|
|
||||||
|
|
||||||
/**
|
// prettier-ignore
|
||||||
* Plugins available to be loaded for individual guilds
|
export const guildPlugins = [
|
||||||
*/
|
|
||||||
export const oldAvailablePlugins = [
|
|
||||||
AutomodPlugin,
|
|
||||||
MessageSaverPlugin,
|
|
||||||
NameHistoryPlugin,
|
|
||||||
CasesPlugin,
|
|
||||||
MutesPlugin,
|
|
||||||
UtilityPlugin,
|
|
||||||
ModActionsPlugin,
|
|
||||||
LogsPlugin,
|
|
||||||
PostPlugin,
|
|
||||||
ReactionRolesPlugin,
|
|
||||||
CensorPlugin,
|
|
||||||
PersistPlugin,
|
|
||||||
SpamPlugin,
|
|
||||||
TagsPlugin,
|
|
||||||
SlowmodePlugin,
|
|
||||||
StarboardPlugin,
|
|
||||||
AutoReactionsPlugin,
|
|
||||||
PingableRolesPlugin,
|
|
||||||
SelfGrantableRolesPlugin,
|
|
||||||
RemindersPlugin,
|
|
||||||
WelcomeMessagePlugin,
|
|
||||||
CustomEventsPlugin,
|
|
||||||
GuildInfoSaverPlugin,
|
|
||||||
CompanionChannelPlugin,
|
|
||||||
LocatePlugin,
|
|
||||||
ChannelArchiverPlugin,
|
|
||||||
RolesPlugin,
|
|
||||||
AutoDeletePlugin,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Plugins that are always loaded (subset of the names of the plugins in availablePlugins)
|
|
||||||
*/
|
|
||||||
export const oldBasePlugins = [
|
|
||||||
GuildInfoSaverPlugin.pluginName,
|
|
||||||
MessageSaverPlugin.pluginName,
|
|
||||||
NameHistoryPlugin.pluginName,
|
|
||||||
CasesPlugin.pluginName,
|
|
||||||
MutesPlugin.pluginName,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Available global plugins (can't be loaded per-guild, only globally)
|
|
||||||
*/
|
|
||||||
export const oldAvailableGlobalPlugins = [BotControlPlugin, UsernameSaver, GuildConfigReloader];
|
|
||||||
|
|
||||||
export const availablePlugins = [
|
|
||||||
UtilityPlugin,
|
UtilityPlugin,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const basePlugins = [];
|
export const globalPlugins = [];
|
||||||
|
|
||||||
export const availableGlobalPlugins = [];
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue