mirror of
https://github.com/ZeppelinBot/Zeppelin.git
synced 2025-05-10 20:35:02 +00:00
Merge branch 'master' into fixLocateUser
This commit is contained in:
commit
b1b4b85e94
65 changed files with 2573 additions and 2888 deletions
|
@ -2,6 +2,8 @@ import { PluginInfo, trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlu
|
|||
import * as t from "io-ts";
|
||||
import {
|
||||
convertDelayStringToMS,
|
||||
disableInlineCode,
|
||||
disableLinkPreviews,
|
||||
getEmojiInString,
|
||||
getInviteCodesInString,
|
||||
getRoleMentions,
|
||||
|
@ -12,12 +14,13 @@ import {
|
|||
noop,
|
||||
SECONDS,
|
||||
stripObjectToScalars,
|
||||
tDeepPartial,
|
||||
tNullable,
|
||||
UnknownUser,
|
||||
verboseChannelMention,
|
||||
} from "../utils";
|
||||
import { configUtils, CooldownManager } from "knub";
|
||||
import { Invite, Member, TextChannel } from "eris";
|
||||
import { Member, TextChannel } from "eris";
|
||||
import escapeStringRegexp from "escape-string-regexp";
|
||||
import { SimpleCache } from "../SimpleCache";
|
||||
import { Queue } from "../Queue";
|
||||
|
@ -32,6 +35,7 @@ import { GuildLogs } from "../data/GuildLogs";
|
|||
import { SavedMessage } from "../data/entities/SavedMessage";
|
||||
import moment from "moment-timezone";
|
||||
import { renderTemplate } from "../templateFormatter";
|
||||
import { transliterate } from "transliteration";
|
||||
import Timeout = NodeJS.Timeout;
|
||||
|
||||
type MessageInfo = { channelId: string; messageId: string };
|
||||
|
@ -46,23 +50,26 @@ type TextTriggerWithMultipleMatchTypes = {
|
|||
};
|
||||
|
||||
interface TriggerMatchResult {
|
||||
trigger: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface MessageTextTriggerMatchResult extends TriggerMatchResult {
|
||||
interface MessageTextTriggerMatchResult<T = any> extends TriggerMatchResult {
|
||||
type: "message" | "embed";
|
||||
str: string;
|
||||
userId: string;
|
||||
messageInfo: MessageInfo;
|
||||
matchedValue: T;
|
||||
}
|
||||
|
||||
interface OtherTextTriggerMatchResult extends TriggerMatchResult {
|
||||
interface OtherTextTriggerMatchResult<T = any> extends TriggerMatchResult {
|
||||
type: "username" | "nickname" | "visiblename" | "customstatus";
|
||||
str: string;
|
||||
userId: string;
|
||||
matchedValue: T;
|
||||
}
|
||||
|
||||
type TextTriggerMatchResult = MessageTextTriggerMatchResult | OtherTextTriggerMatchResult;
|
||||
type TextTriggerMatchResult<T = any> = MessageTextTriggerMatchResult<T> | OtherTextTriggerMatchResult<T>;
|
||||
|
||||
interface TextSpamTriggerMatchResult extends TriggerMatchResult {
|
||||
type: "textspam";
|
||||
|
@ -93,13 +100,16 @@ type AnyTriggerMatchResult =
|
|||
| OtherSpamTriggerMatchResult;
|
||||
|
||||
/**
|
||||
* TRIGGERS
|
||||
* CONFIG SCHEMA FOR TRIGGERS
|
||||
*/
|
||||
|
||||
const MatchWordsTrigger = t.type({
|
||||
words: t.array(t.string),
|
||||
case_sensitive: t.boolean,
|
||||
only_full_words: t.boolean,
|
||||
normalize: t.boolean,
|
||||
loose_matching: t.boolean,
|
||||
loose_matching_threshold: t.number,
|
||||
match_messages: t.boolean,
|
||||
match_embeds: t.boolean,
|
||||
match_visible_names: t.boolean,
|
||||
|
@ -108,10 +118,12 @@ const MatchWordsTrigger = t.type({
|
|||
match_custom_status: t.boolean,
|
||||
});
|
||||
type TMatchWordsTrigger = t.TypeOf<typeof MatchWordsTrigger>;
|
||||
const defaultMatchWordsTrigger: TMatchWordsTrigger = {
|
||||
words: [],
|
||||
const defaultMatchWordsTrigger: Partial<TMatchWordsTrigger> = {
|
||||
case_sensitive: false,
|
||||
only_full_words: true,
|
||||
normalize: false,
|
||||
loose_matching: false,
|
||||
loose_matching_threshold: 4,
|
||||
match_messages: true,
|
||||
match_embeds: true,
|
||||
match_visible_names: false,
|
||||
|
@ -123,6 +135,7 @@ const defaultMatchWordsTrigger: TMatchWordsTrigger = {
|
|||
const MatchRegexTrigger = t.type({
|
||||
patterns: t.array(TSafeRegex),
|
||||
case_sensitive: t.boolean,
|
||||
normalize: t.boolean,
|
||||
match_messages: t.boolean,
|
||||
match_embeds: t.boolean,
|
||||
match_visible_names: t.boolean,
|
||||
|
@ -133,6 +146,7 @@ const MatchRegexTrigger = t.type({
|
|||
type TMatchRegexTrigger = t.TypeOf<typeof MatchRegexTrigger>;
|
||||
const defaultMatchRegexTrigger: Partial<TMatchRegexTrigger> = {
|
||||
case_sensitive: false,
|
||||
normalize: false,
|
||||
match_messages: true,
|
||||
match_embeds: true,
|
||||
match_visible_names: false,
|
||||
|
@ -220,7 +234,7 @@ const VoiceMoveSpamTrigger = BaseSpamTrigger;
|
|||
type TVoiceMoveSpamTrigger = t.TypeOf<typeof VoiceMoveSpamTrigger>;
|
||||
|
||||
/**
|
||||
* ACTIONS
|
||||
* CONFIG SCHEMA FOR ACTIONS
|
||||
*/
|
||||
|
||||
const CleanAction = t.boolean;
|
||||
|
@ -253,6 +267,9 @@ const ChangeNicknameAction = t.type({
|
|||
|
||||
const LogAction = t.boolean;
|
||||
|
||||
const AddRolesAction = t.array(t.string);
|
||||
const RemoveRolesAction = t.array(t.string);
|
||||
|
||||
/**
|
||||
* FULL CONFIG SCHEMA
|
||||
*/
|
||||
|
@ -287,6 +304,8 @@ const Rule = t.type({
|
|||
alert: tNullable(AlertAction),
|
||||
change_nickname: tNullable(ChangeNicknameAction),
|
||||
log: tNullable(LogAction),
|
||||
add_roles: tNullable(AddRolesAction),
|
||||
remove_roles: tNullable(RemoveRolesAction),
|
||||
}),
|
||||
cooldown: tNullable(t.string),
|
||||
});
|
||||
|
@ -297,6 +316,8 @@ const ConfigSchema = t.type({
|
|||
});
|
||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||
|
||||
const PartialConfigSchema = tDeepPartial(ConfigSchema);
|
||||
|
||||
/**
|
||||
* DEFAULTS
|
||||
*/
|
||||
|
@ -361,6 +382,13 @@ const RECENT_NICKNAME_CHANGE_EXPIRY_TIME = 5 * MINUTES;
|
|||
|
||||
const inviteCache = new SimpleCache(10 * MINUTES);
|
||||
|
||||
/**
|
||||
* General plugin flow:
|
||||
* - When a message is posted:
|
||||
* 1. Run logRecentActionsForMessage() -- used for detecting spam
|
||||
* 2. Run matchRuleToMessage() for each automod rule. This checks if any triggers in the rule match the message.
|
||||
* 3. If a rule matched, run applyActionsOnMatch() for that rule/match
|
||||
*/
|
||||
export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "automod";
|
||||
public static configSchema = ConfigSchema;
|
||||
|
@ -499,12 +527,10 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
protected archives: GuildArchives;
|
||||
protected guildLogs: GuildLogs;
|
||||
|
||||
protected static preprocessStaticConfig(config) {
|
||||
if (config.rules && typeof config.rules === "object") {
|
||||
protected static preprocessStaticConfig(config: t.TypeOf<typeof PartialConfigSchema>) {
|
||||
if (config.rules) {
|
||||
// Loop through each rule
|
||||
for (const [name, rule] of Object.entries(config.rules)) {
|
||||
if (rule == null || typeof rule !== "object") continue;
|
||||
|
||||
rule["name"] = name;
|
||||
|
||||
// If the rule doesn't have an explicitly set "enabled" property, set it to true
|
||||
|
@ -513,12 +539,11 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
}
|
||||
|
||||
// Loop through the rule's triggers
|
||||
if (rule["triggers"] != null && Array.isArray(rule["triggers"])) {
|
||||
if (rule["triggers"]) {
|
||||
for (const trigger of rule["triggers"]) {
|
||||
if (trigger == null || typeof trigger !== "object") continue;
|
||||
// Apply default config to the triggers used in this rule
|
||||
for (const [defaultTriggerName, defaultTrigger] of Object.entries(defaultTriggers)) {
|
||||
if (trigger[defaultTriggerName] && typeof trigger[defaultTriggerName] === "object") {
|
||||
if (trigger[defaultTriggerName]) {
|
||||
trigger[defaultTriggerName] = configUtils.mergeConfig({}, defaultTrigger, trigger[defaultTriggerName]);
|
||||
}
|
||||
}
|
||||
|
@ -526,7 +551,7 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
}
|
||||
|
||||
// Enable logging of automod actions by default
|
||||
if (rule["actions"] && typeof rule["actions"] === "object") {
|
||||
if (rule["actions"]) {
|
||||
if (rule["actions"]["log"] == null) {
|
||||
rule["actions"]["log"] = true;
|
||||
}
|
||||
|
@ -583,63 +608,92 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
clearInterval(this.recentNicknameChangesClearInterval);
|
||||
}
|
||||
|
||||
protected evaluateMatchWordsTrigger(trigger: TMatchWordsTrigger, str: string): boolean {
|
||||
/**
|
||||
* @return Matched word
|
||||
*/
|
||||
protected evaluateMatchWordsTrigger(trigger: TMatchWordsTrigger, str: string): null | string {
|
||||
if (trigger.normalize) {
|
||||
str = transliterate(str);
|
||||
}
|
||||
|
||||
const looseMatchingThreshold = Math.min(Math.max(trigger.loose_matching_threshold, 1), 64);
|
||||
|
||||
for (const word of trigger.words) {
|
||||
const pattern = trigger.only_full_words ? `\\b${escapeStringRegexp(word)}\\b` : escapeStringRegexp(word);
|
||||
// When performing loose matching, allow any amount of whitespace or up to looseMatchingThreshold number of other
|
||||
// characters between the matched characters. E.g. if we're matching banana, a loose match could also match b a n a n a
|
||||
let pattern = trigger.loose_matching
|
||||
? [...word].map(c => escapeStringRegexp(c)).join(`(?:\\s*|.{0,${looseMatchingThreshold})`)
|
||||
: escapeStringRegexp(word);
|
||||
|
||||
if (trigger.only_full_words) {
|
||||
pattern = `\\b${pattern}\\b`;
|
||||
}
|
||||
|
||||
const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i");
|
||||
const test = regex.test(str);
|
||||
if (test) return true;
|
||||
if (test) return word;
|
||||
}
|
||||
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
protected evaluateMatchRegexTrigger(trigger: TMatchRegexTrigger, str: string): boolean {
|
||||
/**
|
||||
* @return Matched regex pattern
|
||||
*/
|
||||
protected evaluateMatchRegexTrigger(trigger: TMatchRegexTrigger, str: string): null | string {
|
||||
if (trigger.normalize) {
|
||||
str = transliterate(str);
|
||||
}
|
||||
|
||||
// TODO: Time limit regexes
|
||||
for (const pattern of trigger.patterns) {
|
||||
const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i");
|
||||
const test = regex.test(str);
|
||||
if (test) return true;
|
||||
if (test) return regex.source;
|
||||
}
|
||||
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
protected async evaluateMatchInvitesTrigger(trigger: TMatchInvitesTrigger, str: string): Promise<boolean> {
|
||||
/**
|
||||
* @return Matched invite code
|
||||
*/
|
||||
protected async evaluateMatchInvitesTrigger(trigger: TMatchInvitesTrigger, str: string): Promise<null | string> {
|
||||
const inviteCodes = getInviteCodesInString(str);
|
||||
if (inviteCodes.length === 0) return false;
|
||||
if (inviteCodes.length === 0) return null;
|
||||
|
||||
const uniqueInviteCodes = Array.from(new Set(inviteCodes));
|
||||
|
||||
for (const code of uniqueInviteCodes) {
|
||||
if (trigger.include_invite_codes && trigger.include_invite_codes.includes(code)) {
|
||||
return true;
|
||||
return code;
|
||||
}
|
||||
if (trigger.exclude_invite_codes && !trigger.exclude_invite_codes.includes(code)) {
|
||||
return true;
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
const invites: Array<Invite | null> = await Promise.all(uniqueInviteCodes.map(code => this.resolveInvite(code)));
|
||||
|
||||
for (const invite of invites) {
|
||||
// Always match on unknown invites
|
||||
if (!invite) return true;
|
||||
for (const inviteCode of uniqueInviteCodes) {
|
||||
const invite = await this.resolveInvite(inviteCode);
|
||||
if (!invite) return inviteCode;
|
||||
|
||||
if (trigger.include_guilds && trigger.include_guilds.includes(invite.guild.id)) {
|
||||
return true;
|
||||
return inviteCode;
|
||||
}
|
||||
if (trigger.exclude_guilds && !trigger.exclude_guilds.includes(invite.guild.id)) {
|
||||
return true;
|
||||
return inviteCode;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
protected evaluateMatchLinksTrigger(trigger: TMatchLinksTrigger, str: string): boolean {
|
||||
/**
|
||||
* @return Matched link
|
||||
*/
|
||||
protected evaluateMatchLinksTrigger(trigger: TMatchLinksTrigger, str: string): null | string {
|
||||
const links = getUrlsInString(str, true);
|
||||
|
||||
for (const link of links) {
|
||||
const normalizedHostname = link.hostname.toLowerCase();
|
||||
|
||||
|
@ -647,10 +701,10 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
for (const domain of trigger.include_domains) {
|
||||
const normalizedDomain = domain.toLowerCase();
|
||||
if (normalizedDomain === normalizedHostname) {
|
||||
return true;
|
||||
return domain;
|
||||
}
|
||||
if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) {
|
||||
return true;
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -659,25 +713,25 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
for (const domain of trigger.exclude_domains) {
|
||||
const normalizedDomain = domain.toLowerCase();
|
||||
if (normalizedDomain === normalizedHostname) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
return link.toString();
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
protected matchTextSpamTrigger(
|
||||
recentActionType: RecentActionType,
|
||||
trigger: TBaseTextSpamTrigger,
|
||||
msg: SavedMessage,
|
||||
): TextSpamTriggerMatchResult {
|
||||
): Partial<TextSpamTriggerMatchResult> {
|
||||
const since = moment.utc(msg.posted_at).valueOf() - convertDelayStringToMS(trigger.within);
|
||||
const recentActions = trigger.per_channel
|
||||
? this.getMatchingRecentActions(recentActionType, `${msg.channel_id}-${msg.user_id}`, since)
|
||||
|
@ -699,69 +753,85 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
return null;
|
||||
}
|
||||
|
||||
protected async matchMultipleTextTypesOnMessage(
|
||||
protected async matchMultipleTextTypesOnMessage<T>(
|
||||
trigger: TextTriggerWithMultipleMatchTypes,
|
||||
msg: SavedMessage,
|
||||
cb,
|
||||
): Promise<TextTriggerMatchResult> {
|
||||
matchFn: (str: string) => T | Promise<T> | null,
|
||||
): Promise<Partial<TextTriggerMatchResult<T>>> {
|
||||
const messageInfo: MessageInfo = { channelId: msg.channel_id, messageId: msg.id };
|
||||
const member = this.guild.members.get(msg.user_id);
|
||||
|
||||
if (trigger.match_messages) {
|
||||
const str = msg.data.content;
|
||||
const match = await cb(str);
|
||||
if (match) return { type: "message", str, userId: msg.user_id, messageInfo };
|
||||
const matchResult = await matchFn(str);
|
||||
if (matchResult) {
|
||||
return { type: "message", str, userId: msg.user_id, messageInfo, matchedValue: matchResult };
|
||||
}
|
||||
}
|
||||
|
||||
if (trigger.match_embeds && msg.data.embeds && msg.data.embeds.length) {
|
||||
const str = JSON.stringify(msg.data.embeds[0]);
|
||||
const match = await cb(str);
|
||||
if (match) return { type: "embed", str, userId: msg.user_id, messageInfo };
|
||||
const matchResult = await matchFn(str);
|
||||
if (matchResult) {
|
||||
return { type: "embed", str, userId: msg.user_id, messageInfo, matchedValue: matchResult };
|
||||
}
|
||||
}
|
||||
|
||||
if (trigger.match_visible_names) {
|
||||
const str = member.nick || msg.data.author.username;
|
||||
const match = await cb(str);
|
||||
if (match) return { type: "visiblename", str, userId: msg.user_id };
|
||||
const matchResult = await matchFn(str);
|
||||
if (matchResult) {
|
||||
return { type: "visiblename", str, userId: msg.user_id, matchedValue: matchResult };
|
||||
}
|
||||
}
|
||||
|
||||
if (trigger.match_usernames) {
|
||||
const str = `${msg.data.author.username}#${msg.data.author.discriminator}`;
|
||||
const match = await cb(str);
|
||||
if (match) return { type: "username", str, userId: msg.user_id };
|
||||
const matchResult = await matchFn(str);
|
||||
if (matchResult) {
|
||||
return { type: "username", str, userId: msg.user_id, matchedValue: matchResult };
|
||||
}
|
||||
}
|
||||
|
||||
if (trigger.match_nicknames && member.nick) {
|
||||
const str = member.nick;
|
||||
const match = await cb(str);
|
||||
if (match) return { type: "nickname", str, userId: msg.user_id };
|
||||
const matchResult = await matchFn(str);
|
||||
if (matchResult) {
|
||||
return { type: "nickname", str, userId: msg.user_id, matchedValue: matchResult };
|
||||
}
|
||||
}
|
||||
|
||||
// type 4 = custom status
|
||||
if (trigger.match_custom_status && member.game && member.game.type === 4) {
|
||||
const str = member.game.state;
|
||||
const match = await cb(str);
|
||||
if (match) return { type: "customstatus", str, userId: msg.user_id };
|
||||
const matchResult = await matchFn(str);
|
||||
if (matchResult) {
|
||||
return { type: "customstatus", str, userId: msg.user_id, matchedValue: matchResult };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected async matchMultipleTextTypesOnMember(
|
||||
protected async matchMultipleTextTypesOnMember<T>(
|
||||
trigger: TextTriggerWithMultipleMatchTypes,
|
||||
member: Member,
|
||||
cb,
|
||||
): Promise<TextTriggerMatchResult> {
|
||||
matchFn: (str: string) => T | Promise<T> | null,
|
||||
): Promise<Partial<TextTriggerMatchResult<T>>> {
|
||||
if (trigger.match_usernames) {
|
||||
const str = `${member.user.username}#${member.user.discriminator}`;
|
||||
const match = await cb(str);
|
||||
if (match) return { type: "username", str, userId: member.id };
|
||||
const matchResult = await matchFn(str);
|
||||
if (matchResult) {
|
||||
return { type: "username", str, userId: member.id, matchedValue: matchResult };
|
||||
}
|
||||
}
|
||||
|
||||
if (trigger.match_nicknames && member.nick) {
|
||||
const str = member.nick;
|
||||
const match = await cb(str);
|
||||
if (match) return { type: "nickname", str, userId: member.id };
|
||||
const matchResult = await matchFn(str);
|
||||
if (matchResult) {
|
||||
return { type: "nickname", str, userId: member.id, matchedValue: matchResult };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
@ -781,63 +851,63 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
const match = await this.matchMultipleTextTypesOnMessage(trigger.match_words, msg, str => {
|
||||
return this.evaluateMatchWordsTrigger(trigger.match_words, str);
|
||||
});
|
||||
if (match) return match;
|
||||
if (match) return { ...match, trigger: "match_words" } as TextTriggerMatchResult;
|
||||
}
|
||||
|
||||
if (trigger.match_regex) {
|
||||
const match = await this.matchMultipleTextTypesOnMessage(trigger.match_regex, msg, str => {
|
||||
return this.evaluateMatchRegexTrigger(trigger.match_regex, str);
|
||||
});
|
||||
if (match) return match;
|
||||
if (match) return { ...match, trigger: "match_regex" } as TextTriggerMatchResult;
|
||||
}
|
||||
|
||||
if (trigger.match_invites) {
|
||||
const match = await this.matchMultipleTextTypesOnMessage(trigger.match_invites, msg, str => {
|
||||
return this.evaluateMatchInvitesTrigger(trigger.match_invites, str);
|
||||
});
|
||||
if (match) return match;
|
||||
if (match) return { ...match, trigger: "match_invites" } as TextTriggerMatchResult;
|
||||
}
|
||||
|
||||
if (trigger.match_links) {
|
||||
const match = await this.matchMultipleTextTypesOnMessage(trigger.match_links, msg, str => {
|
||||
return this.evaluateMatchLinksTrigger(trigger.match_links, str);
|
||||
});
|
||||
if (match) return match;
|
||||
if (match) return { ...match, trigger: "match_links" } as TextTriggerMatchResult;
|
||||
}
|
||||
|
||||
if (trigger.message_spam) {
|
||||
const match = this.matchTextSpamTrigger(RecentActionType.Message, trigger.message_spam, msg);
|
||||
if (match) return match;
|
||||
if (match) return { ...match, trigger: "message_spam" } as TextSpamTriggerMatchResult;
|
||||
}
|
||||
|
||||
if (trigger.mention_spam) {
|
||||
const match = this.matchTextSpamTrigger(RecentActionType.Mention, trigger.mention_spam, msg);
|
||||
if (match) return match;
|
||||
if (match) return { ...match, trigger: "mention_spam" } as TextSpamTriggerMatchResult;
|
||||
}
|
||||
|
||||
if (trigger.link_spam) {
|
||||
const match = this.matchTextSpamTrigger(RecentActionType.Link, trigger.link_spam, msg);
|
||||
if (match) return match;
|
||||
if (match) return { ...match, trigger: "link_spam" } as TextSpamTriggerMatchResult;
|
||||
}
|
||||
|
||||
if (trigger.attachment_spam) {
|
||||
const match = this.matchTextSpamTrigger(RecentActionType.Attachment, trigger.attachment_spam, msg);
|
||||
if (match) return match;
|
||||
if (match) return { ...match, trigger: "attachment_spam" } as TextSpamTriggerMatchResult;
|
||||
}
|
||||
|
||||
if (trigger.emoji_spam) {
|
||||
const match = this.matchTextSpamTrigger(RecentActionType.Emoji, trigger.emoji_spam, msg);
|
||||
if (match) return match;
|
||||
if (match) return { ...match, trigger: "emoji_spam" } as TextSpamTriggerMatchResult;
|
||||
}
|
||||
|
||||
if (trigger.line_spam) {
|
||||
const match = this.matchTextSpamTrigger(RecentActionType.Line, trigger.line_spam, msg);
|
||||
if (match) return match;
|
||||
if (match) return { ...match, trigger: "line_spam" } as TextSpamTriggerMatchResult;
|
||||
}
|
||||
|
||||
if (trigger.character_spam) {
|
||||
const match = this.matchTextSpamTrigger(RecentActionType.Character, trigger.character_spam, msg);
|
||||
if (match) return match;
|
||||
if (match) return { ...match, trigger: "character_spam" } as TextSpamTriggerMatchResult;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1047,90 +1117,23 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the actions of the specified rule on the matched message/member
|
||||
*/
|
||||
protected async applyActionsOnMatch(rule: TRule, matchResult: AnyTriggerMatchResult) {
|
||||
const actionsTaken = [];
|
||||
|
||||
let matchSummary = null;
|
||||
let caseExtraNote = null;
|
||||
|
||||
if (rule.cooldown) {
|
||||
let cooldownKey = rule.name + "-";
|
||||
|
||||
if (matchResult.type === "textspam") {
|
||||
cooldownKey += matchResult.channelId ? `${matchResult.channelId}-${matchResult.userId}` : matchResult.userId;
|
||||
} else if (matchResult.type === "message" || matchResult.type === "embed") {
|
||||
cooldownKey += matchResult.userId;
|
||||
} else if (
|
||||
matchResult.type === "username" ||
|
||||
matchResult.type === "nickname" ||
|
||||
matchResult.type === "visiblename" ||
|
||||
matchResult.type === "customstatus"
|
||||
) {
|
||||
cooldownKey += matchResult.userId;
|
||||
} else if (matchResult.type === "otherspam") {
|
||||
cooldownKey += matchResult.userId;
|
||||
} else {
|
||||
cooldownKey = null;
|
||||
}
|
||||
|
||||
if (cooldownKey) {
|
||||
if (this.cooldownManager.isOnCooldown(cooldownKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cooldownTime = convertDelayStringToMS(rule.cooldown, "s");
|
||||
if (cooldownTime) {
|
||||
this.cooldownManager.setCooldown(cooldownKey, cooldownTime);
|
||||
}
|
||||
}
|
||||
if (rule.cooldown && this.checkAndUpdateCooldown(rule, matchResult)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchResult.type === "textspam") {
|
||||
this.activateGracePeriod(matchResult);
|
||||
this.clearSpecificRecentActions(
|
||||
matchResult.actionType,
|
||||
matchResult.channelId ? `${matchResult.channelId}-${matchResult.userId}` : matchResult.userId,
|
||||
);
|
||||
}
|
||||
const matchSummary = this.getMatchSummary(matchResult);
|
||||
|
||||
// Match summary
|
||||
let matchedMessageIds = [];
|
||||
if (matchResult.type === "message" || matchResult.type === "embed") {
|
||||
matchedMessageIds = [matchResult.messageInfo.messageId];
|
||||
} else if (matchResult.type === "textspam" || matchResult.type === "raidspam") {
|
||||
matchedMessageIds = matchResult.messageInfos.map(m => m.messageId);
|
||||
}
|
||||
|
||||
if (matchedMessageIds.length > 1) {
|
||||
const savedMessages = await this.savedMessages.getMultiple(matchedMessageIds);
|
||||
const archiveId = await this.archives.createFromSavedMessages(savedMessages, this.guild);
|
||||
const baseUrl = this.knub.getGlobalConfig().url;
|
||||
const archiveUrl = this.archives.getUrl(baseUrl, archiveId);
|
||||
matchSummary = `Matched messages: <${archiveUrl}>`;
|
||||
} else if (matchedMessageIds.length === 1) {
|
||||
const message = await this.savedMessages.find(matchedMessageIds[0]);
|
||||
const channel = this.guild.channels.get(message.channel_id);
|
||||
const channelMention = channel ? verboseChannelMention(channel) : `\`#${message.channel_id}\``;
|
||||
matchSummary = `Matched message in ${channelMention} (originally posted at **${
|
||||
message.posted_at
|
||||
}**):\n${messageSummary(message)}`;
|
||||
}
|
||||
|
||||
if (matchResult.type === "username") {
|
||||
matchSummary = `Matched username: ${matchResult.str}`;
|
||||
} else if (matchResult.type === "nickname") {
|
||||
matchSummary = `Matched nickname: ${matchResult.str}`;
|
||||
} else if (matchResult.type === "visiblename") {
|
||||
matchSummary = `Matched visible name: ${matchResult.str}`;
|
||||
} else if (matchResult.type === "customstatus") {
|
||||
matchSummary = `Matched custom status: ${matchResult.str}`;
|
||||
}
|
||||
|
||||
caseExtraNote = `Matched automod rule "${rule.name}"`;
|
||||
let caseExtraNote = `Matched automod rule "${rule.name}"`;
|
||||
if (matchSummary) {
|
||||
caseExtraNote += `\n${matchSummary}`;
|
||||
}
|
||||
|
||||
const actionsTaken = [];
|
||||
|
||||
// Actions
|
||||
if (rule.actions.clean) {
|
||||
const messagesToDelete: Array<{ channelId: string; messageId: string }> = [];
|
||||
|
@ -1254,6 +1257,58 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
actionsTaken.push("nickname");
|
||||
}
|
||||
|
||||
if (rule.actions.add_roles) {
|
||||
const userIdsToChange = matchResult.type === "raidspam" ? matchResult.userIds : [matchResult.userId];
|
||||
for (const userId of userIdsToChange) {
|
||||
const member = await this.getMember(userId);
|
||||
if (!member) continue;
|
||||
|
||||
const memberRoles = new Set(member.roles);
|
||||
for (const roleId of rule.actions.add_roles) {
|
||||
memberRoles.add(roleId);
|
||||
}
|
||||
|
||||
if (memberRoles.size === member.roles.length) {
|
||||
// No role changes
|
||||
continue;
|
||||
}
|
||||
|
||||
const rolesArr = Array.from(memberRoles.values());
|
||||
await member.edit({
|
||||
roles: rolesArr,
|
||||
});
|
||||
member.roles = rolesArr; // Make sure we know of the new roles internally as well
|
||||
}
|
||||
|
||||
actionsTaken.push("add roles");
|
||||
}
|
||||
|
||||
if (rule.actions.remove_roles) {
|
||||
const userIdsToChange = matchResult.type === "raidspam" ? matchResult.userIds : [matchResult.userId];
|
||||
for (const userId of userIdsToChange) {
|
||||
const member = await this.getMember(userId);
|
||||
if (!member) continue;
|
||||
|
||||
const memberRoles = new Set(member.roles);
|
||||
for (const roleId of rule.actions.remove_roles) {
|
||||
memberRoles.delete(roleId);
|
||||
}
|
||||
|
||||
if (memberRoles.size === member.roles.length) {
|
||||
// No role changes
|
||||
continue;
|
||||
}
|
||||
|
||||
const rolesArr = Array.from(memberRoles.values());
|
||||
await member.edit({
|
||||
roles: rolesArr,
|
||||
});
|
||||
member.roles = rolesArr; // Make sure we know of the new roles internally as well
|
||||
}
|
||||
|
||||
actionsTaken.push("remove roles");
|
||||
}
|
||||
|
||||
// Don't wait for the rest before continuing to other automod items in the queue
|
||||
(async () => {
|
||||
const user = matchResult.type !== "raidspam" ? this.getUser(matchResult.userId) : new UnknownUser();
|
||||
|
@ -1261,6 +1316,15 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
const safeUser = stripObjectToScalars(user);
|
||||
const safeUsers = users.map(u => stripObjectToScalars(u));
|
||||
|
||||
const logData = {
|
||||
rule: rule.name,
|
||||
user: safeUser,
|
||||
users: safeUsers,
|
||||
actionsTaken: actionsTaken.length ? actionsTaken.join(", ") : "<none>",
|
||||
matchSummary,
|
||||
};
|
||||
const logMessage = this.getLogs().getLogMessage(LogType.AUTOMOD_ACTION, logData);
|
||||
|
||||
if (rule.actions.alert) {
|
||||
const channel = this.guild.channels.get(rule.actions.alert.channel);
|
||||
if (channel && channel instanceof TextChannel) {
|
||||
|
@ -1271,6 +1335,7 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
users: safeUsers,
|
||||
text,
|
||||
matchSummary,
|
||||
logMessage,
|
||||
});
|
||||
channel.createMessage(rendered);
|
||||
actionsTaken.push("alert");
|
||||
|
@ -1282,17 +1347,102 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
}
|
||||
|
||||
if (rule.actions.log) {
|
||||
this.getLogs().log(LogType.AUTOMOD_ACTION, {
|
||||
rule: rule.name,
|
||||
user: safeUser,
|
||||
users: safeUsers,
|
||||
actionsTaken: actionsTaken.length ? actionsTaken.join(", ") : "<none>",
|
||||
matchSummary,
|
||||
});
|
||||
this.getLogs().log(LogType.AUTOMOD_ACTION, logData);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the rule's on cooldown and bump its usage count towards the cooldown up
|
||||
* @return Whether the rule's on cooldown
|
||||
*/
|
||||
protected checkAndUpdateCooldown(rule: TRule, matchResult: AnyTriggerMatchResult): boolean {
|
||||
let cooldownKey = rule.name + "-";
|
||||
|
||||
if (matchResult.type === "textspam") {
|
||||
cooldownKey += matchResult.channelId ? `${matchResult.channelId}-${matchResult.userId}` : matchResult.userId;
|
||||
} else if (matchResult.type === "message" || matchResult.type === "embed") {
|
||||
cooldownKey += matchResult.userId;
|
||||
} else if (
|
||||
matchResult.type === "username" ||
|
||||
matchResult.type === "nickname" ||
|
||||
matchResult.type === "visiblename" ||
|
||||
matchResult.type === "customstatus"
|
||||
) {
|
||||
cooldownKey += matchResult.userId;
|
||||
} else if (matchResult.type === "otherspam") {
|
||||
cooldownKey += matchResult.userId;
|
||||
} else {
|
||||
cooldownKey = null;
|
||||
}
|
||||
|
||||
if (cooldownKey) {
|
||||
if (this.cooldownManager.isOnCooldown(cooldownKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cooldownTime = convertDelayStringToMS(rule.cooldown, "s");
|
||||
if (cooldownTime) {
|
||||
this.cooldownManager.setCooldown(cooldownKey, cooldownTime);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a text summary for the match result for use in logs/alerts
|
||||
*/
|
||||
protected async getMatchSummary(matchResult: AnyTriggerMatchResult): Promise<string> {
|
||||
if (matchResult.type === "message" || matchResult.type === "embed") {
|
||||
const message = await this.savedMessages.find(matchResult.messageInfo.messageId);
|
||||
const channel = this.guild.channels.get(matchResult.messageInfo.channelId);
|
||||
const channelMention = channel ? verboseChannelMention(channel) : `\`#${message.channel_id}\``;
|
||||
|
||||
return trimPluginDescription(`
|
||||
Matched ${this.getMatchedValueText(matchResult)} in message in ${channelMention}:
|
||||
${messageSummary(message)}
|
||||
`);
|
||||
} else if (matchResult.type === "textspam" || matchResult.type === "raidspam") {
|
||||
const savedMessages = await this.savedMessages.getMultiple(matchResult.messageInfos.map(i => i.messageId));
|
||||
const archiveId = await this.archives.createFromSavedMessages(savedMessages, this.guild);
|
||||
const baseUrl = this.knub.getGlobalConfig().url;
|
||||
const archiveUrl = this.archives.getUrl(baseUrl, archiveId);
|
||||
|
||||
return trimPluginDescription(`
|
||||
Matched spam: ${disableLinkPreviews(archiveUrl)}
|
||||
`);
|
||||
} else if (matchResult.type === "username") {
|
||||
return `Matched ${this.getMatchedValueText(matchResult)} in username: ${matchResult.str}`;
|
||||
} else if (matchResult.type === "nickname") {
|
||||
return `Matched ${this.getMatchedValueText(matchResult)} in nickname: ${matchResult.str}`;
|
||||
} else if (matchResult.type === "visiblename") {
|
||||
return `Matched ${this.getMatchedValueText(matchResult)} in visible name: ${matchResult.str}`;
|
||||
} else if (matchResult.type === "customstatus") {
|
||||
return `Matched ${this.getMatchedValueText(matchResult)} in custom status: ${matchResult.str}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a formatted version of the matched value (word, regex pattern, link, etc.) for use in the match summary
|
||||
*/
|
||||
protected getMatchedValueText(matchResult: TextTriggerMatchResult): string | null {
|
||||
if (matchResult.trigger === "match_words") {
|
||||
return `word \`${disableInlineCode(matchResult.matchedValue)}\``;
|
||||
} else if (matchResult.trigger === "match_regex") {
|
||||
return `regex \`${disableInlineCode(matchResult.matchedValue)}\``;
|
||||
} else if (matchResult.trigger === "match_invites") {
|
||||
return `invite code \`${disableInlineCode(matchResult.matchedValue)}\``;
|
||||
} else if (matchResult.trigger === "match_links") {
|
||||
return `link \`${disableInlineCode(matchResult.matchedValue)}\``;
|
||||
}
|
||||
|
||||
return typeof matchResult.matchedValue === "string" ? `\`${disableInlineCode(matchResult.matchedValue)}\`` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run automod actions on new messages
|
||||
*/
|
||||
protected onMessageCreate(msg: SavedMessage) {
|
||||
if (msg.is_bot) return;
|
||||
|
||||
|
@ -1311,6 +1461,7 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
const matchResult = await this.matchRuleToMessage(rule, msg);
|
||||
if (matchResult) {
|
||||
await this.applyActionsOnMatch(rule, matchResult);
|
||||
break; // Don't apply multiple rules to the same message
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -91,7 +91,8 @@ export class LocatePlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
sendWhere(this.guild, member, msg.channel, `${msg.member.mention} |`);
|
||||
}
|
||||
|
||||
@d.command("vcalert", "<member:resolvedMember> <duration:delay> [reminder:string$]", {
|
||||
@d.command("vcalert", "<member:resolvedMember> <duration:delay> <reminder:string$>", {
|
||||
overloads: ["<member:resolvedMember> <duration:delay>", "<member:resolvedMember>"],
|
||||
aliases: ["vca"],
|
||||
extra: {
|
||||
info: {
|
||||
|
|
|
@ -356,9 +356,11 @@ export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
async onMemberUpdate(_, member: Member, oldMember: Member) {
|
||||
if (!oldMember) return;
|
||||
|
||||
const logMember = stripObjectToScalars(member, ["user", "roles"]);
|
||||
|
||||
if (member.nick !== oldMember.nick) {
|
||||
this.guildLogs.log(LogType.MEMBER_NICK_CHANGE, {
|
||||
member,
|
||||
member: logMember,
|
||||
oldNick: oldMember.nick != null ? oldMember.nick : "<none>",
|
||||
newNick: member.nick != null ? member.nick : "<none>",
|
||||
});
|
||||
|
@ -379,7 +381,7 @@ export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
this.guildLogs.log(
|
||||
LogType.MEMBER_ROLE_CHANGES,
|
||||
{
|
||||
member,
|
||||
member: logMember,
|
||||
addedRoles: addedRoles
|
||||
.map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` })
|
||||
.map(r => r.name)
|
||||
|
@ -397,7 +399,7 @@ export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
this.guildLogs.log(
|
||||
LogType.MEMBER_ROLE_ADD,
|
||||
{
|
||||
member,
|
||||
member: logMember,
|
||||
roles: addedRoles
|
||||
.map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` })
|
||||
.map(r => r.name)
|
||||
|
@ -411,7 +413,7 @@ export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
this.guildLogs.log(
|
||||
LogType.MEMBER_ROLE_REMOVE,
|
||||
{
|
||||
member,
|
||||
member: logMember,
|
||||
roles: removedRoles
|
||||
.map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` })
|
||||
.map(r => r.name)
|
||||
|
|
|
@ -125,7 +125,10 @@ export class MessageSaverPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
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));
|
||||
const { savedCount, failed } = await this.saveMessagesToDB(
|
||||
args.channel,
|
||||
pins.map(m => m.id),
|
||||
);
|
||||
|
||||
if (failed.length) {
|
||||
msg.channel.createMessage(
|
||||
|
|
|
@ -197,7 +197,10 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
}
|
||||
|
||||
clearIgnoredEvent(type: IgnoredEventType, userId: any) {
|
||||
this.ignoredEvents.splice(this.ignoredEvents.findIndex(info => type === info.type && userId === info.userId), 1);
|
||||
this.ignoredEvents.splice(
|
||||
this.ignoredEvents.findIndex(info => type === info.type && userId === info.userId),
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
formatReasonWithAttachments(reason: string, attachments: Attachment[]) {
|
||||
|
|
|
@ -13,6 +13,10 @@ import {
|
|||
deactivateMentions,
|
||||
createChunkedMessage,
|
||||
stripObjectToScalars,
|
||||
isValidEmbed,
|
||||
MINUTES,
|
||||
StrictMessageContent,
|
||||
DAYS,
|
||||
} from "../utils";
|
||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
||||
import { ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||
|
@ -23,6 +27,7 @@ import moment, { Moment } from "moment-timezone";
|
|||
import { GuildLogs } from "../data/GuildLogs";
|
||||
import { LogType } from "../data/LogType";
|
||||
import * as t from "io-ts";
|
||||
import humanizeDuration from "humanize-duration";
|
||||
|
||||
const ConfigSchema = t.type({
|
||||
can_post: t.boolean,
|
||||
|
@ -33,9 +38,13 @@ const fsp = fs.promises;
|
|||
|
||||
const COLOR_MATCH_REGEX = /^#?([0-9a-f]{6})$/;
|
||||
|
||||
const SCHEDULED_POST_CHECK_INTERVAL = 15 * SECONDS;
|
||||
const SCHEDULED_POST_CHECK_INTERVAL = 5 * SECONDS;
|
||||
const SCHEDULED_POST_PREVIEW_TEXT_LENGTH = 50;
|
||||
|
||||
const MIN_REPEAT_TIME = 5 * MINUTES;
|
||||
const MAX_REPEAT_TIME = Math.pow(2, 32);
|
||||
const MAX_REPEAT_UNTIL = moment().add(100, "years");
|
||||
|
||||
export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "post";
|
||||
public static configSchema = ConfigSchema;
|
||||
|
@ -142,17 +151,25 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
}
|
||||
|
||||
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 dt1 = moment(str, "YYYY-MM-DD HH:mm:ss");
|
||||
if (dt1 && dt1.isValid()) return dt1;
|
||||
|
||||
const dt2 = moment(str, "YYYY-MM-DD HH:mm");
|
||||
if (dt2 && dt2.isValid()) return dt2;
|
||||
|
||||
const date = moment(str, "YYYY-MM-DD");
|
||||
if (date && date.isValid()) return date;
|
||||
|
||||
const t1 = moment(str, "HH:mm:ss");
|
||||
if (t1 && t1.isValid()) {
|
||||
if (t1.isBefore(moment())) t1.add(1, "day");
|
||||
return t1;
|
||||
}
|
||||
|
||||
const 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 t2 = moment(str, "HH:mm");
|
||||
if (t2 && t2.isValid()) {
|
||||
if (t2.isBefore(moment())) t2.add(1, "day");
|
||||
return t2;
|
||||
}
|
||||
|
||||
const delayStringMS = convertDelayStringToMS(str, "m");
|
||||
|
@ -194,12 +211,205 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
}
|
||||
}
|
||||
|
||||
await this.scheduledPosts.delete(post.id);
|
||||
let shouldClear = true;
|
||||
|
||||
if (post.repeat_interval) {
|
||||
const nextPostAt = moment().add(post.repeat_interval, "ms");
|
||||
|
||||
if (post.repeat_until) {
|
||||
const repeatUntil = moment(post.repeat_until, DBDateFormat);
|
||||
if (nextPostAt.isSameOrBefore(repeatUntil)) {
|
||||
await this.scheduledPosts.update(post.id, {
|
||||
post_at: nextPostAt.format(DBDateFormat),
|
||||
});
|
||||
shouldClear = false;
|
||||
}
|
||||
} else if (post.repeat_times) {
|
||||
if (post.repeat_times > 1) {
|
||||
await this.scheduledPosts.update(post.id, {
|
||||
post_at: nextPostAt.format(DBDateFormat),
|
||||
repeat_times: post.repeat_times - 1,
|
||||
});
|
||||
shouldClear = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldClear) {
|
||||
await this.scheduledPosts.delete(post.id);
|
||||
}
|
||||
}
|
||||
|
||||
this.scheduledPostLoopTimeout = setTimeout(() => this.scheduledPostLoop(), SCHEDULED_POST_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Since !post and !post_embed have a lot of overlap for post scheduling, repeating, etc., that functionality is abstracted out to here
|
||||
*/
|
||||
async actualPostCmd(
|
||||
msg: Message,
|
||||
targetChannel: Channel,
|
||||
content: StrictMessageContent,
|
||||
opts?: {
|
||||
"enable-mentions"?: boolean;
|
||||
schedule?: string;
|
||||
repeat?: number;
|
||||
"repeat-until"?: string;
|
||||
"repeat-times"?: number;
|
||||
},
|
||||
) {
|
||||
if (!(targetChannel instanceof TextChannel)) {
|
||||
msg.channel.createMessage(errorMessage("Channel is not a text channel"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (content == null && msg.attachments.length === 0) {
|
||||
msg.channel.createMessage(errorMessage("Message content or attachment required"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.repeat) {
|
||||
if (opts.repeat < MIN_REPEAT_TIME) {
|
||||
return this.sendErrorMessage(msg.channel, `Minimum time for -repeat is ${humanizeDuration(MIN_REPEAT_TIME)}`);
|
||||
}
|
||||
if (opts.repeat > MAX_REPEAT_TIME) {
|
||||
return this.sendErrorMessage(msg.channel, `Max time for -repeat is ${humanizeDuration(MAX_REPEAT_TIME)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a scheduled or repeated post, figure out the next post date
|
||||
let postAt;
|
||||
if (opts.schedule) {
|
||||
// Schedule the post to be posted later
|
||||
postAt = this.parseScheduleTime(opts.schedule);
|
||||
if (!postAt) {
|
||||
return this.sendErrorMessage(msg.channel, "Invalid schedule time");
|
||||
}
|
||||
} else if (opts.repeat) {
|
||||
postAt = moment().add(opts.repeat, "ms");
|
||||
}
|
||||
|
||||
// For repeated posts, make sure repeat-until or repeat-times is specified
|
||||
let repeatUntil: moment.Moment = null;
|
||||
let repeatTimes: number = null;
|
||||
let repeatDetailsStr: string = null;
|
||||
|
||||
if (opts["repeat-until"]) {
|
||||
repeatUntil = this.parseScheduleTime(opts["repeat-until"]);
|
||||
|
||||
// Invalid time
|
||||
if (!repeatUntil) {
|
||||
return this.sendErrorMessage(msg.channel, "Invalid time specified for -repeat-until");
|
||||
}
|
||||
if (repeatUntil.isBefore(moment())) {
|
||||
return this.sendErrorMessage(msg.channel, "You can't set -repeat-until in the past");
|
||||
}
|
||||
if (repeatUntil.isAfter(MAX_REPEAT_UNTIL)) {
|
||||
return this.sendErrorMessage(
|
||||
msg.channel,
|
||||
"Unfortunately, -repeat-until can only be at most 100 years into the future. Maybe 99 years would be enough?",
|
||||
);
|
||||
}
|
||||
} else if (opts["repeat-times"]) {
|
||||
repeatTimes = opts["repeat-times"];
|
||||
if (repeatTimes <= 0) {
|
||||
return this.sendErrorMessage(msg.channel, "-repeat-times must be 1 or more");
|
||||
}
|
||||
}
|
||||
|
||||
if (repeatUntil && repeatTimes) {
|
||||
return this.sendErrorMessage(msg.channel, "You can only use one of -repeat-until or -repeat-times at once");
|
||||
}
|
||||
|
||||
if (opts.repeat && !repeatUntil && !repeatTimes) {
|
||||
return this.sendErrorMessage(
|
||||
msg.channel,
|
||||
"You must specify -repeat-until or -repeat-times for repeated messages",
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.repeat) {
|
||||
repeatDetailsStr = repeatUntil
|
||||
? `every ${humanizeDuration(opts.repeat)} until ${repeatUntil.format(DBDateFormat)}`
|
||||
: `every ${humanizeDuration(opts.repeat)}, ${repeatTimes} times in total`;
|
||||
}
|
||||
|
||||
// Save schedule/repeat information in DB
|
||||
if (postAt) {
|
||||
if (postAt < moment()) {
|
||||
return this.sendErrorMessage(msg.channel, "Post can't be scheduled to be posted in the past");
|
||||
}
|
||||
|
||||
await this.scheduledPosts.create({
|
||||
author_id: msg.author.id,
|
||||
author_name: `${msg.author.username}#${msg.author.discriminator}`,
|
||||
channel_id: targetChannel.id,
|
||||
content,
|
||||
attachments: msg.attachments,
|
||||
post_at: postAt.format(DBDateFormat),
|
||||
enable_mentions: opts["enable-mentions"],
|
||||
repeat_interval: opts.repeat,
|
||||
repeat_until: repeatUntil ? repeatUntil.format(DBDateFormat) : null,
|
||||
repeat_times: repeatTimes ?? null,
|
||||
});
|
||||
|
||||
if (opts.repeat) {
|
||||
this.logs.log(LogType.SCHEDULED_REPEATED_MESSAGE, {
|
||||
author: stripObjectToScalars(msg.author),
|
||||
channel: stripObjectToScalars(targetChannel),
|
||||
date: postAt.format("YYYY-MM-DD"),
|
||||
time: postAt.format("HH:mm:ss"),
|
||||
repeatInterval: humanizeDuration(opts.repeat),
|
||||
repeatDetails: repeatDetailsStr,
|
||||
});
|
||||
} else {
|
||||
this.logs.log(LogType.SCHEDULED_MESSAGE, {
|
||||
author: stripObjectToScalars(msg.author),
|
||||
channel: stripObjectToScalars(targetChannel),
|
||||
date: postAt.format("YYYY-MM-DD"),
|
||||
time: postAt.format("HH:mm:ss"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// When the message isn't scheduled for later, post it immediately
|
||||
if (!opts.schedule) {
|
||||
await this.postMessage(targetChannel, content, msg.attachments, opts["enable-mentions"]);
|
||||
}
|
||||
|
||||
if (opts.repeat) {
|
||||
this.logs.log(LogType.REPEATED_MESSAGE, {
|
||||
author: stripObjectToScalars(msg.author),
|
||||
channel: stripObjectToScalars(targetChannel),
|
||||
date: postAt.format("YYYY-MM-DD"),
|
||||
time: postAt.format("HH:mm:ss"),
|
||||
repeatInterval: humanizeDuration(opts.repeat),
|
||||
repeatDetails: repeatDetailsStr,
|
||||
});
|
||||
}
|
||||
|
||||
// Bot reply schenanigans
|
||||
let successMessage = opts.schedule
|
||||
? `Message scheduled to be posted in <#${targetChannel.id}> on ${postAt.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`
|
||||
: `Message posted in <#${targetChannel.id}>`;
|
||||
|
||||
if (opts.repeat) {
|
||||
successMessage += `. Message will be automatically reposted every ${humanizeDuration(opts.repeat)}`;
|
||||
|
||||
if (repeatUntil) {
|
||||
successMessage += ` until ${repeatUntil.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`;
|
||||
} else if (repeatTimes) {
|
||||
successMessage += `, ${repeatTimes} times in total`;
|
||||
}
|
||||
|
||||
successMessage += ".";
|
||||
}
|
||||
|
||||
if (targetChannel.id !== msg.channel.id || opts.schedule || opts.repeat) {
|
||||
this.sendSuccessMessage(msg.channel, successMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* COMMAND: Post a regular text message as the bot to the specified channel
|
||||
*/
|
||||
|
@ -207,66 +417,40 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
options: [
|
||||
{
|
||||
name: "enable-mentions",
|
||||
type: "bool",
|
||||
isSwitch: true,
|
||||
},
|
||||
{
|
||||
name: "schedule",
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
name: "repeat",
|
||||
type: "delay",
|
||||
},
|
||||
{
|
||||
name: "repeat-until",
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
name: "repeat-times",
|
||||
type: "number",
|
||||
},
|
||||
],
|
||||
})
|
||||
@d.permission("can_post")
|
||||
async postCmd(
|
||||
msg: Message,
|
||||
args: { channel: Channel; content?: string; "enable-mentions": boolean; schedule?: string },
|
||||
args: {
|
||||
channel: Channel;
|
||||
content?: string;
|
||||
"enable-mentions": boolean;
|
||||
schedule?: string;
|
||||
repeat?: number;
|
||||
"repeat-until"?: string;
|
||||
"repeat-times"?: number;
|
||||
},
|
||||
) {
|
||||
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}>`);
|
||||
}
|
||||
}
|
||||
this.actualPostCmd(msg, args.channel, { content: args.content }, args);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -278,6 +462,19 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
{ name: "content", type: "string" },
|
||||
{ name: "color", type: "string" },
|
||||
{ name: "schedule", type: "string" },
|
||||
{ name: "raw", isSwitch: true, shortcut: "r" },
|
||||
{
|
||||
name: "repeat",
|
||||
type: "delay",
|
||||
},
|
||||
{
|
||||
name: "repeat-until",
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
name: "repeat-times",
|
||||
type: "number",
|
||||
},
|
||||
],
|
||||
})
|
||||
@d.permission("can_post")
|
||||
|
@ -290,13 +487,12 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
content?: string;
|
||||
color?: string;
|
||||
schedule?: string;
|
||||
raw?: boolean;
|
||||
repeat?: number;
|
||||
"repeat-until"?: string;
|
||||
"repeat-times"?: number;
|
||||
},
|
||||
) {
|
||||
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) {
|
||||
|
@ -315,59 +511,32 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
color = parseInt(colorMatch[1], 16);
|
||||
}
|
||||
|
||||
const embed: EmbedBase = {};
|
||||
let 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 (content) {
|
||||
if (args.raw) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch (e) {
|
||||
this.sendErrorMessage(msg.channel, "Syntax error in embed JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
if (postAt < moment()) {
|
||||
return this.sendErrorMessage(msg.channel, "Post can't be scheduled to be posted in the past");
|
||||
}
|
||||
if (!isValidEmbed(parsed)) {
|
||||
this.sendErrorMessage(msg.channel, "Embed is not valid");
|
||||
return;
|
||||
}
|
||||
|
||||
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}>`);
|
||||
embed = Object.assign({}, embed, parsed);
|
||||
} else {
|
||||
embed.description = this.formatContent(content);
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
`),
|
||||
);
|
||||
}
|
||||
this.actualPostCmd(msg, args.channel, { embed }, args);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -472,6 +641,16 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
const parts = [`\`#${i++}\` \`[${p.post_at}]\` ${previewText}${isTruncated ? "..." : ""}`];
|
||||
if (p.attachments.length) parts.push("*(with attachment)*");
|
||||
if (p.content.embed) parts.push("*(embed)*");
|
||||
if (p.repeat_until) {
|
||||
parts.push(`*(repeated every ${humanizeDuration(p.repeat_interval)} until ${p.repeat_until})*`);
|
||||
}
|
||||
if (p.repeat_times) {
|
||||
parts.push(
|
||||
`*(repeated every ${humanizeDuration(p.repeat_interval)}, ${p.repeat_times} more ${
|
||||
p.repeat_times === 1 ? "time" : "times"
|
||||
})*`,
|
||||
);
|
||||
}
|
||||
parts.push(`*(${p.author_name})*`);
|
||||
|
||||
return parts.join(" ");
|
||||
|
@ -480,7 +659,7 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
const finalMessage = trimLines(`
|
||||
${postLines.join("\n")}
|
||||
|
||||
Use \`scheduled_posts show <num>\` to view a scheduled post in full
|
||||
Use \`scheduled_posts <num>\` to view a scheduled post in full
|
||||
Use \`scheduled_posts delete <num>\` to delete a scheduled post
|
||||
`);
|
||||
createChunkedMessage(msg.channel, finalMessage);
|
||||
|
|
|
@ -268,9 +268,17 @@ export class ReactionRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
* :zep_twitch: = 473086848831455234
|
||||
* :zep_ps4: = 543184300250759188
|
||||
*/
|
||||
@d.command("reaction_roles", "<messageId:string> <reactionRolePairs:string$>")
|
||||
@d.command("reaction_roles", "<messageId:string> <reactionRolePairs:string$>", {
|
||||
options: [
|
||||
{
|
||||
name: "exclusive",
|
||||
shortcut: "e",
|
||||
isSwitch: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
@d.permission("can_manage")
|
||||
async reactionRolesCmd(msg: Message, args: { messageId: string; reactionRolePairs: string }) {
|
||||
async reactionRolesCmd(msg: Message, args: { messageId: string; reactionRolePairs: string; exclusive?: boolean }) {
|
||||
const savedMessage = await this.savedMessages.find(args.messageId);
|
||||
if (!savedMessage) {
|
||||
msg.channel.createMessage(errorMessage("Unknown message"));
|
||||
|
@ -331,7 +339,7 @@ export class ReactionRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
|
||||
// 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]);
|
||||
await this.reactionRoles.add(channel.id, targetMessage.id, pair[0], pair[1], args.exclusive);
|
||||
}
|
||||
|
||||
// Apply the reactions themselves
|
||||
|
@ -370,6 +378,14 @@ export class ReactionRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
const matchingReactionRole = await this.reactionRoles.getByMessageAndEmoji(msg.id, emoji.id || emoji.name);
|
||||
if (!matchingReactionRole) return;
|
||||
|
||||
// If the reaction role is exclusive, remove any other roles in the message first
|
||||
if (matchingReactionRole.is_exclusive) {
|
||||
const messageReactionRoles = await this.reactionRoles.getForMessage(msg.id);
|
||||
for (const reactionRole of messageReactionRoles) {
|
||||
this.addMemberPendingRoleChange(userId, "-", reactionRole.role_id);
|
||||
}
|
||||
}
|
||||
|
||||
this.addMemberPendingRoleChange(userId, "+", matchingReactionRole.role_id);
|
||||
}
|
||||
|
||||
|
|
|
@ -70,9 +70,19 @@ export class RemindersPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
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}`),
|
||||
);
|
||||
// Only show created at date if one exists
|
||||
if (moment(reminder.created_at).isValid()) {
|
||||
const target = moment();
|
||||
const diff = target.diff(moment(reminder.created_at, "YYYY-MM-DD HH:mm:ss"));
|
||||
const result = humanizeDuration(diff, { largest: 2, round: true });
|
||||
await channel.createMessage(
|
||||
disableLinkPreviews(
|
||||
`Reminder for <@!${reminder.user_id}>: ${reminder.body} \n\`Set at ${reminder.created_at} (${result} ago)\``,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await channel.createMessage(disableLinkPreviews(`Reminder for <@!${reminder.user_id}>: ${reminder.body}`));
|
||||
}
|
||||
} catch (e) {
|
||||
// Probably random Discord internal server error or missing permissions or somesuch
|
||||
// Try again next round unless we've already tried to post this a bunch of times
|
||||
|
@ -127,7 +137,13 @@ export class RemindersPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
}
|
||||
|
||||
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);
|
||||
await this.reminders.add(
|
||||
msg.author.id,
|
||||
msg.channel.id,
|
||||
reminderTime.format("YYYY-MM-DD HH:mm:ss"),
|
||||
reminderBody,
|
||||
moment().format("YYYY-MM-DD HH:mm:ss"),
|
||||
);
|
||||
|
||||
const msUntilReminder = reminderTime.diff(now);
|
||||
const timeUntilReminder = humanizeDuration(msUntilReminder, { largest: 2, round: true });
|
||||
|
@ -152,7 +168,10 @@ export class RemindersPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
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}`;
|
||||
const target = moment(reminder.remind_at, "YYYY-MM-DD HH:mm:ss");
|
||||
const diff = target.diff(moment());
|
||||
const result = humanizeDuration(diff, { largest: 2, round: true });
|
||||
return `\`${paddedNum}.\` \`${reminder.remind_at} (${result})\` ${reminder.body}`;
|
||||
});
|
||||
|
||||
createChunkedMessage(msg.channel, lines.join("\n"));
|
||||
|
|
146
backend/src/plugins/Roles.ts
Normal file
146
backend/src/plugins/Roles.ts
Normal file
|
@ -0,0 +1,146 @@
|
|||
import { ZeppelinPlugin, trimPluginDescription } from "./ZeppelinPlugin";
|
||||
import * as t from "io-ts";
|
||||
import { stripObjectToScalars, tNullable } from "../utils";
|
||||
import { decorators as d, IPluginOptions, logger, waitForReaction, waitForReply } from "knub";
|
||||
import { Attachment, Constants as ErisConstants, Guild, GuildChannel, Member, Message, TextChannel, User } from "eris";
|
||||
import { GuildLogs } from "../data/GuildLogs";
|
||||
import { LogType } from "../data/LogType";
|
||||
|
||||
const ConfigSchema = t.type({
|
||||
can_assign: t.boolean,
|
||||
assignable_roles: t.array(t.string),
|
||||
});
|
||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||
|
||||
export class RolesPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "roles";
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
public static pluginInfo = {
|
||||
prettyName: "Roles",
|
||||
description: trimPluginDescription(`
|
||||
Enables authorised users to add and remove whitelisted roles with a command.
|
||||
`),
|
||||
};
|
||||
|
||||
protected logs: GuildLogs;
|
||||
|
||||
onLoad() {
|
||||
this.logs = new GuildLogs(this.guildId);
|
||||
}
|
||||
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
can_assign: false,
|
||||
assignable_roles: [],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
level: ">=50",
|
||||
config: {
|
||||
can_assign: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@d.command("addrole", "<member:member> [role:string$]", {
|
||||
extra: {
|
||||
info: {
|
||||
description: "Add a role to the specified member",
|
||||
},
|
||||
},
|
||||
})
|
||||
@d.permission("can_assign")
|
||||
async addRoleCmd(msg: Message, args: { member: Member; role: string }) {
|
||||
if (!this.canActOn(msg.member, args.member, true)) {
|
||||
return this.sendErrorMessage(msg.channel, "Cannot add roles to this user: insufficient permissions");
|
||||
}
|
||||
|
||||
const roleId = await this.resolveRoleId(args.role);
|
||||
if (!roleId) {
|
||||
return this.sendErrorMessage(msg.channel, "Invalid role id");
|
||||
}
|
||||
|
||||
const config = this.getConfigForMsg(msg);
|
||||
if (!config.assignable_roles.includes(roleId)) {
|
||||
return this.sendErrorMessage(msg.channel, "You cannot assign that role");
|
||||
}
|
||||
|
||||
// Sanity check: make sure the role is configured properly
|
||||
const role = (msg.channel as GuildChannel).guild.roles.get(roleId);
|
||||
if (!role) {
|
||||
this.logs.log(LogType.BOT_ALERT, {
|
||||
body: `Unknown role configured for 'roles' plugin: ${roleId}`,
|
||||
});
|
||||
return this.sendErrorMessage(msg.channel, "You cannot assign that role");
|
||||
}
|
||||
|
||||
if (args.member.roles.includes(roleId)) {
|
||||
return this.sendErrorMessage(msg.channel, "Member already has that role");
|
||||
}
|
||||
|
||||
this.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, args.member.id);
|
||||
|
||||
await args.member.addRole(roleId);
|
||||
|
||||
this.logs.log(LogType.MEMBER_ROLE_ADD, {
|
||||
member: stripObjectToScalars(args.member, ["user", "roles"]),
|
||||
roles: role.name,
|
||||
mod: stripObjectToScalars(msg.author),
|
||||
});
|
||||
|
||||
this.sendSuccessMessage(msg.channel, "Role added to user!");
|
||||
}
|
||||
|
||||
@d.command("removerole", "<member:member> [role:string$]", {
|
||||
extra: {
|
||||
info: {
|
||||
description: "Remove a role from the specified member",
|
||||
},
|
||||
},
|
||||
})
|
||||
@d.permission("can_assign")
|
||||
async removeRoleCmd(msg: Message, args: { member: Member; role: string }) {
|
||||
if (!this.canActOn(msg.member, args.member, true)) {
|
||||
return this.sendErrorMessage(msg.channel, "Cannot remove roles from this user: insufficient permissions");
|
||||
}
|
||||
|
||||
const roleId = await this.resolveRoleId(args.role);
|
||||
if (!roleId) {
|
||||
return this.sendErrorMessage(msg.channel, "Invalid role id");
|
||||
}
|
||||
|
||||
const config = this.getConfigForMsg(msg);
|
||||
if (!config.assignable_roles.includes(roleId)) {
|
||||
return this.sendErrorMessage(msg.channel, "You cannot remove that role");
|
||||
}
|
||||
|
||||
// Sanity check: make sure the role is configured properly
|
||||
const role = (msg.channel as GuildChannel).guild.roles.get(roleId);
|
||||
if (!role) {
|
||||
this.logs.log(LogType.BOT_ALERT, {
|
||||
body: `Unknown role configured for 'roles' plugin: ${roleId}`,
|
||||
});
|
||||
return this.sendErrorMessage(msg.channel, "You cannot remove that role");
|
||||
}
|
||||
|
||||
if (!args.member.roles.includes(roleId)) {
|
||||
return this.sendErrorMessage(msg.channel, "Member doesn't have that role");
|
||||
}
|
||||
|
||||
this.logs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, args.member.id);
|
||||
|
||||
await args.member.removeRole(roleId);
|
||||
|
||||
this.logs.log(LogType.MEMBER_ROLE_REMOVE, {
|
||||
member: stripObjectToScalars(args.member, ["user", "roles"]),
|
||||
roles: role.name,
|
||||
mod: stripObjectToScalars(msg.author),
|
||||
});
|
||||
|
||||
this.sendSuccessMessage(msg.channel, "Role removed from user!");
|
||||
}
|
||||
}
|
|
@ -212,7 +212,7 @@ export class SlowmodePlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
options: [
|
||||
{
|
||||
name: "force",
|
||||
type: "bool",
|
||||
isSwitch: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
|
@ -1,58 +1,162 @@
|
|||
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 { decorators as d, IPluginOptions } from "knub";
|
||||
import { ZeppelinPlugin, trimPluginDescription } from "./ZeppelinPlugin";
|
||||
import { Embed, EmbedBase, GuildChannel, Message, TextChannel } from "eris";
|
||||
import {
|
||||
customEmojiRegex,
|
||||
errorMessage,
|
||||
getEmojiInString,
|
||||
getUrlsInString,
|
||||
messageLink,
|
||||
noop,
|
||||
snowflakeRegex,
|
||||
successMessage,
|
||||
TDeepPartialProps,
|
||||
tNullable,
|
||||
tDeepPartial,
|
||||
UnknownUser,
|
||||
EMPTY_CHAR,
|
||||
} 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";
|
||||
import { GuildStarboardMessages } from "../data/GuildStarboardMessages";
|
||||
import { StarboardMessage } from "../data/entities/StarboardMessage";
|
||||
import { GuildStarboardReactions } from "../data/GuildStarboardReactions";
|
||||
|
||||
const StarboardOpts = t.type({
|
||||
channel_id: t.string,
|
||||
stars_required: t.number,
|
||||
star_emoji: tNullable(t.array(t.string)),
|
||||
enabled: tNullable(t.boolean),
|
||||
});
|
||||
type TStarboardOpts = t.TypeOf<typeof StarboardOpts>;
|
||||
|
||||
const ConfigSchema = t.type({
|
||||
can_manage: t.boolean,
|
||||
boards: t.record(t.string, StarboardOpts),
|
||||
can_migrate: t.boolean,
|
||||
});
|
||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||
|
||||
const PartialConfigSchema = tDeepPartial(ConfigSchema);
|
||||
|
||||
const defaultStarboardOpts: Partial<TStarboardOpts> = {
|
||||
star_emoji: ["⭐"],
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "starboard";
|
||||
public static showInDocs = false;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
protected starboards: GuildStarboards;
|
||||
public static pluginInfo = {
|
||||
prettyName: "Starboard",
|
||||
description: trimPluginDescription(`
|
||||
This plugin allows you to set up starboards on your server. Starboards are like user voted pins where messages with enough reactions get immortalized on a "starboard" channel.
|
||||
`),
|
||||
configurationGuide: trimPluginDescription(`
|
||||
### Note on emojis
|
||||
To specify emoji in the config, you need to use the emoji's "raw form".
|
||||
To obtain this, post the emoji with a backslash in front of it.
|
||||
|
||||
- Example with a default emoji: "\:star:" => "⭐"
|
||||
- Example with a custom emoji: "\:mrvnSmile:" => "<:mrvnSmile:543000534102310933>"
|
||||
|
||||
### Basic starboard
|
||||
Any message on the server that gets 5 star reactions will be posted into the starboard channel (604342689038729226).
|
||||
|
||||
~~~yml
|
||||
starboard:
|
||||
config:
|
||||
boards:
|
||||
basic:
|
||||
channel_id: "604342689038729226"
|
||||
stars_required: 5
|
||||
~~~
|
||||
|
||||
### Custom star emoji
|
||||
This is identical to the basic starboard above, but accepts two emoji: the regular star and a custom :mrvnSmile: emoji
|
||||
|
||||
~~~yml
|
||||
starboard:
|
||||
config:
|
||||
boards:
|
||||
basic:
|
||||
channel_id: "604342689038729226"
|
||||
star_emoji: ["⭐", "<:mrvnSmile:543000534102310933>"]
|
||||
stars_required: 5
|
||||
~~~
|
||||
|
||||
### Limit starboard to a specific channel
|
||||
This is identical to the basic starboard above, but only works from a specific channel (473087035574321152).
|
||||
|
||||
~~~yml
|
||||
starboard:
|
||||
config:
|
||||
boards:
|
||||
basic:
|
||||
enabled: false # The starboard starts disabled and is then enabled in a channel override below
|
||||
channel_id: "604342689038729226"
|
||||
stars_required: 5
|
||||
overrides:
|
||||
- channel: "473087035574321152"
|
||||
config:
|
||||
boards:
|
||||
basic:
|
||||
enabled: true
|
||||
~~~
|
||||
`),
|
||||
};
|
||||
|
||||
protected savedMessages: GuildSavedMessages;
|
||||
protected starboardMessages: GuildStarboardMessages;
|
||||
protected starboardReactions: GuildStarboardReactions;
|
||||
|
||||
private onMessageDeleteFn;
|
||||
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
can_manage: false,
|
||||
can_migrate: false,
|
||||
boards: {},
|
||||
},
|
||||
|
||||
overrides: [
|
||||
{
|
||||
level: ">=100",
|
||||
config: {
|
||||
can_manage: true,
|
||||
can_migrate: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
protected static preprocessStaticConfig(config: t.TypeOf<typeof PartialConfigSchema>) {
|
||||
if (config.boards) {
|
||||
for (const [name, opts] of Object.entries(config.boards)) {
|
||||
config.boards[name] = Object.assign({}, defaultStarboardOpts, config.boards[name]);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
protected getStarboardOptsForStarboardChannel(starboardChannel): TStarboardOpts[] {
|
||||
const config = this.getConfigForChannel(starboardChannel);
|
||||
|
||||
const configs = Object.values(config.boards).filter(opts => opts.channel_id === starboardChannel.id);
|
||||
configs.forEach(cfg => {
|
||||
if (cfg.enabled == null) cfg.enabled = defaultStarboardOpts.enabled;
|
||||
if (cfg.star_emoji == null) cfg.star_emoji = defaultStarboardOpts.star_emoji;
|
||||
if (cfg.stars_required == null) cfg.stars_required = defaultStarboardOpts.stars_required;
|
||||
});
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
onLoad() {
|
||||
this.starboards = GuildStarboards.getGuildInstance(this.guildId);
|
||||
this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId);
|
||||
this.starboardMessages = GuildStarboardMessages.getGuildInstance(this.guildId);
|
||||
this.starboardReactions = GuildStarboardReactions.getGuildInstance(this.guildId);
|
||||
|
||||
this.onMessageDeleteFn = this.onMessageDelete.bind(this);
|
||||
this.savedMessages.events.on("delete", this.onMessageDeleteFn);
|
||||
|
@ -62,143 +166,13 @@ export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
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 }) {
|
||||
async onMessageReactionAdd(msg: Message, emoji: { id: string; name: string }, userId: string) {
|
||||
if (!msg.author) {
|
||||
// Message is not cached, fetch it
|
||||
try {
|
||||
|
@ -209,63 +183,80 @@ export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
}
|
||||
}
|
||||
|
||||
const emojiStr = emoji.id ? `${emoji.name}:${emoji.id}` : emoji.name;
|
||||
const applicableStarboards = await this.starboards.getStarboardsByEmoji(emojiStr);
|
||||
// No self-votes!
|
||||
if (msg.author.id === userId) return;
|
||||
|
||||
const user = await this.resolveUser(userId);
|
||||
if (user instanceof UnknownUser) return;
|
||||
if (user.bot) return;
|
||||
|
||||
const config = this.getConfigForMemberIdAndChannelId(userId, msg.channel.id);
|
||||
const applicableStarboards = Object.values(config.boards)
|
||||
.filter(board => board.enabled)
|
||||
// Can't star messages in the starboard channel itself
|
||||
.filter(board => board.channel_id !== msg.channel.id)
|
||||
// Matching emoji
|
||||
.filter(board => {
|
||||
return board.star_emoji.some((boardEmoji: string) => {
|
||||
if (emoji.id) {
|
||||
// Custom emoji
|
||||
const customEmojiMatch = boardEmoji.match(/^<?:.+?:(\d+)>?$/);
|
||||
if (customEmojiMatch) {
|
||||
return customEmojiMatch[1] === emoji.id;
|
||||
}
|
||||
|
||||
return boardEmoji === emoji.id;
|
||||
} else {
|
||||
// Unicode emoji
|
||||
return emoji.name === boardEmoji;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
for (const starboard of applicableStarboards) {
|
||||
// Can't star messages in the starboard channel itself
|
||||
if (msg.channel.id === starboard.channel_id) continue;
|
||||
// Save reaction into the database
|
||||
await this.starboardReactions.createStarboardReaction(msg.id, userId).catch(noop);
|
||||
|
||||
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
|
||||
const starboardMessages = await this.starboardMessages.getMatchingStarboardMessages(starboard.channel_id, msg.id);
|
||||
if (starboardMessages.length > 0) 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);
|
||||
const reactions = await this.starboardReactions.getAllReactionsForMessageId(msg.id);
|
||||
const reactionsCount = reactions.length;
|
||||
if (reactionsCount >= starboard.stars_required) {
|
||||
await this.saveMessageToStarboard(msg, starboard.channel_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@d.event("messageReactionRemove")
|
||||
async onStarboardReactionRemove(msg: Message, emoji: { id: string; name: string }, userId: string) {
|
||||
await this.starboardReactions.deleteStarboardReaction(msg.id, userId);
|
||||
}
|
||||
|
||||
// Ignore self-stars
|
||||
const reactors = await msg.getReaction(reaction);
|
||||
if (reactors.some(u => u.id === msg.author.id)) reactionsCount--;
|
||||
|
||||
return reactionsCount;
|
||||
@d.event("messageReactionRemoveAll")
|
||||
async onMessageReactionRemoveAll(msg: Message) {
|
||||
await this.starboardReactions.deleteAllStarboardReactionsForMessageId(msg.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves/posts a message to the specified starboard. The message is posted as an embed and image attachments are
|
||||
* included as the embed image.
|
||||
* 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);
|
||||
async saveMessageToStarboard(msg: Message, starboardChannelId: string) {
|
||||
const channel = this.guild.channels.get(starboardChannelId);
|
||||
if (!channel) return;
|
||||
|
||||
const time = moment(msg.timestamp, "x").format("YYYY-MM-DD [at] HH:mm:ss [UTC]");
|
||||
|
||||
const embed: any = {
|
||||
const embed: EmbedBase = {
|
||||
footer: {
|
||||
text: `#${(msg.channel as GuildChannel).name} - ${time}`,
|
||||
text: `#${(msg.channel as GuildChannel).name}`,
|
||||
},
|
||||
author: {
|
||||
name: `${msg.author.username}#${msg.author.discriminator}`,
|
||||
},
|
||||
timestamp: new Date(msg.timestamp).toISOString(),
|
||||
};
|
||||
|
||||
if (msg.author.avatarURL) {
|
||||
|
@ -276,6 +267,7 @@ export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
embed.description = msg.content;
|
||||
}
|
||||
|
||||
// Include attachments
|
||||
if (msg.attachments.length) {
|
||||
const attachment = msg.attachments[0];
|
||||
const ext = path
|
||||
|
@ -285,87 +277,96 @@ export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
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);
|
||||
// Include any embed images in the original message
|
||||
if (msg.embeds.length && msg.embeds[0].image) {
|
||||
embed.image = msg.embeds[0].image;
|
||||
}
|
||||
|
||||
embed.fields = [{ name: EMPTY_CHAR, value: `[Jump to message](${messageLink(msg)})` }];
|
||||
|
||||
const starboardMessage = await (channel as TextChannel).createMessage({ embed });
|
||||
await this.starboardMessages.createStarboardMessage(channel.id, msg.id, starboardMessage.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a message from the specified starboard
|
||||
*/
|
||||
async removeMessageFromStarboard(msgId: string, starboard: Starboard) {
|
||||
const starboardMessage = await this.starboards.getStarboardMessageByStarboardIdAndMessageId(starboard.id, msgId);
|
||||
if (!starboardMessage) return;
|
||||
async removeMessageFromStarboard(msg: StarboardMessage) {
|
||||
await this.bot.deleteMessage(msg.starboard_channel_id, msg.starboard_message_id).catch(noop);
|
||||
}
|
||||
|
||||
await this.bot.deleteMessage(starboard.channel_id, starboardMessage.starboard_message_id).catch(noop);
|
||||
await this.starboards.deleteStarboardMessage(starboard.id, msgId);
|
||||
async removeMessageFromStarboardMessages(starboard_message_id: string, channel_id: string) {
|
||||
await this.starboardMessages.deleteStarboardMessage(starboard_message_id, channel_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* When a message is deleted, also delete it from any starboards it's been posted in.
|
||||
* Likewise, if a starboard message (i.e. the bot message in the starboard) is deleted, remove it from the database.
|
||||
* This function is called in response to GuildSavedMessages events.
|
||||
* 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;
|
||||
|
||||
// Deleted source message
|
||||
const starboardMessages = await this.starboardMessages.getStarboardMessagesForMessageId(msg.id);
|
||||
for (const starboardMessage of starboardMessages) {
|
||||
if (!starboardMessage.starboard) continue;
|
||||
this.removeMessageFromStarboard(starboardMessage.message_id, starboardMessage.starboard);
|
||||
this.removeMessageFromStarboard(starboardMessage);
|
||||
}
|
||||
|
||||
// Deleted message from the starboard
|
||||
const deletedStarboardMessages = await this.starboardMessages.getStarboardMessagesForStarboardMessageId(msg.id);
|
||||
if (deletedStarboardMessages.length === 0) return;
|
||||
|
||||
for (const starboardMessage of deletedStarboardMessages) {
|
||||
this.removeMessageFromStarboardMessages(
|
||||
starboardMessage.starboard_message_id,
|
||||
starboardMessage.starboard_channel_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@d.command("starboard migrate_pins", "<pinChannelId:channelId> <starboardChannelId:channelId>")
|
||||
async migratePinsCmd(msg: Message, args: { pinChannelId: string; starboardChannelId }) {
|
||||
const starboard = await this.starboards.getStarboardByChannelId(args.starboardChannelId);
|
||||
@d.command("starboard migrate_pins", "<pinChannel:channel> <starboardName:string>", {
|
||||
extra: {
|
||||
info: {
|
||||
description:
|
||||
"Posts all pins from a channel to the specified starboard. The pins are NOT unpinned automatically.",
|
||||
},
|
||||
},
|
||||
})
|
||||
@d.permission("can_migrate")
|
||||
async migratePinsCmd(msg: Message, args: { pinChannel: GuildChannel; starboardName: string }) {
|
||||
const config = await this.getConfig();
|
||||
const starboard = config.boards[args.starboardName];
|
||||
if (!starboard) {
|
||||
msg.channel.createMessage(errorMessage("The specified channel doesn't have a starboard!"));
|
||||
this.sendErrorMessage(msg.channel, "Unknown starboard specified");
|
||||
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!"));
|
||||
if (!(args.pinChannel instanceof TextChannel)) {
|
||||
this.sendErrorMessage(msg.channel, "Unknown/invalid pin channel id");
|
||||
return;
|
||||
}
|
||||
|
||||
msg.channel.createMessage(`Migrating pins from <#${channel.id}> to <#${args.starboardChannelId}>...`);
|
||||
const starboardChannel = this.guild.channels.get(starboard.channel_id);
|
||||
if (!starboardChannel || !(starboardChannel instanceof TextChannel)) {
|
||||
this.sendErrorMessage(msg.channel, "Starboard has an unknown/invalid channel id");
|
||||
return;
|
||||
}
|
||||
|
||||
const pins = await channel.getPins();
|
||||
msg.channel.createMessage(`Migrating pins from <#${args.pinChannel.id}> to <#${starboardChannel.id}>...`);
|
||||
|
||||
const pins = await args.pinChannel.getPins();
|
||||
pins.reverse(); // Migrate pins starting from the oldest message
|
||||
|
||||
for (const pin of pins) {
|
||||
const existingStarboardMessage = await this.starboards.getStarboardMessageByStarboardIdAndMessageId(
|
||||
starboard.id,
|
||||
const existingStarboardMessage = await this.starboardMessages.getMatchingStarboardMessages(
|
||||
starboardChannel.id,
|
||||
pin.id,
|
||||
);
|
||||
if (existingStarboardMessage) continue;
|
||||
|
||||
await this.saveMessageToStarboard(pin, starboard);
|
||||
if (existingStarboardMessage.length > 0) continue;
|
||||
await this.saveMessageToStarboard(pin, starboardChannel.id);
|
||||
}
|
||||
|
||||
msg.channel.createMessage(successMessage("Pins migrated!"));
|
||||
this.sendSuccessMessage(msg.channel, `Pins migrated from <#${args.pinChannel.id}> to <#${starboardChannel.id}>!`);
|
||||
}
|
||||
}
|
||||
|
|
155
backend/src/plugins/Stats.ts
Normal file
155
backend/src/plugins/Stats.ts
Normal file
|
@ -0,0 +1,155 @@
|
|||
import { ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||
import * as t from "io-ts";
|
||||
import { convertDelayStringToMS, DAYS, HOURS, tAlphanumeric, tDateTime, tDeepPartial, tDelayString } from "../utils";
|
||||
import { IPluginOptions } from "knub";
|
||||
import moment from "moment-timezone";
|
||||
import { GuildStats } from "../data/GuildStats";
|
||||
import { Message } from "eris";
|
||||
import escapeStringRegexp from "escape-string-regexp";
|
||||
import { SavedMessage } from "../data/entities/SavedMessage";
|
||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
||||
|
||||
const tBaseSource = t.type({
|
||||
name: tAlphanumeric,
|
||||
track: t.boolean,
|
||||
retention_period: tDelayString,
|
||||
});
|
||||
|
||||
const tMemberMessagesSource = t.intersection([
|
||||
tBaseSource,
|
||||
t.type({
|
||||
type: t.literal("member_messages"),
|
||||
}),
|
||||
]);
|
||||
type TMemberMessagesSource = t.TypeOf<typeof tMemberMessagesSource>;
|
||||
|
||||
const tChannelMessagesSource = t.intersection([
|
||||
tBaseSource,
|
||||
t.type({
|
||||
type: t.literal("channel_messages"),
|
||||
}),
|
||||
]);
|
||||
type TChannelMessagesSource = t.TypeOf<typeof tChannelMessagesSource>;
|
||||
|
||||
const tKeywordsSource = t.intersection([
|
||||
tBaseSource,
|
||||
t.type({
|
||||
type: t.literal("keywords"),
|
||||
keywords: t.array(t.string),
|
||||
}),
|
||||
]);
|
||||
type TKeywordsSource = t.TypeOf<typeof tKeywordsSource>;
|
||||
|
||||
const tSource = t.union([tMemberMessagesSource, tChannelMessagesSource, tKeywordsSource]);
|
||||
type TSource = t.TypeOf<typeof tSource>;
|
||||
|
||||
const tConfigSchema = t.type({
|
||||
sources: t.record(tAlphanumeric, tSource),
|
||||
});
|
||||
|
||||
type TConfigSchema = t.TypeOf<typeof tConfigSchema>;
|
||||
const tPartialConfigSchema = tDeepPartial(tConfigSchema);
|
||||
|
||||
const DEFAULT_RETENTION_PERIOD = "4w";
|
||||
|
||||
export class StatsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "stats";
|
||||
public static configSchema = tConfigSchema;
|
||||
public static showInDocs = false;
|
||||
|
||||
protected stats: GuildStats;
|
||||
protected savedMessages: GuildSavedMessages;
|
||||
|
||||
private onMessageCreateFn;
|
||||
private cleanStatsInterval;
|
||||
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
sources: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected static preprocessStaticConfig(config: t.TypeOf<typeof tPartialConfigSchema>) {
|
||||
// TODO: Limit min period, min period start date
|
||||
|
||||
if (config.sources) {
|
||||
for (const [key, source] of Object.entries(config.sources)) {
|
||||
source.name = key;
|
||||
|
||||
if (source.track == null) {
|
||||
source.track = true;
|
||||
}
|
||||
|
||||
if (source.retention_period == null) {
|
||||
source.retention_period = DEFAULT_RETENTION_PERIOD;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
protected onLoad() {
|
||||
this.stats = GuildStats.getGuildInstance(this.guildId);
|
||||
this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId);
|
||||
|
||||
this.onMessageCreateFn = this.savedMessages.events.on("create", msg => this.onMessageCreate(msg));
|
||||
|
||||
this.cleanOldStats();
|
||||
this.cleanStatsInterval = setInterval(() => this.cleanOldStats(), 1 * DAYS);
|
||||
}
|
||||
|
||||
protected onUnload() {
|
||||
this.savedMessages.events.off("create", this.onMessageCreateFn);
|
||||
clearInterval(this.cleanStatsInterval);
|
||||
}
|
||||
|
||||
protected async cleanOldStats() {
|
||||
const config = this.getConfig();
|
||||
for (const source of Object.values(config.sources)) {
|
||||
const cutoffMS = convertDelayStringToMS(source.retention_period);
|
||||
const cutoff = moment()
|
||||
.subtract(cutoffMS, "ms")
|
||||
.format("YYYY-MM-DD HH:mm:ss");
|
||||
await this.stats.deleteOldValues(source.name, cutoff);
|
||||
}
|
||||
}
|
||||
|
||||
protected saveMemberMessagesStats(source: TMemberMessagesSource, msg: SavedMessage) {
|
||||
this.stats.saveValue(source.name, msg.user_id, 1);
|
||||
}
|
||||
|
||||
protected saveChannelMessagesStats(source: TChannelMessagesSource, msg: SavedMessage) {
|
||||
this.stats.saveValue(source.name, msg.channel_id, 1);
|
||||
}
|
||||
|
||||
protected saveKeywordsStats(source: TKeywordsSource, msg: SavedMessage) {
|
||||
const content = msg.data.content;
|
||||
if (!content) return;
|
||||
|
||||
for (const keyword of source.keywords) {
|
||||
const regex = new RegExp(`\\b${escapeStringRegexp(keyword)}\\b`, "i");
|
||||
if (content.match(regex)) {
|
||||
this.stats.saveValue(source.name, "keyword", 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMessageCreate(msg: SavedMessage) {
|
||||
const config = this.getConfigForMemberIdAndChannelId(msg.user_id, msg.channel_id);
|
||||
for (const source of Object.values(config.sources)) {
|
||||
if (!source.track) continue;
|
||||
|
||||
if (source.type === "member_messages") {
|
||||
this.saveMemberMessagesStats(source, msg);
|
||||
} else if (source.type === "channel_messages") {
|
||||
this.saveChannelMessagesStats(source, msg);
|
||||
} else if (source.type === "keywords") {
|
||||
this.saveKeywordsStats(source, msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -183,8 +183,20 @@ export class TagsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
msg.channel.createMessage(successMessage(`Tag set! Use it with: \`${prefix}${args.tag}\``));
|
||||
}
|
||||
|
||||
@d.command("tag", "<tag:string>")
|
||||
async tagSourceCmd(msg: Message, args: { tag: string }) {
|
||||
@d.command("tag", "<tag:string>", {
|
||||
options: [
|
||||
{
|
||||
name: "delete",
|
||||
shortcut: "d",
|
||||
isSwitch: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
async tagSourceCmd(msg: Message, args: { tag: string; delete?: boolean }) {
|
||||
if (args.delete) {
|
||||
return this.deleteTagCmd(msg, { tag: args.tag });
|
||||
}
|
||||
|
||||
const tag = await this.tags.find(args.tag);
|
||||
if (!tag) {
|
||||
msg.channel.createMessage(errorMessage("No tag with that name"));
|
||||
|
|
|
@ -29,6 +29,7 @@ import {
|
|||
get,
|
||||
getInviteCodesInString,
|
||||
isSnowflake,
|
||||
messageLink,
|
||||
MINUTES,
|
||||
multiSorter,
|
||||
noop,
|
||||
|
@ -56,6 +57,9 @@ import { getCurrentUptime } from "../uptime";
|
|||
import LCL from "last-commit-log";
|
||||
import * as t from "io-ts";
|
||||
import { ICommandDefinition } from "knub-command-manager";
|
||||
import path from "path";
|
||||
import escapeStringRegexp from "escape-string-regexp";
|
||||
import safeRegex from "safe-regex";
|
||||
|
||||
const ConfigSchema = t.type({
|
||||
can_roles: t.boolean,
|
||||
|
@ -71,16 +75,20 @@ const ConfigSchema = t.type({
|
|||
can_vcmove: t.boolean,
|
||||
can_help: t.boolean,
|
||||
can_about: t.boolean,
|
||||
can_context: t.boolean,
|
||||
});
|
||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||
|
||||
const { performance } = require("perf_hooks");
|
||||
|
||||
const SEARCH_RESULTS_PER_PAGE = 15;
|
||||
const SEARCH_ID_RESULTS_PER_PAGE = 50;
|
||||
|
||||
const MAX_CLEAN_COUNT = 150;
|
||||
const MAX_CLEAN_TIME = 1 * DAYS;
|
||||
const CLEAN_COMMAND_DELETE_DELAY = 5000;
|
||||
const MEMBER_REFRESH_FREQUENCY = 10 * 60 * 1000; // How often to do a full member refresh when using !search or !roles --counts
|
||||
const SEARCH_EXPORT_LIMIT = 1_000_000;
|
||||
|
||||
const activeReloads: Map<string, TextChannel> = new Map();
|
||||
|
||||
|
@ -88,10 +96,14 @@ type MemberSearchParams = {
|
|||
query?: string;
|
||||
role?: string;
|
||||
voice?: boolean;
|
||||
bot?: boolean;
|
||||
sort?: string;
|
||||
"case-sensitive"?: boolean;
|
||||
regex?: boolean;
|
||||
};
|
||||
|
||||
class SearchError extends Error {}
|
||||
|
||||
export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "utility";
|
||||
public static configSchema = ConfigSchema;
|
||||
|
@ -124,6 +136,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
can_vcmove: false,
|
||||
can_help: false,
|
||||
can_about: false,
|
||||
can_context: false,
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
|
@ -138,6 +151,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
can_nickname: true,
|
||||
can_vcmove: true,
|
||||
can_help: true,
|
||||
can_context: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -177,7 +191,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
options: [
|
||||
{
|
||||
name: "counts",
|
||||
type: "bool",
|
||||
isSwitch: true,
|
||||
},
|
||||
{
|
||||
name: "sort",
|
||||
|
@ -320,18 +334,27 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
matchingMembers = matchingMembers.filter(m => m.voiceState.channelID != null);
|
||||
}
|
||||
|
||||
if (args.bot) {
|
||||
matchingMembers = matchingMembers.filter(m => m.bot);
|
||||
}
|
||||
|
||||
if (args.query) {
|
||||
const query = args["case-sensitive"] ? args.query.trimStart() : args.query.toLowerCase().trimStart();
|
||||
let queryRegex: RegExp;
|
||||
if (args.regex) {
|
||||
queryRegex = new RegExp(args.query.trimStart(), args["case-sensitive"] ? "" : "i");
|
||||
} else {
|
||||
queryRegex = new RegExp(escapeStringRegexp(args.query.trimStart()), args["case-sensitive"] ? "" : "i");
|
||||
}
|
||||
|
||||
if (!safeRegex(queryRegex)) {
|
||||
throw new SearchError("Unsafe/too complex regex (star depth is limited to 1)");
|
||||
}
|
||||
|
||||
matchingMembers = matchingMembers.filter(member => {
|
||||
const nick = args["case-sensitive"] ? member.nick : member.nick && member.nick.toLowerCase();
|
||||
if (member.nick && member.nick.match(queryRegex)) return true;
|
||||
|
||||
const fullUsername = args["case-sensitive"]
|
||||
? `${member.user.username}#${member.user.discriminator}`
|
||||
: `${member.user.username}#${member.user.discriminator}`.toLowerCase();
|
||||
|
||||
if (nick && nick.indexOf(query) !== -1) return true;
|
||||
if (fullUsername.indexOf(query) !== -1) return true;
|
||||
const fullUsername = `${member.user.username}#${member.user.discriminator}`;
|
||||
if (fullUsername.match(queryRegex)) return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
|
@ -344,15 +367,18 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
matchingMembers.sort(sorter(m => BigInt(m.id), realSortDir));
|
||||
} else {
|
||||
matchingMembers.sort(
|
||||
multiSorter([[m => m.username.toLowerCase(), realSortDir], [m => m.discriminator, realSortDir]]),
|
||||
multiSorter([
|
||||
[m => m.username.toLowerCase(), realSortDir],
|
||||
[m => m.discriminator, realSortDir],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const lastPage = Math.ceil(matchingMembers.length / SEARCH_RESULTS_PER_PAGE);
|
||||
const lastPage = Math.max(1, Math.ceil(matchingMembers.length / perPage));
|
||||
page = Math.min(lastPage, Math.max(1, page));
|
||||
|
||||
const from = (page - 1) * SEARCH_RESULTS_PER_PAGE;
|
||||
const to = Math.min(from + SEARCH_RESULTS_PER_PAGE, matchingMembers.length);
|
||||
const from = (page - 1) * perPage;
|
||||
const to = Math.min(from + perPage, matchingMembers.length);
|
||||
|
||||
const pageMembers = matchingMembers.slice(from, to);
|
||||
|
||||
|
@ -371,15 +397,23 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
options: [
|
||||
{
|
||||
name: "page",
|
||||
shortcut: "p",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
name: "role",
|
||||
shortcut: "r",
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
name: "voice",
|
||||
type: "bool",
|
||||
shortcut: "v",
|
||||
isSwitch: true,
|
||||
},
|
||||
{
|
||||
name: "bot",
|
||||
shortcut: "b",
|
||||
isSwitch: true,
|
||||
},
|
||||
{
|
||||
name: "sort",
|
||||
|
@ -395,6 +429,15 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
shortcut: "e",
|
||||
isSwitch: true,
|
||||
},
|
||||
{
|
||||
name: "ids",
|
||||
isSwitch: true,
|
||||
},
|
||||
{
|
||||
name: "regex",
|
||||
shortcut: "re",
|
||||
isSwitch: true,
|
||||
},
|
||||
],
|
||||
extra: {
|
||||
info: <CommandInfo>{
|
||||
|
@ -417,15 +460,18 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
msg: Message,
|
||||
args: {
|
||||
query?: string;
|
||||
role?: string;
|
||||
page?: number;
|
||||
role?: string;
|
||||
voice?: boolean;
|
||||
bot?: boolean;
|
||||
sort?: string;
|
||||
"case-sensitive"?: boolean;
|
||||
export?: boolean;
|
||||
ids?: boolean;
|
||||
regex?: boolean;
|
||||
},
|
||||
) {
|
||||
const formatSearchResultLines = (members: Member[]) => {
|
||||
const formatSearchResultList = (members: Member[]): string => {
|
||||
const longestId = members.reduce((longest, member) => Math.max(longest, member.id.length), 0);
|
||||
const lines = members.map(member => {
|
||||
const paddedId = member.id.padEnd(longestId, " ");
|
||||
|
@ -433,23 +479,38 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
if (member.nick) line += ` (${member.nick})`;
|
||||
return line;
|
||||
});
|
||||
return lines;
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
const formatSearchResultIdList = (members: Member[]): string => {
|
||||
return members.map(m => m.id).join(" ");
|
||||
};
|
||||
|
||||
// If we're exporting the results, we don't need all the fancy schmancy pagination stuff.
|
||||
// Just get the results and dump them in an archive.
|
||||
if (args.export) {
|
||||
const results = await this.performMemberSearch(args, 1, Infinity);
|
||||
let results;
|
||||
try {
|
||||
results = await this.performMemberSearch(args, 1, SEARCH_EXPORT_LIMIT);
|
||||
} catch (e) {
|
||||
if (e instanceof SearchError) {
|
||||
return this.sendErrorMessage(msg.channel, e.message);
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (results.totalResults === 0) {
|
||||
return this.sendErrorMessage(msg.channel, "No results found");
|
||||
}
|
||||
|
||||
const resultLines = formatSearchResultLines(results.results);
|
||||
const resultList = args.ids ? formatSearchResultIdList(results.results) : formatSearchResultList(results.results);
|
||||
|
||||
const archiveId = await this.archives.create(
|
||||
trimLines(`
|
||||
Search results (total ${results.totalResults}):
|
||||
|
||||
${resultLines.join("\n")}
|
||||
${resultList}
|
||||
`),
|
||||
moment().add(1, "hour"),
|
||||
);
|
||||
|
@ -468,6 +529,8 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
let clearReactionsFn = null;
|
||||
let clearReactionsTimeout = null;
|
||||
|
||||
const perPage = args.ids ? SEARCH_ID_RESULTS_PER_PAGE : SEARCH_RESULTS_PER_PAGE;
|
||||
|
||||
const loadSearchPage = async page => {
|
||||
if (searching) return;
|
||||
searching = true;
|
||||
|
@ -482,23 +545,37 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
searchMsgPromise.then(m => (originalSearchMsg = m));
|
||||
}
|
||||
|
||||
const searchResult = await this.performMemberSearch(args, page, SEARCH_RESULTS_PER_PAGE);
|
||||
let searchResult;
|
||||
try {
|
||||
searchResult = await this.performMemberSearch(args, page, perPage);
|
||||
} catch (e) {
|
||||
if (e instanceof SearchError) {
|
||||
return this.sendErrorMessage(msg.channel, e.message);
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (searchResult.totalResults === 0) {
|
||||
return this.sendErrorMessage(msg.channel, "No results found");
|
||||
}
|
||||
|
||||
const resultWord = searchResult.totalResults === 1 ? "matching member" : "matching members";
|
||||
const headerText =
|
||||
searchResult.totalResults > SEARCH_RESULTS_PER_PAGE
|
||||
searchResult.totalResults > perPage
|
||||
? trimLines(`
|
||||
**Page ${searchResult.page}** (${searchResult.from}-${searchResult.to}) (total ${searchResult.totalResults})
|
||||
`)
|
||||
: `Found ${searchResult.totalResults} ${resultWord}`;
|
||||
const lines = formatSearchResultLines(searchResult.results);
|
||||
|
||||
const resultList = args.ids
|
||||
? formatSearchResultIdList(searchResult.results)
|
||||
: formatSearchResultList(searchResult.results);
|
||||
|
||||
const result = trimLines(`
|
||||
${headerText}
|
||||
\`\`\`js
|
||||
${lines.join("\n")}
|
||||
${resultList}
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
|
@ -506,7 +583,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
searchMsg.edit(result);
|
||||
|
||||
// Set up pagination reactions if needed. The reactions are cleared after a timeout.
|
||||
if (searchResult.totalResults > SEARCH_RESULTS_PER_PAGE) {
|
||||
if (searchResult.totalResults > perPage) {
|
||||
if (!hasReactions) {
|
||||
hasReactions = true;
|
||||
searchMsg.addReaction("⬅");
|
||||
|
@ -514,6 +591,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
searchMsg.addReaction("🔄");
|
||||
|
||||
const removeListenerFn = this.on("messageReactionAdd", (rMsg: Message, emoji, userId) => {
|
||||
if (rMsg.id !== searchMsg.id) return;
|
||||
if (userId !== msg.author.id) return;
|
||||
if (!["⬅", "➡", "🔄"].includes(emoji.name)) return;
|
||||
|
||||
|
@ -571,6 +649,8 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
count: savedMessages.length,
|
||||
archiveUrl,
|
||||
});
|
||||
|
||||
return { archiveUrl };
|
||||
}
|
||||
|
||||
@d.command("clean", "<count:number>", {
|
||||
|
@ -683,17 +763,19 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
|
||||
let responseMsg: Message;
|
||||
if (messagesToClean.length > 0) {
|
||||
await this.cleanMessages(targetChannel, messagesToClean, msg.author);
|
||||
const cleanResult = await this.cleanMessages(targetChannel, messagesToClean, msg.author);
|
||||
|
||||
let responseText = `Cleaned ${messagesToClean.length} ${messagesToClean.length === 1 ? "message" : "messages"}`;
|
||||
if (targetChannel.id !== msg.channel.id) responseText += ` in <#${targetChannel.id}>`;
|
||||
if (targetChannel.id !== msg.channel.id) {
|
||||
responseText += ` in <#${targetChannel.id}>\n${cleanResult.archiveUrl}`;
|
||||
}
|
||||
|
||||
responseMsg = await msg.channel.createMessage(successMessage(responseText));
|
||||
} else {
|
||||
responseMsg = await msg.channel.createMessage(errorMessage(`Found no messages to clean!`));
|
||||
}
|
||||
|
||||
if (targetChannel.id !== msg.channel.id) {
|
||||
if (targetChannel.id === msg.channel.id) {
|
||||
// Delete the !clean command and the bot response if a different channel wasn't specified
|
||||
// (so as not to spam the cleaned channel with the command itself)
|
||||
setTimeout(() => {
|
||||
|
@ -710,9 +792,16 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
basicUsage: "!info 106391128718245888",
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: "compact",
|
||||
shortcut: "c",
|
||||
isSwitch: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
@d.permission("can_info")
|
||||
async infoCmd(msg: Message, args: { user?: User | UnknownUser }) {
|
||||
async infoCmd(msg: Message, args: { user?: User | UnknownUser; compact?: boolean }) {
|
||||
const user = args.user || msg.author;
|
||||
|
||||
let member;
|
||||
|
@ -734,15 +823,40 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
embed.title = `${user.username}#${user.discriminator}`;
|
||||
embed.thumbnail = { url: user.avatarURL };
|
||||
|
||||
embed.fields.push({
|
||||
name: "User information",
|
||||
value:
|
||||
trimLines(`
|
||||
ID: **${user.id}**
|
||||
Profile: <@!${user.id}>
|
||||
Created: **${accountAge} ago (${createdAt.format("YYYY-MM-DD[T]HH:mm:ss")})**
|
||||
`) + embedPadding,
|
||||
});
|
||||
if (args.compact) {
|
||||
embed.fields.push({
|
||||
name: "User information",
|
||||
value: trimLines(`
|
||||
Profile: <@!${user.id}>
|
||||
Created: **${accountAge} ago (${createdAt.format("YYYY-MM-DD[T]HH:mm:ss")})**
|
||||
`),
|
||||
});
|
||||
if (member) {
|
||||
const joinedAt = moment(member.joinedAt);
|
||||
const joinAge = humanizeDuration(moment().valueOf() - member.joinedAt, {
|
||||
largest: 2,
|
||||
round: true,
|
||||
});
|
||||
embed.fields[0].value += `\nJoined: **${joinAge} ago (${joinedAt.format("YYYY-MM-DD[T]HH:mm:ss")})**`;
|
||||
} else {
|
||||
embed.fields.push({
|
||||
name: "!! USER IS NOT ON THE SERVER !!",
|
||||
value: embedPadding,
|
||||
});
|
||||
}
|
||||
msg.channel.createMessage({ embed });
|
||||
return;
|
||||
} else {
|
||||
embed.fields.push({
|
||||
name: "User information",
|
||||
value:
|
||||
trimLines(`
|
||||
ID: **${user.id}**
|
||||
Profile: <@!${user.id}>
|
||||
Created: **${accountAge} ago (${createdAt.format("YYYY-MM-DD[T]HH:mm:ss")})**
|
||||
`) + embedPadding,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
embed.title = `Unknown user`;
|
||||
}
|
||||
|
@ -782,7 +896,6 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
value: embedPadding,
|
||||
});
|
||||
}
|
||||
|
||||
const cases = (await this.cases.getByUserId(user.id)).filter(c => !c.is_hidden);
|
||||
|
||||
if (cases.length > 0) {
|
||||
|
@ -994,7 +1107,12 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
);
|
||||
|
||||
// Clean up test messages
|
||||
this.bot.deleteMessages(messages[0].channel.id, messages.map(m => m.id)).catch(noop);
|
||||
this.bot
|
||||
.deleteMessages(
|
||||
messages[0].channel.id,
|
||||
messages.map(m => m.id),
|
||||
)
|
||||
.catch(noop);
|
||||
}
|
||||
|
||||
@d.command("source", "<messageId:string>", {
|
||||
|
@ -1021,6 +1139,30 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
msg.channel.createMessage(`Message source: ${url}`);
|
||||
}
|
||||
|
||||
@d.command("context", "<channel:channel> <messageId:string>", {
|
||||
extra: {
|
||||
info: <CommandInfo>{
|
||||
description: "Get a link to the context of the specified message",
|
||||
basicUsage: "!context 94882524378968064 650391267720822785",
|
||||
},
|
||||
},
|
||||
})
|
||||
@d.permission("can_context")
|
||||
async contextCmd(msg: Message, args: { channel: Channel; messageId: string }) {
|
||||
if (!(args.channel instanceof TextChannel)) {
|
||||
this.sendErrorMessage(msg.channel, "Channel must be a text channel");
|
||||
return;
|
||||
}
|
||||
|
||||
const previousMessage = (await this.bot.getMessages(args.channel.id, 1, args.messageId))[0];
|
||||
if (!previousMessage) {
|
||||
this.sendErrorMessage(msg.channel, "Message context not found");
|
||||
return;
|
||||
}
|
||||
|
||||
msg.channel.createMessage(messageLink(this.guildId, previousMessage.channel.id, previousMessage.id));
|
||||
}
|
||||
|
||||
@d.command("vcmove", "<member:resolvedMember> <channel:string$>", {
|
||||
extra: {
|
||||
info: <CommandInfo>{
|
||||
|
@ -1192,8 +1334,25 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
const uptime = getCurrentUptime();
|
||||
const prettyUptime = humanizeDuration(uptime, { largest: 2, round: true });
|
||||
|
||||
const lcl = new LCL();
|
||||
const lastCommit = await lcl.getLastCommit();
|
||||
let lastCommit;
|
||||
|
||||
try {
|
||||
// From project root
|
||||
// FIXME: Store these paths properly somewhere
|
||||
const lcl = new LCL(path.resolve(__dirname, "..", "..", ".."));
|
||||
lastCommit = await lcl.getLastCommit();
|
||||
} catch (e) {} // tslint:disable-line:no-empty
|
||||
|
||||
let lastUpdate;
|
||||
let version;
|
||||
|
||||
if (lastCommit) {
|
||||
lastUpdate = moment(lastCommit.committer.date, "X").format("LL [at] H:mm [(UTC)]");
|
||||
version = lastCommit.shortHash;
|
||||
} else {
|
||||
lastUpdate = "?";
|
||||
version = "?";
|
||||
}
|
||||
|
||||
const shard = this.bot.shards.get(this.bot.guildShardMap[this.guildId]);
|
||||
|
||||
|
@ -1205,8 +1364,8 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
const basicInfoRows = [
|
||||
["Uptime", prettyUptime],
|
||||
["Last reload", `${lastReload} ago`],
|
||||
["Last update", moment(lastCommit.committer.date, "X").format("LL [at] H:mm [(UTC)]")],
|
||||
["Version", lastCommit.shortHash],
|
||||
["Last update", lastUpdate],
|
||||
["Version", version],
|
||||
["API latency", `${shard.latency}ms`],
|
||||
];
|
||||
|
||||
|
|
|
@ -12,14 +12,16 @@ import {
|
|||
resolveMember,
|
||||
resolveUser,
|
||||
resolveUserId,
|
||||
tDeepPartial,
|
||||
trimEmptyStartEndLines,
|
||||
trimIndents,
|
||||
UnknownUser,
|
||||
resolveRoleId,
|
||||
} 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 { decodeAndValidateStrict, StrictValidationError, validate } from "../validatorUtils";
|
||||
import { SimpleCache } from "../SimpleCache";
|
||||
|
||||
const SLOW_RESOLVE_THRESHOLD = 1500;
|
||||
|
@ -52,8 +54,8 @@ export interface CommandInfo {
|
|||
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 firstLineIndentation = (lines[0].match(/^ +/g) || [""])[0].length;
|
||||
return trimIndents(emptyLinesTrimmed, firstLineIndentation);
|
||||
}
|
||||
|
||||
const inviteCache = new SimpleCache<Promise<Invite>>(10 * MINUTES, 200);
|
||||
|
@ -69,14 +71,14 @@ export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plug
|
|||
throw new PluginRuntimeError(message, this.runtimePluginName, this.guildId);
|
||||
}
|
||||
|
||||
protected canActOn(member1, member2) {
|
||||
if (member1.id === member2.id || member2.id === this.bot.user.id) {
|
||||
protected canActOn(member1: Member, member2: Member, allowSameLevel = false) {
|
||||
if (member2.id === this.bot.user.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ourLevel = this.getMemberLevel(member1);
|
||||
const memberLevel = this.getMemberLevel(member2);
|
||||
return ourLevel > memberLevel;
|
||||
return allowSameLevel ? ourLevel >= memberLevel : ourLevel > memberLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -121,6 +123,13 @@ export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plug
|
|||
? options.overrides
|
||||
: (defaultOptions.overrides || []).concat(options.overrides || []);
|
||||
|
||||
// Before preprocessing the static config, do a loose check by checking the schema as deeply partial.
|
||||
// This way the preprocessing function can trust that if a property exists, its value will be the correct (partial) type.
|
||||
const initialLooseCheck = this.configSchema ? validate(tDeepPartial(this.configSchema), mergedConfig) : null;
|
||||
if (initialLooseCheck) {
|
||||
throw initialLooseCheck;
|
||||
}
|
||||
|
||||
mergedConfig = this.preprocessStaticConfig(mergedConfig);
|
||||
|
||||
const decodedConfig = this.configSchema ? decodeAndValidateStrict(this.configSchema, mergedConfig) : mergedConfig;
|
||||
|
@ -229,6 +238,16 @@ export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plug
|
|||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a role from the passed string. The passed string can be a role ID, a role mention or a role name.
|
||||
* In the event of duplicate role names, this function will return the first one it comes across.
|
||||
* @param roleResolvable
|
||||
*/
|
||||
async resolveRoleId(roleResolvable: string): Promise<string | null> {
|
||||
const roleId = await resolveRoleId(this.bot, this.guildId, roleResolvable);
|
||||
return roleId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
|
|
@ -27,6 +27,7 @@ import { LocatePlugin } from "./LocateUser";
|
|||
import { GuildConfigReloader } from "./GuildConfigReloader";
|
||||
import { ChannelArchiverPlugin } from "./ChannelArchiver";
|
||||
import { AutomodPlugin } from "./Automod";
|
||||
import { RolesPlugin } from "./Roles";
|
||||
|
||||
/**
|
||||
* Plugins available to be loaded for individual guilds
|
||||
|
@ -58,6 +59,7 @@ export const availablePlugins = [
|
|||
CompanionChannelPlugin,
|
||||
LocatePlugin,
|
||||
ChannelArchiverPlugin,
|
||||
RolesPlugin,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue