diff --git a/index.d.ts b/index.d.ts index d3151f4..158c4ea 100644 --- a/index.d.ts +++ b/index.d.ts @@ -111,6 +111,22 @@ export default class Twitter { * @returns {Stream} */ public stream(resource: string, parameters: object): Stream; + + /** + * Creates an instance of the original {@instance Twitter} with labs API capabilities + * @return {TwitterLabs} - a twitter labs instance + */ + public withLabs(): TwitterLabs; + + /** + * Add rule for the filter stream API + * + * @param {LabsFilterStreamRule[]} rules a list of rules for the filter stream API + * @param {boolean} [dryRun] optional parameter to mark the request as a dry run + * @returns {Promise} Promise response from Twitter API + * @see {@link https://developer.twitter.com/en/docs/labs/filtered-stream/api-reference/post-tweets-stream-filter-rules Twitter API} + */ + public static labsFilterStreamRule(value: string, tag?: string): FilterStreamRule; } /* In reality snowflakes are BigInts. Once BigInt is supported by browsers and Node per default, we could adjust this type. @@ -157,10 +173,10 @@ interface BearerResponse { type TokenResponse = | { - oauth_token: OauthToken; - oauth_token_secret: OauthTokenSecret; - oauth_callback_confirmed: 'true'; - } + oauth_token: OauthToken; + oauth_token_secret: OauthTokenSecret; + oauth_callback_confirmed: 'true'; + } | { oauth_callback_confirmed: 'false' }; interface AccessTokenResponse { @@ -176,3 +192,82 @@ declare class Stream extends EventEmitter { parse(buffer: Buffer): void; destroy(): void; } + +export class TwitterLabs extends Twitter { + /** + * Construct the data and headers for an authenticated HTTP request to the Twitter Labs API + * @param {'GET | 'POST' | 'PUT'} method + * @param {'1' | '2'} version + * @param {string} resource - the API endpoint + * @param {object} queryParams - query params object + */ + private _makeLabsRequest(method: 'GET' | 'POST' | 'PUT', version: '1' | '2', + resource: string, queryParams: object): { + requestData: { url: string; method: string }; + headers: { Authorization: string } | OAuth.Header; + }; + + /** + * Add rule for the filter stream API + * + * @param {FilterStreamRule[]} rules a list of rules for the filter stream API + * @param {boolean} [dryRun] optional parameter to mark the request as a dry run + * @returns {Promise} Promise response from Twitter API + * @see {@link https://developer.twitter.com/en/docs/labs/filtered-stream/api-reference/post-tweets-stream-filter-rules Twitter API} + */ + public addRules(rules: FilterStreamRule[], dryRun?: boolean): Promise + + /** + * Get registered rules + * + * @returns {Promise} Promise response from Twitter API + * @see {@link https://developer.twitter.com/en/docs/labs/filtered-stream/api-reference/get-tweets-stream-filter-rules Twitter API} + */ + public getRules(...ids: string[]): Promise + + /** + * Delete registered rules + * + * @param {string[]} Rule IDs that has been registered + * @param {boolean} [dryRun] optional parameter to mark request as a dry run + * @returns {Promise} Promise response from Twitter API + * @see {@link https://developer.twitter.com/en/docs/labs/filtered-stream/api-reference/get-tweets-stream-filter-rules Twitter API} + */ + public deleteRules(ids: string[], dryRun?: boolean): Promise + + + /** + * Start filter stream using saved rules + * + * @param {{expansions: Expansions[], format: Format, 'place.format': Format, + * 'tweet.format': Format, 'user.format': Format}} [queryParams] + * @returns {Stream} stream object for the filter stream + * @see {@link https://developer.twitter.com/en/docs/labs/filtered-stream/api-reference/get-tweets-stream-filter Twitter API} + */ + filterStream(queryParams?: FilterStreamParams): Stream +} + +/** + * Rule structure when adding twitter labs filter stream rules + */ +type FilterStreamRule = { value: string, meta?: string }; + +/** + * Twitter labs response format + * @see {@link https://developer.twitter.com/en/docs/labs/overview/whats-new/formats About format} + */ +type LabsFormat = 'compact' | 'detailed' | 'default'; + +/** + * Twitter labs expansions + * @see {@link https://developer.twitter.com/en/docs/labs/overview/whats-new/expansions About expansions} + */ +type LabsExpansion = 'attachment.poll_ids' | 'attachments.media_keys' | 'author_id' | 'entities.mentions.username' | 'geo.place_id' + | 'in_reply_to_user_id' | 'referenced_tweets.id' | 'referenced_tweets.id.author_id'; +type FilterStreamParams = { + expansions?: LabsExpansion[], + format?: LabsFormat, + 'place.format'?: LabsFormat, + 'tweet.format'?: LabsFormat, + 'user.format'?: LabsFormat +}; \ No newline at end of file diff --git a/package.json b/package.json index 420812d..e7acd8b 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "scripts": { "lint": "eslint --fix ./", "prepare": "microbundle {stream,twitter}.js && bundlesize", - "test": "eslint --fix . && jest --detectOpenHandles", + "test": "eslint --fix . && jest --testPathIgnorePatterns=labs --detectOpenHandles", + "test-labs": "eslint --fix . && jest --testPathPattern=labs --detectOpenHandles", "release": "npm run -s prepare && npm test && git tag $npm_package_version && git push && git push --tags && npm publish" }, "husky": { @@ -68,4 +69,4 @@ "maxSize": "3 kB" } ] -} +} \ No newline at end of file diff --git a/test/twitter.labs.test.js b/test/twitter.labs.test.js new file mode 100644 index 0000000..0b2f00d --- /dev/null +++ b/test/twitter.labs.test.js @@ -0,0 +1,140 @@ +require('dotenv').config(); +const Twitter = require('../twitter'); + +const { + TWITTER_CONSUMER_KEY, + TWITTER_CONSUMER_SECRET, + ACCESS_TOKEN, + ACCESS_TOKEN_SECRET, +} = process.env; + +function newClient() { + return new Twitter({ + consumer_key: TWITTER_CONSUMER_KEY, + consumer_secret: TWITTER_CONSUMER_SECRET, + access_token_key: ACCESS_TOKEN, + access_token_secret: ACCESS_TOKEN_SECRET, + }); +} + + +describe('LABS - creating with labs', () => { + let client; + let clientWithLabs; + beforeAll(() => { + client = newClient(); + clientWithLabs = client.withLabs(); + }); + + it('should create object with all twitter functions', () => { + for (const funcName of Object.getOwnPropertyNames(Twitter.prototype)) { + expect(clientWithLabs[funcName]).toBeDefined(); + expect(clientWithLabs[funcName]).toBeInstanceOf(Function); + } + }); + + it('should create object with all twitter properties', () => { + for (const propertyName of Object.getOwnPropertyNames(client)) { + expect(clientWithLabs[propertyName]).toBeDefined(); + expect(clientWithLabs[propertyName]); + } + }); +}); + +describe('LABS - filter stream labs', () => { + let clientWithLabs; + let addedRules; + let addedRulesId; + + // create labs instance and add initial rules + beforeAll(async () => { + const bearerToken = await newClient().getBearerToken(); + clientWithLabs = new Twitter({ bearer_token: bearerToken.access_token }).withLabs(); + const rulesToAdd = [ + Twitter.labsFilterStreamRule('twitter'), + Twitter.labsFilterStreamRule('testing'), + Twitter.labsFilterStreamRule('hello'), + ]; + const response = await clientWithLabs.addRules(rulesToAdd); + addedRules = response.data; + addedRulesId = response.data.map(d => d.id); + }); + + // delete initialized rules + afterAll(async () => { + await clientWithLabs.deleteRules(addedRulesId); + }); + + it('should create new rules when adding non-existent rules', async () => { + const rulesToAdd = [Twitter.labsFilterStreamRule('random1'), Twitter.labsFilterStreamRule('random2')]; + const addRulesResponse = await clientWithLabs.addRules(rulesToAdd, true); + + expect(addRulesResponse).toMatchObject({ + data: [ + { value: 'random1', id: expect.any(String) }, + { value: 'random2', id: expect.any(String) }, + ], + meta: { + summary: { + created: 2, + }, + }, + }); + }); + + it('should not create new rules when adding existing rules', async () => { + const rulesToAdd = [Twitter.labsFilterStreamRule('twitter'), Twitter.labsFilterStreamRule('testing')]; + const addRulesResponse = await clientWithLabs.addRules(rulesToAdd, true); + + expect(addRulesResponse).toMatchObject({ + meta: { + summary: { + created: 0, + }, + }, + }); + }); + + it('should delete rules that exist', async () => { + const deleteRulesResponse = await clientWithLabs.deleteRules(addedRulesId, true); + + expect(deleteRulesResponse).toMatchObject({ + meta: { + summary: { + deleted: addedRulesId.length, + }, + }, + }); + }); + + it('should be an error when deleting rules that does not exist', async () => { + const deleteRulesResponse = await clientWithLabs.deleteRules(['239197139192', '28319317192'], true); + + expect(deleteRulesResponse).toMatchObject({ + meta: { + summary: { + deleted: 0, + not_deleted: 2, + }, + }, errors: [ + { errors: [{ message: 'Rule does not exist', parameters: {} }] }, + { errors: [{ message: 'Rule does not exist', parameters: {} }] }, + ], + }); + }); + + it('should get all currently available rules when no IDs are given', async () => { + const getRulesResponse = await clientWithLabs.getRules(); + expect(getRulesResponse.data).toBeDefined(); + expect(getRulesResponse.data).toContainEqual(...addedRules); + }); + + it('should get only specified rules when IDs are given', async () => { + const getRulesResponse = await clientWithLabs.getRules(addedRulesId.slice(0, 2)); + expect(getRulesResponse.data).toBeDefined(); + expect(getRulesResponse.data).toHaveLength(2); + expect(getRulesResponse.data).toContainEqual(...addedRules.slice(0, 2)); + expect(getRulesResponse.data).not.toContainEqual(addedRules[2]); + }); + +}); diff --git a/test/twitter.test.js b/test/twitter.test.js index 0a6a40b..e50de38 100644 --- a/test/twitter.test.js +++ b/test/twitter.test.js @@ -152,7 +152,7 @@ describe('posting', () => { let client; beforeAll(() => (client = newClient())); - it('should DM user, including special characters', async () => { + it.skip('should DM user, including special characters', async () => { const message = randomString(); // prevent overzealous abuse detection // POST with JSON body and no parameters per https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/new-event diff --git a/twitter.js b/twitter.js index 273a647..af6e67a 100644 --- a/twitter.js +++ b/twitter.js @@ -7,6 +7,9 @@ const Stream = require('./stream'); const getUrl = (subdomain, endpoint = '1.1') => `https://${subdomain}.twitter.com/${endpoint}`; +const getLabsUrl = (version, endpoint) => + `https://api.twitter.com/labs/${version}/tweets/${endpoint}`; + const createOauthClient = ({ key, secret }) => { const client = OAuth({ consumer: { key, secret }, @@ -180,7 +183,7 @@ class Twitter { let parameters = { oauth_verifier: options.oauth_verifier, oauth_token: options.oauth_token }; if (parameters.oauth_verifier && parameters.oauth_token) requestData.url += '?' + querystring.stringify(parameters); - const headers = this.client.toHeader( this.client.authorize(requestData) ); + const headers = this.client.toHeader(this.client.authorize(requestData)); const results = await Fetch(requestData.url, { method: 'POST', @@ -191,6 +194,18 @@ class Twitter { return results; } + _makeAuthorizationHeader(requestData) { + if (this.authType === 'User') { + return this.client.toHeader( + this.client.authorize(requestData, this.token), + ); + } else { + return { + Authorization: `Bearer ${this.config.bearer_token}`, + }; + } + } + /** * Construct the data and headers for an authenticated HTTP request to the Twitter API * @param {string} method - 'GET' or 'POST' @@ -208,16 +223,7 @@ class Twitter { if (method === 'POST') requestData.data = parameters; else requestData.url += '?' + querystring.stringify(parameters); - let headers = {}; - if (this.authType === 'User') { - headers = this.client.toHeader( - this.client.authorize(requestData, this.token), - ); - } else { - headers = { - Authorization: `Bearer ${this.config.bearer_token}`, - }; - } + let headers = this._makeAuthorizationHeader(requestData); return { requestData, headers, @@ -351,6 +357,171 @@ class Twitter { return stream; } + + withLabs() { + return new TwitterLabs(this); + } + + /** + * Create a simple rule structure, useful when twitter labs filter stream rules + * + * @param {string} value the rule value + * @param {string} [tag] tag associated with rule + * @returns {{value: string, tag?: string}} object that can be used to add rules + */ + static labsFilterStreamRule(value, tag) { + if (tag) return { value, tag }; + else return { value }; + } +} + +/** + * Rule structure when adding twitter labs filter stream rules + * @typedef {{value: string, tag?: string}} LabsFilterStreamRule + */ + +/** + * Twitter labs expansions + * @typedef {'attachment.poll_ids'|'attachments.media_keys'|'author_id'|'entities.mentions.username'| + * 'geo.place_id'|'in_reply_to_user_id'|'referenced_tweets.id'|'referenced_tweets.id.author_id'} LabsExpansion + * @see {@link https://developer.twitter.com/en/docs/labs/overview/whats-new/expansions About expansions} + */ + +/** + * Twitter labs response format + * @typedef {'compact'|'detailed'|'default'} LabsFormat + * @see {@link https://developer.twitter.com/en/docs/labs/overview/whats-new/formats About format} + */ + +/** + * Twitter class that enhances its functionalities with Twitter Labs API calls + * @augments {Twitter} + */ +class TwitterLabs extends Twitter { + /** + * Class that also enables requests to Twitter Labs APIs using the given original {@link Twitter} instances + * + * @constructor + * @param {Twitter} originalTwitter original twitter instance + */ + constructor(originalTwitter) { + super(); + // copy properties for ease + Object.defineProperties(this, Object.getOwnPropertyDescriptors(originalTwitter)); + } + + _makeLabsRequest(method, version, resource, queryParams) { + const requestData = { + url: `${getLabsUrl(version, resource)}`, + method, + }; + if (queryParams) requestData.url += '?' + querystring.stringify(queryParams); + + let headers = this._makeAuthorizationHeader(requestData); + return { + requestData, + headers, + }; + } + + /** + * Add rule for the filter stream API + * + * @param {LabsFilterStreamRule[]} rules a list of rules for the filter stream API + * @param {boolean} [dryRun] optional parameter to mark the request as a dry run + * @returns {Promise} Promise response from Twitter API + * @see {@link https://developer.twitter.com/en/docs/labs/filtered-stream/api-reference/post-tweets-stream-filter-rules Twitter API} + */ + addRules(rules, dryRun) { + let queryParams = {}; + if (dryRun) queryParams = { dry_run: true }; + const { requestData, headers } = this._makeLabsRequest('POST', '1', 'stream/filter/rules', queryParams); + const postHeaders = Object.assign({}, baseHeaders, headers); + return Fetch(requestData.url, { + method: requestData.method, + headers: postHeaders, + body: JSON.stringify({ add: rules }), + }).then(Twitter._handleResponse); + } + + /** + * Get registered rules + * + * @returns {Promise} Promise response from Twitter API + * @see {@link https://developer.twitter.com/en/docs/labs/filtered-stream/api-reference/get-tweets-stream-filter-rules Twitter API} + */ + getRules(...ids) { + let queryParams = {}; + if (ids) queryParams = { ids: ids.join(',') }; + const { requestData, headers } = this._makeLabsRequest('GET', '1', 'stream/filter/rules', queryParams); + return Fetch(requestData.url, { + method: requestData.method, + headers, + }).then(Twitter._handleResponse); + } + + /** + * Delete registered rules + * + * @param {string[]} Rule IDs that has been registered + * @param {boolean} [dryRun] optional parameter to mark request as a dry run + * @returns {Promise} Promise response from Twitter API + * @see {@link https://developer.twitter.com/en/docs/labs/filtered-stream/api-reference/get-tweets-stream-filter-rules Twitter API} + */ + deleteRules(ids, dryRun) { + let queryParams = {}; + if (dryRun) queryParams = { dry_run: dryRun }; + const { requestData, headers } = this._makeLabsRequest('POST', '1', 'stream/filter/rules', queryParams); + const postHeaders = Object.assign({}, baseHeaders, headers); + return Fetch(requestData.url, { + method: requestData.method, + headers: postHeaders, + body: JSON.stringify({ delete: { ids } }), + }).then(Twitter._handleResponse); + } + + /** + * Start filter stream using saved rules + * + * @param {{expansions: LabsExpansion[], format: LabsFormat, 'place.format': LabsFormat, + * 'tweet.format': LabsFormat, 'user.format': LabsFormat}} [queryParams] + * @returns {Stream} stream object for the filter stream + * @see {@link https://developer.twitter.com/en/docs/labs/filtered-stream/api-reference/get-tweets-stream-filter Twitter API} + */ + filterStream(queryParams) { + if (queryParams && queryParams.expansions) { + queryParams.expansions = queryParams.expansions.join(','); + } + const { requestData, headers } = this._makeLabsRequest('GET', '1', 'stream/filter', queryParams); + console.log(requestData.url); + + const stream = new Stream(); + const request = Fetch(requestData.url, { + method: requestData.method, + headers, + }); + + request + .then(response => { + stream.destroy = this.stream.destroy = () => response.body.destroy(); + + if (response.ok) { + stream.emit('start', response); + } else { + response._headers = response.headers; + stream.emit('error', response); + } + + response.body + .on('data', chunk => stream.parse(chunk)) + .on('error', error => stream.emit('error', error)) + .on('end', () => stream.emit('end', response)); + }) + .catch(error => stream.emit('error', error)); + + return stream; + } + } module.exports = Twitter;