diff --git a/config/default.yml b/config/default.yml index 27241077..3b3548e9 100644 --- a/config/default.yml +++ b/config/default.yml @@ -12,6 +12,8 @@ forbiddenTicketPrefix: '!' requiredTicketPrefix: '' +filterRemovalTimeout: 300000 + embedDeletionEmoji: '🗑️' maxSearchResults: 5 @@ -38,9 +40,11 @@ request: - ☑️ - ❌ - 💬 + - 📁 ignorePrependResponseMessageEmoji: ✅ ignoreResolutionEmoji: 💬 + bulkEmoji: 📁 resolveDelay: 10000 progressMessageAddDelay: 10000 diff --git a/config/template.yml b/config/template.yml index 7cbee21f..5ec06a0f 100644 --- a/config/template.yml +++ b/config/template.yml @@ -37,6 +37,9 @@ forbiddenTicketPrefix: <string> # When omitted or empty, no prefix is required for posting embeds. requiredTicketPrefix: <string> +# The time (in milliseconds) the bot will wait before removing a '!jira bulk' filter +filterRemovalTimeout: <number> + # An emoji or emoji ID which, when reacted to a bot embed, deletes it. embedDeletionEmoji: <string> @@ -106,6 +109,9 @@ request: # An emoji or emoji ID which, when used, doesn't trigger the response template message. ignorePrependResponseMessageEmoji: <string> + # An emoji or emoji ID which indicates a request message to be added to a bulk action. + bulkEmoji: <string> + # The amount of time in milliseconds between a volunteer reacts to the message and the bot deletes its message. resolveDelay: <number> diff --git a/src/BotConfig.ts b/src/BotConfig.ts index 2b1a37c5..3a132824 100644 --- a/src/BotConfig.ts +++ b/src/BotConfig.ts @@ -28,6 +28,7 @@ export class RequestConfig { public suggestedEmoji: string[]; public ignorePrependResponseMessageEmoji: string; public ignoreResolutionEmoji: string; + public bulkEmoji: string; public resolveDelay: number; public progressMessageAddDelay: number; public prependResponseMessage: PrependResponseMessageType; @@ -52,6 +53,7 @@ export class RequestConfig { this.suggestedEmoji = getOrDefault( 'request.suggestedEmoji', [] ); this.ignorePrependResponseMessageEmoji = config.get( 'request.ignorePrependResponseMessageEmoji' ); this.ignoreResolutionEmoji = config.get( 'request.ignoreResolutionEmoji' ); + this.bulkEmoji = config.get( 'request.bulkEmoji' ); this.resolveDelay = config.get( 'request.resolveDelay' ); this.progressMessageAddDelay = config.get( 'request.progressMessageAddDelay' ); @@ -113,6 +115,8 @@ export default class BotConfig { public static requiredTicketPrefix: string; public static forbiddenTicketPrefix: string; + public static filterRemovalTimeout: number; + public static embedDeletionEmoji: string; public static maxSearchResults: number; @@ -140,6 +144,8 @@ export default class BotConfig { this.forbiddenTicketPrefix = getOrDefault( 'forbiddenTicketPrefix', '' ); this.requiredTicketPrefix = getOrDefault( 'requiredTicketPrefix', '' ); + this.filterRemovalTimeout = config.get( 'filterRemovalTimeout' ); + this.embedDeletionEmoji = getOrDefault( 'embedDeletionEmoji', '' ); this.maxSearchResults = config.get( 'maxSearchResults' ); diff --git a/src/commands/BulkCommand.ts b/src/commands/BulkCommand.ts new file mode 100644 index 00000000..cdcf9499 --- /dev/null +++ b/src/commands/BulkCommand.ts @@ -0,0 +1,86 @@ +import { Message, MessageReaction, User } from 'discord.js'; +import PrefixCommand from './PrefixCommand'; +import Command from './Command'; +import { RequestsUtil } from '../util/RequestsUtil'; +import { EmojiUtil } from '../util/EmojiUtil'; +import BotConfig from '../BotConfig'; +import RequestResolveEventHandler from '../events/request/RequestResolveEventHandler'; +import MojiraBot from '../MojiraBot'; + +export default class BulkCommand extends PrefixCommand { + public readonly aliases = ['bulk', 'filter']; + + public static currentBulkReactions = new Map<User, Message[]>(); + + public async run( message: Message, args: string ): Promise<boolean> { + let emoji: string; + + if ( args.length ) { + emoji = EmojiUtil.getEmoji( args ); + if ( !emoji ) { + await message.channel.send( `**Error:** ${ args } is not a valid emoji.` ); + return false; + } + } + + const ticketKeys: string[] = []; + let firstMentioned: string; + + try { + const bulkMessages = BulkCommand.currentBulkReactions.get( message.author ); + const originMessages: Message[] = []; + if ( bulkMessages ) { + for ( const bulk of bulkMessages ) { + originMessages.push( await RequestsUtil.getOriginMessage( bulk ) ); + + if ( emoji ) { + let reaction: MessageReaction; + if ( bulk.reactions.cache.has( emoji ) ) { + reaction = bulk.reactions.cache.get( emoji ); + } else { + reaction = await bulk.react( emoji ); + } + if ( emoji != BotConfig.request.bulkEmoji ) { + await new RequestResolveEventHandler( MojiraBot.client.user.id ).onEvent( reaction, message.author ); + return true; + } else { + await bulk.reactions.cache.get( BotConfig.request.bulkEmoji ).users.remove( message.author ); + } + } + } + originMessages.forEach( origin => ticketKeys.push( ...RequestsUtil.getTickets( origin.content ) ) ); + firstMentioned = ticketKeys[0]; + if ( emoji == BotConfig.request.bulkEmoji ) { + BulkCommand.currentBulkReactions.delete( message.author ); + return true; + } + } else { + return false; + } + } catch ( err ) { + Command.logger.error( err ); + return false; + } + + const filter = `https://bugs.mojang.com/browse/${ firstMentioned }?jql=key%20in(${ ticketKeys.join( '%2C' ) })`; + + try { + await message.channel.send( `${ message.author.toString() } ${ filter }` ); + } catch ( err ) { + Command.logger.error( err ); + return false; + } + + try { + await message.react( '✅' ); + } catch ( err ) { + Command.logger.error( err ); + } + + return true; + } + + public asString( args: string ): string { + return `!jira bulk ${ args }`; + } +} diff --git a/src/commands/CommandRegistry.ts b/src/commands/CommandRegistry.ts index f448d89a..d665e6b4 100644 --- a/src/commands/CommandRegistry.ts +++ b/src/commands/CommandRegistry.ts @@ -1,4 +1,5 @@ import BugCommand from './BugCommand'; +import BulkCommand from './BulkCommand'; import HelpCommand from './HelpCommand'; import PingCommand from './PingCommand'; import MooCommand from './MooCommand'; @@ -11,6 +12,7 @@ import TipsCommand from './TipsCommand'; export default class CommandRegistry { public static BUG_COMMAND = new BugCommand(); + public static BULK_COMMAND = new BulkCommand(); public static HELP_COMMAND = new HelpCommand(); public static MENTION_COMMAND = new MentionCommand(); public static MOO_COMMAND = new MooCommand(); diff --git a/src/commands/PollCommand.ts b/src/commands/PollCommand.ts index a9a7b07e..934ff5e1 100644 --- a/src/commands/PollCommand.ts +++ b/src/commands/PollCommand.ts @@ -1,13 +1,12 @@ import PrefixCommand from './PrefixCommand'; import { Message, TextChannel, DMChannel, MessageEmbed, NewsChannel } from 'discord.js'; import Command from './Command'; -import emojiRegex = require( 'emoji-regex/text.js' ); +import { EmojiUtil } from '../util/EmojiUtil'; import PermissionRegistry from '../permissions/PermissionRegistry'; import { ReactionsUtil } from '../util/ReactionsUtil'; interface PollOption { emoji: string; - emojiName?: string; rawEmoji: string; text: string; } @@ -103,26 +102,16 @@ export default class PollCommand extends PrefixCommand { const optionArgs = /^\s*(\S+)\s+(.+)\s*$/.exec( option ); - const customEmoji = /^<a?:(\w+):(\d+)>/; - const unicodeEmoji = emojiRegex(); - if ( !optionArgs ) { await this.sendSyntaxMessage( message.channel, 'Invalid options' ); return false; } const emoji = optionArgs[1]; - if ( customEmoji.test( emoji ) || unicodeEmoji.test( emoji ) ) { - let emojiName = emoji; - let rawEmoji = emoji; - const emojiMatch = customEmoji.exec( emoji ); - if ( emojiMatch ) { - emojiName = emojiMatch[1]; - rawEmoji = emojiMatch[2]; - } + const rawEmoji = EmojiUtil.getEmoji( emoji ); + if ( rawEmoji ) { options.push( { emoji: emoji, - emojiName: emojiName, rawEmoji: rawEmoji, text: optionArgs[2], } ); diff --git a/src/events/request/RequestResolveEventHandler.ts b/src/events/request/RequestResolveEventHandler.ts index 6706bdac..4ca1e61a 100644 --- a/src/events/request/RequestResolveEventHandler.ts +++ b/src/events/request/RequestResolveEventHandler.ts @@ -1,6 +1,7 @@ import { MessageReaction, User } from 'discord.js'; import * as log4js from 'log4js'; import BotConfig, { PrependResponseMessageType } from '../../BotConfig'; +import BulkCommand from '../../commands/BulkCommand'; import ResolveRequestMessageTask from '../../tasks/ResolveRequestMessageTask'; import TaskScheduler from '../../tasks/TaskScheduler'; import { RequestsUtil } from '../../util/RequestsUtil'; @@ -27,26 +28,34 @@ export default class RequestResolveEventHandler implements EventHandler<'message this.logger.info( `User ${ user.tag } added '${ reaction.emoji.name }' reaction to request message '${ reaction.message.id }'` ); TaskScheduler.clearMessageTasks( reaction.message ); - await reaction.message.edit( reaction.message.embeds[0].setColor( RequestsUtil.getEmbedColor( user ) ) ); - - if ( BotConfig.request.prependResponseMessage == PrependResponseMessageType.WhenResolved - && BotConfig.request.ignorePrependResponseMessageEmoji !== reaction.emoji.name ) { - const origin = await RequestsUtil.getOriginMessage( reaction.message ); - if ( origin ) { - try { - await reaction.message.edit( RequestsUtil.getResponseMessage( origin ) ); - } catch ( error ) { - this.logger.error( error ); + + if ( BotConfig.request.bulkEmoji !== reaction.emoji.name ) { + if ( BotConfig.request.prependResponseMessage == PrependResponseMessageType.WhenResolved + && BotConfig.request.ignorePrependResponseMessageEmoji !== reaction.emoji.name ) { + const origin = await RequestsUtil.getOriginMessage( reaction.message ); + if ( origin ) { + try { + await reaction.message.edit( RequestsUtil.getResponseMessage( origin ) ); + } catch ( error ) { + this.logger.error( error ); + } } } - } - if ( BotConfig.request.ignoreResolutionEmoji !== reaction.emoji.name ) { - TaskScheduler.addOneTimeMessageTask( - reaction.message, - new ResolveRequestMessageTask( reaction.emoji, user ), - BotConfig.request.resolveDelay || 0 - ); + if ( BotConfig.request.ignoreResolutionEmoji !== reaction.emoji.name ) { + await reaction.message.edit( reaction.message.embeds[0].setColor( RequestsUtil.getEmbedColor( user ) ) ); + TaskScheduler.addOneTimeMessageTask( + reaction.message, + new ResolveRequestMessageTask( reaction.emoji, user, this.botUserId ), + BotConfig.request.resolveDelay || 0 + ); + } + } else { + if ( !BulkCommand.currentBulkReactions.has( user ) ) { + BulkCommand.currentBulkReactions.set( user, [ reaction.message ] ); + } else { + BulkCommand.currentBulkReactions.get( user ).push( reaction.message ); + } } }; } diff --git a/src/events/request/RequestUnresolveEventHandler.ts b/src/events/request/RequestUnresolveEventHandler.ts index 379dce22..68857709 100644 --- a/src/events/request/RequestUnresolveEventHandler.ts +++ b/src/events/request/RequestUnresolveEventHandler.ts @@ -1,6 +1,7 @@ import { MessageReaction, User } from 'discord.js'; import * as log4js from 'log4js'; import BotConfig, { PrependResponseMessageType } from '../../BotConfig'; +import BulkCommand from '../../commands/BulkCommand'; import TaskScheduler from '../../tasks/TaskScheduler'; import DiscordUtil from '../../util/DiscordUtil'; import { RequestsUtil } from '../../util/RequestsUtil'; @@ -28,19 +29,22 @@ export default class RequestUnresolveEventHandler implements EventHandler<'messa this.logger.info( `User ${ user.tag } removed '${ emoji.name }' reaction from request message '${ message.id }'` ); - await message.edit( message.embeds[0].setColor( RequestsUtil.getEmbedColor() ) ); - - if ( BotConfig.request.prependResponseMessage == PrependResponseMessageType.WhenResolved ) { - try { - await message.edit( '' ); - } catch ( error ) { - this.logger.error( error ); + if ( BotConfig.request.bulkEmoji !== emoji.name ) { + await message.edit( message.embeds[0].setColor( RequestsUtil.getEmbedColor() ) ); + if ( BotConfig.request.prependResponseMessage == PrependResponseMessageType.WhenResolved ) { + try { + await message.edit( '' ); + } catch ( error ) { + this.logger.error( error ); + } } - } - if ( message.reactions.cache.size <= BotConfig.request.suggestedEmoji.length ) { - this.logger.info( `Cleared message task for request message '${ message.id }'` ); - TaskScheduler.clearMessageTasks( message ); + if ( message.reactions.cache.size <= BotConfig.request.suggestedEmoji.length ) { + this.logger.info( `Cleared message task for request message '${ message.id }'` ); + TaskScheduler.clearMessageTasks( message ); + } + } else if ( BulkCommand.currentBulkReactions.has( user ) ) { + BulkCommand.currentBulkReactions.set( user, BulkCommand.currentBulkReactions.get( user ).filter( stored => stored != message ) ); } }; } \ No newline at end of file diff --git a/src/tasks/ResolveRequestMessageTask.ts b/src/tasks/ResolveRequestMessageTask.ts index e7b2553c..2ec6ffbe 100644 --- a/src/tasks/ResolveRequestMessageTask.ts +++ b/src/tasks/ResolveRequestMessageTask.ts @@ -4,17 +4,20 @@ import DiscordUtil from '../util/DiscordUtil'; import { RequestsUtil } from '../util/RequestsUtil'; import MessageTask from './MessageTask'; import * as log4js from 'log4js'; +import BulkCommand from '../commands/BulkCommand'; export default class ResolveRequestMessageTask extends MessageTask { private static logger = log4js.getLogger( 'ResolveRequestMessageTask' ); private readonly emoji: EmojiResolvable; private readonly user: User; + private readonly botUserId: string; - constructor( emoji: EmojiResolvable, user: User ) { + constructor( emoji: EmojiResolvable, user: User, botUserId: string ) { super(); this.emoji = emoji; this.user = user; + this.botUserId = botUserId; } public async run( copy: Message ): Promise<void> { @@ -33,6 +36,16 @@ export default class ResolveRequestMessageTask extends MessageTask { if ( origin ) { try { + await origin.reactions.cache.forEach( async reaction => { + if ( reaction.emoji.name == BotConfig.request.bulkEmoji ) { + const users = await reaction.users.fetch(); + users.forEach( user => { + if ( user.id != this.botUserId ) { + BulkCommand.currentBulkReactions.set( user, BulkCommand.currentBulkReactions.get( user ).filter( message => origin != message ) ); + } + } ); + } + } ); await origin.reactions.removeAll(); } catch ( error ) { ResolveRequestMessageTask.logger.error( error ); diff --git a/src/util/EmojiUtil.ts b/src/util/EmojiUtil.ts new file mode 100644 index 00000000..a3bad84c --- /dev/null +++ b/src/util/EmojiUtil.ts @@ -0,0 +1,17 @@ +import emojiRegex = require( 'emoji-regex/text.js' ); + +export class EmojiUtil { + public static getEmoji( args: string ): string { + const customEmoji = /^<a?:(.+):(\d+)>/; + const unicodeEmoji = emojiRegex(); + let rawEmoji: string; + if ( customEmoji.test( args ) ) { + rawEmoji = customEmoji.exec( args )[2]; + } else if ( unicodeEmoji.test( args ) ) { + rawEmoji = args; + } else { + return undefined; + } + return rawEmoji; + } +} \ No newline at end of file diff --git a/src/util/RequestsUtil.ts b/src/util/RequestsUtil.ts index fbf94808..d3497302 100644 --- a/src/util/RequestsUtil.ts +++ b/src/util/RequestsUtil.ts @@ -1,8 +1,8 @@ import { EmbedField, Message, TextChannel, User } from 'discord.js'; import * as log4js from 'log4js'; import BotConfig from '../BotConfig'; -import DiscordUtil from './DiscordUtil'; import MentionCommand from '../commands/MentionCommand'; +import DiscordUtil from './DiscordUtil'; import MojiraBot from '../MojiraBot'; interface OriginIds { @@ -74,6 +74,16 @@ export class RequestsUtil { .replace( '{{message}}', message.content.replace( /(^|\n)/g, '$1> ' ) ); } + public static getTickets( content: string ): string[] { + let ticketMatch: RegExpExecArray; + const regex = MentionCommand.getTicketIdRegex(); + const ticketMatches: string[] = []; + while ( ( ticketMatch = regex.exec( content ) ) !== null ) { + ticketMatches.push( ticketMatch[1] ); + } + return ticketMatches; + } + // https://stackoverflow.com/a/3426956 private static hashCode( str: string ): number { let hash = 0;