diff --git a/.travis.yml b/.travis.yml index 06b247f7f..6bdf028d3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,12 +1,13 @@ language: node_js node_js: - "6" + - "7" cache: directories: - node_modules install: npm install script: - - npm run test + - npm run lint - bash ./deploy/deploy.sh env: global: diff --git a/deploy/deploy.sh b/deploy/deploy.sh index dabad94b9..13811122b 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -3,36 +3,40 @@ set -e -function build { - # Build docs - npm run docs +function tests { + npm run test-docs + VERSIONED=false npm run web-dist + exit 0 +} - # Build the webpack +function build { + npm run docs VERSIONED=false npm run web-dist } -# Ignore Travis checking PRs +# Only run tests for PRs if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then - echo "deploy.sh: Ignoring PR build" - build - exit 0 + echo -e "\e[36m\e[1mBuild triggered for PR #$TRAVIS_PULL_REQUEST to branch $TRAVIS_BRANCH - only running tests." + tests fi -# Ignore travis checking other branches irrelevant to users -if [ "$TRAVIS_BRANCH" == "gh-pages" -o "$TRAVIS_BRANCH" == "gh-pages-dev" -o "$TRAVIS_BRANCH" == "docs" -o "$TRAVIS_BRANCH" == "webpack" -o "$TRAVIS_BRANCH" == "v8" ]; then - echo "deploy.sh: Ignoring push to blacklisted branch" - build - exit 0 -fi - -SOURCE=$TRAVIS_BRANCH - -# Make sure tag pushes are handled +# Figure out the source of the build if [ -n "$TRAVIS_TAG" ]; then - echo "deploy.sh: This is a tag build, proceeding accordingly" + echo -e "\e[36m\e[1mBuild triggered for tag \"$TRAVIS_TAG\"." SOURCE=$TRAVIS_TAG +else + echo -e "\e[36m\e[1mBuild triggered for branch \"$TRAVIS_BRANCH\"." + SOURCE=$TRAVIS_BRANCH fi +# Only run tests for Node versions other than 6 +if [ "$TRAVIS_NODE_VERSION" != "6" ]; then + echo -e "\e[36m\e[1mBuild triggered with Node v$TRAVIS_NODE_VERSION - only running tests." + tests +fi + +build + # Initialise some useful variables REPO=`git config remote.origin.url` SSH_REPO=${REPO/https:\/\/github.com\//git@github.com:} @@ -48,15 +52,11 @@ chmod 600 deploy_key eval `ssh-agent -s` ssh-add deploy_key -# Build everything -build - # Checkout the repo in the target branch so we can build docs and push to it TARGET_BRANCH="docs" git clone $REPO out -b $TARGET_BRANCH -# Move the generated JSON file to the newly-checked-out repo, to be committed -# and pushed +# Move the generated JSON file to the newly-checked-out repo, to be committed and pushed mv docs/docs.json out/$SOURCE.json # Commit and push diff --git a/package.json b/package.json index 272b93bfc..44e762eb9 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "test": "eslint src && docgen --source src --custom docs/index.yml", "docs": "docgen --source src --custom docs/index.yml --output docs/docs.json", - "test-docs": "docgen --source src --custom docs", + "test-docs": "docgen --source src --custom docs/index.yml", "lint": "eslint src", "web-dist": "node ./node_modules/parallel-webpack/bin/run.js" }, @@ -32,6 +32,7 @@ "runkitExampleFilename": "./docs/examples/ping.js", "dependencies": { "@types/node": "^6.0.0", + "long": "^3.2.0", "pako": "^1.0.0", "prism-media": "hydrabolt/prism-media#master", "superagent": "^3.3.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..213848fe1 100644 --- a/src/client/rest/RESTMethods.js +++ b/src/client/rest/RESTMethods.js @@ -3,6 +3,7 @@ const Collection = require('../../util/Collection'); const splitMessage = require('../../util/SplitMessage'); const parseEmoji = require('../../util/ParseEmoji'); const escapeMarkdown = require('../../util/EscapeMarkdown'); +const transformSearchOptions = require('../../util/TransformSearchOptions'); const User = require('../../structures/User'); const GuildMember = require('../../structures/GuildMember'); @@ -12,6 +13,8 @@ const Invite = require('../../structures/Invite'); const Webhook = require('../../structures/Webhook'); const UserProfile = require('../../structures/UserProfile'); const ClientOAuth2Application = require('../../structures/ClientOAuth2Application'); +const Channel = require('../../structures/Channel'); +const Guild = require('../../structures/Guild'); class RESTMethods { constructor(restManager) { @@ -117,6 +120,30 @@ class RESTMethods { ); } + search(target, options) { + options = transformSearchOptions(options, this.client); + + const queryString = Object.keys(options) + .filter(k => options[k]) + .map(k => [k, options[k]]) + .map(x => x.join('=')) + .join('&'); + + let type; + if (target instanceof Channel) { + type = 'channel'; + } else if (target instanceof Guild) { + type = 'guild'; + } else { + throw new TypeError('Target must be a TextChannel, DMChannel, GroupDMChannel, or Guild.'); + } + + const url = `${Constants.Endpoints[`${type}Search`](target.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/ClientUser.js b/src/structures/ClientUser.js index 35ec22bde..6a8532141 100644 --- a/src/structures/ClientUser.js +++ b/src/structures/ClientUser.js @@ -116,7 +116,7 @@ class ClientUser extends User { * .catch(console.error); */ setAvatar(avatar) { - if (avatar.startsWith('data:')) { + if (typeof avatar === 'string' && avatar.startsWith('data:')) { return this.client.rest.methods.updateCurrentUser({ avatar }); } else { return this.client.resolver.resolveBuffer(avatar).then(data => diff --git a/src/structures/Guild.js b/src/structures/Guild.js index 368269509..472166fbc 100644 --- a/src/structures/Guild.js +++ b/src/structures/Guild.js @@ -703,6 +703,27 @@ class Guild { return this.client.rest.methods.setRolePositions(this.id, updatedRoles); } + /** + * Performs a search + * @param {MessageSearchOptions} [options={}] Options to pass to 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`. + * @example + * guild.search({ + * content: 'discord.js', + * before: '2016-11-17' + * }) + * .then(res => { + * const hit = res[0].find(m => m.hit).content; + * console.log(`I found: **${hit}**`); + * }) + * .catch(console.error); + */ + search(options) { + return this.client.rest.methods.search(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/User.js b/src/structures/User.js index f7148289a..fd0a8d305 100644 --- a/src/structures/User.js +++ b/src/structures/User.js @@ -111,9 +111,9 @@ class User { * @readonly */ get defaultAvatarURL() { - let defaultAvatars = Object.values(Constants.DefaultAvatars); - let defaultAvatar = this.discriminator % defaultAvatars.length; - return Constants.Endpoints.assets(`${defaultAvatars[defaultAvatar]}.png`); + const avatars = Object.keys(Constants.DefaultAvatars); + const avatar = avatars[this.discriminator % avatars.length]; + return Constants.Endpoints.assets(`${Constants.DefaultAvatars[avatar]}.png`); } /** diff --git a/src/structures/interface/TextBasedChannel.js b/src/structures/interface/TextBasedChannel.js index 353c0a9cf..1249b57dc 100644 --- a/src/structures/interface/TextBasedChannel.js +++ b/src/structures/interface/TextBasedChannel.js @@ -3,7 +3,6 @@ const Message = require('../Message'); const MessageCollector = require('../MessageCollector'); const Collection = require('../../util/Collection'); - /** * Interface for classes that have text-channel-like features * @interface @@ -214,6 +213,27 @@ class TextBasedChannel { }); } + /** + * Performs a search + * @param {MessageSearchOptions} [options={}] Options to pass to 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`. + * @example + * channel.search({ + * content: 'discord.js', + * before: '2016-11-17' + * }) + * .then(res => { + * const hit = res[0].find(m => m.hit).content; + * console.log(`I found: **${hit}**`); + * }) + * .catch(console.error); + */ + search(options) { + return this.client.rest.methods.search(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`, diff --git a/src/util/TransformSearchOptions.js b/src/util/TransformSearchOptions.js new file mode 100644 index 000000000..e61056aed --- /dev/null +++ b/src/util/TransformSearchOptions.js @@ -0,0 +1,75 @@ +const long = require('long'); + +/** + * @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`, + * or add `-` to negate (e.g. `-file`) + * @property {ChannelResolvable} [channel] Channel to limit search to (only for guild search endpoint) + * @property {UserResolvable} [author] Author to limit search + * @property {string} [authorType] One of `user`, `bot`, `webhook`, or add `-` to negate (e.g. `-webhook`) + * @property {string} [sortBy='recent'] `recent` or `relevant` + * @property {string} [sortOrder='desc'] `asc` or `desc` + * @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) + * @property {number} [offset=0] Offset the "pages" of results (since you can only see 25 at a time) + * @property {UserResolvable} [mentions] Mentioned user filter + * @property {boolean} [mentionsEveryone] If everyone is mentioned + * @property {string} [linkHostname] Filter links by hostname + * @property {string} [embedProvider] The name of an embed provider + * @property {string} [embedType] one of `image`, `video`, `url`, `rich` + * @property {string} [attachmentFilename] The name of an attachment + * @property {string} [attachmentExtention] The extension of an attachment + * @property {Date} [before] Date to find messages before + * @property {Date} [after] Date to find messages before + * @property {Date} [during] Date to find messages during (range of date to date + 24 hours) + */ + +module.exports = function TransformSearchOptions(options, client) { + if (options.before) { + if (!(options.before instanceof Date)) options.before = new Date(options.before); + options.maxID = long.fromNumber(options.before.getTime() - 14200704e5).shiftLeft(22).toString(); + } + + if (options.after) { + if (!(options.after instanceof Date)) options.after = new Date(options.after); + options.minID = long.fromNumber(options.after.getTime() - 14200704e5).shiftLeft(22).toString(); + } + + if (options.during) { + if (!(options.during instanceof Date)) options.during = new Date(options.during); + const t = options.during.getTime() - 14200704e5; + options.minID = long.fromNumber(t).shiftLeft(22).toString(); + options.maxID = long.fromNumber(t + 86400000).shift(222).toString(); + } + + if (options.channel) options.channel = client.resolver.resolveChannelID(options.channel); + + if (options.author) options.author = client.resolver.resolveUserID(options.author); + + if (options.mentions) options.mentions = client.resolver.resolveUserID(options.options.mentions); + + return { + content: options.content, + max_id: options.maxID, + min_id: options.minID, + has: options.has, + channel_id: options.channel, + author_id: options.author, + author_type: options.authorType, + context_size: options.contextSize, + sort_by: options.sortBy, + sort_order: options.sortOrder, + limit: options.limit, + offset: options.offset, + mentions: options.mentions, + mentions_everyone: options.mentionsEveryone, + link_hostname: options.linkHostname, + embed_provider: options.embedProvider, + embed_type: options.embedType, + attachment_filename: options.attachmentFilename, + attachment_extension: options.attachmentExtension, + }; +};