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`,