diff --git a/.eslintrc.js b/.eslintrc.js index 6c649e93..5f3fca26 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -34,6 +34,11 @@ module.exports = { selector: ['variableLike', 'memberLike'], format: ['camelCase'], }, + { + selector: ['variableLike', 'memberLike'], + modifiers: ['static', 'readonly'], + format: ['UPPER_CASE'], + }, { selector: 'enumMember', format: ['UPPER_CASE'], diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 6420deae..95f68812 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: '18.x' + node-version: '20.x' # Installing dependencies - run: yarn diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0ac0659b..dfd005bd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: '18.x' + node-version: '20.x' registry-url: https://registry.npmjs.org/ # Installing dependencies diff --git a/.gitignore b/.gitignore index 491c6e50..75d240ba 100644 --- a/.gitignore +++ b/.gitignore @@ -133,7 +133,7 @@ dist .pnp.* # Test files -.test/ +test # Documentation -.docs +docs diff --git a/.prettierrc b/.prettierrc index d8a9682e..3fa6ea6f 100644 --- a/.prettierrc +++ b/.prettierrc @@ -3,7 +3,7 @@ "bracketSpacing": true, "endOfLine": "auto", "printWidth": 120, - "quoteProps": "consistent", + "quoteProps": "as-needed", "semi": true, "singleQuote": true, "tabWidth": 4, diff --git a/README.md b/README.md index 0bfee008..f10e303c 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,9 @@ By default, Rettiwt-API uses 'guest' authentication. If however, access to the f Here, - - \ is the email of the Twitter account to be used for authentication. - - \ is the username associtated with the Twitter account. - - \ is the password to the Twitter account. + - `` is the email of the Twitter account to be used for authentication. + - `` is the username associated with the Twitter account. + - `` is the password to the Twitter account. 3. The string returned after running the command is the API_KEY. Store it in a secure place for later use. @@ -68,7 +68,7 @@ The API_KEY generated by logging in is what allows Rettiwt-API to authenticate a - If you have no idea of programming, it's recommended to use the CLI. - The CLI provides an easy to use interface which does not require any knowledge of JavaScript or programming -- Please skip to 'CLI-Usage' section for details. +- Please skip to [CLI-Usage](https://rishikant181.github.io/Rettiwt-API/#md:cli-usage) for details. ## Usage as a dependency @@ -88,19 +88,20 @@ For example, for generating the API_KEY, the command will be modified as follows ## The Rettiwt class -When used as a dependency, the Rettiwt class is entry point for accessing the Twitter API. +When used as a dependency, the [Rettiwt](https://rishikant181.github.io/Rettiwt-API/classes/Rettiwt.html) class is entry point for accessing the Twitter API. A new Rettiwt instance can be initialized using the following code snippets: - `const rettiwt = new Rettiwt()` (for 'guest' authentication) - `const rettiwt = new Rettiwt({ apiKey: API_KEY })` (for 'user' authentication) -The Rettiwt class has two members: +The Rettiwt class has three members: -- 'tweet' member, for accessing resources related to tweets -- 'user' member, for accessing resources related to users +- `auth` memeber, for managing authentication +- `tweet` member, for accessing resources related to tweets +- `user` member, for accessing resources related to users -For details regarding usage of these members for accessing the Twitter API, refer to the 'Features' section. +For details regarding usage of these members for accessing the Twitter API, refer to [Features](https://rishikant181.github.io/Rettiwt-API/#md:features). ## Usage @@ -109,7 +110,7 @@ The following examples may help you to get started using the library: ### 1. Getting the details of a target Twitter user ```js -const { Rettiwt } = require('rettiwt-api'); +import { Rettiwt } from 'rettiwt-api'; // Creating a new Rettiwt instance // Note that for accessing user details, 'guest' authentication can be used @@ -128,7 +129,7 @@ rettiwt.user.details('') ### 2. Getting the list of tweets that match a given filter ```js -const { Rettiwt } = require('rettiwt-api'); +import { Rettiwt } from 'rettiwt-api'; // Creating a new Rettiwt instance using the API_KEY const rettiwt = new Rettiwt({ apiKey: API_KEY }); @@ -150,12 +151,14 @@ rettiwt.tweet.search({ }); ``` +For more information regarding the different available filter options, please refer to [TweetFilter](https://rishikant181.github.io/Rettiwt-API/classes/TweetFilter.html). + ### 3. Getting the next batch of data using a cursor The previous example fetches the the list of tweets matching the given filter. Since no count is specified, in this case, a default of 20 such Tweets are fetched initially. The following example demonstrates how to use the [cursor string](https://rishikant181.github.io/Rettiwt-API/classes/Cursor.html#value) obtained from the [response](https://rishikant181.github.io/Rettiwt-API/classes/CursoredData.html) object's [next](https://rishikant181.github.io/Rettiwt-API/classes/CursoredData.html#next) field, from the previous example, to fetch the next batch of tweets: ```js -const { Rettiwt } = require('rettiwt-api'); +import { Rettiwt } from 'rettiwt-api'; // Creating a new Rettiwt instance using the API_KEY const rettiwt = new Rettiwt({ apiKey: API_KEY }); @@ -179,7 +182,32 @@ rettiwt.tweet.search({ }); ``` -For more information regarding the different available filter options, please refer to [TweetFilter](https://rishikant181.github.io/Rettiwt-Core/classes/TweetFilter.html). +### 4. Getting an API_KEY during runtime, using 'user' authentication + +Sometimes, you might want to generate an API_KEY on the fly, in situations such as implementing Twitter login in your application. The following example demonstrates how to generate an API_KEY during runtime: + +```js +import { Rettiwt } from 'rettiwt-api'; + +// Creating a new Rettiwt instance +const rettiwt = new Rettiwt(); + +// Logging in an getting the API_KEY +rettiwt.auth.login('', '', '') +.then(apiKey => { + // Use the API_KEY + ... +}) +.catch(err => { + console.log(err); +}); +``` + +Where, + +- `` is the email associated with the Twitter account to be logged into. +- `` is the username associated with the Twitter account. +- `` is the password to the Twitter account. ## Using a proxy @@ -189,11 +217,28 @@ For masking of IP address using a proxy server, use the following code snippet f /** * PROXY_URL is the URL or configuration for the proxy server you want to use.` */ -const rettiwt = Rettiwt({ apiKey: API_KEY, proxyUrl: PROXY_URL }); +const rettiwt = new Rettiwt({ apiKey: API_KEY, proxyUrl: PROXY_URL }); ``` This creates a Rettiwt instance which uses the given proxy server for making requests to Twitter. +## Cloud environment + +When using this library in an application deployed in a cloud environment, the library might throw error 429, even when under rate limits. This happens because Twitter's v1.1 API endpoints seemingly blocks access from cloud services' IP ranges. These v1.1 API endpoints are the ones used for authentication and as such, authentication tasks are blocked while deployed on cloud environments. + +This issue can be bypassed by using a proxy only for authentication, using the following code snippet for creating a new Rettiwt instance: + +`const rettiwt = new Rettiwt({ authProxyUrl: PROXY_URL });` + +Where, + +- `PROXY_URL` is the URL to the proxy server to use. + +Authentication proxy is required only in the following two scenarios: + +1. While using 'guest' authentication. +2. While creating API_KEY by 'user' authentication. + ## Debug logs Sometimes, when the library shows unexpected behaviour, for troubleshooting purposes, debug logs can be enabled which will help in tracking down the issue and working on a potential fix. Currently, debug logs are printed to the console and are enabled by setting the 'logging' property of the config to true, while creating an instance of Rettiwt: @@ -202,13 +247,18 @@ Sometimes, when the library shows unexpected behaviour, for troubleshooting purp /** * By default, is no value for 'logging' is supplied, logging is disabled. */ -const rettiwt = Rettiwt({ apiKey: API_KEY, logging: true }); +const rettiwt = new Rettiwt({ apiKey: API_KEY, logging: true }); ``` ## Features So far, the following operations are supported: +### Authentication + +- [Logging in as user](https://rishikant181.github.io/Rettiwt-API/classes/AuthService.html#login) +- [Logging in as guest](https://rishikant181.github.io/Rettiwt-API/classes/AuthService.html#guest) + ### Tweets - [Getting the details of a tweet](https://rishikant181.github.io/Rettiwt-API/classes/TweetService.html#details) @@ -235,12 +285,12 @@ Rettiwt-API provides an easy to use command-line interface which does not requir By default, the CLI operates in 'guest' authentication. If you want to use 'user' authentication: -1. Generate an API_KEY as described in 'Authentication' section. +1. Generate an API_KEY as described in [Authentication](https://rishikant181.github.io/Rettiwt-API/#md:authentication). 2. Store the output API_KEY as an environment variable with the name 'API_KEY'. - - Additionaly, store the API_KEY in a file for later use. + - Additionally, store the API_KEY in a file for later use. - Make sure to generate an API_KEY only once, and use it every time you need it. 3. The CLI automatically reads this environment variable to authenticate against Twitter. - - Additionaly, the API_KEY can also be passed in manually using the '-k' option as follows: `rettiwt -k ` + - Additionally, the API_KEY can also be passed in manually using the '-k' option as follows: `rettiwt -k ` Help for the CLI can be obtained from the CLI itself: @@ -253,5 +303,5 @@ The complete API reference can be found at [this](https://rishikant181.github.io ## Additional information -- This API uses the cookies of a Twitter account to fetch data from Twitter and as such, there is always a chance (altough a measly one) of getting the account banned by Twitter algorithm. +- This API uses the cookies of a Twitter account to fetch data from Twitter and as such, there is always a chance (although a measly one) of getting the account banned by Twitter algorithm. - There have been no reports of accounts getting banned, but you have been warned, even though the chances of getting banned is negligible, it is not zero! diff --git a/package.json b/package.json index c4436221..f1cf4cd4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rettiwt-api", - "version": "2.4.2", + "version": "2.5.0", "main": "dist/index.js", "types": "dist/index.d.ts", "description": "An API for fetching data from TwitterAPI, without any rate limits!", @@ -12,7 +12,7 @@ "prepare": "tsc", "format": "prettier --write .", "lint": "eslint --max-warnings 0 .", - "docs": "typedoc src/index.ts", + "docs": "typedoc --excludePrivate --excludeProtected --excludeInternal src/index.ts", "debug": "nodemon ./dist/index.js --inspect=0.0.0.0:9229" }, "repository": { @@ -29,11 +29,11 @@ }, "homepage": "https://rishikant181.github.io/Rettiwt-API/", "dependencies": { - "axios": "1.3.2", + "axios": "1.6.3", "commander": "11.1.0", "https-proxy-agent": "7.0.2", - "rettiwt-auth": "2.0.0", - "rettiwt-core": "3.2.1" + "rettiwt-auth": "2.1.0", + "rettiwt-core": "3.3.0" }, "devDependencies": { "@types/node": "20.4.1", diff --git a/src/Rettiwt.ts b/src/Rettiwt.ts index d92b7e9b..09ea7c99 100644 --- a/src/Rettiwt.ts +++ b/src/Rettiwt.ts @@ -1,16 +1,17 @@ // SERVICES +import { AuthService } from './services/public/AuthService'; import { TweetService } from './services/public/TweetService'; import { UserService } from './services/public/UserService'; -// MODELS -import { RettiwtConfig } from './models/internal/RettiwtConfig'; +// TYPES +import { IRettiwtConfig } from './types/RettiwtConfig'; /** * The class for accessing Twitter API. * * The created Rettiwt instance can be configured by passing in a configuration object to the constructor. * - * For details regarding the available configuration options, refer to {@link RettiwtConfig} + * For details regarding the available configuration options, refer to {@link IRettiwtConfig} * * @example Creating a Rettiwt instance with 'guest' authentication: * ``` @@ -41,12 +42,15 @@ import { RettiwtConfig } from './models/internal/RettiwtConfig'; * import { Rettiwt } from 'rettiwt-api'; * * // Creating a new Rettiwt instance - * const rettiwt = new Rettiwt({ apiKey: 'API_KEY', loggin: true, proxyUrl: 'URL_TO_PROXY_SERVER' }); + * const rettiwt = new Rettiwt({ apiKey: 'API_KEY', logging: true, proxyUrl: 'URL_TO_PROXY_SERVER' }); * ``` * * @public */ export class Rettiwt { + /** The instance used to authenticate. */ + public auth: AuthService; + /** The instance used to fetch data related to tweets. */ public tweet: TweetService; @@ -58,7 +62,8 @@ export class Rettiwt { * * @param config - The config object for configuring the Rettiwt instance. */ - public constructor(config?: RettiwtConfig) { + public constructor(config?: IRettiwtConfig) { + this.auth = new AuthService(config); this.tweet = new TweetService(config); this.user = new UserService(config); } diff --git a/src/cli.ts b/src/cli.ts index 71f8526c..d595cb47 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -34,7 +34,7 @@ const rettiwt: Rettiwt = new Rettiwt({ // Adding sub-commands program.addCommand(tweet(rettiwt)); program.addCommand(user(rettiwt)); -program.addCommand(auth()); +program.addCommand(auth(rettiwt)); // Finalizing the CLI program.parse(); diff --git a/src/commands/Auth.ts b/src/commands/Auth.ts index b4ae9a03..370cfb40 100644 --- a/src/commands/Auth.ts +++ b/src/commands/Auth.ts @@ -1,11 +1,11 @@ // PACKAGES import { Command, createCommand } from 'commander'; -import { Auth } from 'rettiwt-auth'; +import { Rettiwt } from '../Rettiwt'; // UTILITY import { output } from '../helper/CliUtils'; -function createAuthCommand(): Command { +function createAuthCommand(rettiwt: Rettiwt): Command { // Creating the 'auth' command const auth = createCommand('auth').description('Manage authentication'); @@ -16,26 +16,15 @@ function createAuthCommand(): Command { .argument('', 'The username associated with the Twitter account') .argument('', 'The password to the Twitter account') .action(async (email: string, username: string, password: string) => { - // Logging in and getting the credentials - let apiKey: string = - ( - await new Auth().getUserCredential({ email: email, userName: username, password: password }) - ).toHeader().cookie ?? ''; - - // Converting the credentials to base64 string - apiKey = Buffer.from(apiKey).toString('base64'); + const apiKey: string = await rettiwt.auth.login(email, username, password); output(apiKey); }); // Guest auth.command('guest') - .description('Generate a new guest API key') + .description('Generate a new guest key') .action(async () => { - // Getting a new guest API key - let guestKey: string = (await new Auth().getGuestCredential()).guestToken ?? ''; - - // Converting the credentials to base64 string - guestKey = Buffer.from(guestKey).toString('base64'); + const guestKey: string = await rettiwt.auth.guest(); output(guestKey); }); diff --git a/src/commands/Tweet.ts b/src/commands/Tweet.ts index 8531682e..7d2d1928 100644 --- a/src/commands/Tweet.ts +++ b/src/commands/Tweet.ts @@ -35,7 +35,26 @@ function createTweetCommand(rettiwt: Rettiwt): Command { .option('-f, --from ', "Matches the tweets made by list of given users, separated by ';'") .option('-t, --to ', "Matches the tweets made to the list of given users, separated by ';'") .option('-w, --words ', "Matches the tweets containing the given list of words, separated by ';'") + .option('-p, --phrase ', 'Matches the tweets containing the exact phrase') + .option( + '--optional-words ', + "Matches the tweets containing any of the given list of words, separated by ';'", + ) + .option( + '--exclude-words ', + "Matches the tweets that do not contain any of the give list of words, separated by ';'", + ) .option('-h, --hashtags ', "Matches the tweets containing the given list of hashtags, separated by ';'") + .option( + '-m, --mentions ', + "Matches the tweets that mention the give list of usernames, separated by ';'", + ) + .option('-r, --min-replies ', 'Matches the tweets that have a minimum of given number of replies') + .option('-l, --min-likes ', 'Matches the tweets that have a minimum of given number of likes') + .option('-x, --min-retweets ', 'Matches the tweets that have a minimum of given number of retweets') + .option('-q, --quoted ', 'Matches the tweets that quote the tweet with the given id') + .option('--exclude-links', 'Matches tweets that do not contain links') + .option('--exclude-replies', 'Matches the tweets that are not replies') .option('-s, --start ', 'Matches the tweets made since the given date (valid date string)') .option('-e, --end ', 'Matches the tweets made upto the given date (valid date string)') .action(async (count?: string, cursor?: string, options?: TweetSearchOptions) => { @@ -88,8 +107,12 @@ function createTweetCommand(rettiwt: Rettiwt): Command { .command('post') .description('Post a tweet (text only)') .argument('', 'The text to post as a tweet') - .action(async (text: string) => { - const result = await rettiwt.tweet.tweet(text); + .option('-m, --media [string]', "The path to the media item(s) to be posted, separated by ';'") + .action(async (text: string, options?: { media?: string }) => { + const result = await rettiwt.tweet.tweet( + text, + options?.media ? options?.media.split(';').map((item) => ({ path: item })) : undefined, + ); output(result); }); @@ -125,7 +148,17 @@ class TweetSearchOptions { public from?: string; public to?: string; public words?: string; + public phrase?: string; + public optionalWords?: string; + public excludeWords?: string; public hashtags?: string; + public mentions?: string; + public minReplies?: number; + public minLikes?: number; + public minRetweets?: number; + public quoted?: string; + public excludeLinks?: boolean = false; + public excludeReplies?: boolean = false; public start?: string; public end?: string; @@ -138,7 +171,17 @@ class TweetSearchOptions { this.from = options?.from; this.to = options?.to; this.words = options?.words; + this.phrase = options?.phrase; + this.optionalWords = options?.optionalWords; + this.excludeWords = options?.excludeWords; this.hashtags = options?.hashtags; + this.mentions = options?.mentions; + this.minReplies = options?.minReplies; + this.minLikes = options?.minLikes; + this.minRetweets = options?.minRetweets; + this.quoted = options?.quoted; + this.excludeLinks = options?.excludeLinks; + this.excludeReplies = options?.excludeReplies; this.start = options?.start; this.end = options?.end; } @@ -152,8 +195,18 @@ class TweetSearchOptions { return new TweetFilter({ fromUsers: this.from ? this.from.split(';') : undefined, toUsers: this.to ? this.to.split(';') : undefined, - words: this.words ? this.words.split(';') : undefined, + includeWords: this.words ? this.words.split(';') : undefined, + includePhrase: this.phrase, + optionalWords: this.optionalWords ? this.optionalWords.split(';') : undefined, + excludeWords: this.excludeWords ? this.excludeWords.split(';') : undefined, hashtags: this.hashtags ? this.hashtags.split(';') : undefined, + mentions: this.mentions ? this.mentions.split(';') : undefined, + minReplies: this.minReplies, + minLikes: this.minLikes, + minRetweets: this.minRetweets, + quoted: this.quoted, + links: !this.excludeLinks, + replies: !this.excludeReplies, startDate: this.start ? new Date(this.start) : undefined, endDate: this.end ? new Date(this.end) : undefined, }); diff --git a/src/enums/ApiErrors.ts b/src/enums/Api.ts similarity index 100% rename from src/enums/ApiErrors.ts rename to src/enums/Api.ts diff --git a/src/enums/HTTP.ts b/src/enums/HTTP.ts deleted file mode 100644 index 6f576803..00000000 --- a/src/enums/HTTP.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * The different types of http status codes - * - * @internal - */ -export enum EHttpStatus { - BAD_REQUEST = 400, - UNAUTHORIZED = 401, - FORBIDDEN = 403, - NOT_FOUND = 404, - METHOD_NOT_ALLOWED = 405, - REQUEST_TIMEOUT = 408, - TOO_MANY_REQUESTS = 429, - INTERNAL_SERVER_ERROR = 500, - BAD_GATEWAY = 502, - SERVICE_UNAVAILABLE = 503, -} diff --git a/src/enums/Http.ts b/src/enums/Http.ts new file mode 100644 index 00000000..29825f58 --- /dev/null +++ b/src/enums/Http.ts @@ -0,0 +1,68 @@ +/** + * The different types of http status codes + * + * @internal + */ +export enum EHttpStatus { + CONTINUE = 100, + SWITCHING_PROTOCOLS = 101, + PROCESSING = 102, + OK = 200, + CREATED = 201, + ACCEPTED = 202, + NON_AUTHORITATIVE_INFORMATION = 203, + NO_CONTENT = 204, + RESET_CONTENT = 205, + PARTIAL_CONTENT = 206, + MULTI_STATUS = 207, + ALREADY_REPORTED = 208, + IM_USED = 226, + MULTIPLE_CHOICES = 300, + MOVED_PERMANENTLY = 301, + FOUND = 302, + SEE_OTHER = 303, + NOT_MODIFIED = 304, + USE_PROXY = 305, + SWITCH_PROXY = 306, + TEMPORARY_REDIRECT = 307, + PERMANENT_REDIRECT = 308, + BAD_REQUEST = 400, + UNAUTHORIZED = 401, + PAYMENT_REQUIRED = 402, + FORBIDDEN = 403, + NOT_FOUND = 404, + METHOD_NOT_ALLOWED = 405, + NOT_ACCEPTABLE = 406, + PROXY_AUTHENTICATION_REQUIRED = 407, + REQUEST_TIMEOUT = 408, + CONFLICT = 409, + GONE = 410, + LENGTH_REQUIRED = 411, + PRECONDITION_FAILED = 412, + PAYLOAD_TOO_LARGE = 413, + URI_TOO_LONG = 414, + UNSUPPORTED_MEDIA_TYPE = 415, + RANGE_NOT_SATISFIABLE = 416, + EXPECTATION_FAILED = 417, + I_AM_A_TEAPOT = 418, + MISDIRECTED_REQUEST = 421, + UNPROCESSABLE_ENTITY = 422, + LOCKED = 423, + FAILED_DEPENDENCY = 424, + UPGRADE_REQUIRED = 426, + PRECONDITION_REQUIRED = 428, + TOO_MANY_REQUESTS = 429, + REQUEST_HEADER_FIELDS_TOO_LARGE = 431, + UNAVAILABLE_FOR_LEGAL_REASONS = 451, + INTERNAL_SERVER_ERROR = 500, + NOT_IMPLEMENTED = 501, + BAD_GATEWAY = 502, + SERVICE_UNAVAILABLE = 503, + GATEWAY_TIMEOUT = 504, + HTTP_VERSION_NOT_SUPPORTED = 505, + VARIANT_ALSO_NEGOTIATES = 506, + INSUFFICIENT_STORAGE = 507, + LOOP_DETECTED = 508, + NOT_EXTENDED = 510, + NETWORK_AUTHENTICATION_REQUIRED = 511, +} diff --git a/src/enums/Logging.ts b/src/enums/Logging.ts index a03d84fb..d997023f 100644 --- a/src/enums/Logging.ts +++ b/src/enums/Logging.ts @@ -6,6 +6,7 @@ export enum ELogActions { FETCH = 'FETCH', POST = 'POST', + UPLOAD = 'UPLOAD', EXTRACT = 'EXTRACT', DESERIALIZE = 'DESERIALIZE', AUTHORIZATION = 'AUTHORIZATION', diff --git a/src/index.ts b/src/index.ts index 4a5d1204..517cf40f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,29 +1,34 @@ // MAIN export * from './Rettiwt'; -// Exporting enums -export * from './enums/ApiErrors'; -export * from './enums/HTTP'; +// EXTERNAL +export { TweetFilter } from 'rettiwt-core'; + +// ENUMS +export * from './enums/Api'; +export * from './enums/Http'; export * from './enums/Logging'; -// Exporting models -export * from './models/internal/RettiwtConfig'; -export * from './models/public/CursoredData'; -export * from './models/public/List'; -export * from './models/public/Tweet'; -export * from './models/public/User'; -export { TweetFilter } from 'rettiwt-core'; +// ERROR MODELS +export * from './models/errors/ApiError'; +export * from './models/errors/HttpError'; +export * from './models/errors/RettiwtError'; + +// DATA MODELS +export * from './models/data/CursoredData'; +export * from './models/data/List'; +export * from './models/data/Tweet'; +export * from './models/data/User'; -// Exporting services +// SERVICES +export * from './services/internal/ErrorService'; export * from './services/internal/FetcherService'; export * from './services/internal/LogService'; +export * from './services/public/AuthService'; export * from './services/public/TweetService'; export * from './services/public/UserService'; -// Exporting types -export * from './types/internal/RettiwtConfig'; -export * from './types/public/CursoredData'; -export * from './types/public/List'; -export * from './types/public/Tweet'; -export * from './types/public/User'; -export { ITweetFilter } from 'rettiwt-core'; +// TYPES +export * from './types/args/TweetMediaArgs'; +export * from './types/RettiwtConfig'; +export * from './types/ErrorHandler'; diff --git a/src/models/public/CursoredData.ts b/src/models/data/CursoredData.ts similarity index 79% rename from src/models/public/CursoredData.ts rename to src/models/data/CursoredData.ts index a9a62894..e9ab289c 100644 --- a/src/models/public/CursoredData.ts +++ b/src/models/data/CursoredData.ts @@ -2,9 +2,6 @@ import { Tweet } from './Tweet'; import { User } from './User'; -// TYPES -import { ICursor, ICursoredData } from '../../types/public/CursoredData'; - /** * The data that us fetched batch-wise along with a cursor. * @@ -12,8 +9,11 @@ import { ICursor, ICursoredData } from '../../types/public/CursoredData'; * * @public */ -export class CursoredData implements ICursoredData { +export class CursoredData { + /** The list of data of the given type. */ public list: T[] = []; + + /** The cursor to the next batch of data. */ public next: Cursor; /** @@ -31,7 +31,8 @@ export class CursoredData implements ICursoredData { * * @public */ -export class Cursor implements ICursor { +export class Cursor { + /** The cursor string. */ public value: string; /** diff --git a/src/models/public/List.ts b/src/models/data/List.ts similarity index 69% rename from src/models/public/List.ts rename to src/models/data/List.ts index b96850ee..b9231fe1 100644 --- a/src/models/public/List.ts +++ b/src/models/data/List.ts @@ -1,21 +1,31 @@ // PACKAGES import { IList as IRawList } from 'rettiwt-core'; -// TYPES -import { IList } from '../../types/public/List'; - /** * The details of a single Twitter List. * * @public */ -export class List implements IList { +export class List { + /** The rest id of the list. */ public id: string; + + /** The name of the list. */ public name: string; + + /** The date and time of creation of the list, int UTC string format. */ public createdAt: string; + + /** The list description. */ public description: string; + + /** The number of memeber of the list. */ public memberCount: number; + + /** The number of subscribers of the list. */ public subscriberCount: number; + + /** The rest id of the user who created the list. */ public createdBy: string; /** diff --git a/src/models/data/Media.ts b/src/models/data/Media.ts new file mode 100644 index 00000000..117e94e3 --- /dev/null +++ b/src/models/data/Media.ts @@ -0,0 +1,19 @@ +// PACKAGES +import { IMediaUploadInitializeResponse } from 'rettiwt-core'; + +/** + * The details of a single media file. + * + * @public + */ +export class Media { + /** The id of the media. */ + public id: string; + + /** + * @param media - The raw media data. + */ + public constructor(media: IMediaUploadInitializeResponse) { + this.id = media.media_id_string; + } +} diff --git a/src/models/public/Tweet.ts b/src/models/data/Tweet.ts similarity index 76% rename from src/models/public/Tweet.ts rename to src/models/data/Tweet.ts index 26865b6e..96e2ab51 100644 --- a/src/models/public/Tweet.ts +++ b/src/models/data/Tweet.ts @@ -6,9 +6,6 @@ import { EMediaType, } from 'rettiwt-core'; -// TYPES -import { ITweet, ITweetEntities } from '../../types/public/Tweet'; - // MODELS import { User } from './User'; @@ -20,21 +17,50 @@ import { normalizeText } from '../../helper/JsonUtils'; * * @public */ -export class Tweet implements ITweet { +export class Tweet { + /** The rest id of the tweet. */ public id: string; + + /** The details of the user who made the tweet. */ public tweetBy: User; + + /** The date and time of creation of the tweet, in UTC string format. */ public createdAt: string; + + /** Additional tweet entities like urls, mentions, etc. */ public entities: TweetEntities; + + /** The urls of the media contents of the tweet (if any). */ public media: TweetMedia[]; + + /** The rest id of the tweet which is quoted in the tweet. */ public quoted: string; + + /** The full text content of the tweet. */ public fullText: string; + + /** The rest id of the user to which the tweet is a reply. */ public replyTo: string; + + /** The language in which the tweet is written. */ public lang: string; + + /** The number of quotes of the tweet. */ public quoteCount: number; + + /** The number of replies to the tweet. */ public replyCount: number; + + /** The number of retweets of the tweet. */ public retweetCount: number; + + /** The number of likes of the tweet. */ public likeCount: number; + + /** The number of views of a tweet. */ public viewCount: number; + + /** The number of bookmarks of a tweet. */ public bookmarkCount: number; /** @@ -66,9 +92,14 @@ export class Tweet implements ITweet { * * @public */ -export class TweetEntities implements ITweetEntities { +export class TweetEntities { + /** The list of hashtags mentioned in the tweet. */ public hashtags: string[] = []; + + /** The list of urls mentioned in the tweet. */ public urls: string[] = []; + + /** The list of IDs of users mentioned in the tweet. */ public mentionedUsers: string[] = []; /** @@ -106,7 +137,10 @@ export class TweetEntities implements ITweetEntities { * @public */ export class TweetMedia { + /** The type of media. */ public type: EMediaType; + + /** The direct URL to the media. */ public url: string = ''; /** diff --git a/src/models/public/User.ts b/src/models/data/User.ts similarity index 67% rename from src/models/public/User.ts rename to src/models/data/User.ts index e75b43d1..51ed6804 100644 --- a/src/models/public/User.ts +++ b/src/models/data/User.ts @@ -1,28 +1,52 @@ // PACKAGES import { IUser as IRawUser } from 'rettiwt-core'; -// TYPES -import { IUser } from '../../types/public/User'; - /** * The details of a single user. * * @public */ -export class User implements IUser { +export class User { + /** The rest id of the user. */ public id: string; + + /** The username/screenname of the user. */ public userName: string; + + /** The full name of the user. */ public fullName: string; + + /** The creation date of user's account. */ public createdAt: string; + + /** The user's description. */ public description: string; + + /** Whether the account is verified or not. */ public isVerified: boolean; + + /** The number of tweets liked by the user. */ public favouritesCount: number; + + /** The number of followers of the user. */ public followersCount: number; + + /** The number of following of the user. */ public followingsCount: number; + + /** The number of tweets made by the user. */ public statusesCount: number; + + /** The location of user as provided by user. */ public location: string; + + /** The rest id of the tweet pinned in the user's profile. */ public pinnedTweet: string; + + /** The url of the profile banner image. */ public profileBanner: string; + + /** The url of the profile image. */ public profileImage: string; /** diff --git a/src/models/errors/ApiError.ts b/src/models/errors/ApiError.ts new file mode 100644 index 00000000..9b8d6cd6 --- /dev/null +++ b/src/models/errors/ApiError.ts @@ -0,0 +1,24 @@ +// ERRORS +import { RettiwtError } from './RettiwtError'; + +/** + * Represents an error that is thrown by Twitter API. + * + * @internal + */ +export class ApiError extends RettiwtError { + /** The error code thrown by Twitter API. */ + public code: number; + + /** + * Initializes a new ApiError based on the given error details. + * + * @param errorCode - The error code thrown by Twitter API. + * @param message - Any additional error message. + */ + public constructor(errorCode: number, message?: string) { + super(message); + + this.code = errorCode; + } +} diff --git a/src/models/errors/HttpError.ts b/src/models/errors/HttpError.ts new file mode 100644 index 00000000..ad389db3 --- /dev/null +++ b/src/models/errors/HttpError.ts @@ -0,0 +1,24 @@ +// ERRORS +import { RettiwtError } from './RettiwtError'; + +/** + * Represents an HTTP error that occues while making a request to Twitter API. + * + * @internal + */ +export class HttpError extends RettiwtError { + /** The HTTP status code. */ + public status: number; + + /** + * Initializes a new HttpError based on the given error details. + * + * @param httpStatus - The HTTP status code received upon making the request + * @param message - Any additional error message. + */ + public constructor(httpStatus: number, message?: string) { + super(message); + + this.status = httpStatus; + } +} diff --git a/src/models/errors/RettiwtError.ts b/src/models/errors/RettiwtError.ts new file mode 100644 index 00000000..27c26c28 --- /dev/null +++ b/src/models/errors/RettiwtError.ts @@ -0,0 +1,12 @@ +/** + * Represents an error that arises inside the package. + * + * @internal + */ +export class RettiwtError extends Error { + public constructor(message?: string) { + super(message); + + Object.setPrototypeOf(this, RettiwtError.prototype); + } +} diff --git a/src/models/errors/TimeoutError.ts b/src/models/errors/TimeoutError.ts new file mode 100644 index 00000000..25d7891d --- /dev/null +++ b/src/models/errors/TimeoutError.ts @@ -0,0 +1,18 @@ +// ERRORS +import { RettiwtError } from './RettiwtError'; + +/** + * Represents an HTTP error that occues while making a request to Twitter API. + * + * @internal + */ +export class TimeoutError extends RettiwtError { + /** + * Initializes a new TimeoutError based on the given error details. + * + * @param message - Error message with the configured timeout. + */ + public constructor(message?: string) { + super(message); + } +} diff --git a/src/models/internal/RettiwtConfig.ts b/src/models/internal/RettiwtConfig.ts deleted file mode 100644 index 0e5e142e..00000000 --- a/src/models/internal/RettiwtConfig.ts +++ /dev/null @@ -1,26 +0,0 @@ -// TYPES -import { IRettiwtConfig } from '../../types/internal/RettiwtConfig'; - -/** - * The configuration for initializing a new Rettiwt instance. - * - * @internal - */ -export class RettiwtConfig implements IRettiwtConfig { - public apiKey?: string; - public guestKey?: string; - public proxyUrl?: URL; - public logging?: boolean; - - /** - * Initializes a new configuration object from the given config. - * - * @param config - The configuration object. - */ - public constructor(config: RettiwtConfig) { - this.apiKey = config.apiKey; - this.guestKey = config.guestKey; - this.proxyUrl = config.proxyUrl; - this.logging = config.logging; - } -} diff --git a/src/services/internal/ErrorService.ts b/src/services/internal/ErrorService.ts new file mode 100644 index 00000000..95244058 --- /dev/null +++ b/src/services/internal/ErrorService.ts @@ -0,0 +1,158 @@ +// PACKAGES +import axios, { AxiosError, AxiosResponse } from 'axios'; +import { findKeyByValue } from '../../helper/JsonUtils'; + +// TYPES +import { IErrorHandler } from '../../types/ErrorHandler'; + +// ENUMS +import { EApiErrors } from '../../enums/Api'; +import { EHttpStatus } from '../../enums/Http'; +import { EErrorCodes } from 'rettiwt-core'; + +// ERRORS +import { ApiError } from '../../models/errors/ApiError'; +import { HttpError } from '../../models/errors/HttpError'; +import { TimeoutError } from '../../models/errors/TimeoutError'; + +// TODO Refactor and document this module + +/** + * The base service that handles any errors. + * + * @public + */ +export class ErrorService implements IErrorHandler { + /** + * Error message used when the specific error type is not defined in the required enums. + */ + protected static readonly DEFAULT_ERROR_MESSAGE = 'Unknown error'; + + /** + * The method called when an error response is received from Twitter API. + * + * @param error - The error caught while making HTTP request to Twitter API. + */ + public handle(error: unknown): void { + if (!axios.isAxiosError(error)) { + throw error; + } + + this.handleTimeoutError(error); + + const axiosResponse = this.getAxiosResponse(error); + this.handleApiError(axiosResponse); + this.handleHttpError(axiosResponse); + } + + /** + * Handles exceeded timeout, configured in RettiwtConfig. + * + * @param error - The error object. + * @throws An error if the configured request timeout has been exceeded. + */ + protected handleTimeoutError(error: AxiosError): void { + if (error.code === 'ECONNABORTED') { + throw new TimeoutError(error.message); + } + } + + /** + * Retrieves the response data from the given error. + * + * @param error - The error object. + * @returns The response data. + * @throws The original error if it is not an HTTP error with a response. + */ + protected getAxiosResponse(error: AxiosError): AxiosResponse { + if (error.response) { + return error.response; + } + + throw error; + } + + /** + * Handles HTTP error in a response. + * + * @param response - The response object received. + * @throws An error with the corresponding HTTP status text if any HTTP-related error has occurred. + */ + protected handleHttpError(response: AxiosResponse): void { + throw this.createHttpError(response.status); + } + + /** + * Handles API error in a response. + * + * @param response - The response object received. + * @throws An error with the corresponding API error message if any API-related error has occurred. + */ + protected handleApiError(response: AxiosResponse): void { + const errorCode = this.getErrorCode(response); + + if (errorCode === undefined) { + return; + } + + throw this.createApiError(errorCode); + } + + /** + * Creates an HTTP error instance based on the provided HTTP status. + * + * @param httpStatus - The HTTP status code. + * @returns An HTTP error instance. + */ + protected createHttpError(httpStatus: number): HttpError { + return new HttpError(httpStatus, this.getHttpErrorMessage(httpStatus)); + } + + /** + * Retrieves the HTTP error message based on the provided HTTP status. + * + * @param httpStatus - The HTTP status code. + * @returns The HTTP error message. + */ + protected getHttpErrorMessage(httpStatus: number): string { + return Object.values(EHttpStatus).includes(httpStatus) + ? EHttpStatus[httpStatus] + : ErrorService.DEFAULT_ERROR_MESSAGE; + } + + /** + * Retrieves the API error code from the Axios response data. + * + * @param response - The response object received. + * @returns The error code, or undefined if not found. + */ + protected getErrorCode(response: AxiosResponse): number | undefined { + const errors = (response.data as { errors: { code: number }[] }).errors; + + return !!errors && errors.length ? errors[0].code : undefined; + } + + /** + * Creates an API error instance based on the provided error code. + * + * @param errorCode - The error code. + * @returns An API error instance. + */ + protected createApiError(errorCode: number): ApiError { + return new ApiError(errorCode, this.getApiErrorMessage(errorCode)); + } + + /** + * Retrieves the API error message based on the provided error code. + * + * @param errorCode - The error code. + * @returns The API error message. + */ + protected getApiErrorMessage(errorCode: number): string { + const errorCodeKey = findKeyByValue(EErrorCodes, errorCode.toString()); + + return !!errorCodeKey && errorCodeKey in EApiErrors + ? EApiErrors[errorCodeKey as keyof typeof EApiErrors] + : ErrorService.DEFAULT_ERROR_MESSAGE; + } +} diff --git a/src/services/internal/FetcherService.ts b/src/services/internal/FetcherService.ts index e6b29838..bd195ebc 100644 --- a/src/services/internal/FetcherService.ts +++ b/src/services/internal/FetcherService.ts @@ -1,7 +1,8 @@ // PACKAGES import { Request, - Args, + FetchArgs, + PostArgs, EResourceType, ICursor as IRawCursor, ITweet as IRawTweet, @@ -9,29 +10,34 @@ import { ITimelineTweet, ITimelineUser, IResponse, - EErrorCodes, + EUploadSteps, + IMediaUploadInitializeResponse, } from 'rettiwt-core'; -import axios, { AxiosRequestConfig, AxiosRequestHeaders, AxiosResponse } from 'axios'; +import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; import https, { Agent } from 'https'; import { AuthCredential, Auth } from 'rettiwt-auth'; import { HttpsProxyAgent } from 'https-proxy-agent'; // SERVICES +import { ErrorService } from './ErrorService'; import { LogService } from './LogService'; +// TYPES +import { IRettiwtConfig } from '../../types/RettiwtConfig'; +import { IErrorHandler } from '../../types/ErrorHandler'; + // ENUMS -import { EHttpStatus } from '../../enums/HTTP'; -import { EApiErrors } from '../../enums/ApiErrors'; +import { EApiErrors } from '../../enums/Api'; import { ELogActions } from '../../enums/Logging'; // MODELS -import { RettiwtConfig } from '../../models/internal/RettiwtConfig'; -import { CursoredData } from '../../models/public/CursoredData'; -import { Tweet } from '../../models/public/Tweet'; -import { User } from '../../models/public/User'; +import { CursoredData } from '../../models/data/CursoredData'; +import { Tweet } from '../../models/data/Tweet'; +import { User } from '../../models/data/User'; // HELPERS -import { findByFilter, findKeyByValue } from '../../helper/JsonUtils'; +import { findByFilter } from '../../helper/JsonUtils'; +import { statSync } from 'fs'; /** * The base service that handles all HTTP requests. @@ -45,16 +51,25 @@ export class FetcherService { /** Whether the instance is authenticated or not. */ private readonly isAuthenticated: boolean; + /** The URL to the proxy server to use for authentication. */ + protected readonly authProxyUrl?: URL; + /** The HTTPS Agent to use for requests to Twitter API. */ private readonly httpsAgent: Agent; + /** The max wait time for a response. */ + private readonly timeout: number; + /** The log service instance to use to logging. */ private readonly logger: LogService; + /** The service used to handle HTTP and API errors */ + private readonly errorHandler: IErrorHandler; + /** * @param config - The config object for configuring the Rettiwt instance. */ - public constructor(config?: RettiwtConfig) { + public constructor(config?: IRettiwtConfig) { // If API key is supplied if (config?.apiKey) { this.cred = this.getAuthCredential(config.apiKey); @@ -68,8 +83,11 @@ export class FetcherService { this.cred = undefined; } this.isAuthenticated = config?.apiKey ? true : false; + this.authProxyUrl = config?.authProxyUrl ?? config?.proxyUrl; this.httpsAgent = this.getHttpsAgent(config?.proxyUrl); + this.timeout = config?.timeout ?? 0; this.logger = new LogService(config?.logging); + this.errorHandler = config?.errorHandler ?? new ErrorService(); } /** @@ -92,9 +110,6 @@ export class FetcherService { * @returns The generated AuthCredential. */ private getGuestCredential(guestKey: string): AuthCredential { - // Converting guestKey from base64 to string - guestKey = Buffer.from(guestKey).toString('ascii'); - return new AuthCredential(undefined, guestKey); } @@ -133,79 +148,33 @@ export class FetcherService { return new https.Agent(); } - /** - * The middleware for handling any http error. - * - * @param res - The response object received. - * @returns The received response, if no HTTP errors are found. - * @throws An error if any HTTP-related error has occured. - */ - private handleHttpError(res: AxiosResponse>): AxiosResponse> { - /** - * If the status code is not 200 =\> the HTTP request was not successful. hence throwing error - */ - if (res.status != 200 && res.status in EHttpStatus) { - throw new Error(EHttpStatus[res.status]); - } - - return res; - } - - /** - * The middleware for handling any Twitter API-level errors. - * - * @param res - The response object received. - * @returns The received response, if no API errors are found. - * @throws An error if any API-related error has occured. - */ - private handleApiError(res: AxiosResponse>): AxiosResponse> { - // If error exists - if (res.data.errors && res.data.errors.length) { - // Getting the error code - const code: number = res.data.errors[0].code; - - // Getting the error message - const message: string = EApiErrors[ - findKeyByValue(EErrorCodes, `${code}`) as keyof typeof EApiErrors - ] as string; - - // Throw the error - throw new Error(message); - } - - return res; - } - /** * Makes an HTTP request according to the given parameters. * + * @typeParam ResType - The type of the returned response data. * @param config - The request configuration. * @returns The response received. */ - private async request(config: Request): Promise>> { + private async request(config: AxiosRequestConfig): Promise> { // Checking authorization for the requested resource - this.checkAuthorization(config.endpoint); + this.checkAuthorization(config.url as EResourceType); // If not authenticated, use guest authentication - this.cred = this.cred ?? (await new Auth().getGuestCredential()); + this.cred = this.cred ?? (await new Auth({ proxyUrl: this.authProxyUrl }).getGuestCredential()); - /** - * Creating axios request configuration from the input configuration. - */ - const axiosRequest: AxiosRequestConfig = { - url: config.url, - method: config.type, - data: config.payload, - headers: JSON.parse(JSON.stringify(this.cred.toHeader())) as AxiosRequestHeaders, - httpsAgent: this.httpsAgent, - }; + // Setting additional request parameters + config.headers = { ...config.headers, ...this.cred.toHeader() }; + config.httpAgent = this.httpsAgent; + config.timeout = this.timeout; /** - * After making the request, the response is then passed to HTTP error handling middleware for HTTP error handling. + * If Axios request results in an error, catch it and rethrow a more specific error. */ - return await axios>(axiosRequest) - .then((res) => this.handleHttpError(res)) - .then((res) => this.handleApiError(res)); + return await axios(config).catch((error: unknown) => { + this.errorHandler.handle(error); + + throw error; + }); } /** @@ -308,16 +277,16 @@ export class FetcherService { */ protected async fetch( resourceType: EResourceType, - args: Args, + args: FetchArgs, ): Promise> { // Logging this.logger.log(ELogActions.FETCH, { resourceType: resourceType, args: args }); // Preparing the HTTP request - const request: Request = new Request(resourceType, args); + const request: AxiosRequestConfig = new Request(resourceType, args).toAxiosRequestConfig(); // Getting the raw data - const res = await this.request(request).then((res) => res.data); + const res = await this.request>(request).then((res) => res.data); // Extracting data const extractedData = this.extractData(res, resourceType); @@ -335,16 +304,61 @@ export class FetcherService { * @param args - Resource specific arguments. * @returns Whether posting was successful or not. */ - protected async post(resourceType: EResourceType, args: Args): Promise { + protected async post(resourceType: EResourceType, args: PostArgs): Promise { // Logging this.logger.log(ELogActions.POST, { resourceType: resourceType, args: args }); // Preparing the HTTP request - const request: Request = new Request(resourceType, args); + const request: AxiosRequestConfig = new Request(resourceType, args).toAxiosRequestConfig(); // Posting the data - await this.request(request); + await this.request(request); return true; } + + /** + * Uploads the given media file to Twitter + * + * @param media - The path to the media file to upload. + * @returns The id of the uploaded media. + */ + protected async upload(media: string): Promise { + // INITIALIZE + + // Logging + this.logger.log(ELogActions.UPLOAD, { step: EUploadSteps.INITIALIZE }); + + const id: string = ( + await this.request( + new Request(EResourceType.MEDIA_UPLOAD, { + upload: { step: EUploadSteps.INITIALIZE, size: statSync(media).size }, + }).toAxiosRequestConfig(), + ) + ).data.media_id_string; + + // APPEND + + // Logging + this.logger.log(ELogActions.UPLOAD, { step: EUploadSteps.APPEND }); + + await this.request( + new Request(EResourceType.MEDIA_UPLOAD, { + upload: { step: EUploadSteps.APPEND, id: id, media: media }, + }).toAxiosRequestConfig(), + ); + + // FINALIZE + + // Logging + this.logger.log(ELogActions.UPLOAD, { step: EUploadSteps.APPEND }); + + await this.request( + new Request(EResourceType.MEDIA_UPLOAD, { + upload: { step: EUploadSteps.FINALIZE, id: id }, + }).toAxiosRequestConfig(), + ); + + return id; + } } diff --git a/src/services/public/AuthService.ts b/src/services/public/AuthService.ts new file mode 100644 index 00000000..65e531a9 --- /dev/null +++ b/src/services/public/AuthService.ts @@ -0,0 +1,97 @@ +// PACKAGES +import { Auth } from 'rettiwt-auth'; + +// SERVICES +import { FetcherService } from '../internal/FetcherService'; + +// TYPES +import { IRettiwtConfig } from '../../types/RettiwtConfig'; + +/** + * Handles authentication. + * + * @public + */ +export class AuthService extends FetcherService { + /** + * @param config - The config object for configuring the Rettiwt instance. + * + * @internal + */ + public constructor(config?: IRettiwtConfig) { + super(config); + } + + /** + * Login to twitter using account credentials. + * + * @param email - The email id associated with the Twitter account. + * @param userName - The username associated with the Twitter account. + * @param password - The password to the Twitter account. + * @returns The API_KEY for the Twitter account. + * + * @example + * ``` + * import { Rettiwt } from 'rettiwt-api'; + * + * // Creating a new Rettiwt instance + * const rettiwt = new Rettiwt(); + * + * // Logging in an getting the API_KEY + * rettiwt.auth.login("email@domain.com", "username", "password") + * .then(apiKey => { + * // Use the API_KEY + * ... + * }) + * .catch(err => { + * console.log(err); + * }); + * ``` + */ + public async login(email: string, userName: string, password: string): Promise { + // Logging in and getting the credentials + let apiKey: string = + (( + await new Auth({ proxyUrl: this.authProxyUrl }).getUserCredential({ + email: email, + userName: userName, + password: password, + }) + ).toHeader().cookie as string) ?? ''; + + // Converting the credentials to base64 string + apiKey = Buffer.from(apiKey).toString('base64'); + + return apiKey; + } + + /** + * Login to twitter as guest. + * + * @returns A new guest key. + * + * @example + * ``` + * import { Rettiwt } from 'rettiwt-api'; + * + * // Creating a new Rettiwt instance + * const rettiwt = new Rettiwt(); + * + * // Logging in an getting a new guest key + * rettiwt.auth.guest() + * .then(guestKey => { + * // Use the guest key + * ... + * }) + * .catch(err => { + * console.log(err); + * }); + * ``` + */ + public async guest(): Promise { + // Getting a new guest key + const guestKey: string = (await new Auth().getGuestCredential()).guestToken ?? ''; + + return guestKey; + } +} diff --git a/src/services/public/TweetService.ts b/src/services/public/TweetService.ts index d5480e91..6ecde2ee 100644 --- a/src/services/public/TweetService.ts +++ b/src/services/public/TweetService.ts @@ -1,14 +1,17 @@ // PACKAGES -import { EResourceType, TweetFilter } from 'rettiwt-core'; +import { EResourceType, MediaArgs, TweetFilter } from 'rettiwt-core'; // SERVICES import { FetcherService } from '../internal/FetcherService'; +// TYPES +import { IRettiwtConfig } from '../../types/RettiwtConfig'; + // MODELS -import { RettiwtConfig } from '../../models/internal/RettiwtConfig'; -import { Tweet } from '../../models/public/Tweet'; -import { User } from '../../models/public/User'; -import { CursoredData } from '../../models/public/CursoredData'; +import { Tweet } from '../../models/data/Tweet'; +import { User } from '../../models/data/User'; +import { CursoredData } from '../../models/data/CursoredData'; +import { ITweetMediaArgs } from '../../types/args/TweetMediaArgs'; /** * Handles fetching of data related to tweets. @@ -21,7 +24,7 @@ export class TweetService extends FetcherService { * * @internal */ - public constructor(config?: RettiwtConfig) { + public constructor(config?: IRettiwtConfig) { super(config); } @@ -220,10 +223,11 @@ export class TweetService extends FetcherService { /** * Post a tweet. * - * @param tweetText - The text to be posted, length must be \<= 280 characters. + * @param text - The text to be posted, length must be \<= 280 characters. + * @param media - The list of media to post in the tweet. * @returns Whether posting was successful or not. * - * @example + * @example Posting a simple text * ``` * import { Rettiwt } from 'rettiwt-api'; * @@ -240,11 +244,42 @@ export class TweetService extends FetcherService { * }); * ``` * + * @example Posting a tweet with an image + * ``` + * import { Rettiwt } from 'rettiwt-api'; + * + * // Creating a new Rettiwt instance using the given 'API_KEY' + * const rettiwt = new Rettiwt({ apiKey: API_KEY }); + * + * // Posting a tweet, containing an image called 'mountains.jpg', to twitter + * rettiwt.tweet.tweet('What a nice view!', [{ path: 'mountains.jpg' }]) + * .then(res => { + * console.log(res); + * }) + * .catch(err => { + * console.log(err); + * }); + * ``` + * * @public */ - public async tweet(tweetText: string): Promise { + public async tweet(text: string, media?: ITweetMediaArgs[]): Promise { + /** Stores the list of media that has been uploaded */ + const uploadedMedia: MediaArgs[] = []; + + // If tweet includes media, upload the media items + if (media) { + for (const item of media) { + // Uploading the media item and getting it's allocated id + const id: string = await this.upload(item.path); + + // Storing the uploaded media item + uploadedMedia.push({ id: id, tags: item.tags }); + } + } + // Posting the tweet - const data = await this.post(EResourceType.CREATE_TWEET, { tweetText: tweetText }); + const data = await this.post(EResourceType.CREATE_TWEET, { tweet: { text: text, media: uploadedMedia } }); return data; } diff --git a/src/services/public/UserService.ts b/src/services/public/UserService.ts index 6f73c3de..a7cf8ab6 100644 --- a/src/services/public/UserService.ts +++ b/src/services/public/UserService.ts @@ -4,13 +4,15 @@ import { EResourceType } from 'rettiwt-core'; // SERVICES import { FetcherService } from '../internal/FetcherService'; +// TYPES +import { IRettiwtConfig } from '../../types/RettiwtConfig'; + // MODELS -import { RettiwtConfig } from '../../models/internal/RettiwtConfig'; -import { User } from '../../models/public/User'; -import { Tweet } from '../../models/public/Tweet'; +import { User } from '../../models/data/User'; +import { Tweet } from '../../models/data/Tweet'; // TYPES -import { CursoredData } from '../../models/public/CursoredData'; +import { CursoredData } from '../../models/data/CursoredData'; /** * Handles fetching of data related to user account @@ -23,7 +25,7 @@ export class UserService extends FetcherService { * * @internal */ - public constructor(config?: RettiwtConfig) { + public constructor(config?: IRettiwtConfig) { super(config); } diff --git a/src/types/ErrorHandler.ts b/src/types/ErrorHandler.ts new file mode 100644 index 00000000..4eb1f93a --- /dev/null +++ b/src/types/ErrorHandler.ts @@ -0,0 +1,13 @@ +/** + * Defines the error handler that processes API/HTTP errors in the responses. + * + * @public + */ +export interface IErrorHandler { + /** + * The method called when an error response is received from Twitter API. + * + * @param error - The error caught while making request to Twitter API. + */ + handle(error: unknown): void; +} diff --git a/src/types/RettiwtConfig.ts b/src/types/RettiwtConfig.ts new file mode 100644 index 00000000..0cd0185a --- /dev/null +++ b/src/types/RettiwtConfig.ts @@ -0,0 +1,40 @@ +// TYPES +import { IErrorHandler } from './ErrorHandler'; + +/** + * The configuration for initializing a new Rettiwt instance. + * + * @public + */ +export interface IRettiwtConfig { + /** The apiKey (cookie) to use for authenticating Rettiwt against Twitter API. */ + apiKey?: string; + + /** The guestKey (guest token) to use for guest access to Twitter API. */ + guestKey?: string; + + /** + * Optional URL to proxy server to use for requests to Twitter API. + * + * @remarks When deploying to cloud platforms, if setting {@link IRettiwtConfig.authProxyUrl} does not resolve Error 429, then this might be required. + */ + proxyUrl?: URL; + + /** + * Optional URL to proxy server to use for authentication against Twitter API. + * + * @remarks Required when deploying to cloud platforms to bypass Error 429. + * + * @defaultValue Same as {@link IRettiwtConfig.proxyUrl} + */ + authProxyUrl?: URL; + + /** The max wait time (in milli-seconds) for a response; if not set, Twitter server timeout is used. */ + timeout?: number; + + /** Whether to write logs to console or not. */ + logging?: boolean; + + /** Optional custom error handler to define error conditions and process API/HTTP errors in responses. */ + errorHandler?: IErrorHandler; +} diff --git a/src/types/args/TweetMediaArgs.ts b/src/types/args/TweetMediaArgs.ts new file mode 100644 index 00000000..d9e1621b --- /dev/null +++ b/src/types/args/TweetMediaArgs.ts @@ -0,0 +1,16 @@ +/** + * The arguments specifying the media to be posted in a single tweet. + * + * @public + */ +export interface ITweetMediaArgs { + /** + * The path to the media file. + * + * @remarks The size of the media file must be \<= 5242880 bytes. + */ + path: string; + + /** The list usernames of users to be tagged in the media. */ + tags?: string[]; +} diff --git a/src/types/internal/RettiwtConfig.ts b/src/types/internal/RettiwtConfig.ts deleted file mode 100644 index 58d0364d..00000000 --- a/src/types/internal/RettiwtConfig.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * The configuration for initializing a new Rettiwt instance. - * - * @internal - */ -export interface IRettiwtConfig { - /** The apiKey (cookie) to use for authenticating Rettiwt against Twitter API. */ - apiKey?: string; - - /** The guestKey (guest token) to use for guest access to Twitter API. */ - guestKey?: string; - - /** Optional URL with proxy configuration to use for requests to Twitter API. */ - proxyUrl?: URL; - - /** Whether to write logs to console or not. */ - logging?: boolean; -} diff --git a/src/types/public/CursoredData.ts b/src/types/public/CursoredData.ts deleted file mode 100644 index 68b9716a..00000000 --- a/src/types/public/CursoredData.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * The data that us fetched batch-wise along with a cursor. - * - * @typeParam T - Type of data present in the list. - * - * @public - */ -export interface ICursoredData { - /** The list of data of the given type. */ - list: T[]; - - /** The cursor to the next batch of data. */ - next: ICursor; -} - -/** - * The cursor to the batch of data to be fetched. - * - * @public - */ -export interface ICursor { - /** The cursor string. */ - value: string; -} diff --git a/src/types/public/List.ts b/src/types/public/List.ts deleted file mode 100644 index 6f0a06bf..00000000 --- a/src/types/public/List.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * The details of a single Twitter List. - * - * @public - */ -export interface IList { - /** The rest id of the list. */ - id: string; - - /** The name of the list. */ - name: string; - - /** The date and time of creation of the list, int UTC string format. */ - createdAt: string; - - /** The list description. */ - description: string; - - /** The number of memeber of the list. */ - memberCount: number; - - /** The number of subscribers of the list. */ - subscriberCount: number; - - /** The rest id of the user who created the list. */ - createdBy: string; -} diff --git a/src/types/public/Tweet.ts b/src/types/public/Tweet.ts deleted file mode 100644 index cba6abfa..00000000 --- a/src/types/public/Tweet.ts +++ /dev/null @@ -1,86 +0,0 @@ -// PACKAGES -import { EMediaType } from 'rettiwt-core'; - -// TYPES -import { IUser } from './User'; - -/** - * The details of a single Tweet. - * - * @public - */ -export interface ITweet { - /** The rest id of the tweet. */ - id: string; - - /** The details of the user who made the tweet. */ - tweetBy: IUser; - - /** The date and time of creation of the tweet, in UTC string format. */ - createdAt: string; - - /** Additional tweet entities like urls, mentions, etc. */ - entities: ITweetEntities; - - /** The urls of the media contents of the tweet (if any). */ - media: ITweetMedia[]; - - /** The rest id of the tweet which is quoted in the tweet. */ - quoted: string; - - /** The full text content of the tweet. */ - fullText: string; - - /** The rest id of the user to which the tweet is a reply. */ - replyTo: string; - - /** The language in which the tweet is written. */ - lang: string; - - /** The number of quotes of the tweet. */ - quoteCount: number; - - /** The number of replies to the tweet. */ - replyCount: number; - - /** The number of retweets of the tweet. */ - retweetCount: number; - - /** The number of likes of the tweet. */ - likeCount: number; - - /** The number of views of a tweet. */ - viewCount: number; - - /** The number of bookmarks of a tweet. */ - bookmarkCount: number; -} - -/** - * The different types parsed entities like urls, media, mentions, hashtags, etc. - * - * @public - */ -export interface ITweetEntities { - /** The list of hashtags mentioned in the tweet. */ - hashtags: string[]; - - /** The list of urls mentioned in the tweet. */ - urls: string[]; - - /** The list of IDs of users mentioned in the tweet. */ - mentionedUsers: string[]; -} - -/** - * A single media content. - * - * @public - */ -export interface ITweetMedia { - /** The type of media. */ - type: EMediaType; - - /** The direct URL to the media. */ - url: string; -} diff --git a/src/types/public/User.ts b/src/types/public/User.ts deleted file mode 100644 index 9789d817..00000000 --- a/src/types/public/User.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * The details of a single user. - * - * @public - */ -export interface IUser { - /** The rest id of the user. */ - id: string; - - /** The username/screenname of the user. */ - userName: string; - - /** The full name of the user. */ - fullName: string; - - /** The creation date of user's account. */ - createdAt: string; - - /** The user's description. */ - description: string; - - /** Whether the account is verified or not. */ - isVerified: boolean; - - /** The number of tweets liked by the user. */ - favouritesCount: number; - - /** The number of followers of the user. */ - followersCount: number; - - /** The number of following of the user. */ - followingsCount: number; - - /** The number of tweets made by the user. */ - statusesCount: number; - - /** The location of user as provided by user. */ - location: string; - - /** The rest id of the tweet pinned in the user's profile. */ - pinnedTweet: string; - - /** The url of the profile banner image. */ - profileBanner: string; - - /** The url of the profile image. */ - profileImage: string; -} diff --git a/yarn.lock b/yarn.lock index 08c7cabc..342e37b7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -109,10 +109,10 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.0.tgz#591c1ce3a702c45ee15f47a42ade72c2fd78978a" integrity sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw== -"@types/validator@^13.7.10": - version "13.11.1" - resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.11.1.tgz#6560af76ed54490e68c42f717ab4e742ba7be74b" - integrity sha512-d/MUkJYdOeKycmm75Arql4M5+UuXmf4cHdHKsyw1GcvnNgL6s77UkgSgJ8TE/rI5PYsnwYq5jkcWBLuN/MpQ1A== +"@types/validator@^13.11.8": + version "13.11.8" + resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.11.8.tgz#bb1162ec0fe6f87c95ca812f15b996fcc5e1e2dc" + integrity sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ== "@typescript-eslint/eslint-plugin@6.0.0": version "6.0.0" @@ -274,19 +274,10 @@ asynckit@^0.4.0: resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== -axios@1.3.2: - version "1.3.2" - resolved "https://registry.npmjs.org/axios/-/axios-1.3.2.tgz" - integrity sha512-1M3O703bYqYuPhbHeya5bnhpYVsDDRyQSabNja04mZtboLNSuZ4YrltestrLXfHgmzua4TpUqRiVKbiQuo2epw== - dependencies: - follow-redirects "^1.15.0" - form-data "^4.0.0" - proxy-from-env "^1.1.0" - -axios@1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.4.0.tgz#38a7bf1224cd308de271146038b551d725f0be1f" - integrity sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA== +axios@1.6.3: + version "1.6.3" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.3.tgz#7f50f23b3aa246eff43c54834272346c396613f4" + integrity sha512-fWyNdeawGam70jXSVlKl+SUNVcL6j6W79CuSIPfi6HnDUmSCH6gyUys/HrqHeA/wU0Az41rRgean494d0Jb+ww== dependencies: follow-redirects "^1.15.0" form-data "^4.0.0" @@ -352,14 +343,14 @@ chokidar@^3.5.2: optionalDependencies: fsevents "~2.3.2" -class-validator@0.14.0: - version "0.14.0" - resolved "https://registry.yarnpkg.com/class-validator/-/class-validator-0.14.0.tgz#40ed0ecf3c83b2a8a6a320f4edb607be0f0df159" - integrity sha512-ct3ltplN8I9fOwUd8GrP8UQixwff129BkEtuWDKL5W45cQuLd19xqmTLu5ge78YDm/fdje6FMt0hGOhl0lii3A== +class-validator@0.14.1: + version "0.14.1" + resolved "https://registry.yarnpkg.com/class-validator/-/class-validator-0.14.1.tgz#ff2411ed8134e9d76acfeb14872884448be98110" + integrity sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ== dependencies: - "@types/validator" "^13.7.10" - libphonenumber-js "^1.10.14" - validator "^13.7.0" + "@types/validator" "^13.11.8" + libphonenumber-js "^1.10.53" + validator "^13.9.0" color-convert@^2.0.1: version "2.0.1" @@ -632,7 +623,7 @@ follow-redirects@^1.15.0: resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz" integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== -form-data@^4.0.0: +form-data@4.0.0, form-data@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz" integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== @@ -848,10 +839,10 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" -libphonenumber-js@^1.10.14: - version "1.10.44" - resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.10.44.tgz#6709722461173e744190494aaaec9c1c690d8ca8" - integrity sha512-svlRdNBI5WgBjRC20GrCfbFiclbF0Cx+sCcQob/C1r57nsoq0xg8r65QbTyVyweQIlB33P+Uahyho6EMYgcOyQ== +libphonenumber-js@^1.10.53: + version "1.10.54" + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.10.54.tgz#8dfba112f49d1b9c2a160e55f9697f22e50f0841" + integrity sha512-P+38dUgJsmh0gzoRDoM4F5jLbyfztkU6PY6eSK6S5HwTi/LPvnwXqVCQZlAy1FxZ5c48q25QhxGQ0pq+WQcSlQ== locate-path@^6.0.0: version "6.0.0" @@ -1089,21 +1080,24 @@ resolve@~1.19.0: is-core-module "^2.1.0" path-parse "^1.0.6" -rettiwt-auth@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/rettiwt-auth/-/rettiwt-auth-2.0.0.tgz#e5eaa0cdb736acc52f9c1b6b31dae685f4d14073" - integrity sha512-zVEhc4Ce/5G6Rt53/nwCBnjgohmIBT+F7XMjgl0XjngwXFvUptSY5jISGzN6655teZBva92wAeupIkoSd0pxJg== +rettiwt-auth@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/rettiwt-auth/-/rettiwt-auth-2.1.0.tgz#43fedd40cab5b775b92a1bef740d7b2a36553463" + integrity sha512-N3G1kX/4TFYvUumxcACHO9ngVnhM+DhpuRn2/JxWlgHuztAQShbO9ux3hfMducCqzzjdcMLPJhrEDt9fwSbpaQ== dependencies: - axios "1.4.0" + axios "1.6.3" commander "11.1.0" cookiejar "2.1.4" + https-proxy-agent "7.0.2" -rettiwt-core@3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/rettiwt-core/-/rettiwt-core-3.2.1.tgz#de8015eff45ba1bf591360716956916a385e4782" - integrity sha512-cLkv+8/e48nxsHuplj2M8z/854jSihNm0DPK9V+xxeqdIz4vd/5fmdmfqe7Gl04sVENzC/w8MFGBU6Z8glXqvw== +rettiwt-core@3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/rettiwt-core/-/rettiwt-core-3.3.0.tgz#c856b1be47137c5289da65edd4560f2d95a76d73" + integrity sha512-m75NzF9eGO/2mxRJpakWF7nmSjXyaYuGLIxKlekP0xl5MNZNKlE3r/yV0lHADZOajCkW+XYXN7AbB0lphgOoMg== dependencies: - class-validator "0.14.0" + axios "1.6.3" + class-validator "0.14.1" + form-data "4.0.0" reusify@^1.0.4: version "1.0.4" @@ -1264,7 +1258,7 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -validator@^13.7.0: +validator@^13.9.0: version "13.11.0" resolved "https://registry.yarnpkg.com/validator/-/validator-13.11.0.tgz#23ab3fd59290c61248364eabf4067f04955fbb1b" integrity sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==