Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added Twitter Labs filter stream v1 support #127

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,70 @@ See the [OAuth example](#oauth-authentication).

See the [OAuth example](#oauth-authentication).

## Twitter Labs Support

[Twitter Labs](https://developer.twitter.com/en/labs) is early access to new endpoints developed by Twitter. Using Twitter Labs requires explicit opt-in, therefore Twitter Labs functionalities are also supported in an opt-in manner.

In order to create an instance that comes with Twitter Labs functionalities all you need to do is the following

```es6
const client = new Twitter({
consumer_key: "xyz",
consumer_secret: "xyz"
});

/**
* will produce object instance with Twitter Labs
* functionalities in ADDITION to the base Twitter API functionalities
*/
const clientWithLabs = client.withLabs()
```

All the options fed to the base `Twitter` instance will be copied over to the instance with Twitter Labs support, and so you don't need to do any more setup.

However, not all Twitter Labs APIs are supported currently. If an API is not yet implemented, you can consider making a PR! Please see [contribution guidelines](##contributing).

### Supported Twitter Labs APIs

#### Filtered streams

Support for [Twitter Labs filtered streams](https://developer.twitter.com/en/docs/labs/filtered-stream/api-reference) is provided. The following is an example on how to use it:

```es6
const client = new Twitter({
consumer_key: "xyz",
consumer_secret: "xyz"
});

const bearerToken = await client.getBearerToken()

const app = new Twitter({
bearer_token: bearerToken.access_token
})
const appWithLabs = app.withLabs()

await appWithLabs.addRules([{value: 'twitter'}, {value: 'javascript'}])
const stream = appWithLabs.filterStream()
.on("start", response => console.log("start"))
.on("data", tweet => console.log("data", tweet.text))
.on("ping", () => console.log("ping"))
.on("error", error => console.log("error", error))
.on("end", response => console.log("end"));

// To stop the stream:
process.nextTick(() => stream.destroy()); // emits "end" and "error" events
```

The streaming functionality uses the same underlying streaming capabilities as shown in the [stream section](##Streams).

The methods to interact with the whole filtered stream API suite are:
- `addRules(rules, dryRun)`
- `getRules(...ids)`
- `deleteRules(ids, dryRun)`
- `filterStream(queryParams)`

JSDoc and Typescript documentation are provided for all of them.

## Examples

You can find many more examples for various resources/endpoints in [the tests](test).
Expand Down Expand Up @@ -375,7 +439,9 @@ With the library nearing v1.0, contributions are welcome! Areas especially in ne
ACCESS_TOKEN=...
ACCESS_TOKEN_SECRET=...
```
5. `yarn/npm test` and make sure all tests pass
5.
- `yarn/npm test` and make sure all tests pass
- `yarn/npm run test-labs` to run Twitter Labs related tests and make sure all tests pass
6. Add your contribution, along with test case(s). Note: feel free to skip the ["should DM user"](https://github.com/draftbit/twitter-lite/blob/34e8dbb3efb9a45564275f16473af59dbc4409e5/twitter.test.js#L167) test during development by changing that `it()` call to `it.skip()`, but remember to revert that change before committing. This will prevent your account from being flagged as [abusing the API to send too many DMs](https://github.com/draftbit/twitter-lite/commit/5ee2ce4232faa07453ea2f0b4d63ee7a6d119ce7).
7. Make sure all tests pass. **NOTE: tests will take over 10 minutes to finish.**
8. Commit using a [descriptive message](https://chris.beams.io/posts/git-commit/) (please squash commits into one per fix/improvement!)
Expand Down
103 changes: 99 additions & 4 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>} 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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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<object>} 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<object>

/**
* Get registered rules
*
* @returns {Promise<object>} 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<object>

/**
* 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<object>} 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<object>


/**
* 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
};
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -68,4 +69,4 @@
"maxSize": "3 kB"
}
]
}
}
140 changes: 140 additions & 0 deletions test/twitter.labs.test.js
Original file line number Diff line number Diff line change
@@ -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]);
});

});
2 changes: 1 addition & 1 deletion test/twitter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading