diff --git a/README.md b/README.md index 0238cd6..bae04ea 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ Learn how to use the full potential of `twitter-api-v2`. - [Create a client and make your first request](./doc/basics.md) - [Handle Twitter authentication flows](./doc/auth.md) - [Explore some examples](./doc/examples.md) + - [Use and create plugins](./doc/plugins.md) - Use endpoints wrappers — ensure typings of request & response - [Available endpoint wrappers for v1.1 API](./doc/v1.md) - [Available endpoint wrappers for v2 API](./doc/v2.md) diff --git a/doc/http-wrappers.md b/doc/http-wrappers.md index 550d250..eb21533 100644 --- a/doc/http-wrappers.md +++ b/doc/http-wrappers.md @@ -44,50 +44,4 @@ console.log(res.rateLimit, res.data) // [headers] // Customize sent HTTP headers client.v1.post('statuses/update.json', { status: 'Hello' }, { headers: { 'X-Custom-Header': 'My Header Value' } }) -``` - -## Advanced: make a custom signed request - -`twitter-api-v2` gives you a client that handles all the request signin boilerplate for you. - -Sometimes, you need to dive deep and make the request on your own. -2 raw helpers allow you to make the request you want: -- `.send`: Make a request, awaits its complete response, parse it and returns it -- `.sendStream`: Make a requests, returns a stream when server responds OK - -**Warning**: When you use those methods, you need to prefix your requests (no auto-prefixing)! -Make sure you use a URL that begins with `https://...` with raw request managers. - -### .send - -**Template types**: `T = any` - -**Args**: `IGetHttpRequestArgs` - -**Returns**: (async) `TwitterResponse` - -```ts -const response = await client.send({ - method: 'GET', - url: 'https://api.twitter.com/2/tweets/search/all', - query: { max_results: 200 }, - headers: { 'X-Custom-Header': 'True' }, -}); - -response.data; // Twitter response body: { data: Tweet[], meta: {...} } -response.rateLimit.limit; // Ex: 900 -``` - -### .sendStream - -**Args**: `IGetHttpRequestArgs` - -**Returns**: (async) `TweetStream` - -```ts -const stream = await client.sendStream({ - method: 'GET', - url: 'https://api.twitter.com/2/tweets/sample/stream', -}); -// For response handling, see streaming documentation -``` +``` \ No newline at end of file diff --git a/doc/plugins.md b/doc/plugins.md new file mode 100644 index 0000000..f8f77fb --- /dev/null +++ b/doc/plugins.md @@ -0,0 +1,65 @@ +# Plugins for `twitter-api-v2` + +Since version `1.11.0`, library supports plugins. +Plugins are objects exposing specific functions, called by the library at specific times. + +## Using plugins + +Import your plugin, instanciate them (if needed), and give them in the `plugins` array of client settings. + +```ts +import { TwitterApi } from 'twitter-api-v2' +import { TwitterApiCachePluginRedis } from '@twitter-api-v2/plugin-cache-redis' + +const redisPlugin = new TwitterApiCachePluginRedis(redisInstance) + +const client = new TwitterApi(yourKeys, { plugins: [redisPlugin] }) +``` + +## Writing plugins + +You can write object/classes that implements the following interface: +```ts +interface ITwitterApiClientPlugin { + // Classic requests + /* Executed when request is about to be prepared. OAuth headers, body, query normalization hasn't been done yet. */ + onBeforeRequestConfig?: TTwitterApiBeforeRequestConfigHook + /* Executed when request is about to be made. Headers/body/query has been prepared, and HTTP options has been initialized. */ + onBeforeRequest?: TTwitterApiBeforeRequestHook + /* Executed when a request succeeds (failed requests don't trigger this hook). */ + onAfterRequest?: TTwitterApiAfterRequestHook + // Stream requests + /* Executed when a stream request is about to be prepared. This method **can't** return a `Promise`. */ + onBeforeStreamRequestConfig?: TTwitterApiBeforeStreamRequestConfigHook + // Request token + /* Executed after a `.generateAuthLink`, mainly to allow automatic collect of `oauth_token`/`oauth_token_secret` couples. */ + onOAuth1RequestToken?: TTwitterApiAfterOAuth1RequestTokenHook + /* Executed after a `.generateOAuth2AuthLink`, mainly to allow automatic collect of `state`/`codeVerifier` couples. */ + onOAuth2RequestToken?: TTwitterApiAfterOAuth2RequestTokenHook +} +``` + +Every method is optional, because you can implement whatever you want to listen to. + +Method types: +```ts +type TTwitterApiBeforeRequestConfigHook = (args: ITwitterApiBeforeRequestConfigHookArgs) => PromiseOrType | void> +type TTwitterApiBeforeRequestHook = (args: ITwitterApiBeforeRequestHookArgs) => void | Promise +type TTwitterApiAfterRequestHook = (args: ITwitterApiAfterRequestHookArgs) => void | Promise +type TTwitterApiBeforeStreamRequestConfigHook = (args: ITwitterApiBeforeRequestConfigHookArgs) => void +type TTwitterApiAfterOAuth1RequestTokenHook = (args: ITwitterApiAfterOAuth1RequestTokenHookArgs) => void | Promise +type TTwitterApiAfterOAuth2RequestTokenHook = (args: ITwitterApiAfterOAuth2RequestTokenHookArgs) => void | Promise +``` + +A simple plugin implementation that logs GET requests can be: + +```ts +class TwitterApiLoggerPlugin implements ITwitterApiClientPlugin { + onBeforeRequestConfig(args: ITwitterApiBeforeRequestConfigHookArgs) { + const method = args.params.method.toUpperCase() + console.log(`${method} ${args.url.toString()} ${JSON.stringify(args.params.query)}`) + } +} + +const client = new TwitterApi(yourKeys, { plugins: [new TwitterApiLoggerPlugin()] }) +``` diff --git a/doc/rate-limiting.md b/doc/rate-limiting.md index 335ddd2..d7f1773 100644 --- a/doc/rate-limiting.md +++ b/doc/rate-limiting.md @@ -1,36 +1,27 @@ # Rate limiting -## Get last rate limit info +## Extract rate limit information with plugins -### Using endpoint wrappers - -You can obtain lastly collected information of rate limit for each already used endpoint. - -First, you need to know **which endpoint URL is concerned by the used endpoint wrapper**, for example, -for `.v1.tweets`, it is `statuses/lookup.json`. The endpoint is always specified in the lib documentation. - -Use the endpoint URL to know: -- The last received status of rate limiting with `.getLastRateLimitStatus` -- If the stored rate limit information has expired with `.isRateLimitStatusObsolete` -- If you hit the rate limit the last time you called this endpoint, with `.hasHitRateLimit` +Plugin `@twitter-api-v2/plugin-rate-limit` can help you to store/get rate limit information. +It stores automatically rate limits sent by Twitter at each request and gives you an API to get them when you need to. ```ts -// Usage of statuses/lookup.json -const tweets = await client.v1.tweets(['20', '30']); +import { TwitterApi } from 'twitter-api-v2' +import { TwitterApiRateLimitPlugin } from '@twitter-api-v2/plugin-rate-limit' -// Don't forget to add .v1, otherwise you need to prefix -// your endpoint URL with https://api.twitter.com/... :) -console.log(client.v1.getLastRateLimitStatus('statuses/lookup.json')); -// => { limit: 900, remaining: 899, reset: 1631015719 } +const rateLimitPlugin = new TwitterApiRateLimitPlugin() +const client = new TwitterApi(yourKeys, { plugins: [rateLimitPlugin] }) -console.log(client.v1.isRateLimitStatusObsolete('statuses/lookup.json')); -// => false if 'reset' property mentions a timestamp in the future +// ...make requests... +await client.v2.me() +// ... -console.log(client.v1.hasHitRateLimit('statuses/lookup.json')); -// => false if 'remaining' property is > 0 +const currentRateLimitForMe = await rateLimitPlugin.v2.getRateLimit('users/me') +console.log(currentRateLimitForMe.limit) // 75 +console.log(currentRateLimitForMe.remaining) // 74 ``` -### Special case of HTTP methods helpers +## With HTTP methods helpers If you use a HTTP method helper (`.get`, `.post`, ...), you can get a **full response** object that directly contains the rate limit information, even if the request didn't fail! diff --git a/doc/v1.md b/doc/v1.md index 89642fe..5d6dd63 100644 --- a/doc/v1.md +++ b/doc/v1.md @@ -831,10 +831,13 @@ you **must** specify the file type using `options.type`. **Arguments**: - `file: string | number | Buffer | fs.promises.FileHandle`: File path (`string`) or file description (`number`) or raw file (`Buffer`) or file handle (`fs.promises.FileHandle`) - `options?: UploadMediaV1Params` - - `options.type` File type (Enum `'jpg' | 'longmp4' | 'mp4' | 'png' | 'gif' | 'srt' | 'webp'`). + - `options.mimeType` MIME type as a string. To help you across allowed MIME types, enum `EUploadMimeType` is here for you. This option is **required if file is not specified as `string`**. - If you already know the MIME type, you can specifiy the MIME type as a string, instead of using one of the previously seen enum values. - `options.target` Target type `tweet` or `dm`. Defaults to `tweet`. **You must specify it if you send a media to use in DMs.** + - `options.longVideo` Specify `true` here if you're sending a video and it can exceed 120 seconds. Otherwise, this option has no effet. + - `options.shared` Specify `true` here if you want to use this media in Welcome Direct Messages. + - `options.additionalOwners` List of user IDs (except you) allowed to use the new media ID. + - `options.maxConcurrentUploads` Number of concurrent chunk uploads allowed to be sent. Defaults to `3`. **Returns**: `string`: Media ID to give to tweets/DMs @@ -848,10 +851,10 @@ const newTweet = await client.v1.tweet('Hello!', { media_ids: mediaId }); import { fileTypeFromFile } from 'file-type'; // You can use file-type to guess the file content const path = '149e4f3.tmp'; -const mediaId = await client.v1.uploadMedia(path, { type: (await fileTypeFromFile(path)).mime }); +const mediaId = await client.v1.uploadMedia(path, { mimeType: (await fileTypeFromFile(path)).mime }); // Through a Buffer -const mediaId = await client.v1.uploadMedia(Buffer.from([...]), { type: 'png' }); +const mediaId = await client.v1.uploadMedia(Buffer.from([...]), { mimeType: EUploadMimeType.Png }); ``` ### Media info diff --git a/package-lock.json b/package-lock.json index 226026a..5265a96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "twitter-api-v2", - "version": "1.6.5", + "version": "1.10.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "twitter-api-v2", - "version": "1.6.5", + "version": "1.10.3", "license": "Apache-2.0", "devDependencies": { "@types/chai": "^4.2.16", diff --git a/package.json b/package.json index 4d3f20d..8bcb7ab 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "test-list": "npm run mocha test/list.*.test.ts", "test-space": "npm run mocha test/space.v2.test.ts", "test-account": "npm run mocha test/account.*.test.ts", + "test-plugin": "npm run mocha test/plugin.test.ts", "prepublish": "npm run build" }, "repository": "github:plhery/node-twitter-api-v2", diff --git a/src/client-mixins/request-handler.helper.ts b/src/client-mixins/request-handler.helper.ts index d1e4c43..087913d 100644 --- a/src/client-mixins/request-handler.helper.ts +++ b/src/client-mixins/request-handler.helper.ts @@ -1,3 +1,4 @@ +import type { Socket } from 'net'; import { request } from 'https'; import type { IncomingMessage, ClientRequest } from 'http'; import { TwitterApiV2Settings } from '../settings'; @@ -24,10 +25,13 @@ interface IBuildErrorParams { export class RequestHandlerHelper { protected req!: ClientRequest; protected res!: IncomingMessage; + protected requestErrorHandled = false; protected responseData = ''; constructor(protected requestData: TRequestFullData | TRequestFullStreamData) {} + /* Request helpers */ + get hrefPathname() { const url = this.requestData.url; return url.hostname + url.pathname; @@ -41,23 +45,7 @@ export class RequestHandlerHelper { return this.requestData.url.href.startsWith('https://api.twitter.com/oauth/'); } - protected getRateLimitFromResponse(res: IncomingMessage) { - let rateLimit: TwitterRateLimit | undefined = undefined; - - if (res.headers['x-rate-limit-limit']) { - rateLimit = { - limit: Number(res.headers['x-rate-limit-limit']), - remaining: Number(res.headers['x-rate-limit-remaining']), - reset: Number(res.headers['x-rate-limit-reset']), - }; - - if (this.requestData.rateLimitSaver) { - this.requestData.rateLimitSaver(rateLimit); - } - } - - return rateLimit; - } + /* Error helpers */ protected createRequestError(error: Error): ApiRequestError { if (TwitterApiV2Settings.debug) { @@ -127,6 +115,8 @@ export class RequestHandlerHelper { }); } + /* Response helpers */ + protected getResponseDataStream(res: IncomingMessage) { if (this.isCompressionDisabled()) { return res; @@ -186,17 +176,59 @@ export class RequestHandlerHelper { return data; } + protected getRateLimitFromResponse(res: IncomingMessage) { + let rateLimit: TwitterRateLimit | undefined = undefined; + + if (res.headers['x-rate-limit-limit']) { + rateLimit = { + limit: Number(res.headers['x-rate-limit-limit']), + remaining: Number(res.headers['x-rate-limit-remaining']), + reset: Number(res.headers['x-rate-limit-reset']), + }; + + if (this.requestData.rateLimitSaver) { + this.requestData.rateLimitSaver(rateLimit); + } + } + + return rateLimit; + } + + /* Request event handlers */ + + protected onSocketEventHandler(reject: TRequestRejecter, socket: Socket) { + socket.on('close', this.onSocketCloseHandler.bind(this, reject)); + } + + protected onSocketCloseHandler(reject: TRequestRejecter) { + this.req.removeAllListeners('timeout'); + const res = this.res; + + if (res) { + // Response ok, res.close/res.end can handle request ending + return; + } + if (!this.requestErrorHandled) { + return reject(this.createRequestError(new Error('Socket closed without any information.'))); + } + + // else: other situation + } + protected requestErrorHandler(reject: TRequestRejecter, requestError: Error) { this.requestData.requestEventDebugHandler?.('request-error', { requestError }) + this.requestErrorHandled = true; reject(this.createRequestError(requestError)); - this.req.removeAllListeners('timeout'); } protected timeoutErrorHandler() { + this.requestErrorHandled = true; this.req.destroy(new Error('Request timeout.')); } + /* Response event handlers */ + protected classicResponseHandler(resolve: TResponseResolver, reject: TResponseRejecter, res: IncomingMessage) { this.res = res; @@ -219,7 +251,6 @@ export class RequestHandlerHelper { } protected onResponseEndHandler(resolve: TResponseResolver, reject: TResponseRejecter) { - this.req.removeAllListeners('timeout'); const rateLimit = this.getRateLimitFromResponse(this.res); let data: any; @@ -250,7 +281,6 @@ export class RequestHandlerHelper { } protected onResponseCloseHandler(resolve: TResponseResolver, reject: TResponseRejecter) { - this.req.removeAllListeners('timeout'); const res = this.res; if (res.aborted) { @@ -293,6 +323,8 @@ export class RequestHandlerHelper { } } + /* Wrappers for request lifecycle */ + protected debugRequest() { const url = this.requestData.url; @@ -335,6 +367,7 @@ export class RequestHandlerHelper { } protected registerRequestEventDebugHandlers(req: ClientRequest) { + req.on('close', () => this.requestData.requestEventDebugHandler!('close')); req.on('abort', () => this.requestData.requestEventDebugHandler!('abort')); req.on('socket', socket => { @@ -358,6 +391,8 @@ export class RequestHandlerHelper { // Handle request errors req.on('error', this.requestErrorHandler.bind(this, reject)); + req.on('socket', this.onSocketEventHandler.bind(this, reject)); + req.on('response', this.classicResponseHandler.bind(this, resolve, reject)); if (this.requestData.options.timeout) { diff --git a/src/client-mixins/request-maker.mixin.ts b/src/client-mixins/request-maker.mixin.ts index 47d9d7e..54eceee 100644 --- a/src/client-mixins/request-maker.mixin.ts +++ b/src/client-mixins/request-maker.mixin.ts @@ -1,4 +1,4 @@ -import { IClientSettings, TwitterRateLimit, TwitterResponse } from '../types'; +import type { IClientSettings, ITwitterApiClientPlugin, TClientTokens, TwitterRateLimit, TwitterResponse } from '../types'; import TweetStream from '../stream/TweetStream'; import type { ClientRequestArgs } from 'http'; import { trimUndefinedProperties } from '../helpers'; @@ -6,35 +6,67 @@ import OAuth1Helper from './oauth1.helper'; import RequestHandlerHelper from './request-handler.helper'; import RequestParamHelpers from './request-param.helper'; import { OAuth2Helper } from './oauth2.helper'; -import type { IGetHttpRequestArgs, IGetStreamRequestArgs, IGetStreamRequestArgsAsync, IGetStreamRequestArgsSync, IWriteAuthHeadersArgs, TRequestFullStreamData } from '../types/request-maker.mixin.types'; - -export abstract class ClientRequestMaker { - protected _bearerToken?: string; - protected _consumerToken?: string; - protected _consumerSecret?: string; - protected _accessToken?: string; - protected _accessSecret?: string; - protected _basicToken?: string; - protected _clientId?: string; - protected _clientSecret?: string; +import type { + IGetHttpRequestArgs, + IGetStreamRequestArgs, + IGetStreamRequestArgsAsync, + IGetStreamRequestArgsSync, + IWriteAuthHeadersArgs, + TAcceptedInitToken, + TRequestFullStreamData, +} from '../types/request-maker.mixin.types'; +import { IComputedHttpRequestArgs } from '../types/request-maker.mixin.types'; + +export class ClientRequestMaker { + // Public tokens + public bearerToken?: string; + public consumerToken?: string; + public consumerSecret?: string; + public accessToken?: string; + public accessSecret?: string; + public basicToken?: string; + public clientId?: string; + public clientSecret?: string; + public rateLimits: { [endpoint: string]: TwitterRateLimit } = {}; + public clientSettings: Partial = {}; + + // Private computed properties protected _oauth?: OAuth1Helper; - protected _rateLimits: { [endpoint: string]: TwitterRateLimit } = {}; - protected _clientSettings: Partial = {}; protected static readonly BODY_METHODS = new Set(['POST', 'PUT', 'PATCH']); + constructor(settings?: Partial) { + if (settings) { + this.clientSettings = settings; + } + } + + /** @deprecated - Switch to `@twitter-api-v2/plugin-rate-limit` */ + public getRateLimits() { + return this.rateLimits; + } + protected saveRateLimit(originalUrl: string, rateLimit: TwitterRateLimit) { - this._rateLimits[originalUrl] = rateLimit; + this.rateLimits[originalUrl] = rateLimit; } /** Send a new request and returns a wrapped `Promise`. */ - send(requestParams: IGetHttpRequestArgs) : Promise> { + public async send(requestParams: IGetHttpRequestArgs) : Promise> { + // Pre-request config hooks + if (this.clientSettings.plugins) { + const possibleResponse = await this.applyPreRequestConfigHooks(requestParams); + + if (possibleResponse) { + return possibleResponse; + } + } + const args = this.getHttpRequestArgs(requestParams); const options: Partial = { method: args.method, headers: args.headers, timeout: requestParams.timeout, - agent: this._clientSettings.httpAgent, + agent: this.clientSettings.httpAgent, }; const enableRateLimitSave = requestParams.enableRateLimitSave !== false; @@ -42,15 +74,27 @@ export abstract class ClientRequestMaker { RequestParamHelpers.setBodyLengthHeader(options, args.body); } - return new RequestHandlerHelper({ + // Pre-request hooks + if (this.clientSettings.plugins) { + await this.applyPreRequestHooks(requestParams, args, options); + } + + const response = await new RequestHandlerHelper({ url: args.url, options, body: args.body, rateLimitSaver: enableRateLimitSave ? this.saveRateLimit.bind(this, args.rawUrl) : undefined, requestEventDebugHandler: requestParams.requestEventDebugHandler, - compression: requestParams.compression ?? this._clientSettings.compression ?? true, + compression: requestParams.compression ?? this.clientSettings.compression ?? true, }) .makeRequest(); + + // Post-request hooks + if (this.clientSettings.plugins) { + await this.applyPostRequestHooks(requestParams, args, options, response); + } + + return response; } /** @@ -59,16 +103,21 @@ export abstract class ClientRequestMaker { * Request will be sent only if `autoConnect` is not set or `true`: return type will be `Promise`. * If `autoConnect` is `false`, a `TweetStream` is directly returned and you should call `stream.connect()` by yourself. */ - sendStream(requestParams: IGetHttpRequestArgs & IGetStreamRequestArgsSync) : TweetStream; - sendStream(requestParams: IGetHttpRequestArgs & IGetStreamRequestArgsAsync) : Promise>; - sendStream(requestParams: IGetHttpRequestArgs & IGetStreamRequestArgs) : Promise> | TweetStream; + public sendStream(requestParams: IGetHttpRequestArgs & IGetStreamRequestArgsSync) : TweetStream; + public sendStream(requestParams: IGetHttpRequestArgs & IGetStreamRequestArgsAsync) : Promise>; + public sendStream(requestParams: IGetHttpRequestArgs & IGetStreamRequestArgs) : Promise> | TweetStream; + + public sendStream(requestParams: IGetHttpRequestArgs & IGetStreamRequestArgs) : Promise> | TweetStream { + // Pre-request hooks + if (this.clientSettings.plugins) { + this.applyPreStreamRequestConfigHooks(requestParams); + } - sendStream(requestParams: IGetHttpRequestArgs & IGetStreamRequestArgs) : Promise> | TweetStream { const args = this.getHttpRequestArgs(requestParams); const options: Partial = { method: args.method, headers: args.headers, - agent: this._clientSettings.httpAgent, + agent: this.clientSettings.httpAgent, }; const enableRateLimitSave = requestParams.enableRateLimitSave !== false; const enableAutoConnect = requestParams.autoConnect !== false; @@ -83,7 +132,7 @@ export abstract class ClientRequestMaker { body: args.body, rateLimitSaver: enableRateLimitSave ? this.saveRateLimit.bind(this, args.rawUrl) : undefined, payloadIsError: requestParams.payloadIsError, - compression: requestParams.compression ?? this._clientSettings.compression ?? true, + compression: requestParams.compression ?? this.clientSettings.compression ?? true, }; const stream = new TweetStream(requestData); @@ -97,43 +146,116 @@ export abstract class ClientRequestMaker { /* Token helpers */ + public initializeToken(token?: TAcceptedInitToken) { + if (typeof token === 'string') { + this.bearerToken = token; + } + else if (typeof token === 'object' && 'appKey' in token) { + this.consumerToken = token.appKey; + this.consumerSecret = token.appSecret; + + if (token.accessToken && token.accessSecret) { + this.accessToken = token.accessToken; + this.accessSecret = token.accessSecret; + } + + this._oauth = this.buildOAuth(); + } + else if (typeof token === 'object' && 'username' in token) { + const key = encodeURIComponent(token.username) + ':' + encodeURIComponent(token.password); + this.basicToken = Buffer.from(key).toString('base64'); + } + else if (typeof token === 'object' && 'clientId' in token) { + this.clientId = token.clientId; + this.clientSecret = token.clientSecret; + } + } + + public getActiveTokens(): TClientTokens { + if (this.bearerToken) { + return { + type: 'oauth2', + bearerToken: this.bearerToken, + }; + } + else if (this.basicToken) { + return { + type: 'basic', + token: this.basicToken, + }; + } + else if (this.consumerSecret && this._oauth) { + return { + type: 'oauth-1.0a', + appKey: this.consumerToken!, + appSecret: this.consumerSecret!, + accessToken: this.accessToken, + accessSecret: this.accessSecret, + }; + } + else if (this.clientId) { + return { + type: 'oauth2-user', + clientId: this.clientId!, + }; + } + return { type: 'none' }; + } + protected buildOAuth() { - if (!this._consumerSecret || !this._consumerToken) + if (!this.consumerSecret || !this.consumerToken) throw new Error('Invalid consumer tokens'); return new OAuth1Helper({ - consumerKeys: { key: this._consumerToken, secret: this._consumerSecret }, + consumerKeys: { key: this.consumerToken, secret: this.consumerSecret }, }); } protected getOAuthAccessTokens() { - if (!this._accessSecret || !this._accessToken) + if (!this.accessSecret || !this.accessToken) return; return { - key: this._accessToken, - secret: this._accessSecret, + key: this.accessToken, + secret: this.accessSecret, }; } + /* Plugin helpers */ + + public getPlugins() { + return this.clientSettings.plugins ?? []; + } + + public hasPlugins() { + return !!this.clientSettings.plugins?.length; + } + + public async applyPluginMethod(method: K, args: Parameters[K]>[0]) { + for (const plugin of this.getPlugins()) { + await plugin[method]?.(args as any); + } + } + + /* Request helpers */ protected writeAuthHeaders({ headers, bodyInSignature, url, method, query, body }: IWriteAuthHeadersArgs) { headers = { ...headers }; - if (this._bearerToken) { - headers.Authorization = 'Bearer ' + this._bearerToken; + if (this.bearerToken) { + headers.Authorization = 'Bearer ' + this.bearerToken; } - else if (this._basicToken) { + else if (this.basicToken) { // Basic auth, to request a bearer token - headers.Authorization = 'Basic ' + this._basicToken; + headers.Authorization = 'Basic ' + this.basicToken; } - else if (this._clientId && this._clientSecret) { + else if (this.clientId && this.clientSecret) { // Basic auth with clientId + clientSecret - headers.Authorization = 'Basic ' + OAuth2Helper.getAuthHeader(this._clientId, this._clientSecret); + headers.Authorization = 'Basic ' + OAuth2Helper.getAuthHeader(this.clientId, this.clientSecret); } - else if (this._consumerSecret && this._oauth) { + else if (this.consumerSecret && this._oauth) { // Merge query and body const data = bodyInSignature ? RequestParamHelpers.mergeQueryAndBodyForOAuth(query, body) : query; @@ -149,38 +271,42 @@ export abstract class ClientRequestMaker { return headers; } + protected getUrlObjectFromUrlString(url: string) { + // Add protocol to URL if needed + if (!url.startsWith('http')) { + url = 'https://' + url; + } + + // Convert URL to object that will receive all URL modifications + return new URL(url); + } + protected getHttpRequestArgs({ - url, method, query: rawQuery = {}, + url: stringUrl, method, query: rawQuery = {}, body: rawBody = {}, headers, forceBodyMode, enableAuth, params, - }: IGetHttpRequestArgs) { + }: IGetHttpRequestArgs): IComputedHttpRequestArgs { let body: string | Buffer | undefined = undefined; method = method.toUpperCase(); headers = headers ?? {}; - // Add user agent header (Twitter recommands it) + // Add user agent header (Twitter recommends it) if (!headers['x-user-agent']) { headers['x-user-agent'] = 'Node.twitter-api-v2'; } - // Add protocol to URL if needed - if (!url.startsWith('http')) { - url = 'https://' + url; - } - - // Convert URL to object that will receive all URL modifications - const urlObject = new URL(url); + const url = this.getUrlObjectFromUrlString(stringUrl); // URL without query string to save as endpoint name - const rawUrl = urlObject.origin + urlObject.pathname; + const rawUrl = url.origin + url.pathname; // Apply URL parameters if (params) { - RequestParamHelpers.applyRequestParametersToUrl(urlObject, params); + RequestParamHelpers.applyRequestParametersToUrl(url, params); } - // Build an URL without anything in QS, and QSP in query + // Build a URL without anything in QS, and QSP in query const query = RequestParamHelpers.formatQueryToString(rawQuery); - RequestParamHelpers.moveUrlQueryParamsIntoObject(urlObject, query); + RequestParamHelpers.moveUrlQueryParamsIntoObject(url, query); // Delete undefined parameters if (!(rawBody instanceof Buffer)) { @@ -188,28 +314,75 @@ export abstract class ClientRequestMaker { } // OAuth signature should not include parameters when using multipart. - const bodyType = forceBodyMode ?? RequestParamHelpers.autoDetectBodyType(urlObject); + const bodyType = forceBodyMode ?? RequestParamHelpers.autoDetectBodyType(url); // If undefined or true, enable auth by headers if (enableAuth !== false) { // OAuth needs body signature only if body is URL encoded. const bodyInSignature = ClientRequestMaker.BODY_METHODS.has(method) && bodyType === 'url'; - headers = this.writeAuthHeaders({ headers, bodyInSignature, method, query, url: urlObject, body: rawBody }); + headers = this.writeAuthHeaders({ headers, bodyInSignature, method, query, url, body: rawBody }); } if (ClientRequestMaker.BODY_METHODS.has(method)) { body = RequestParamHelpers.constructBodyParams(rawBody, headers, bodyType) || undefined; } - RequestParamHelpers.addQueryParamsToUrl(urlObject, query); + RequestParamHelpers.addQueryParamsToUrl(url, query); return { rawUrl, - url: urlObject, + url, method, headers, body, }; } + + /* Plugin helpers */ + + protected async applyPreRequestConfigHooks(requestParams: IGetHttpRequestArgs) { + const url = this.getUrlObjectFromUrlString(requestParams.url); + + for (const plugin of this.getPlugins()) { + const result = await plugin.onBeforeRequestConfig?.({ + url, + params: requestParams, + }); + + if (result) { + return result; + } + } + } + + protected applyPreStreamRequestConfigHooks(requestParams: IGetHttpRequestArgs) { + const url = this.getUrlObjectFromUrlString(requestParams.url); + + for (const plugin of this.getPlugins()) { + plugin.onBeforeStreamRequestConfig?.({ + url, + params: requestParams, + }); + } + } + + protected async applyPreRequestHooks(requestParams: IGetHttpRequestArgs, computedParams: IComputedHttpRequestArgs, requestOptions: Partial) { + await this.applyPluginMethod('onBeforeRequest', { + url: this.getUrlObjectFromUrlString(requestParams.url), + params: requestParams, + computedParams, + requestOptions, + }); + } + + protected async applyPostRequestHooks(requestParams: IGetHttpRequestArgs, computedParams: IComputedHttpRequestArgs, requestOptions: Partial, response: TwitterResponse) { + await this.applyPluginMethod('onAfterRequest', { + url: this.getUrlObjectFromUrlString(requestParams.url), + params: requestParams, + computedParams, + requestOptions, + response, + }); + } } diff --git a/src/client.base.ts b/src/client.base.ts index 91d8354..9b21d30 100644 --- a/src/client.base.ts +++ b/src/client.base.ts @@ -1,11 +1,9 @@ -import type { IClientSettings, TClientTokens, TwitterApiBasicAuth, TwitterApiOAuth2Init, TwitterApiTokens, TwitterRateLimit, TwitterResponse, UserV1, UserV2Result } from './types'; -import { - ClientRequestMaker, -} from './client-mixins/request-maker.mixin'; +import type { IClientSettings, ITwitterApiClientPlugin, TwitterApiBasicAuth, TwitterApiOAuth2Init, TwitterApiTokens, TwitterRateLimit, TwitterResponse, UserV1, UserV2Result } from './types'; +import { ClientRequestMaker } from './client-mixins/request-maker.mixin'; import TweetStream from './stream/TweetStream'; import { sharedPromise, SharedPromise } from './helpers'; import { API_V1_1_PREFIX, API_V2_PREFIX } from './globals'; -import type { TCustomizableRequestArgs, TRequestBody, TRequestQuery } from './types/request-maker.mixin.types'; +import type { TAcceptedInitToken, TCustomizableRequestArgs, TRequestBody, TRequestQuery } from './types/request-maker.mixin.types'; export type TGetClientRequestArgs = TCustomizableRequestArgs & { prefix?: string; @@ -43,10 +41,11 @@ export type TStreamClientRequestArgsWithoutAutoConnect = TStreamClientRequestArg /** * Base class for Twitter instances */ -export default abstract class TwitterApiBase extends ClientRequestMaker { +export default abstract class TwitterApiBase { protected _prefix: string | undefined; protected _currentUser: SharedPromise | null = null; protected _currentUserV2: SharedPromise | null = null; + protected _requestMaker: ClientRequestMaker; /** * Create a new TwitterApi object without authentication. @@ -74,49 +73,15 @@ export default abstract class TwitterApiBase extends ClientRequestMaker { constructor(instance: TwitterApiBase, settings?: Partial); public constructor( - token?: TwitterApiTokens | TwitterApiOAuth2Init | TwitterApiBasicAuth | string | TwitterApiBase, + token?: TAcceptedInitToken | TwitterApiBase, settings: Partial = {}, ) { - super(); - - if (typeof token === 'string') { - this._bearerToken = token; - } - else if (token instanceof TwitterApiBase) { - this._accessToken = token._accessToken; - this._accessSecret = token._accessSecret; - this._consumerToken = token._consumerToken; - this._consumerSecret = token._consumerSecret; - this._oauth = token._oauth; - this._prefix = token._prefix; - this._bearerToken = token._bearerToken; - this._basicToken = token._basicToken; - this._clientId = token._clientId; - this._clientSecret = token._clientSecret; - this._rateLimits = token._rateLimits; - } - else if (typeof token === 'object' && 'appKey' in token) { - this._consumerToken = token.appKey; - this._consumerSecret = token.appSecret; - - if (token.accessToken && token.accessSecret) { - this._accessToken = token.accessToken; - this._accessSecret = token.accessSecret; - } - - this._oauth = this.buildOAuth(); - } - else if (typeof token === 'object' && 'username' in token) { - const key = encodeURIComponent(token.username) + ':' + encodeURIComponent(token.password); - this._basicToken = Buffer.from(key).toString('base64'); + if (token instanceof TwitterApiBase) { + this._requestMaker = token._requestMaker; } - else if (typeof token === 'object' && 'clientId' in token) { - this._clientId = token.clientId; - this._clientSecret = token.clientSecret; - } - - if (settings) { - this._clientSettings = { ...settings }; + else { + this._requestMaker = new ClientRequestMaker(settings); + this._requestMaker.initializeToken(token); } } @@ -133,40 +98,23 @@ export default abstract class TwitterApiBase extends ClientRequestMaker { return clone; } - public getActiveTokens(): TClientTokens { - if (this._bearerToken) { - return { - type: 'oauth2', - bearerToken: this._bearerToken, - }; - } - else if (this._basicToken) { - return { - type: 'basic', - token: this._basicToken, - }; - } - else if (this._consumerSecret && this._oauth) { - return { - type: 'oauth-1.0a', - appKey: this._consumerToken!, - appSecret: this._consumerSecret!, - accessToken: this._accessToken, - accessSecret: this._accessSecret, - }; - } - else if (this._clientId) { - return { - type: 'oauth2-user', - clientId: this._clientId!, - }; - } - return { type: 'none' }; + public getActiveTokens() { + return this._requestMaker.getActiveTokens(); + } + + /* Rate limit cache / Plugins */ + + public getPlugins() { + return this._requestMaker.getPlugins(); } - /* Rate limit cache */ + public getPluginOfType(type: { new(...args: any[]): T }): T | undefined { + return this.getPlugins().find(plugin => plugin instanceof type) as T | undefined; + } /** + * @deprecated - Migrate to plugin `@twitter-api-v2/plugin-rate-limit` + * * Tells if you hit the Twitter rate limit for {endpoint}. * (local data only, this should not ask anything to Twitter) */ @@ -178,6 +126,8 @@ export default abstract class TwitterApiBase extends ClientRequestMaker { } /** + * @deprecated - Migrate to plugin `@twitter-api-v2/plugin-rate-limit` + * * Tells if you hit the returned Twitter rate limit for {endpoint} has expired. * If client has no saved rate limit data for {endpoint}, this will gives you `true`. */ @@ -192,12 +142,14 @@ export default abstract class TwitterApiBase extends ClientRequestMaker { } /** + * @deprecated - Migrate to plugin `@twitter-api-v2/plugin-rate-limit` + * * Get the last obtained Twitter rate limit information for {endpoint}. * (local data only, this should not ask anything to Twitter) */ public getLastRateLimitStatus(endpoint: string): TwitterRateLimit | undefined { const endpointWithPrefix = endpoint.match(/^https?:\/\//) ? endpoint : (this._prefix + endpoint); - return this._rateLimits[endpointWithPrefix]; + return this._requestMaker.getRateLimits()[endpointWithPrefix]; } /* Current user cache */ @@ -254,7 +206,7 @@ export default abstract class TwitterApiBase extends ClientRequestMaker { if (prefix) url = prefix + url; - const resp = await this.send({ + const resp = await this._requestMaker.send({ url, method: 'GET', query, @@ -275,7 +227,7 @@ export default abstract class TwitterApiBase extends ClientRequestMaker { if (prefix) url = prefix + url; - const resp = await this.send({ + const resp = await this._requestMaker.send({ url, method: 'DELETE', query, @@ -292,7 +244,7 @@ export default abstract class TwitterApiBase extends ClientRequestMaker { if (prefix) url = prefix + url; - const resp = await this.send({ + const resp = await this._requestMaker.send({ url, method: 'POST', body, @@ -309,7 +261,7 @@ export default abstract class TwitterApiBase extends ClientRequestMaker { if (prefix) url = prefix + url; - const resp = await this.send({ + const resp = await this._requestMaker.send({ url, method: 'PUT', body, @@ -326,7 +278,7 @@ export default abstract class TwitterApiBase extends ClientRequestMaker { if (prefix) url = prefix + url; - const resp = await this.send({ + const resp = await this._requestMaker.send({ url, method: 'PATCH', body, @@ -344,7 +296,7 @@ export default abstract class TwitterApiBase extends ClientRequestMaker { getStream(url: string, query?: TRequestQuery, options?: TStreamClientRequestArgs) : Promise> | TweetStream; public getStream(url: string, query?: TRequestQuery, { prefix = this._prefix, ...rest }: TStreamClientRequestArgs = {}) : Promise> | TweetStream { - return this.sendStream({ + return this._requestMaker.sendStream({ url: prefix ? prefix + url : url, method: 'GET', query, @@ -357,7 +309,7 @@ export default abstract class TwitterApiBase extends ClientRequestMaker { postStream(url: string, body?: TRequestBody, options?: TStreamClientRequestArgs) : Promise> | TweetStream; public postStream(url: string, body?: TRequestBody, { prefix = this._prefix, ...rest }: TStreamClientRequestArgs = {}) : Promise> | TweetStream { - return this.sendStream({ + return this._requestMaker.sendStream({ url: prefix ? prefix + url : url, method: 'POST', body, diff --git a/src/client.subclient.ts b/src/client.subclient.ts index d4b9906..d59e076 100644 --- a/src/client.subclient.ts +++ b/src/client.subclient.ts @@ -5,23 +5,10 @@ import TwitterApiBase from './client.base'; */ export default abstract class TwitterApiSubClient extends TwitterApiBase { constructor(instance: TwitterApiBase) { - super(); - if (!(instance instanceof TwitterApiBase)) { - throw new Error('You must instance TwitterApiv1 instance from existing TwitterApi instance.'); + throw new Error('You must instance SubTwitterApi instance from existing TwitterApi instance.'); } - const inst: TwitterApiSubClient = instance; - - this._bearerToken = inst._bearerToken; - this._consumerToken = inst._consumerToken; - this._consumerSecret = inst._consumerSecret; - this._accessToken = inst._accessToken; - this._accessSecret = inst._accessSecret; - this._basicToken = inst._basicToken; - this._oauth = inst._oauth; - this._clientId = inst._clientId; - this._rateLimits = inst._rateLimits; - this._clientSettings = { ...inst._clientSettings }; + super(instance); } } diff --git a/src/client/readonly.ts b/src/client/readonly.ts index a9c4257..95f6f90 100644 --- a/src/client/readonly.ts +++ b/src/client/readonly.ts @@ -4,7 +4,7 @@ import { AccessOAuth2TokenArgs, AccessOAuth2TokenResult, AccessTokenResult, - BearerTokenResult, BuildOAuth2RequestLinkArgs, LoginResult, + BearerTokenResult, BuildOAuth2RequestLinkArgs, IOAuth2RequestTokenResult, LoginResult, RequestTokenArgs, RequestTokenResult, TOAuth2Scope, @@ -106,6 +106,13 @@ export default class TwitterApiReadOnly extends TwitterApiBase { url += `&screen_name=${encodeURIComponent(screenName)}`; } + if (this._requestMaker.hasPlugins()) { + this._requestMaker.applyPluginMethod('onOAuth1RequestToken', { + url, + oauthResult, + }); + } + return { url, ...oauthResult, @@ -134,17 +141,21 @@ export default class TwitterApiReadOnly extends TwitterApiBase { * ``` */ public async login(oauth_verifier: string): Promise { + const tokens = this.getActiveTokens(); + if (tokens.type !== 'oauth-1.0a') + throw new Error('You must setup TwitterApi instance with consumer keys to accept OAuth 1.0 login'); + const oauth_result = await this.post( 'https://api.twitter.com/oauth/access_token', - { oauth_token: this._accessToken, oauth_verifier } + { oauth_token: tokens.accessToken, oauth_verifier } ); const client = new TwitterApi({ - appKey: this._consumerToken!, - appSecret: this._consumerSecret!, + appKey: tokens.appKey, + appSecret: tokens.appSecret, accessToken: oauth_result.oauth_token, accessSecret: oauth_result.oauth_token_secret, - }, this._clientSettings); + }, this._requestMaker.clientSettings); return { accessToken: oauth_result.oauth_token, @@ -168,15 +179,16 @@ export default class TwitterApiReadOnly extends TwitterApiBase { * ``` */ public async appLogin() { - if (!this._consumerToken || !this._consumerSecret) - throw new Error('You must setup TwitterApi instance with consumers to enable app-only login'); + const tokens = this.getActiveTokens(); + if (tokens.type !== 'oauth-1.0a') + throw new Error('You must setup TwitterApi instance with consumer keys to accept app-only login'); // Create a client with Basic authentication - const basicClient = new TwitterApi({ username: this._consumerToken, password: this._consumerSecret }); + const basicClient = new TwitterApi({ username: tokens.appKey, password: tokens.appSecret }); const res = await basicClient.post('https://api.twitter.com/oauth2/token', { grant_type: 'client_credentials' }); // New object with Bearer token - return new TwitterApi(res.access_token, this._clientSettings); + return new TwitterApi(res.access_token, this._requestMaker.clientSettings); } /* OAuth 2 user authentication */ @@ -203,8 +215,8 @@ export default class TwitterApiReadOnly extends TwitterApiBase { * // Save `state` and `codeVerifier` somewhere, it will be needed for next auth step. * ``` */ - generateOAuth2AuthLink(redirectUri: string, options: Partial = {}) { - if (!this._clientId) { + generateOAuth2AuthLink(redirectUri: string, options: Partial = {}): IOAuth2RequestTokenResult { + if (!this._requestMaker.clientId) { throw new Error( 'Twitter API instance is not initialized with client ID. You can find your client ID in Twitter Developer Portal. ' + 'Please build an instance with: new TwitterApi({ clientId: \'\' })', @@ -220,7 +232,7 @@ export default class TwitterApiReadOnly extends TwitterApiBase { const url = new URL('https://twitter.com/i/oauth2/authorize'); const query = { response_type: 'code', - client_id: this._clientId, + client_id: this._requestMaker.clientId, redirect_uri: redirectUri, state, code_challenge: codeChallenge, @@ -230,12 +242,21 @@ export default class TwitterApiReadOnly extends TwitterApiBase { RequestParamHelpers.addQueryParamsToUrl(url, query); - return { + const result: IOAuth2RequestTokenResult = { url: url.toString(), state, codeVerifier, codeChallenge, }; + + if (this._requestMaker.hasPlugins()) { + this._requestMaker.applyPluginMethod('onOAuth2RequestToken', { + result, + redirectUri, + }); + } + + return result; } /** @@ -263,7 +284,7 @@ export default class TwitterApiReadOnly extends TwitterApiBase { * ``` */ async loginWithOAuth2({ code, codeVerifier, redirectUri }: AccessOAuth2TokenArgs) { - if (!this._clientId) { + if (!this._requestMaker.clientId) { throw new Error( 'Twitter API instance is not initialized with client ID. ' + 'Please build an instance with: new TwitterApi({ clientId: \'\' })', @@ -275,8 +296,8 @@ export default class TwitterApiReadOnly extends TwitterApiBase { code_verifier: codeVerifier, redirect_uri: redirectUri, grant_type: 'authorization_code', - client_id: this._clientId, - client_secret: this._clientSecret, + client_id: this._requestMaker.clientId, + client_secret: this._requestMaker.clientSecret, }); return this.parseOAuth2AccessTokenResult(accessTokenResult); @@ -293,7 +314,7 @@ export default class TwitterApiReadOnly extends TwitterApiBase { * ``` */ async refreshOAuth2Token(refreshToken: string) { - if (!this._clientId) { + if (!this._requestMaker.clientId) { throw new Error( 'Twitter API instance is not initialized with client ID. ' + 'Please build an instance with: new TwitterApi({ clientId: \'\' })', @@ -303,8 +324,8 @@ export default class TwitterApiReadOnly extends TwitterApiBase { const accessTokenResult = await this.post('https://api.twitter.com/2/oauth2/token', { refresh_token: refreshToken, grant_type: 'refresh_token', - client_id: this._clientId, - client_secret: this._clientSecret, + client_id: this._requestMaker.clientId, + client_secret: this._requestMaker.clientSecret, }); return this.parseOAuth2AccessTokenResult(accessTokenResult); @@ -317,7 +338,7 @@ export default class TwitterApiReadOnly extends TwitterApiBase { * or refresh token (if you've called `.refreshOAuth2Token` before). */ async revokeOAuth2Token(token: string, tokenType: 'access_token' | 'refresh_token' = 'access_token') { - if (!this._clientId) { + if (!this._requestMaker.clientId) { throw new Error( 'Twitter API instance is not initialized with client ID. ' + 'Please build an instance with: new TwitterApi({ clientId: \'\' })', @@ -325,15 +346,15 @@ export default class TwitterApiReadOnly extends TwitterApiBase { } return await this.post('https://api.twitter.com/2/oauth2/revoke', { - client_id: this._clientId, - client_secret: this._clientSecret, + client_id: this._requestMaker.clientId, + client_secret: this._requestMaker.clientSecret, token, token_type_hint: tokenType, }); } protected parseOAuth2AccessTokenResult(result: AccessOAuth2TokenResult) { - const client = new TwitterApi(result.access_token, this._clientSettings); + const client = new TwitterApi(result.access_token, this._requestMaker.clientSettings); const scope = result.scope.split(' ').filter(e => e) as TOAuth2Scope[]; return { diff --git a/src/helpers.ts b/src/helpers.ts index ed26218..0cc33b3 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -71,6 +71,6 @@ export function safeDeprecationWarning(message: IDeprecationWarning) { `, ${message.problem}.\n${message.resolution}.`; console.warn(formattedMsg); - console.warn('To disable this message, import TwitterApiV2Settings from twitter-api-v2 and set TwitterApiV2Settings.deprecationWarnings to false.'); + console.warn('To disable this message, import variable TwitterApiV2Settings from twitter-api-v2 and set TwitterApiV2Settings.deprecationWarnings to false.'); deprecationWarningsCache.add(hash); } diff --git a/src/test/utils.ts b/src/test/utils.ts index 4194387..c2ba27c 100644 --- a/src/test/utils.ts +++ b/src/test/utils.ts @@ -16,6 +16,15 @@ export function getUserClient(this: any) { }); } +export function getUserKeys() { + return { + appKey: process.env.CONSUMER_TOKEN!, + appSecret: process.env.CONSUMER_SECRET!, + accessToken: process.env.OAUTH_TOKEN!, + accessSecret: process.env.OAUTH_SECRET!, + }; +} + export async function sleepTest(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } @@ -28,6 +37,13 @@ export function getRequestClient() { }); } +export function getRequestKeys() { + return { + appKey: process.env.CONSUMER_TOKEN!, + appSecret: process.env.CONSUMER_SECRET!, + }; +} + // Test auth 1.0a flow export function getAuthLink(callback: string) { return getRequestClient().generateAuthLink(callback); diff --git a/src/types/auth.types.ts b/src/types/auth.types.ts index 7e793e2..b2fa154 100644 --- a/src/types/auth.types.ts +++ b/src/types/auth.types.ts @@ -40,6 +40,13 @@ export interface RequestTokenResult { oauth_callback_confirmed: 'true'; } +export interface IOAuth2RequestTokenResult { + url: string; + state: string; + codeVerifier: string; + codeChallenge: string; +} + export interface AccessTokenResult { oauth_token: string; oauth_token_secret: string; diff --git a/src/types/client.types.ts b/src/types/client.types.ts index b7b8122..951c80a 100644 --- a/src/types/client.types.ts +++ b/src/types/client.types.ts @@ -1,4 +1,5 @@ import type { Agent } from 'http'; +import type { ITwitterApiClientPlugin } from './plugins'; import type { TRequestCompressionLevel } from './request-maker.mixin.types'; export enum ETwitterStreamEvent { @@ -67,5 +68,6 @@ export type TClientTokens = IClientTokenNone | IClientTokenBearer | IClientToken export interface IClientSettings { /** Used to send HTTPS requests. This is mostly used to make requests work behind a proxy. */ httpAgent: Agent; + plugins: ITwitterApiClientPlugin[]; compression: TRequestCompressionLevel; } diff --git a/src/types/index.ts b/src/types/index.ts index fb5739d..cfc014a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,4 +4,5 @@ export * from './errors.types'; export * from './responses.types'; export * from './client.types'; export * from './auth.types'; +export * from './plugins'; export { IGetHttpRequestArgs } from './request-maker.mixin.types'; diff --git a/src/types/plugins/client.plugins.types.ts b/src/types/plugins/client.plugins.types.ts new file mode 100644 index 0000000..cf0e9d2 --- /dev/null +++ b/src/types/plugins/client.plugins.types.ts @@ -0,0 +1,56 @@ +import type { ClientRequestArgs } from 'http'; +import type { IGetHttpRequestArgs } from '../request-maker.mixin.types'; +import type { TwitterResponse } from '../responses.types'; +import type { IComputedHttpRequestArgs } from '../request-maker.mixin.types'; +import type { IOAuth2RequestTokenResult, RequestTokenResult } from '../auth.types'; +import type { PromiseOrType } from '../shared.types'; + +export interface ITwitterApiClientPlugin { + // Classic requests + onBeforeRequestConfig?: TTwitterApiBeforeRequestConfigHook; + onBeforeRequest?: TTwitterApiBeforeRequestHook; + onAfterRequest?: TTwitterApiAfterRequestHook; + // Stream requests + onBeforeStreamRequestConfig?: TTwitterApiBeforeStreamRequestConfigHook; + // Request token + onOAuth1RequestToken?: TTwitterApiAfterOAuth1RequestTokenHook; + onOAuth2RequestToken?: TTwitterApiAfterOAuth2RequestTokenHook; +} + +// - Requests - + +export interface ITwitterApiBeforeRequestConfigHookArgs { + url: URL; + params: IGetHttpRequestArgs; +} + +export interface ITwitterApiBeforeRequestHookArgs extends ITwitterApiBeforeRequestConfigHookArgs { + computedParams: IComputedHttpRequestArgs; + requestOptions: Partial +} + +export interface ITwitterApiAfterRequestHookArgs extends ITwitterApiBeforeRequestHookArgs { + response: TwitterResponse; +} + +export type TTwitterApiBeforeRequestConfigHook = (args: ITwitterApiBeforeRequestConfigHookArgs) => PromiseOrType | void>; +export type TTwitterApiBeforeRequestHook = (args: ITwitterApiBeforeRequestHookArgs) => void | Promise; +export type TTwitterApiAfterRequestHook = (args: ITwitterApiAfterRequestHookArgs) => void | Promise; + +export type TTwitterApiBeforeStreamRequestConfigHook = (args: ITwitterApiBeforeRequestConfigHookArgs) => void; + + +// - Auth - + +export interface ITwitterApiAfterOAuth1RequestTokenHookArgs { + url: string; + oauthResult: RequestTokenResult; +} + +export interface ITwitterApiAfterOAuth2RequestTokenHookArgs { + result: IOAuth2RequestTokenResult; + redirectUri: string; +} + +export type TTwitterApiAfterOAuth1RequestTokenHook = (args: ITwitterApiAfterOAuth1RequestTokenHookArgs) => void | Promise; +export type TTwitterApiAfterOAuth2RequestTokenHook = (args: ITwitterApiAfterOAuth2RequestTokenHookArgs) => void | Promise; diff --git a/src/types/plugins/index.ts b/src/types/plugins/index.ts new file mode 100644 index 0000000..744e8b7 --- /dev/null +++ b/src/types/plugins/index.ts @@ -0,0 +1,2 @@ + +export * from './client.plugins.types'; diff --git a/src/types/request-maker.mixin.types.ts b/src/types/request-maker.mixin.types.ts index 0124060..2fd6e15 100644 --- a/src/types/request-maker.mixin.types.ts +++ b/src/types/request-maker.mixin.types.ts @@ -1,7 +1,9 @@ import type { RequestOptions } from 'https'; +import type TwitterApiBase from '../client.base'; +import type { TwitterApiBasicAuth, TwitterApiOAuth2Init, TwitterApiTokens } from './client.types'; import type { TwitterRateLimit } from './responses.types'; -export type TRequestDebuggerHandlerEvent = 'abort' | 'socket' | 'socket-error' | 'socket-connect' +export type TRequestDebuggerHandlerEvent = 'abort' | 'close' | 'socket' | 'socket-error' | 'socket-connect' | 'socket-close' | 'socket-end' | 'socket-lookup' | 'socket-timeout' | 'request-error' | 'response' | 'response-aborted' | 'response-error' | 'response-close' | 'response-end'; export type TRequestDebuggerHandler = (event: TRequestDebuggerHandlerEvent, data?: any) => void; @@ -52,6 +54,14 @@ export interface IGetHttpRequestArgs { compression?: TRequestCompressionLevel; } +export interface IComputedHttpRequestArgs { + rawUrl: string; + url: URL; + method: string; + headers: Record; + body: string | Buffer | undefined; +} + export interface IGetStreamRequestArgs { payloadIsError?: (data: any) => boolean; autoConnect?: boolean; @@ -69,3 +79,4 @@ export interface IGetStreamRequestArgsSync { export type TCustomizableRequestArgs = Pick; +export type TAcceptedInitToken = TwitterApiTokens | TwitterApiOAuth2Init | TwitterApiBasicAuth | string; diff --git a/src/types/v1/tweet.v1.types.ts b/src/types/v1/tweet.v1.types.ts index 030c180..4e1dc44 100644 --- a/src/types/v1/tweet.v1.types.ts +++ b/src/types/v1/tweet.v1.types.ts @@ -102,13 +102,25 @@ export interface SendTweetV1Params extends AskTweetV1Params { export type TUploadTypeV1 = 'mp4' | 'longmp4' | 'gif' | 'jpg' | 'png' | 'srt' | 'webp'; +export enum EUploadMimeType { + Jpeg = 'image/jpeg', + Mp4 = 'video/mp4', + Gif = 'image/gif', + Png = 'image/png', + Srt = 'text/plain', + Webp = 'image/webp' +} + export interface UploadMediaV1Params { + /** @deprecated Directly use `mimeType` parameter with one of the allowed MIME types in `EUploadMimeType`. */ type: TUploadTypeV1 | string; + mimeType: EUploadMimeType | string; + target: 'tweet' | 'dm'; chunkLength: number; - additionalOwners: string; + shared: boolean; + longVideo: boolean; + additionalOwners: string | string[]; maxConcurrentUploads: number; - target: 'tweet' | 'dm'; - shared?: boolean; } export interface MediaMetadataV1Params { diff --git a/src/v1/client.v1.write.ts b/src/v1/client.v1.write.ts index 4b8735e..5566e45 100644 --- a/src/v1/client.v1.write.ts +++ b/src/v1/client.v1.write.ts @@ -23,6 +23,7 @@ import { GetListV1Params, AddOrRemoveListMembersV1Params, UpdateListV1Params, + EUploadMimeType, } from '../types'; import * as fs from 'fs'; import { getFileHandle, getFileSizeFromFileHandle, getMediaCategoryByMime, getMimeType, readFileIntoBuffer, readNextPartOf, sleepSecs, TFileHandle } from './media-helpers.v1'; @@ -388,7 +389,7 @@ export default class TwitterApiv1ReadWrite extends TwitterApiv1ReadOnly { } } - protected async getUploadMediaRequirements(file: TUploadableMedia, { type, target }: Partial = {}) { + protected async getUploadMediaRequirements(file: TUploadableMedia, { mimeType, type, target, longVideo }: Partial = {}) { // Get the file handle (if not buffer) let fileHandle: TFileHandle; @@ -396,23 +397,23 @@ export default class TwitterApiv1ReadWrite extends TwitterApiv1ReadOnly { fileHandle = await getFileHandle(file); // Get the mimetype - const mimeType = getMimeType(file, type); + const realMimeType = getMimeType(file, type, mimeType); // Get the media category let mediaCategory: string; // If explicit longmp4 OR explicit MIME type and not DM target - if (type === 'longmp4' || (type === 'video/mp4' && target !== 'dm')) { + if (realMimeType === EUploadMimeType.Mp4 && ((!mimeType && !type && target !== 'dm') || longVideo)) { mediaCategory = 'amplify_video'; } else { - mediaCategory = getMediaCategoryByMime(mimeType, target ?? 'tweet'); + mediaCategory = getMediaCategoryByMime(realMimeType, target ?? 'tweet'); } return { fileHandle, mediaCategory, fileSize: await getFileSizeFromFileHandle(fileHandle), - mimeType, + mimeType: realMimeType, }; } catch (e) { // Close file if any diff --git a/src/v1/media-helpers.v1.ts b/src/v1/media-helpers.v1.ts index 3bcfa69..88530f9 100644 --- a/src/v1/media-helpers.v1.ts +++ b/src/v1/media-helpers.v1.ts @@ -1,5 +1,7 @@ import * as fs from 'fs'; +import { safeDeprecationWarning } from '../helpers'; import type { TUploadableMedia, TUploadTypeV1 } from '../types'; +import { EUploadMimeType } from '../types'; // ------------- // Media helpers @@ -58,8 +60,10 @@ export async function getFileSizeFromFileHandle(fileHandle: TFileHandle) { } } -export function getMimeType(file: TUploadableMedia, type?: TUploadTypeV1 | string) { - if (typeof file === 'string' && !type) { +export function getMimeType(file: TUploadableMedia, type?: TUploadTypeV1 | string, mimeType?: EUploadMimeType | string) { + if (typeof mimeType === 'string') { + return mimeType; + } else if (typeof file === 'string' && !type) { return getMimeByName(file); } else if (typeof type === 'string') { return getMimeByType(type); @@ -69,31 +73,46 @@ export function getMimeType(file: TUploadableMedia, type?: TUploadTypeV1 | strin } function getMimeByName(name: string) { - if (name.endsWith('.jpeg') || name.endsWith('.jpg')) return 'image/jpeg'; - if (name.endsWith('.png')) return 'image/png'; - if (name.endsWith('.webp')) return 'image/webp'; - if (name.endsWith('.gif')) return 'image/gif'; - if (name.endsWith('.mpeg4') || name.endsWith('.mp4')) return 'video/mp4'; - if (name.endsWith('.srt')) return 'text/plain'; - - return 'image/jpeg'; + if (name.endsWith('.jpeg') || name.endsWith('.jpg')) return EUploadMimeType.Jpeg; + if (name.endsWith('.png')) return EUploadMimeType.Png; + if (name.endsWith('.webp')) return EUploadMimeType.Webp; + if (name.endsWith('.gif')) return EUploadMimeType.Gif; + if (name.endsWith('.mpeg4') || name.endsWith('.mp4')) return EUploadMimeType.Mp4; + if (name.endsWith('.srt')) return EUploadMimeType.Srt; + + safeDeprecationWarning({ + instance: 'TwitterApiv1ReadWrite', + method: 'uploadMedia', + problem: `options.mimeType is missing and filename couldn't help to resolve MIME type, so it will fallback to image/jpeg`, + resolution: `If you except to give filenames without extensions, please specify explicitlty the MIME type using options.mimeType`, + }); + + return EUploadMimeType.Jpeg; } function getMimeByType(type: TUploadTypeV1 | string) { - if (type === 'gif') return 'image/gif'; - if (type === 'jpg') return 'image/jpeg'; - if (type === 'png') return 'image/png'; - if (type === 'webp') return 'image/webp'; - if (type === 'srt') return 'text/plain'; - if (type === 'mp4' || type === 'longmp4') return 'video/mp4'; + safeDeprecationWarning({ + instance: 'TwitterApiv1ReadWrite', + method: 'uploadMedia', + problem: `you're using options.type`, + resolution: `Remove options.type argument and migrate to options.mimeType which takes the real MIME type. ` + + `If you're using type=longmp4, add options.longVideo alongside of mimeType=EUploadMimeType.Mp4`, + }); + + if (type === 'gif') return EUploadMimeType.Gif; + if (type === 'jpg') return EUploadMimeType.Jpeg; + if (type === 'png') return EUploadMimeType.Png; + if (type === 'webp') return EUploadMimeType.Webp; + if (type === 'srt') return EUploadMimeType.Srt; + if (type === 'mp4' || type === 'longmp4') return EUploadMimeType.Mp4; return type; } export function getMediaCategoryByMime(name: string, target: 'tweet' | 'dm') { - if (name === 'video/mp4') return target === 'tweet' ? 'TweetVideo' : 'DmVideo'; - if (name === 'image/gif') return target === 'tweet' ? 'TweetGif' : 'DmGif'; - if (name === 'text/plain') return 'Subtitles'; + if (name === EUploadMimeType.Mp4) return target === 'tweet' ? 'TweetVideo' : 'DmVideo'; + if (name === EUploadMimeType.Gif) return target === 'tweet' ? 'TweetGif' : 'DmGif'; + if (name === EUploadMimeType.Srt) return 'Subtitles'; else return target === 'tweet' ? 'TweetImage' : 'DmImage'; } diff --git a/test/media-upload.test.ts b/test/media-upload.test.ts index 8241faf..e8f7498 100644 --- a/test/media-upload.test.ts +++ b/test/media-upload.test.ts @@ -1,6 +1,6 @@ import 'mocha'; import { expect } from 'chai'; -import { TwitterApi } from '../src'; +import { EUploadMimeType, TwitterApi } from '../src'; import { getUserClient } from '../src/test/utils'; import * as fs from 'fs'; import * as path from 'path'; @@ -27,14 +27,14 @@ describe('Media upload for v1.1 API', () => { it('Upload a JPG image from file handle', async () => { // Upload media (from fileHandle) - const fromHandle = await client.v1.uploadMedia(await fs.promises.open(jpgImg, 'r'), { type: 'jpg' }); + const fromHandle = await client.v1.uploadMedia(await fs.promises.open(jpgImg, 'r'), { mimeType: EUploadMimeType.Jpeg }); expect(fromHandle).to.be.an('string'); expect(fromHandle).to.have.length.greaterThan(0); }).timeout(maxTimeout); it('Upload a JPG image from numbered file handle', async () => { // Upload media (from numbered fileHandle) - const fromNumberFh = await client.v1.uploadMedia(fs.openSync(jpgImg, 'r'), { type: 'jpg', maxConcurrentUploads: 1 }); + const fromNumberFh = await client.v1.uploadMedia(fs.openSync(jpgImg, 'r'), { type: EUploadMimeType.Jpeg, maxConcurrentUploads: 1 }); expect(fromNumberFh).to.be.an('string'); expect(fromNumberFh).to.have.length.greaterThan(0); }).timeout(maxTimeout); @@ -48,7 +48,7 @@ describe('Media upload for v1.1 API', () => { it('Upload a GIF image from buffer', async () => { // Upload media (from buffer) - const fromBuffer = await client.v1.uploadMedia(await fs.promises.readFile(gifImg), { type: 'gif' }); + const fromBuffer = await client.v1.uploadMedia(await fs.promises.readFile(gifImg), { type: EUploadMimeType.Gif }); expect(fromBuffer).to.be.an('string'); expect(fromBuffer).to.have.length.greaterThan(0); }).timeout(maxTimeout); diff --git a/test/plugin.test.ts b/test/plugin.test.ts new file mode 100644 index 0000000..fd0ebc1 --- /dev/null +++ b/test/plugin.test.ts @@ -0,0 +1,83 @@ +import 'mocha'; +import { expect } from 'chai'; +import { getUserKeys, getRequestKeys } from '../src/test/utils'; +import type { + ITwitterApiClientPlugin, + TwitterResponse, + ITwitterApiBeforeRequestConfigHookArgs, + ITwitterApiAfterRequestHookArgs, + ITwitterApiAfterOAuth1RequestTokenHookArgs, +} from '../src'; +import { TwitterApi } from '../src'; + +class SimpleCacheTestPlugin implements ITwitterApiClientPlugin { + protected cache: { [urlHash: string]: TwitterResponse } = {}; + + onBeforeRequestConfig(args: ITwitterApiBeforeRequestConfigHookArgs) { + const hash = this.getHashFromRequest(args); + return this.cache[hash]; + } + + onAfterRequest(args: ITwitterApiAfterRequestHookArgs) { + const hash = this.getHashFromRequest(args); + this.cache[hash] = args.response; + } + + protected getHashFromRequest({ url, params }: ITwitterApiBeforeRequestConfigHookArgs) { + const strQuery = JSON.stringify(params.query ?? {}); + const strParams = JSON.stringify(params.params ?? {}); + + return params.method.toUpperCase() + ' ' + url.toString() + '|' + strQuery + '|' + strParams; + } +} + +class SimpleOAuthStepHelperPlugin implements ITwitterApiClientPlugin { + protected cache: { [oauthToken: string]: string } = {}; + + onOAuth1RequestToken(args: ITwitterApiAfterOAuth1RequestTokenHookArgs) { + this.cache[args.oauthResult.oauth_token] = args.oauthResult.oauth_token_secret; + } + + login(oauthToken: string, oauthVerifier: string) { + if (!oauthVerifier || !this.isOAuthTokenValid(oauthToken)) { + throw new Error('Invalid or expired token.'); + } + + const client = new TwitterApi({ + ...getRequestKeys(), + accessToken: oauthToken, + accessSecret: this.cache[oauthToken], + }); + + return client.login(oauthVerifier); + } + + isOAuthTokenValid(oauthToken: string) { + return !!this.cache[oauthToken]; + } +} + +describe('Plugin API', () => { + it('Cache a single request with a plugin', async () => { + const client = new TwitterApi(getUserKeys(), { plugins: [new SimpleCacheTestPlugin()] }); + + const user = await client.v1.verifyCredentials(); + const anotherRequest = await client.v1.verifyCredentials(); + + expect(user).to.equal(anotherRequest); + }).timeout(1000 * 30); + + it('Remember OAuth token secret between step 1 and 2 of authentication', async () => { + const client = new TwitterApi(getRequestKeys(), { plugins: [new SimpleOAuthStepHelperPlugin()] }); + + const { oauth_token } = await client.generateAuthLink('oob'); + + // Is oauth token registred in cache? + const loginPlugin = client.getPluginOfType(SimpleOAuthStepHelperPlugin)!; + expect(loginPlugin.isOAuthTokenValid(oauth_token)).to.equal(true); + + // Must login through + // const { client: loggedClient, accessToken, accessSecret } = await loginPlugin.login(oauth_token, 'xxxxxxxx'); + // - Save accessToken, accessSecret to persistent storage + }).timeout(1000 * 30); +});