3
0
Fork 0
mirror of https://github.com/ZeppelinBot/Zeppelin.git synced 2025-05-10 12:25:02 +00:00

Reorganize project. Add folder for shared code between backend/dashboard. Switch from jest to ava for tests.

This commit is contained in:
Dragory 2019-11-02 22:11:26 +02:00
parent 80a82fe348
commit 16111bbe84
162 changed files with 11056 additions and 9900 deletions

View file

@ -0,0 +1,129 @@
import { decorators as d, IBasePluginConfig, IPluginOptions } 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, isEmoji, successMessage } from "../utils";
import { CommandInfo, trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin";
import * as t from "io-ts";
const ConfigSchema = t.type({
can_manage: t.boolean,
});
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export class AutoReactionsPlugin extends ZeppelinPlugin<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;
private onMessageCreateFn;
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return {
config: {
can_manage: false,
},
overrides: [
{
level: ">=100",
config: {
can_manage: true,
},
},
],
};
}
onLoad() {
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);
msg.channel.createMessage(successMessage(`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);
msg.channel.createMessage(successMessage(`Auto-reactions disabled in <#${args.channelId}>`));
}
async onMessageCreate(msg: SavedMessage) {
const autoReaction = await this.autoReactions.getForChannel(msg.channel_id);
if (!autoReaction) return;
const realMsg = await this.bot.getMessage(msg.channel_id, msg.id);
if (!realMsg) return;
for (const reaction of autoReaction.reactions) {
realMsg.addReaction(reaction);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,234 @@
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 { ZeppelinPlugin } from "./ZeppelinPlugin";
import { GuildArchives } from "../data/GuildArchives";
import { GlobalZeppelinPlugin } from "./GlobalZeppelinPlugin";
import * as t from "io-ts";
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) {
channel.createMessage(successMessage("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")
@d.permission("can_use")
async serversCmd(msg: Message) {
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());
joinedGuilds.sort(sorter(g => g.name.toLowerCase()));
const longestId = joinedGuilds.reduce((longest, guild) => Math.max(longest, guild.id.length), 0);
const lines = joinedGuilds.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"));
}
@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;
}
msg.channel.createMessage(successMessage(`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);
msg.channel.createMessage(successMessage(`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 {
msg.channel.createMessage(successMessage(`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 ZeppelinPlugin)) {
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));
}
}

View file

@ -0,0 +1,280 @@
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 { PluginInfo, trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin";
import { GuildArchives } from "../data/GuildArchives";
import { IPluginOptions } from "knub";
import { GuildLogs } from "../data/GuildLogs";
import { LogType } from "../data/LogType";
import * as t from "io-ts";
import { tNullable } from "../utils";
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 ZeppelinPlugin<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}`;
}
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.throwPluginRuntimeError(`Unknown case ID: ${args.caseId}`);
}
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 };
}
/**
* A helper for posting to the case log channel.
* Returns silently if the case log channel isn't specified or is invalid.
*/
public 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;
return caseLogChannel.createMessage(content, file);
}
/**
* 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;
}
}
}

View file

@ -0,0 +1,296 @@
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, ZeppelinPlugin } from "./ZeppelinPlugin";
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 ZeppelinPlugin<TConfigSchema> {
public static pluginName = "censor";
public static configSchema = ConfigSchema;
public static pluginInfo = {
prettyName: "Censor",
description: trimPluginDescription(`
Censor words, tokens, links, regex, etc.
`),
};
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();
}
}
}

View file

@ -0,0 +1,152 @@
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 { ZeppelinPlugin } from "./ZeppelinPlugin";
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 ZeppelinPlugin {
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`,
});
}
}

View file

@ -0,0 +1,119 @@
import { decorators as d, IPluginOptions, logger } from "knub";
import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin";
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 ZeppelinPlugin<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);
}
}

View file

@ -0,0 +1,201 @@
import { ZeppelinPlugin } from "./ZeppelinPlugin";
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 ZeppelinPlugin<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 });
}
}

View file

@ -0,0 +1,117 @@
import { GlobalPlugin, IBasePluginConfig, IPluginOptions, logger, configUtils } from "knub";
import { PluginRuntimeError } from "../PluginRuntimeError";
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";
const SLOW_RESOLVE_THRESHOLD = 1500;
export class GlobalZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends GlobalPlugin<TConfig> {
public static configSchema: t.TypeC<any>;
public static dependencies = [];
/**
* 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);
}
}

View file

@ -0,0 +1,52 @@
import moment from "moment-timezone";
import { ZeppelinPlugin } from "./ZeppelinPlugin";
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);
}
}

View file

@ -0,0 +1,25 @@
import { ZeppelinPlugin } from "./ZeppelinPlugin";
import { AllowedGuilds } from "../data/AllowedGuilds";
import { MINUTES } from "../utils";
export class GuildInfoSaverPlugin extends ZeppelinPlugin {
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);
}
}

View file

@ -0,0 +1,220 @@
import { decorators as d, IPluginOptions, getInviteLink, logger } from "knub";
import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin";
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, errorMessage, successMessage, MINUTES } 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 * 1000;
export class LocatePlugin extends ZeppelinPlugin<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;
private usersWithAlerts: string[] = [];
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();
}
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);
}
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>", {
extra: {
info: {
description: "Posts an instant invite to the voice channel that `<member>` is in",
},
},
})
@d.permission("can_where")
async whereCmd(msg: Message, args: { member: Member; time?: number; reminder?: string }) {
const member = await resolveMember(this.bot, this.guild, args.member.id);
sendWhere(this.guild, member, msg.channel, `${msg.member.mention} |`);
}
@d.command("vcalert", "<member:resolvedMember> [duration:delay] [reminder:string$]", {
aliases: ["vca"],
extra: {
info: {
description: "Sets up an alert that notifies you any time `<member>` switches or joins voice channels",
},
},
})
@d.permission("can_alert")
async vcalertCmd(msg: Message, args: { member: Member; duration?: number; reminder?: string }) {
const time = args.duration || 10 * MINUTES;
const alertTime = moment().add(time, "millisecond");
const body = args.reminder || "None";
this.alerts.add(msg.author.id, args.member.id, msg.channel.id, alertTime.format("YYYY-MM-DD HH:mm:ss"), body);
if (!this.usersWithAlerts.includes(args.member.id)) {
this.usersWithAlerts.push(args.member.id);
}
msg.channel.createMessage(
`If ${args.member.mention} joins or switches VC in the next ${humanizeDuration(time)} i will notify you`,
);
}
@d.command("vcalerts")
@d.permission("can_alert")
async listVcalertCmd(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}\` Member: <@!${alert.user_id}> Reminder: \`${alert.body}\``;
});
createChunkedMessage(msg.channel, lines.join("\n"));
}
@d.command("vcalerts delete", "<num:number>", {
aliases: ["vcalerts d"],
})
@d.permission("can_alert")
async deleteVcalertCmd(msg: Message, args: { num: number }) {
const alerts = await this.alerts.getAlertsByRequestorId(msg.member.id);
alerts.sort(sorter("expires_at"));
const lastNum = alerts.length + 1;
if (args.num > lastNum || args.num < 0) {
msg.channel.createMessage(errorMessage("Unknown alert"));
return;
}
const toDelete = alerts[args.num - 1];
await this.alerts.delete(toDelete.id);
msg.channel.createMessage(successMessage("Alert deleted"));
}
@d.event("voiceChannelJoin")
async userJoinedVC(member: Member, channel: Channel) {
if (this.usersWithAlerts.includes(member.id)) {
this.sendAlerts(member.id);
await this.removeUserIdFromActiveAlerts(member.id);
}
}
@d.event("voiceChannelSwitch")
async userSwitchedVC(member: Member, newChannel: Channel, oldChannel: Channel) {
if (this.usersWithAlerts.includes(member.id)) {
this.sendAlerts(member.id);
await this.removeUserIdFromActiveAlerts(member.id);
}
}
@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`;
sendWhere(this.guild, member, this.bot.getChannel(alert.channel_id) as TextableChannel, prepend);
this.alerts.delete(alert.id);
});
}
async removeUserIdFromActiveAlerts(userId: string) {
const index = this.usersWithAlerts.indexOf(userId);
if (index > -1) {
this.usersWithAlerts.splice(index, 1);
}
}
}
export 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 {
const invite = await createInvite(voice);
channel.createMessage(
prepend + ` ${member.mention} is in the following channel: ${voice.name} https://${getInviteLink(invite)}`,
);
}
}
export async function createInvite(vc: VoiceChannel) {
const existingInvites = await vc.getInvites();
if (existingInvites.length !== 0) {
return existingInvites[0];
} else {
return vc.createInvite(undefined);
}
}

594
backend/src/plugins/Logs.ts Normal file
View file

@ -0,0 +1,594 @@
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, Guild, Member, TextChannel, User } from "eris";
import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line
import {
createChunkedMessage,
disableCodeBlocks,
disableLinkPreviews,
findRelevantAuditLogEntry,
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 { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin";
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),
});
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 ZeppelinPlugin<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 (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)) {
return;
}
}
}
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)) {
return;
}
}
}
const message = await this.getLogMessage(type, data);
if (message) {
if (opts.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);
}, opts.batch_time || 2000);
}
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 userOrMember => {
if (!userOrMember) return "";
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);
return memberConfig.ping_user ? verboseUserMention(user) : verboseUserName(user);
},
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 (e instanceof DiscordRESTError && 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;
if (member.nick !== oldMember.nick) {
this.guildLogs.log(LogType.MEMBER_NICK_CHANGE, {
member,
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);
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,
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,
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,
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),
});
}
}

View file

@ -0,0 +1,141 @@
import { Plugin, decorators as d, IPluginOptions } from "knub";
import { GuildChannel, Message, TextChannel } from "eris";
import { GuildSavedMessages } from "../data/GuildSavedMessages";
import { successMessage } from "../utils";
import { ZeppelinPlugin } from "./ZeppelinPlugin";
import * as t from "io-ts";
const ConfigSchema = t.type({
can_manage: t.boolean,
});
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export class MessageSaverPlugin extends ZeppelinPlugin<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) {
msg.channel.createMessage(
successMessage(
`Saved ${savedCount} messages. The following messages could not be saved: ${failed.join(", ")}
`,
),
);
} else {
msg.channel.createMessage(successMessage(`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) {
msg.channel.createMessage(
successMessage(
`Saved ${savedCount} messages. The following messages could not be saved: ${failed.join(", ")}
`,
),
);
} else {
msg.channel.createMessage(successMessage(`Saved ${savedCount} messages!`));
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,616 @@
import { Member, Message, User } from "eris";
import { GuildCases } from "../data/GuildCases";
import moment from "moment-timezone";
import { ZeppelinPlugin } from "./ZeppelinPlugin";
import { GuildMutes } from "../data/GuildMutes";
import {
chunkMessageLines,
DBDateFormat,
errorMessage,
INotifyUserResult,
noop,
notifyUser,
NotifyUserStatus,
stripObjectToScalars,
successMessage,
tNullable,
ucfirst,
UnknownUser,
} 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";
const ConfigSchema = t.type({
mute_role: tNullable(t.string),
move_to_voice_channel: tNullable(t.string),
dm_on_mute: t.boolean,
message_on_mute: t.boolean,
message_channel: tNullable(t.string),
mute_message: tNullable(t.string),
timed_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: INotifyUserResult;
updatedExistingMute: boolean;
};
export type UnmuteResult = {
case: Case;
};
const EXPIRED_MUTE_CHECK_INTERVAL = 60 * 1000;
let FIRST_CHECK_TIME = Date.now();
const FIRST_CHECK_INCREMENT = 5 * 1000;
export class MutesPlugin extends ZeppelinPlugin<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;
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return {
config: {
mute_role: null,
move_to_voice_channel: null,
dm_on_mute: false,
message_on_mute: 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}",
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);
// 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,
caseArgs: Partial<CaseArgs> = {},
): Promise<MuteResult> {
const muteRole = this.getConfig().mute_role;
if (!muteRole) return;
const timeUntilUnmute = muteTime && humanizeDuration(muteTime);
// No mod specified -> mark Zeppelin as the mod
if (!caseArgs.modId) {
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
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: INotifyUserResult = { status: NotifyUserStatus.Ignored };
if (existingMute) {
await this.mutes.updateExpiryTime(user.id, muteTime);
} else {
await this.mutes.addMute(user.id, muteTime);
// If it's a new mute, attempt to message the user
const config = this.getMatchingConfig({ member, userId });
const template = muteTime ? config.timed_mute_message : config.mute_message;
const muteMessage =
template &&
(await renderTemplate(template, {
guildName: this.guild.name,
reason,
time: timeUntilUnmute,
}));
if (reason && muteMessage) {
if (user instanceof User) {
notifyResult = await notifyUser(this.bot, this.guild, user, muteMessage, {
useDM: config.dm_on_mute,
useChannel: config.message_on_mute,
channelId: config.message_channel,
});
} else {
notifyResult = { status: NotifyUserStatus.Failed };
}
}
}
// 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, ...(caseArgs.extraNotes || [])];
for (const noteReason of reasons) {
await casesPlugin.createCaseNote({
caseId: existingMute.case_id,
modId: caseArgs.modId,
body: noteReason,
noteDetails,
postInCaseLogOverride: false,
});
}
if (caseArgs.postInCaseLogOverride !== false) {
casesPlugin.postCaseToCaseLogChannel(existingMute.case_id);
}
} else {
// Create new case
const noteDetails = [`Muted ${muteTime ? `for ${timeUntilUnmute}` : "indefinitely"}`];
if (notifyResult.status !== NotifyUserStatus.Ignored) {
noteDetails.push(ucfirst(notifyResult.text));
}
theCase = await casesPlugin.createCase({
...caseArgs,
userId,
modId: caseArgs.modId,
type: CaseTypes.Mute,
reason,
noteDetails,
});
await this.mutes.setCaseId(user.id, theCase.id);
}
// Log the action
const mod = await this.resolveUser(caseArgs.modId);
if (muteTime) {
this.serverLogs.log(LogType.MEMBER_TIMED_MUTE, {
mod: stripObjectToScalars(mod),
user: stripObjectToScalars(user),
time: timeUntilUnmute,
});
} else {
this.serverLogs.log(LogType.MEMBER_MUTE, {
mod: stripObjectToScalars(mod),
user: stripObjectToScalars(user),
});
}
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);
if (!existingMute) return;
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 (unmuteTime) {
// Schedule timed unmute (= just set the mute's duration)
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`,
);
}
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`);
}
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,
});
} else {
this.serverLogs.log(LogType.MEMBER_UNMUTE, {
mod: stripObjectToScalars(mod),
user: stripObjectToScalars(user),
});
}
return {
case: createdCase,
};
}
@d.command("mutes", [], {
options: [
{
name: "age",
type: "delay",
},
{
name: "left",
isSwitch: true,
},
],
})
@d.permission("can_view_list")
protected async muteListCmd(msg: Message, args: { age?: number; left?: boolean }) {
const lines = [];
// Create a loading message as this can potentially take some time
const loadingMessage = await msg.channel.createMessage("Loading mutes...");
// 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;
});
let filteredMutes: IMuteWithDetails[] = activeMutes;
let hasFilters = false;
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;
}
// Mute count
let totalMutes = filteredMutes.length;
// Create a message lines 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());
for (const mute of filteredMutes) {
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 = humanizeDuration(timeUntilExpiry, { largest: 2, round: true });
line += ` ⏰ Expires in ${humanizedTime}`;
} else {
line += ` ⏰ Doesn't expire`;
}
const timeFromMute = moment(mute.created_at, DBDateFormat).diff(moment());
const humanizedTimeFromMute = humanizeDuration(timeFromMute, { largest: 2, round: true });
line += ` 🕒 Muted ${humanizedTimeFromMute} ago`;
if (mute.banned) {
line += ` 🔨 User was banned`;
} else if (!mute.member) {
line += ` ❌ Has left the server`;
}
lines.push(line);
}
// Find manually added mute roles and create a mesage line for each (but only if no filters have been specified)
if (!hasFilters) {
const muteUserIds = activeMutes.reduce((set, m) => set.add(m.user_id), new Set());
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.push(
...manuallyMutedMembers.map(member => {
return `<@!${member.id}> (**${member.user.username}#${member.user.discriminator}**, \`${member.id}\`) 🔧 Manual mute`;
}),
);
}
let message;
if (totalMutes > 0) {
message = hasFilters
? `Results (${totalMutes} total):\n\n${lines.join("\n")}`.trim()
: `Active mutes (${totalMutes} total):\n\n${lines.join("\n")}`.trim();
} else {
message = hasFilters ? "No mutes found with the specified filters!" : "No active mutes!";
}
await loadingMessage.delete().catch(noop);
const chunks = chunkMessageLines(message);
for (const chunk of chunks) {
msg.channel.createMessage(chunk);
}
// let the user know we are done
if (chunks.length > 2) {
msg.channel.createMessage(successMessage("All mutes for the specified filters posted!"));
}
}
/**
* 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++;
}
}
msg.channel.createMessage(successMessage(`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++;
}
}
msg.channel.createMessage(successMessage(`Cleared ${cleared} mutes from members that don't have the mute role`));
}
@d.command("clear_mute", "<userId:string>")
@d.permission("can_cleanup")
protected async clearMuteCmd(msg: Message, args: { userId: string }) {
const mute = await this.mutes.findExistingMuteForUserId(args.userId);
if (!mute) {
msg.channel.createMessage(errorMessage("No active mutes found for that user id"));
return;
}
await this.mutes.clear(args.userId);
msg.channel.createMessage(successMessage(`Active mute cleared`));
}
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 }) },
});
}
}
}

View file

@ -0,0 +1,81 @@
import { decorators as d, IPluginOptions } from "knub";
import { GuildNicknameHistory, MAX_NICKNAME_ENTRIES_PER_USER } from "../data/GuildNicknameHistory";
import { Member, Message } from "eris";
import { createChunkedMessage, disableCodeBlocks } from "../utils";
import { ZeppelinPlugin } from "./ZeppelinPlugin";
import { MAX_USERNAME_ENTRIES_PER_USER, UsernameHistory } from "../data/UsernameHistory";
import * as t from "io-ts";
const ConfigSchema = t.type({
can_view: t.boolean,
});
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export class NameHistoryPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "name_history";
public static showInDocs = false;
public static configSchema = ConfigSchema;
protected nicknameHistory: GuildNicknameHistory;
protected usernameHistory: UsernameHistory;
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();
}
@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;
let message = `Name history for **${currentUsername}**:`;
if (nicknameRows.length) {
message += `\n\n__Last ${MAX_NICKNAME_ENTRIES_PER_USER} nicknames:__\n${nicknameRows.join("\n")}`;
}
if (usernameRows.length) {
message += `\n\n__Last ${MAX_USERNAME_ENTRIES_PER_USER} usernames:__\n${usernameRows.join("\n")}`;
}
createChunkedMessage(msg.channel, message);
}
@d.event("guildMemberUpdate")
async onGuildMemberUpdate(_, member: Member) {
const latestEntry = await this.nicknameHistory.getLastEntry(member.id);
if (!latestEntry || latestEntry.nickname !== member.nick) {
// tslint:disable-line
await this.nicknameHistory.addEntry(member.id, member.nick);
}
}
}

View file

@ -0,0 +1,115 @@
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, ZeppelinPlugin } from "./ZeppelinPlugin";
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,
});
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export class PersistPlugin extends ZeppelinPlugin<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 (config.persist_voice_mutes && member.voiceState && member.voiceState.mute) {
persist = true;
persistData.is_voice_muted = true;
}
if (persist) {
this.persistedData.set(member.id, persistData);
}
}
@d.event("guildMemberAdd")
async onGuildMemberAdd(_, member: Member) {
const persistedData = await this.persistedData.find(member.id);
if (!persistedData) 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 = rolesToRestore;
}
}
if (config.persist_nicknames && persistedData.nickname) {
restoredData.push("nickname");
toRestore.nick = persistedData.nickname;
}
if (config.persist_voice_mutes && persistedData.is_voice_muted) {
restoredData.push("voice mute");
toRestore.mute = true;
}
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(", "),
});
}
}
}

View file

@ -0,0 +1,150 @@
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 { ZeppelinPlugin } from "./ZeppelinPlugin";
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 ZeppelinPlugin<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);
msg.channel.createMessage(
successMessage(`**${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);
msg.channel.createMessage(successMessage(`**${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",
);
}
}
}

519
backend/src/plugins/Post.ts Normal file
View file

@ -0,0 +1,519 @@
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,
} from "../utils";
import { GuildSavedMessages } from "../data/GuildSavedMessages";
import { ZeppelinPlugin } from "./ZeppelinPlugin";
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";
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 = 15 * SECONDS;
const SCHEDULED_POST_PREVIEW_TEXT_LENGTH = 50;
export class PostPlugin extends ZeppelinPlugin<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 dtMatch = str.match(/^\d{4}-\d{2}-\d{2} \d{1,2}:\d{1,2}(:\d{1,2})?$/);
if (dtMatch) {
const dt = moment(str, dtMatch[1] ? "YYYY-MM-DD H:m:s" : "YYYY-MM-DD H:m");
return dt;
}
const tMatch = str.match(/^\d{1,2}:\d{1,2}(:\d{1,2})?$/);
if (tMatch) {
const dt = moment(str, tMatch[1] ? "H:m:s" : "H:m");
if (dt.isBefore(moment())) dt.add(1, "day");
return dt;
}
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})`,
);
}
}
await this.scheduledPosts.delete(post.id);
}
this.scheduledPostLoopTimeout = setTimeout(() => this.scheduledPostLoop(), SCHEDULED_POST_CHECK_INTERVAL);
}
/**
* COMMAND: Post a regular text message as the bot to the specified channel
*/
@d.command("post", "<channel:channel> [content:string$]", {
options: [
{
name: "enable-mentions",
type: "bool",
},
{
name: "schedule",
type: "string",
},
],
})
@d.permission("can_post")
async postCmd(
msg: Message,
args: { channel: Channel; content?: string; "enable-mentions": boolean; schedule?: string },
) {
if (!(args.channel instanceof TextChannel)) {
msg.channel.createMessage(errorMessage("Channel is not a text channel"));
return;
}
if (args.content == null && msg.attachments.length === 0) {
msg.channel.createMessage(errorMessage("Text content or attachment required"));
return;
}
if (args.schedule) {
// Schedule the post to be posted later
const postAt = this.parseScheduleTime(args.schedule);
if (!postAt) {
return this.sendErrorMessage(msg.channel, "Invalid schedule time");
}
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: args.channel.id,
content: { content: args.content },
attachments: msg.attachments,
post_at: postAt.format(DBDateFormat),
enable_mentions: args["enable-mentions"],
});
this.sendSuccessMessage(
msg.channel,
`Message scheduled to be posted in <#${args.channel.id}> on ${postAt.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`,
);
this.logs.log(LogType.SCHEDULED_MESSAGE, {
author: stripObjectToScalars(msg.author),
channel: stripObjectToScalars(args.channel),
date: postAt.format("YYYY-MM-DD"),
time: postAt.format("HH:mm:ss"),
});
} else {
// Post the message immediately
await this.postMessage(args.channel, args.content, msg.attachments, args["enable-mentions"]);
if (args.channel.id !== msg.channel.id) {
this.sendSuccessMessage(msg.channel, `Message posted in <#${args.channel.id}>`);
}
}
}
/**
* 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" },
],
})
@d.permission("can_post")
async postEmbedCmd(
msg: Message,
args: {
channel: Channel;
title?: string;
maincontent?: string;
content?: string;
color?: string;
schedule?: string;
},
) {
if (!(args.channel instanceof TextChannel)) {
msg.channel.createMessage(errorMessage("Channel is not a text channel"));
return;
}
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);
}
const embed: EmbedBase = {};
if (args.title) embed.title = args.title;
if (content) embed.description = this.formatContent(content);
if (color) embed.color = color;
if (args.schedule) {
// Schedule the post to be posted later
const postAt = this.parseScheduleTime(args.schedule);
if (!postAt) {
return this.sendErrorMessage(msg.channel, "Invalid schedule time");
}
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: args.channel.id,
content: { embed },
attachments: msg.attachments,
post_at: postAt.format(DBDateFormat),
});
await this.sendSuccessMessage(
msg.channel,
`Embed scheduled to be posted in <#${args.channel.id}> on ${postAt.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`,
);
this.logs.log(LogType.SCHEDULED_MESSAGE, {
author: stripObjectToScalars(msg.author),
channel: stripObjectToScalars(args.channel),
date: postAt.format("YYYY-MM-DD"),
time: postAt.format("HH:mm:ss"),
});
} else {
const createdMsg = await args.channel.createMessage({ embed });
this.savedMessages.setPermanent(createdMsg.id);
if (msg.channel.id !== args.channel.id) {
await this.sendSuccessMessage(msg.channel, `Embed posted in <#${args.channel.id}>`);
}
}
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}post_embed -title "Some title" content goes here\`
The \`-content\` option will soon be removed in favor of this.
`),
);
}
}
/**
* 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)*");
parts.push(`*(${p.author_name})*`);
return parts.join(" ");
});
const finalMessage = trimLines(`
${postLines.join("\n")}
Use \`scheduled_posts show <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);
}
}

View file

@ -0,0 +1,386 @@
import { decorators as d, IPluginOptions, logger } from "knub";
import { CustomEmoji, errorMessage, isSnowflake, noop, sleep, successMessage } from "../utils";
import { GuildReactionRoles } from "../data/GuildReactionRoles";
import { Message, TextChannel } from "eris";
import { ZeppelinPlugin } from "./ZeppelinPlugin";
import { GuildSavedMessages } from "../data/GuildSavedMessages";
import { Queue } from "../Queue";
import { ReactionRole } from "../data/entities/ReactionRole";
import Timeout = NodeJS.Timeout;
import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line
import * as t from "io-ts";
/**
* 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 ZeppelinPlugin<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 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.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 (e instanceof DiscordRESTError) {
logger.warn(
`Reaction roles for unknown message ${channelId}/${messageId} in guild ${this.guild.name} (${this.guildId})`,
);
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 () => {
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()),
});
} catch (e) {
logger.warn(
`Failed to apply role changes to ${member.username}#${member.discriminator} (${member.id}): ${e.message}`,
);
}
this.pendingRoleChanges.delete(memberId);
}
},
};
this.pendingRoleChanges.set(memberId, newPendingRoleChangeObj);
}
const pendingRoleChangeObj = this.pendingRoleChanges.get(memberId);
pendingRoleChangeObj.changes.push({ mode, roleId });
if (pendingRoleChangeObj.timeout) clearTimeout(pendingRoleChangeObj.timeout);
setTimeout(() => 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();
msg.channel.createMessage(successMessage("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);
msg.channel.createMessage(successMessage("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$>")
@d.permission("can_manage")
async reactionRolesCmd(msg: Message, args: { messageId: string; reactionRolePairs: string }) {
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;
}
if (!this.canUseEmoji(pair[0])) {
msg.channel.createMessage(errorMessage("I can only use regular emojis and custom emojis from servers I'm on"));
return;
}
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]);
}
// Apply the reactions themselves
const reactionRoles = await this.reactionRoles.getForMessage(targetMessage.id);
await this.applyReactionRoleReactionsToMessage(targetMessage.channel.id, targetMessage.id, reactionRoles);
msg.channel.createMessage(successMessage("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;
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);
}
}

View file

@ -0,0 +1,180 @@
import { decorators as d, IPluginOptions } from "knub";
import { ZeppelinPlugin } from "./ZeppelinPlugin";
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 ZeppelinPlugin<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 {
await channel.createMessage(
disableLinkPreviews(`<@!${reminder.user_id}> You asked me to remind you: ${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://discordapp.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);
const msUntilReminder = reminderTime.diff(now);
const timeUntilReminder = humanizeDuration(msUntilReminder, { largest: 2, round: true });
msg.channel.createMessage(
successMessage(
`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, " ");
return `\`${paddedNum}.\` \`${reminder.remind_at}\` ${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);
msg.channel.createMessage(successMessage("Reminder deleted"));
}
}

View file

@ -0,0 +1,330 @@
import { decorators as d, IPluginOptions } from "knub";
import { GuildSelfGrantableRoles } from "../data/GuildSelfGrantableRoles";
import { GuildChannel, Message, Role, TextChannel } from "eris";
import { asSingleLine, chunkArray, errorMessage, sorter, successMessage, trimLines } from "../utils";
import { ZeppelinPlugin } from "./ZeppelinPlugin";
import * as t from "io-ts";
const ConfigSchema = t.type({
can_manage: t.boolean,
can_use: t.boolean,
can_ignore_cooldown: t.boolean,
});
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export class SelfGrantableRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "self_grantable_roles";
public static showInDocs = false;
public static configSchema = ConfigSchema;
protected selfGrantableRoles: GuildSelfGrantableRoles;
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return {
config: {
can_manage: false,
can_use: false,
can_ignore_cooldown: false,
},
overrides: [
{
level: ">=50",
config: {
can_ignore_cooldown: true,
},
},
{
level: ">=100",
config: {
can_manage: true,
},
},
],
};
}
onLoad() {
this.selfGrantableRoles = GuildSelfGrantableRoles.getGuildInstance(this.guildId);
}
@d.command("role remove", "<roleNames:string...>")
@d.permission("can_use")
@d.cooldown(2500, "can_ignore_cooldown")
async roleRemoveCmd(msg: Message, args: { roleNames: string[] }) {
const lock = await this.locks.acquire(`grantableRoles:${msg.author.id}`);
const channelGrantableRoles = await this.selfGrantableRoles.getForChannel(msg.channel.id);
if (channelGrantableRoles.length === 0) {
lock.unlock();
return;
}
const nonMatchingRoleNames: string[] = [];
const rolesToRemove: Set<Role> = new Set();
// Match given role names with actual grantable roles
const roleNames = new Set(
args.roleNames
.map(n => n.split(/[\s,]+/))
.flat()
.map(v => v.toLowerCase()),
);
for (const roleName of roleNames) {
let matched = false;
for (const grantableRole of channelGrantableRoles) {
let matchedAlias = false;
for (const alias of grantableRole.aliases) {
const normalizedAlias = alias.toLowerCase();
if (roleName === normalizedAlias && this.guild.roles.has(grantableRole.role_id)) {
rolesToRemove.add(this.guild.roles.get(grantableRole.role_id));
matched = true;
matchedAlias = true;
break;
}
}
if (matchedAlias) break;
}
if (!matched) {
nonMatchingRoleNames.push(roleName);
}
}
// Remove the roles
if (rolesToRemove.size) {
const rolesToRemoveArr = Array.from(rolesToRemove.values());
const roleIdsToRemove = rolesToRemoveArr.map(r => r.id);
const newRoleIds = msg.member.roles.filter(roleId => !roleIdsToRemove.includes(roleId));
try {
await msg.member.edit({
roles: newRoleIds,
});
const removedRolesStr = rolesToRemoveArr.map(r => `**${r.name}**`);
const removedRolesWord = rolesToRemoveArr.length === 1 ? "role" : "roles";
if (nonMatchingRoleNames.length) {
msg.channel.createMessage(
successMessage(
`<@!${msg.author.id}> Removed ${removedRolesStr.join(", ")} ${removedRolesWord};` +
` couldn't recognize the other roles you mentioned`,
),
);
} else {
msg.channel.createMessage(
successMessage(`<@!${msg.author.id}> Removed ${removedRolesStr.join(", ")} ${removedRolesWord}`),
);
}
} catch (e) {
msg.channel.createMessage(errorMessage(`<@!${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...>")
@d.permission("can_use")
@d.cooldown(1500, "can_ignore_cooldown")
async roleCmd(msg: Message, args: { roleNames: string[] }) {
const lock = await this.locks.acquire(`grantableRoles:${msg.author.id}`);
const channelGrantableRoles = await this.selfGrantableRoles.getForChannel(msg.channel.id);
if (channelGrantableRoles.length === 0) {
lock.unlock();
return;
}
const nonMatchingRoleNames: string[] = [];
const rolesToGrant: Set<Role> = new Set();
// Match given role names with actual grantable roles
const roleNames = new Set(
args.roleNames
.map(n => n.split(/[\s,]+/))
.flat()
.map(v => v.toLowerCase()),
);
for (const roleName of roleNames) {
let matched = false;
for (const grantableRole of channelGrantableRoles) {
let matchedAlias = false;
for (const alias of grantableRole.aliases) {
const normalizedAlias = alias.toLowerCase();
if (roleName === normalizedAlias && this.guild.roles.has(grantableRole.role_id)) {
rolesToGrant.add(this.guild.roles.get(grantableRole.role_id));
matched = true;
matchedAlias = true;
break;
}
}
if (matchedAlias) break;
}
if (!matched) {
nonMatchingRoleNames.push(roleName);
}
}
// Grant the roles
if (rolesToGrant.size) {
const rolesToGrantArr = Array.from(rolesToGrant.values());
const roleIdsToGrant = rolesToGrantArr.map(r => r.id);
const newRoleIds = Array.from(new Set(msg.member.roles.concat(roleIdsToGrant)).values());
try {
await msg.member.edit({
roles: newRoleIds,
});
const grantedRolesStr = rolesToGrantArr.map(r => `**${r.name}**`);
const grantedRolesWord = rolesToGrantArr.length === 1 ? "role" : "roles";
if (nonMatchingRoleNames.length) {
msg.channel.createMessage(
successMessage(
`<@!${msg.author.id}> Granted you the ${grantedRolesStr.join(", ")} ${grantedRolesWord};` +
` couldn't recognize the other roles you mentioned`,
),
);
} else {
msg.channel.createMessage(
successMessage(`<@!${msg.author.id}> Granted you the ${grantedRolesStr.join(", ")} ${grantedRolesWord}`),
);
}
} catch (e) {
msg.channel.createMessage(
errorMessage(`<@!${msg.author.id}> Got an error while trying to grant you the roles`),
);
}
} else {
msg.channel.createMessage(
errorMessage(`<@!${msg.author.id}> Unknown ${args.roleNames.length === 1 ? "role" : "roles"}`),
);
}
lock.unlock();
}
@d.command("role help", [], {
aliases: ["role"],
})
@d.permission("can_use")
@d.cooldown(5000, "can_ignore_cooldown")
async roleHelpCmd(msg: Message) {
const channelGrantableRoles = await this.selfGrantableRoles.getForChannel(msg.channel.id);
if (channelGrantableRoles.length === 0) return;
const prefix = this.guildConfig.prefix;
const firstRole = channelGrantableRoles[0].aliases[0];
const secondRole = channelGrantableRoles[1] ? channelGrantableRoles[1].aliases[0] : null;
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 \`!role remove ${firstRole}\`,
again replacing **${firstRole}** with the role you want to remove.
`);
const helpMessage = trimLines(`
${help1}
${help2}
**Available roles:**
${channelGrantableRoles.map(r => r.aliases[0]).join(", ")}
`);
const helpEmbed = {
title: "How to get roles",
description: helpMessage,
color: parseInt("42bff4", 16),
};
msg.channel.createMessage({ embed: helpEmbed });
}
@d.command("self_grantable_roles add", "<channel:channel> <roleId:string> [aliases:string...]")
@d.permission("can_manage")
async addSelfGrantableRoleCmd(msg: Message, args: { channel: GuildChannel; roleId: string; aliases?: string[] }) {
if (!(args.channel instanceof TextChannel)) {
msg.channel.createMessage(errorMessage("Invalid channel (must be a text channel)"));
return;
}
const role = this.guild.roles.get(args.roleId);
if (!role) {
msg.channel.createMessage(errorMessage("Unknown role"));
return;
}
const aliases = (args.aliases || []).map(n => n.split(/[\s,]+/)).flat();
aliases.push(role.name.replace(/\s+/g, ""));
const uniqueAliases = Array.from(new Set(aliases).values());
// Remove existing self grantable role on that channel, if one exists
await this.selfGrantableRoles.delete(args.channel.id, role.id);
// Add new one
await this.selfGrantableRoles.add(args.channel.id, role.id, uniqueAliases);
msg.channel.createMessage(
successMessage(`Self-grantable role **${role.name}** added to **#${args.channel.name}**`),
);
}
@d.command("self_grantable_roles delete", "<channel:channel> <roleId:string>")
@d.permission("can_manage")
async deleteSelfGrantableRoleCmd(msg: Message, args: { channel: GuildChannel; roleId: string }) {
await this.selfGrantableRoles.delete(args.channel.id, args.roleId);
const roleName = this.guild.roles.has(args.roleId) ? this.guild.roles.get(args.roleId).name : args.roleId;
msg.channel.createMessage(
successMessage(`Self-grantable role **${roleName}** removed from **#${args.channel.name}**`),
);
}
@d.command("self_grantable_roles", "<channel:channel>")
@d.permission("can_manage")
async selfGrantableRolesCmd(msg: Message, args: { channel: GuildChannel }) {
if (!(args.channel instanceof TextChannel)) {
msg.channel.createMessage(errorMessage("Invalid channel (must be a text channel)"));
return;
}
const channelGrantableRoles = await this.selfGrantableRoles.getForChannel(args.channel.id);
if (channelGrantableRoles.length === 0) {
msg.channel.createMessage(errorMessage(`No self-grantable roles on **#${args.channel.name}**`));
return;
}
channelGrantableRoles.sort(sorter(gr => gr.aliases.join(", ")));
const longestId = channelGrantableRoles.reduce((longest, gr) => Math.max(longest, gr.role_id.length), 0);
const lines = channelGrantableRoles.map(gr => {
const paddedId = gr.role_id.padEnd(longestId, " ");
return `${paddedId} ${gr.aliases.join(", ")}`;
});
const batches = chunkArray(lines, 20);
for (const batch of batches) {
await msg.channel.createMessage(`\`\`\`js\n${batch.join("\n")}\n\`\`\``);
}
}
}

View file

@ -0,0 +1,441 @@
import { decorators as d, IPluginOptions, logger } from "knub";
import { GuildChannel, Message, TextChannel, Constants as ErisConstants, User } from "eris";
import {
convertDelayStringToMS,
createChunkedMessage,
errorMessage,
noop,
stripObjectToScalars,
successMessage,
UnknownUser,
} from "../utils";
import { GuildSlowmodes } from "../data/GuildSlowmodes";
import humanizeDuration from "humanize-duration";
import { ZeppelinPlugin } from "./ZeppelinPlugin";
import { SavedMessage } from "../data/entities/SavedMessage";
import { GuildSavedMessages } from "../data/GuildSavedMessages";
import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line
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 ZeppelinPlugin<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 (e instanceof DiscordRESTError && 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) {
msg.channel.createMessage(
successMessage(
`Slowmode disabled! Failed to clear slowmode from the following users:\n\n<@!${failedUsers.join(">\n<@!")}>`,
),
);
} else {
msg.channel.createMessage(successMessage("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",
type: "bool",
},
],
})
@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),
});
}
}
}
}

506
backend/src/plugins/Spam.ts Normal file
View file

@ -0,0 +1,506 @@
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 { ZeppelinPlugin } from "./ZeppelinPlugin";
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 ZeppelinPlugin<TConfigSchema> {
public static pluginName = "spam";
public static configSchema = ConfigSchema;
public static pluginInfo = {
prettyName: "Spam protection",
};
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", {
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", {
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",
);
}
}
}

View file

@ -0,0 +1,371 @@
import { decorators as d, waitForReply, utils as knubUtils, IBasePluginConfig, IPluginOptions } from "knub";
import { ZeppelinPlugin } from "./ZeppelinPlugin";
import { GuildStarboards } from "../data/GuildStarboards";
import { GuildChannel, Message, TextChannel } from "eris";
import {
customEmojiRegex,
errorMessage,
getEmojiInString,
getUrlsInString,
noop,
snowflakeRegex,
successMessage,
} from "../utils";
import { Starboard } from "../data/entities/Starboard";
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";
const ConfigSchema = t.type({
can_manage: t.boolean,
});
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "starboard";
public static showInDocs = false;
public static configSchema = ConfigSchema;
protected starboards: GuildStarboards;
protected savedMessages: GuildSavedMessages;
private onMessageDeleteFn;
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return {
config: {
can_manage: false,
},
overrides: [
{
level: ">=100",
config: {
can_manage: true,
},
},
],
};
}
onLoad() {
this.starboards = GuildStarboards.getGuildInstance(this.guildId);
this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId);
this.onMessageDeleteFn = this.onMessageDelete.bind(this);
this.savedMessages.events.on("delete", this.onMessageDeleteFn);
}
onUnload() {
this.savedMessages.events.off("delete", this.onMessageDeleteFn);
}
/**
* An interactive setup for creating a starboard
*/
@d.command("starboard create")
@d.permission("can_manage")
async setupCmd(msg: Message) {
const cancelMsg = () => msg.channel.createMessage("Cancelled");
msg.channel.createMessage(
`⭐ Let's make a starboard! What channel should we use as the board? ("cancel" to cancel)`,
);
let starboardChannel;
do {
const reply = await waitForReply(this.bot, msg.channel as TextChannel, msg.author.id, 60000);
if (reply.content == null || reply.content === "cancel") return cancelMsg();
starboardChannel = knubUtils.resolveChannel(this.guild, reply.content || "");
if (!starboardChannel) {
msg.channel.createMessage("Invalid channel. Try again?");
continue;
}
const existingStarboard = await this.starboards.getStarboardByChannelId(starboardChannel.id);
if (existingStarboard) {
msg.channel.createMessage("That channel already has a starboard. Try again?");
starboardChannel = null;
continue;
}
} while (starboardChannel == null);
msg.channel.createMessage(`Ok. Which emoji should we use as the trigger? ("cancel" to cancel)`);
let emoji;
do {
const reply = await waitForReply(this.bot, msg.channel as TextChannel, msg.author.id);
if (reply.content == null || reply.content === "cancel") return cancelMsg();
const allEmojis = getEmojiInString(reply.content || "");
if (!allEmojis.length) {
msg.channel.createMessage("Invalid emoji. Try again?");
continue;
}
emoji = allEmojis[0];
const customEmojiMatch = emoji.match(customEmojiRegex);
if (customEmojiMatch) {
// <:name:id> to name:id, as Eris puts them in the message reactions object
emoji = `${customEmojiMatch[1]}:${customEmojiMatch[2]}`;
}
} while (emoji == null);
msg.channel.createMessage(
`And how many reactions are required to immortalize a message in the starboard? ("cancel" to cancel)`,
);
let requiredReactions;
do {
const reply = await waitForReply(this.bot, msg.channel as TextChannel, msg.author.id);
if (reply.content == null || reply.content === "cancel") return cancelMsg();
requiredReactions = parseInt(reply.content || "", 10);
if (Number.isNaN(requiredReactions)) {
msg.channel.createMessage("Invalid number. Try again?");
continue;
}
if (typeof requiredReactions === "number") {
if (requiredReactions <= 0) {
msg.channel.createMessage("The number must be higher than 0. Try again?");
continue;
} else if (requiredReactions > 65536) {
msg.channel.createMessage("The number must be smaller than 65536. Try again?");
continue;
}
}
} while (requiredReactions == null);
msg.channel.createMessage(
`And finally, which channels can messages be starred in? "All" for any channel. ("cancel" to cancel)`,
);
let channelWhitelist;
do {
const reply = await waitForReply(this.bot, msg.channel as TextChannel, msg.author.id);
if (reply.content == null || reply.content === "cancel") return cancelMsg();
if (reply.content.toLowerCase() === "all") {
channelWhitelist = null;
break;
}
channelWhitelist = reply.content.match(new RegExp(snowflakeRegex, "g"));
let hasInvalidChannels = false;
for (const id of channelWhitelist) {
const channel = this.guild.channels.get(id);
if (!channel || !(channel instanceof TextChannel)) {
msg.channel.createMessage(`Couldn't recognize channel <#${id}> (\`${id}\`). Try again?`);
hasInvalidChannels = true;
break;
}
}
if (hasInvalidChannels) continue;
} while (channelWhitelist == null);
await this.starboards.create(starboardChannel.id, channelWhitelist, emoji, requiredReactions);
msg.channel.createMessage(successMessage("Starboard created!"));
}
/**
* Deletes the starboard from the specified channel. The already-posted starboard messages are retained.
*/
@d.command("starboard delete", "<channelId:channelId>")
@d.permission("can_manage")
async deleteCmd(msg: Message, args: { channelId: string }) {
const starboard = await this.starboards.getStarboardByChannelId(args.channelId);
if (!starboard) {
msg.channel.createMessage(errorMessage(`Channel <#${args.channelId}> doesn't have a starboard!`));
return;
}
await this.starboards.delete(starboard.channel_id);
msg.channel.createMessage(successMessage(`Starboard deleted from <#${args.channelId}>!`));
}
/**
* 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 }) {
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;
}
}
const emojiStr = emoji.id ? `${emoji.name}:${emoji.id}` : emoji.name;
const applicableStarboards = await this.starboards.getStarboardsByEmoji(emojiStr);
for (const starboard of applicableStarboards) {
// Can't star messages in the starboard channel itself
if (msg.channel.id === starboard.channel_id) continue;
if (starboard.channel_whitelist) {
const allowedChannelIds = starboard.channel_whitelist.split(",");
if (!allowedChannelIds.includes(msg.channel.id)) continue;
}
// If the message has already been posted to this starboard, we don't need to do anything else here
const existingSavedMessage = await this.starboards.getStarboardMessageByStarboardIdAndMessageId(
starboard.id,
msg.id,
);
if (existingSavedMessage) return;
const reactionsCount = await this.countReactions(msg, emojiStr);
if (reactionsCount >= starboard.reactions_required) {
await this.saveMessageToStarboard(msg, starboard);
}
}
}
/**
* Counts the specific reactions in the message, ignoring the message author
*/
async countReactions(msg: Message, reaction) {
let reactionsCount = (msg.reactions[reaction] && msg.reactions[reaction].count) || 0;
// Ignore self-stars
const reactors = await msg.getReaction(reaction);
if (reactors.some(u => u.id === msg.author.id)) reactionsCount--;
return reactionsCount;
}
/**
* 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: Starboard) {
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: any = {
footer: {
text: `#${(msg.channel as GuildChannel).name} - ${time}`,
},
author: {
name: `${msg.author.username}#${msg.author.discriminator}`,
},
};
if (msg.author.avatarURL) {
embed.author.icon_url = msg.author.avatarURL;
}
if (msg.content) {
embed.description = msg.content;
}
if (msg.attachments.length) {
const attachment = msg.attachments[0];
const ext = path
.extname(attachment.filename)
.slice(1)
.toLowerCase();
if (["jpeg", "jpg", "png", "gif", "webp"].includes(ext)) {
embed.image = { url: attachment.url };
}
} else if (msg.content) {
const links = getUrlsInString(msg.content);
for (const link of links) {
const parts = link
.toString()
.replace(/\/$/, "")
.split(".");
const ext = parts[parts.length - 1].toLowerCase();
if (
(link.hostname === "i.imgur.com" || link.hostname === "cdn.discordapp.com") &&
["jpeg", "jpg", "png", "gif", "webp"].includes(ext)
) {
embed.image = { url: link.toString() };
break;
}
}
}
const starboardMessage = await (channel as TextChannel).createMessage({
content: `https://discordapp.com/channels/${this.guildId}/${msg.channel.id}/${msg.id}`,
embed,
});
await this.starboards.createStarboardMessage(starboard.id, msg.id, starboardMessage.id);
}
/**
* Remove a message from the specified starboard
*/
async removeMessageFromStarboard(msgId: string, starboard: Starboard) {
const starboardMessage = await this.starboards.getStarboardMessageByStarboardIdAndMessageId(starboard.id, msgId);
if (!starboardMessage) return;
await this.bot.deleteMessage(starboard.channel_id, starboardMessage.starboard_message_id).catch(noop);
await this.starboards.deleteStarboardMessage(starboard.id, msgId);
}
/**
* When a message is deleted, also delete it from any starboards it's been posted in.
* This function is called in response to GuildSavedMessages events.
* TODO: When a message is removed from the starboard itself, i.e. the bot's embed is removed, also remove that message from the starboard_messages database table
*/
async onMessageDelete(msg: SavedMessage) {
const starboardMessages = await this.starboards.with("starboard").getStarboardMessagesByMessageId(msg.id);
if (!starboardMessages.length) return;
for (const starboardMessage of starboardMessages) {
if (!starboardMessage.starboard) continue;
this.removeMessageFromStarboard(starboardMessage.message_id, starboardMessage.starboard);
}
}
@d.command("starboard migrate_pins", "<pinChannelId:channelId> <starboardChannelId:channelId>")
async migratePinsCmd(msg: Message, args: { pinChannelId: string; starboardChannelId }) {
const starboard = await this.starboards.getStarboardByChannelId(args.starboardChannelId);
if (!starboard) {
msg.channel.createMessage(errorMessage("The specified channel doesn't have a starboard!"));
return;
}
const channel = (await this.guild.channels.get(args.pinChannelId)) as GuildChannel & TextChannel;
if (!channel) {
msg.channel.createMessage(errorMessage("Could not find the specified channel to migrate pins from!"));
return;
}
msg.channel.createMessage(`Migrating pins from <#${channel.id}> to <#${args.starboardChannelId}>...`);
const pins = await channel.getPins();
pins.reverse(); // Migrate pins starting from the oldest message
for (const pin of pins) {
const existingStarboardMessage = await this.starboards.getStarboardMessageByStarboardIdAndMessageId(
starboard.id,
pin.id,
);
if (existingStarboardMessage) continue;
await this.saveMessageToStarboard(pin, starboard);
}
msg.channel.createMessage(successMessage("Pins migrated!"));
}
}

364
backend/src/plugins/Tags.ts Normal file
View file

@ -0,0 +1,364 @@
import { decorators as d, IPluginOptions, logger } from "knub";
import { Member, Message, TextChannel } from "eris";
import { errorMessage, successMessage, stripObjectToScalars, tNullable } 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 { ZeppelinPlugin } from "./ZeppelinPlugin";
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";
const TagCategory = t.type({
prefix: tNullable(t.string),
delete_with_command: tNullable(t.boolean),
tags: t.record(t.string, t.string),
can_use: tNullable(t.boolean),
});
const ConfigSchema = t.type({
prefix: t.string,
delete_with_command: t.boolean,
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 ZeppelinPlugin<TConfigSchema> {
public static pluginName = "tags";
public static configSchema = ConfigSchema;
public static pluginInfo = {
prettyName: "Tags",
};
protected archives: GuildArchives;
protected tags: GuildTags;
protected savedMessages: GuildSavedMessages;
private onMessageCreateFn;
private onMessageDeleteFn;
protected tagFunctions;
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return {
config: {
prefix: "!!",
delete_with_command: true,
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.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 = {
countdown(toDate) {
if (typeof toDate !== "string") return "";
const now = moment();
const target = moment(toDate, "YYYY-MM-DD HH:mm:ss");
if (!target.isValid()) return "";
const diff = target.diff(now);
const result = humanizeDuration(diff, { largest: 2, round: true });
return diff >= 0 ? result : `${result} ago`;
},
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 "";
},
};
}
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);
msg.channel.createMessage(successMessage("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;
msg.channel.createMessage(successMessage(`Tag set! Use it with: \`${prefix}${args.tag}\``));
}
@d.command("tag", "<tag:string>")
async tagSourceCmd(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;
}
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: string,
member: Member,
): Promise<string | null> {
const variableStr = str.slice(prefix.length + tagName.length).trim();
const tagArgs = parseArguments(variableStr).map(v => v.value);
// Format the string
try {
let rendered = await this.renderTag(tagBody, tagArgs, {
member: stripObjectToScalars(member, ["user"]),
user: stripObjectToScalars(member.user),
});
rendered = rendered.trim();
if (rendered === "") return;
if (rendered.length > 2000) return;
return rendered;
} 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;
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) break;
}
}
if (renderedTag) {
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;
renderedTag = await this.renderSafeTagFromMessage(msg.data.content, prefix, tagName, tag.body, member);
}
if (!renderedTag) return;
deleteWithCommand = config.delete_with_command;
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;
}
}
}

View file

@ -0,0 +1,33 @@
import { decorators as d, GlobalPlugin } from "knub";
import { UsernameHistory } from "../data/UsernameHistory";
import { Member, User } from "eris";
import { GlobalZeppelinPlugin } from "./GlobalZeppelinPlugin";
export class UsernameSaver extends GlobalZeppelinPlugin {
public static pluginName = "username_saver";
protected usernameHistory: UsernameHistory;
async onLoad() {
this.usernameHistory = new UsernameHistory();
}
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("userUpdate", null, false)
async onUserUpdate(user: User) {
this.updateUsername(user);
}
@d.event("guildMemberAdd", null, false)
async onGuildMemberAdd(_, member: Member) {
this.updateUsername(member.user);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,80 @@
import { ZeppelinPlugin } from "./ZeppelinPlugin";
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 ZeppelinPlugin<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),
});
}
}
}
}

View file

@ -0,0 +1,274 @@
import { IBasePluginConfig, IPluginOptions, logger, Plugin, configUtils } from "knub";
import { PluginRuntimeError } from "../PluginRuntimeError";
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,
MINUTES,
resolveMember,
resolveUser,
resolveUserId,
trimEmptyStartEndLines,
trimIndents,
UnknownUser,
} from "../utils";
import { Invite, Member, User } from "eris";
import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line
import { performance } from "perf_hooks";
import { decodeAndValidateStrict, StrictValidationError } from "../validatorUtils";
import { SimpleCache } from "../SimpleCache";
const SLOW_RESOLVE_THRESHOLD = 1500;
/**
* Wrapper for the string type that indicates the text will be parsed as Markdown later
*/
type TMarkdown = string;
export interface PluginInfo {
prettyName: string;
description?: TMarkdown;
usageGuide?: TMarkdown;
configurationGuide?: TMarkdown;
}
export interface CommandInfo {
description?: TMarkdown;
basicUsage?: TMarkdown;
examples?: TMarkdown;
usageGuide?: TMarkdown;
parameterDescriptions?: {
[key: string]: TMarkdown;
};
optionDescriptions?: {
[key: string]: TMarkdown;
};
}
export function trimPluginDescription(str) {
const emptyLinesTrimmed = trimEmptyStartEndLines(str);
const lines = emptyLinesTrimmed.split("\n");
const lastLineIndentation = (lines[lines.length - 1].match(/^ +/g) || [""])[0].length;
return trimIndents(emptyLinesTrimmed, lastLineIndentation);
}
const inviteCache = new SimpleCache<Promise<Invite>>(10 * MINUTES, 200);
export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plugin<TConfig> {
public static pluginInfo: PluginInfo;
public static showInDocs: boolean = true;
public static configSchema: t.TypeC<any>;
public static dependencies = [];
protected throwPluginRuntimeError(message: string) {
throw new PluginRuntimeError(message, this.runtimePluginName, this.guildId);
}
protected canActOn(member1, member2) {
if (member1.id === member2.id || member2.id === this.bot.user.id) {
return false;
}
const ourLevel = this.getMemberLevel(member1);
const memberLevel = this.getMemberLevel(member2);
return ourLevel > memberLevel;
}
/**
* 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 ZeppelinPlugin).getStaticDefaultOptions() as IPluginOptions<TConfig>;
}
/**
* Allows the plugin to preprocess the config before it's validated.
* Useful for e.g. adding default properties to dynamic objects.
*/
protected static preprocessStaticConfig(config: any) {
return config;
}
/**
* 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();
let mergedConfig = configUtils.mergeConfig({}, defaultOptions.config || {}, options.config || {});
const mergedOverrides = options.replaceDefaultOverrides
? options.overrides
: (defaultOptions.overrides || []).concat(options.overrides || []);
mergedConfig = this.preprocessStaticConfig(mergedConfig);
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 ZeppelinPlugin).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();
}
public canUseEmoji(snowflake): boolean {
if (isUnicodeEmoji(snowflake)) {
return true;
} else if (isSnowflake(snowflake)) {
for (const guild of this.bot.guilds.values()) {
if (guild.emojis.some(e => (e as any).id === snowflake)) {
return true;
}
}
} else {
throw new PluginRuntimeError(`Invalid emoji: ${snowflake}`, this.runtimePluginName, this.guildId);
}
}
/**
* Intended for cross-plugin functionality
*/
public getRuntimeOptions() {
return this.getMergedOptions();
}
getUser(userResolvable: string): User | UnknownUser {
const id = resolveUserId(this.bot, userResolvable);
return id ? this.bot.users.get(id) || new UnknownUser({ id }) : new UnknownUser();
}
/**
* Resolves a user from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc.
* If the user is not found in the cache, it's fetched from the API.
*/
async resolveUser(userResolvable: string): Promise<User | UnknownUser> {
const start = performance.now();
const user = await resolveUser(this.bot, userResolvable);
const time = performance.now() - start;
if (time >= SLOW_RESOLVE_THRESHOLD) {
const rounded = Math.round(time);
logger.warn(`Slow user resolve (${rounded}ms): ${userResolvable}`);
}
return user;
}
/**
* Resolves a member from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc.
* If the member is not found in the cache, it's fetched from the API.
*/
async getMember(memberResolvable: string, forceFresh = false): Promise<Member> {
const start = performance.now();
let member;
if (forceFresh) {
const userId = await resolveUserId(this.bot, memberResolvable);
try {
member = userId && (await this.bot.getRESTGuildMember(this.guild.id, userId));
} catch (e) {
if (!(e instanceof DiscordRESTError)) {
throw e;
}
}
if (member) member.id = member.user.id;
} else {
member = await resolveMember(this.bot, this.guild, memberResolvable);
}
const time = performance.now() - start;
if (time >= SLOW_RESOLVE_THRESHOLD) {
const rounded = Math.round(time);
logger.warn(`Slow member resolve (${rounded}ms): ${memberResolvable} in ${this.guild.name} (${this.guild.id})`);
}
return member;
}
async resolveInvite(code: string): Promise<Invite | null> {
if (inviteCache.has(code)) {
return inviteCache.get(code);
}
const promise = this.bot.getInvite(code).catch(() => null);
inviteCache.set(code, promise);
return promise;
}
}

View file

@ -0,0 +1,77 @@
import { MessageSaverPlugin } from "./MessageSaver";
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";
/**
* Plugins available to be loaded for individual guilds
*/
export const availablePlugins = [
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,
];
/**
* Plugins that are always loaded (subset of the names of the plugins in availablePlugins)
*/
export const basePlugins = [
GuildInfoSaverPlugin.pluginName,
MessageSaverPlugin.pluginName,
NameHistoryPlugin.pluginName,
CasesPlugin.pluginName,
MutesPlugin.pluginName,
];
/**
* Available global plugins (can't be loaded per-guild, only globally)
*/
export const availableGlobalPlugins = [BotControlPlugin, UsernameSaver, GuildConfigReloader];