diff --git a/package.json b/package.json index eddbfdcd1..a26af5637 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "runkitExampleFilename": "./docs/examples/ping.js", "dependencies": { "@types/node": "^6.0.0", + "long": "^3.2.0", "pako": "^1.0.0", "superagent": "^3.3.0", "tweetnacl": "^0.14.0", diff --git a/src/client/ClientDataResolver.js b/src/client/ClientDataResolver.js index d38fb7c95..c17d4944e 100644 --- a/src/client/ClientDataResolver.js +++ b/src/client/ClientDataResolver.js @@ -123,6 +123,19 @@ class ClientDataResolver { return null; } + /** + * Resolves a ChannelResolvable to a Channel object + * @param {ChannelResolvable} channel The channel resolvable to resolve + * @returns {?string} + */ + resolveChannelID(channel) { + if (channel instanceof Channel) return channel.id; + if (typeof channel === 'string') return channel; + if (channel instanceof Message) return channel.channel.id; + if (channel instanceof Guild) return channel.defaultChannel.id; + return null; + } + /** * Data that can be resolved to give an invite code. This can be: * * An invite code diff --git a/src/client/rest/RESTMethods.js b/src/client/rest/RESTMethods.js index 70062482e..0484aec1d 100644 --- a/src/client/rest/RESTMethods.js +++ b/src/client/rest/RESTMethods.js @@ -117,6 +117,18 @@ class RESTMethods { ); } + search(type, id, options) { + const queryString = Object.keys(options) + .filter(k => options[k]) + .map(k => [k, options[k]]) + .map(x => x.join('=')) + .join('&'); + const url = `${Constants.Endpoints[`${type}Search`](id)}?${queryString}`; + return this.rest.makeRequest('get', url, true).then(body => + body.messages.map(x => x.map(m => new Message(this.client.channels.get(m.channel_id), m, this.client))) + ); + } + createChannel(guild, channelName, channelType, overwrites) { if (overwrites instanceof Collection) overwrites = overwrites.array(); return this.rest.makeRequest('post', Constants.Endpoints.guildChannels(guild.id), true, { diff --git a/src/structures/Guild.js b/src/structures/Guild.js index 368269509..8a45507eb 100644 --- a/src/structures/Guild.js +++ b/src/structures/Guild.js @@ -3,6 +3,7 @@ const Role = require('./Role'); const Emoji = require('./Emoji'); const Presence = require('./Presence').Presence; const GuildMember = require('./GuildMember'); +const MessageSearch = require('./MessageSearch'); const Constants = require('../util/Constants'); const Collection = require('../util/Collection'); const cloneObject = require('../util/CloneObject'); @@ -703,6 +704,25 @@ class Guild { return this.client.rest.methods.setRolePositions(this.id, updatedRoles); } + /** + * Performs a search + * @param {MessageSearchOptions} [options={}] Options to pass to the search + * @returns {MessageSearch} + * @example + * guild.search() + * .content('discord.js') + * .before('2016-11-17') + * .execute() + * .then(res => { + * const hit = res[0].find(m => m.hit).content; + * console.log(`I found: **${hit}**`); + * }) + * .catch(console.error); + */ + search(options) { + return new MessageSearch(this, options); + } + /** * Whether this Guild equals another Guild. It compares all properties, so for most operations * it is advisable to just compare `guild.id === guild2.id` as it is much faster and is often diff --git a/src/structures/Message.js b/src/structures/Message.js index 7fcc5b4b8..85a17c549 100644 --- a/src/structures/Message.js +++ b/src/structures/Message.js @@ -172,6 +172,12 @@ class Message { * @type {?string} */ this.webhookID = data.webhook_id || null; + + /** + * Whether this message is a hit in a search + * @type {?boolean} + */ + this.hit = typeof data.hit === 'boolean' ? data.hit : null; } patch(data) { // eslint-disable-line complexity diff --git a/src/structures/MessageSearch.js b/src/structures/MessageSearch.js new file mode 100644 index 000000000..50135f7b4 --- /dev/null +++ b/src/structures/MessageSearch.js @@ -0,0 +1,215 @@ +const long = require('long'); +let TextChannel, DMChannel, GroupDMChannel, Guild; + +/** + * @typedef {Object} MessageSearchOptions + * @property {string} [content] Message content + * @property {string} [maxID] Maximum ID for the filter + * @property {string} [minID] Minimum ID for the filter + * @property {string} [has] One of `link`, `embed`, `file`, `video`, `image`, or `sound` + * @property {string} [channelID] Channel ID to limit search to (only for guild search endpoint) + * @property {string} [authorID] Author ID to limit search + * @property {string} [sortBy='recent'] `recent` or `relevant` + * @property {number} [contextSize=2] How many messages to get around the matched message (0 to 2) + * @property {number} [limit=25] Maximum number of results to get (1 to 25) + */ + +/** + * Fluent interface for running a search against a guild or channel + */ +class MessageSearch { + /** + * @param {TextChannel|DMChannel|GroupDMChannel|Guild} target Target of the search + * @param {MessageSearchOptions} [options] Options for the search + */ + constructor(target, options = {}) { + if (!TextChannel) { + TextChannel = require('./TextChannel'); + DMChannel = require('./DMChannel'); + GroupDMChannel = require('./GroupDMChannel'); + Guild = require('./Guild'); + } + + if (target instanceof TextChannel || target instanceof DMChannel || target instanceof GroupDMChannel) { + /** + * The type of search, either `channel` or `guild` + * @type {string} + */ + this.type = 'channel'; + } else if (target instanceof Guild) { + this.type = 'guild'; + } else { + throw new TypeError('Target must be a TextChannel, DMChannel, GroupDMChannel, or Guild.'); + } + + /** + * Client to use + * @type {Client} + */ + this.client = target.client; + + /** + * ID of the search target + * @type {string} + */ + this.id = target.id; + + /** + * Options for the search + * @type {MessageSearchOptions} + */ + this.options = options; + } + + /** + * Sets the content for the search + * @param {string} content Content to search for + * @returns {MessageSearch} + */ + content(content) { + this.options.content = content; + return this; + } + + /** + * Sets the minimum ID for the search + * @param {string} id Snowflake minimum ID + * @returns {MessageSearch} + */ + minID(id) { + this.options.minID = id; + return this; + } + + /** + * Sets the maximum ID for the search + * @param {string} id Snowflake maximum ID + * @returns {MessageSearch} + */ + maxID(id) { + this.options.maxID = id; + return this; + } + + /** + * Sets the before date for the search + * @param {Date} date Date to find messages before + * @returns {MessageSearch} + */ + before(date) { + if (typeof date !== Date) date = new Date(date); + return this.maxID(long.fromNumber(date.getTime() - 14200704e5).shiftLeft(22).toString()); + } + + /** + * Sets the after date for the search + * @param {Date} date Date to find messages after + * @returns {MessageSearch} + */ + after(date) { + if (typeof date !== Date) date = new Date(date); + return this.minID(long.fromNumber(date.getTime() - 14200704e5).shiftLeft(22).toString()); + } + + /** + * Sets the during date for the search + * @param {Date} date Date to find messages during (range of date to date + 24 hours) + * @returns {MessageSearch} + */ + during(date) { + if (typeof date !== Date) date = new Date(date); + const t = date.getTime() - 14200704e5; + this.minID(long.fromNumber(t).shiftLeft(22).toString()); + this.maxID(long.fromNumber(t + 86400000).shift(222).toString()); + return this; + } + + /** + * Sets the filter for the search + * @param {string} type Filter for some type of embed or attachment that can be in the message + * must be one of ['link', 'embed', 'file', 'video', 'image', 'sound'] + * @returns {MessageSearch} + */ + has(type) { + const allowed = ['link', 'embed', 'file', 'video', 'image', 'sound']; + if (!allowed.includes(type)) throw new Error(`Type must be one of [${allowed.join(', ')}]`); + this.options.has = type; + return this; + } + + /** + * Sets the author for the search + * @param {UserResolvable} user User to only find messages from + * @returns {MessageSearch} + */ + from(user) { + this.options.authorID = this.client.resolver.resolverUserID(user); + return this; + } + + /** + * Sets the channel for the search + * @param {ChannelResolvable} channel Channel to only find messages from + * This is only for use with a guild search + * @returns {MessageSearch} + */ + in(channel) { + this.options.channelID = this.client.resolver.resolveChannelID(channel); + return this; + } + + /** + * Sets the maximum results for the search + * @param {number} limit Maximum number of results (1 to 25) + * @returns {MessageSearch} + */ + limit(limit) { + if (limit < 1 || limit > 25) throw new RangeError('Limit must be within 1 to 25.'); + this.options.limit = limit; + return this; + } + + /** + * Sets the context size for the search + * @param {number} size Number of messages to get around the matched message (0 to 2) + * @returns {MessageSearch} + */ + contextSize(size) { + if (size < 0 || size > 2) throw new RangeError('Context size must be within 0 to 2'); + this.options.contextSize = size; + return this; + } + + /** + * Sets the sorting order for the search + * @param {string} [type='recent'] Sorting type (`recent` or `relevant`) + * @returns {MessageSearch} + */ + sort(type) { + if (type !== 'recent' || type !== 'relevant') throw new Error('Sort type must be `recent` or `relevant`.'); + this.options.sortBy = type; + return this; + } + + /** + * Executes the search + * @returns {Promise>} + * An array containing arrays of messages. Each inner array is a search context cluster. + * The message which has triggered the result will have the `hit` property set to `true`. + */ + execute() { + return this.client.rest.methods.search(this.type, this.id, { + content: this.options.content, + max_id: this.options.maxID, + min_id: this.options.minID, + has: this.options.has, + channel_id: this.options.channelID, + author_id: this.options.authorID, + context_size: this.options.contextSize, + sort_by: this.options.sortBy, + limit: this.options.limit, + }); + } +} + +module.exports = MessageSearch; diff --git a/src/structures/interface/TextBasedChannel.js b/src/structures/interface/TextBasedChannel.js index 353c0a9cf..08ba4f388 100644 --- a/src/structures/interface/TextBasedChannel.js +++ b/src/structures/interface/TextBasedChannel.js @@ -1,6 +1,7 @@ const path = require('path'); const Message = require('../Message'); const MessageCollector = require('../MessageCollector'); +const MessageSearch = require('../MessageSearch'); const Collection = require('../../util/Collection'); @@ -214,6 +215,25 @@ class TextBasedChannel { }); } + /** + * Performs a search + * @param {MessageSearchOptions} [options={}] Options to pass to the search + * @returns {MessageSearch} + * @example + * channel.search() + * .content('discord.js') + * .before('2016-11-17') + * .execute() + * .then(res => { + * const hit = res[0].find(m => m.hit).content; + * console.log(`I found: **${hit}**`); + * }) + * .catch(console.error); + */ + search(options) { + return new MessageSearch(this, options); + } + /** * Starts a typing indicator in the channel. * @param {number} [count] The number of times startTyping should be considered to have been called @@ -361,6 +381,7 @@ exports.applyToClass = (structure, full = false) => { '_cacheMessage', 'fetchMessages', 'fetchMessage', + 'search', 'bulkDelete', 'startTyping', 'stopTyping', diff --git a/src/util/Constants.js b/src/util/Constants.js index cee2192f1..dc80da5aa 100644 --- a/src/util/Constants.js +++ b/src/util/Constants.js @@ -122,6 +122,7 @@ const Endpoints = exports.Endpoints = { guildMemberNickname: (guildID) => `${Endpoints.guildMember(guildID, '@me')}/nick`, guildChannels: (guildID) => `${Endpoints.guild(guildID)}/channels`, guildEmojis: (guildID) => `${Endpoints.guild(guildID)}/emojis`, + guildSearch: (guildID) => `${Endpoints.guild(guildID)}/messages/search`, // channels channels: `${API}/channels`, @@ -132,6 +133,7 @@ const Endpoints = exports.Endpoints = { channelPermissions: (channelID) => `${Endpoints.channel(channelID)}/permissions`, channelMessage: (channelID, messageID) => `${Endpoints.channelMessages(channelID)}/${messageID}`, channelWebhooks: (channelID) => `${Endpoints.channel(channelID)}/webhooks`, + channelSearch: (channelID) => `${Endpoints.channelMessages(channelID)}/search`, // message reactions messageReactions: (channelID, messageID) => `${Endpoints.channelMessage(channelID, messageID)}/reactions`,